Merge remote-tracking branch 'upstream/main' into feat/variables-duplicate-group-2084

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor BrownandClaude Sonnet 5.5 committed 2026-11-12 05:00:10 -05:00
commit 3d781caa92
145 files changed
+6105 -337

No files matched your search

+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); a text style binds a number variable to its weight (#2174); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server cLine truncated
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -365,6 +365,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | M4, #2222: a boolean group takes a corner radius that rounds every sharp corner of its result. Numbers unchanged |
| 2026-11-10 | minor | M12, #2226 (step 1 of 4): mind maps in the document and engine. `board.mindMap` places a first node with three children on curved branches and `board.mindMapAdd` adds a child or a sibling; the normalize pass lays a touched map out again from its first node (88 between columns, 48 between siblings, stacks centred on their parents). No toolbar entry yet; numbers unchanged. |
| 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`, 繁體中文(台灣)) draft catalog: every UI string (1,975), command label (641) and menu wording (31), translated clean-room from the English source; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant` read it while an explicit Hans stays Simplified; the desktop UI prefers an installed Noto Sans CJK TC, then Microsoft JhengHei, PingFang TC and Heiti TC; no font is bundled; native review pending. Numbers unchanged |
| 2026-11-06 | patch | M1, #2028: Edit ▸ Set default properties gives new rectangles, ellipses, polygons and stars drawn with the tools (and, set from a text layer, new text) the selected layer's properties; it only copied them for Paste properties. Tests: `default_props_tests` (3). Numbers unchanged |
+11 -20
View File
@@ -37,7 +37,7 @@ use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate};
use serde_json::{Value, json};
use wasm_bindgen::JsCast;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{JsFuture, future_to_promise};
use wasm_bindgen_futures::JsFuture;
use crate::saved_files::SavedFiles;
@@ -150,15 +150,6 @@ async fn sleep(ms: i32) {
let _ = JsFuture::from(promise).await;
}
/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
fn outcome(reply: &Value) -> Result<JsValue, JsValue> {
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
} else {
Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
}
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
@@ -169,20 +160,20 @@ pub fn install() -> Result<(), JsValue> {
Rc::new(PageApi::new(APP, Methods::default(), request)).install()?;
let window = web_sys::window().ok_or("no window")?;
let obj: js_sys::Object = js_sys::Reflect::get(&window, &APP.into())?.unchecked_into();
set_fn(
&obj,
"inspect",
Closure::<dyn Fn() -> js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("document.inspect", json!({})).await) }))
.into_js_value(),
)?;
// `inspect` is `docInspect` under its earlier name: the crate's function, so it times out too.
let doc_inspect = js_sys::Reflect::get(&obj, &"docInspect".into())?;
js_sys::Reflect::set(&obj, &"inspect".into(), &doc_inspect)?;
// `openFile` runs `file.open` through the crate's `execute`, so a call the app does not answer times out and is cancelled.
let execute: js_sys::Function = js_sys::Reflect::get(&obj, &"execute".into())?.unchecked_into();
let this = obj.clone();
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
Closure::<dyn Fn(String, js_sys::Uint8Array) -> JsValue>::new(move |name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
future_to_promise(async move {
outcome(&send("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}})).await)
})
execute
.call2(&this, &"file.open".into(), &to_js(&json!({"name": name, "data": data})))
.unwrap_or_else(|e| js_sys::Promise::reject(&e).into())
})
.into_js_value(),
)?;
+8 -4
View File
@@ -395,7 +395,8 @@ impl NodeKind {
)
}
/// Kinds whose corners can be rounded from the panel.
/// Kinds whose corners can be rounded from the panel. A boolean group has one radius for every
/// sharp corner of its result.
pub fn has_corner_radius(&self) -> bool {
matches!(
self,
@@ -408,16 +409,19 @@ impl NodeKind {
| NodeKind::Star { .. }
| NodeKind::Vector { .. }
| NodeKind::Section { .. }
| NodeKind::BooleanOp { .. }
)
}
/// Kinds with a radius per corner (the others round every corner alike).
/// Kinds with a radius per corner (the others, boolean groups included, round every corner alike).
pub fn has_independent_corners(&self) -> bool {
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
self.has_corner_radius()
&& !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. } | NodeKind::BooleanOp { .. })
}
/// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network
/// has a closed region (an open path's rounded points are drawn without it).
/// has a closed region (an open path's rounded points are drawn without it). A boolean group's
/// smoothing applies to its result's corners between straight edges.
pub fn has_corner_smoothing(&self) -> bool {
match self {
NodeKind::Vector { network } => !network.regions.is_empty(),
+9
View File
@@ -1453,8 +1453,10 @@ pub struct PrintSettings {
/// is printed this far out, to be trimmed off.
pub bleed: f64,
/// Crop marks outside the bleed, at the trim's corners.
#[serde(alias = "crop_marks")]
pub crop_marks: bool,
/// Bleed marks outside the bleed, at the bleed's corners.
#[serde(alias = "bleed_marks")]
pub bleed_marks: bool,
/// CMYK colour (converted from RGB) instead of RGB.
pub cmyk: bool,
@@ -1523,25 +1525,32 @@ pub struct ExportSetting {
pub print: PrintSettings,
/// Only the layer itself (on), or every layer that shows in its area (off).
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "contents_only")]
pub contents_only: bool,
/// The layer's own box, cutting off what it paints outside it (shadows, outside strokes).
#[serde(skip_serializing_if = "is_false")]
#[serde(alias = "include_bounding_box")]
pub include_bounding_box: bool,
/// SVG: text as outlines (on) or as `<text>` elements (off).
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "svg_outline_text")]
pub svg_outline_text: bool,
/// SVG: layer names as `id` attributes.
#[serde(skip_serializing_if = "is_false")]
#[serde(alias = "svg_id_attribute")]
pub svg_id_attribute: bool,
/// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines.
#[serde(skip_serializing_if = "is_true")]
#[serde(alias = "svg_simplify_stroke")]
pub svg_simplify_stroke: bool,
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
#[serde(alias = "vd_compat")]
pub vd_compat: VdCompat,
/// PNG, JPG and WebP: the color profile the file is written in. SVG, PDF and XML are always
/// sRGB, whatever this says.
#[serde(skip_serializing_if = "ExportColorProfile::is_default")]
#[serde(alias = "color_profile")]
pub color_profile: ExportColorProfile,
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
#[serde(skip_serializing_if = "is_default_quality")]
+40
View File
@@ -902,6 +902,46 @@ fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
}
}
/// The reporter's network (#1951): a corner cut off by a segment drawn before the corner point was
/// deleted, so the region's loop is the open chain of the other four segments and the cutting
/// segment, joining the chain's two ends, is in no loop. Rounded, the fill and the region trace
/// the stroke's outline, cut by an arc at the chain's ends as at every other point.
#[test]
fn a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment() {
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![3, 1, 2, 0]);
}
/// The same network with the joining segment listed first, and with the loop walked the other way.
#[test]
fn an_open_loop_follows_the_segment_joining_its_ends_listed_first_or_walked_backwards() {
open_loop_follows_the_segment_joining_its_ends([(3, 4), (0, 3), (1, 2), (2, 0), (4, 1)], vec![4, 2, 3, 1]);
open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![0, 2, 1, 3]);
}
fn open_loop_follows_the_segment_joining_its_ends(segments: [(usize, usize); 5], open_loop: Vec<usize>) {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let corners = [Point::new(0.0, 0.0), Point::new(525.0, 525.0), Point::new(0.0, 525.0), Point::new(333.0, 0.0), Point::new(525.0, 183.0)];
let mut net = VectorNetwork::from_points(&corners, false);
net.segments = segments.iter().map(|&(a, b)| Segment::line(a, b)).collect();
net.regions.push(Region { loops: vec![open_loop], winding: WindingRule::NonZero, fills: None, paint_space: None });
for v in &mut net.vertices {
v.radius = 125.0;
}
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let stroke = net.stroke_path();
let fill = net.fill_path();
let region = net.rounded().unwrap().region_path(0).unwrap();
for (i, p) in corners.iter().enumerate() {
assert!(keeps_off(&stroke, *p) > 2.0, "the stroke is cut at point {i}");
assert!(keeps_off(&fill, *p) > 2.0, "the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
assert!(keeps_off(&region, *p) > 2.0, "the region runs into point {i} ({:.2} from it)", keeps_off(&region, *p));
}
assert!((length(&fill) - length(&stroke)).abs() < 1e-6, "fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
assert!((length(&region) - length(&stroke)).abs() < 1e-6, "region {} and stroke {} trace the same outline", length(&region), length(&stroke));
}
#[test]
fn stored_variable_ids_resolve_only_their_exact_collection() {
let mut vars = crate::proto_expr_tests::vars();
+122 -1
View File
@@ -293,6 +293,10 @@ pub struct ParaList {
/// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters.
#[serde(skip_serializing_if = "crate::node::is_default")]
pub direction: TextDirection,
/// The paragraph's own wrap style (a wrap style can be set on the text layer, a paragraph or a
/// text style); `None` follows the layer's [`TextData::wrap_style`].
#[serde(skip_serializing_if = "Option::is_none")]
pub wrap: Option<WrapStyle>,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -337,6 +341,9 @@ pub struct ParagraphStyle {
pub paragraph_indent: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub list: ListStyle,
/// The wrap style (Auto asks nothing).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub wrap_style: WrapStyle,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub features: std::collections::BTreeMap<String, bool>,
}
@@ -838,6 +845,33 @@ impl TextData {
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
}
/// Paragraph `p`'s wrap style: its own, else the layer's.
pub fn para_wrap_style(&self, p: usize) -> WrapStyle {
self.paragraphs.get(p).and_then(|pl| pl.wrap).unwrap_or(self.wrap_style)
}
/// The layer level: every paragraph takes wrap style `w` (their own overrides go).
pub fn set_wrap_style(&mut self, w: WrapStyle) {
self.wrap_style = w;
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
let last = self.paragraph_count() - 1;
self.set_para_lists_between(0, last, false, |pl| pl.wrap = None);
}
/// The paragraph level: the paragraphs chars `start..end` touch take wrap style `w`; one equal
/// to the layer's is no override.
pub fn set_para_wrap_styles(&mut self, start: usize, end: usize, w: WrapStyle) {
let layer = self.wrap_style;
self.set_para_lists(start, end, |pl| pl.wrap = (w != layer).then_some(w));
}
/// The effective wrap style the paragraphs chars `start..end` touch share, or None when they differ.
pub fn common_wrap_style(&self, start: usize, end: usize) -> Option<WrapStyle> {
let (a, b) = self.para_span(start, end);
let first = self.para_wrap_style(a);
(a..=b).all(|p| self.para_wrap_style(p) == first).then_some(first)
}
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
fn fill_paragraphs(&mut self) {
let n = self.paragraph_count();
@@ -930,7 +964,16 @@ impl TextData {
Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list,
_ => self.list,
};
ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() }
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
let first = self.para_wrap_style(0);
let wrap_style = if (1..self.paragraph_count()).all(|p| self.para_wrap_style(p) == first) { first } else { self.wrap_style };
ParagraphStyle {
paragraph_spacing: self.paragraph_spacing,
paragraph_indent: self.paragraph_indent,
list,
wrap_style,
features: self.features.clone(),
}
}
/// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and
@@ -942,6 +985,7 @@ impl TextData {
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
let last = self.paragraph_count() - 1;
self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list);
self.set_wrap_style(ps.wrap_style);
}
/// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the
@@ -952,6 +996,7 @@ impl TextData {
self.paragraph_indent = ps.paragraph_indent;
self.features.clone_from(&ps.features);
self.set_para_lists(start, end, |pl| pl.list = ps.list);
self.set_para_wrap_styles(start, end, ps.wrap_style);
}
/// Follow text style `id`'s paragraph-level definition wherever chars link to it.
@@ -970,6 +1015,7 @@ impl TextData {
&& self.paragraph_indent == ps.paragraph_indent
&& self.features == ps.features
&& self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list)
&& self.common_wrap_style(start, end) == Some(ps.wrap_style)
}
/// Whether the whole text shows what text style `ps` sets at the paragraph and layer level.
@@ -1124,6 +1170,69 @@ mod case_tests {
assert_eq!(t.para_list(0), ParaList::default());
}
#[test]
fn wrap_style_per_paragraph_loads_from_old_files_and_folds() {
use WrapStyle::{Auto, Balance, Pretty};
let old = r#"{"list":"numbered","indent":1}"#;
assert_eq!(serde_json::from_str::<ParaList>(old).unwrap().wrap, None);
let mut t = TextData::new("a\nb");
let key = t.layout_key();
t.set_para_wrap_styles(0, 0, Balance);
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Balance, Auto));
assert_ne!(t.layout_key(), key);
let back: TextData = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap();
assert_eq!(back, t);
t.set_para_wrap_styles(0, 0, Auto);
assert!(t.paragraphs.is_empty());
t.set_para_wrap_styles(0, 0, Balance);
t.set_wrap_style(Pretty);
assert!(t.paragraphs.is_empty());
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Pretty, Pretty));
}
#[test]
fn wrap_style_loads_from_old_paragraph_styles_and_texts() {
use WrapStyle::Auto;
// A paragraph style saved before it carried a wrap style.
let old = r#"{"paragraph_spacing":4.0,"list":"bulleted"}"#;
let ps: ParagraphStyle = serde_json::from_str(old).unwrap();
assert_eq!((ps.wrap_style, ps.paragraph_spacing, ps.list), (Auto, 4.0, ListStyle::Bulleted));
assert!(!serde_json::to_string(&ParagraphStyle::default()).unwrap().contains("wrap_style"), "Auto is not saved");
// A text saved before its paragraphs carried a wrap style.
let mut t = TextData::new("a\nb");
t.set_para_lists(0, 0, |p| p.list = ListStyle::Bulleted);
let mut v = serde_json::to_value(&t).unwrap();
for p in v["paragraphs"].as_array_mut().unwrap() {
assert!(p.as_object_mut().unwrap().remove("wrap").is_none(), "Auto is not saved");
}
v.as_object_mut().unwrap().remove("wrap_style");
let old: TextData = serde_json::from_value(v).unwrap();
assert_eq!(old.wrap_style, Auto);
assert_eq!(old.paragraphs.len(), 2);
assert!(old.paragraphs.iter().all(|p| p.wrap.is_none()));
assert_eq!((old.para_wrap_style(0), old.para_wrap_style(1)), (Auto, Auto));
assert_eq!(old, t);
}
#[test]
fn set_wrap_style_clears_an_override_on_the_empty_last_paragraph() {
use WrapStyle::{Auto, Balance, Pretty};
let mut t = TextData::new("ab\n");
assert_eq!(t.paragraph_count(), 2);
// The empty paragraph after the final newline: a caret there reaches it.
t.set_para_wrap_styles(3, 3, Balance);
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Auto, Balance));
t.set_wrap_style(Pretty);
assert!(t.paragraphs.is_empty(), "{:?}", t.paragraphs);
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Pretty, Pretty));
// Back to the layer's own style: no override is left on either paragraph.
t.set_para_wrap_styles(3, 3, Auto);
assert_eq!(t.para_wrap_style(1), Auto);
t.set_wrap_style(Auto);
assert!(t.paragraphs.is_empty());
assert_eq!(t.common_wrap_style(0, t.char_count()), Some(Auto));
}
#[test]
fn clear_list_starts_touches_only_starts() {
use ListStyle::{None as P, Numbered as N};
@@ -1190,4 +1299,16 @@ mod whole_text_paragraph_style_tests {
plain.take_paragraph_style(&ps);
assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len()));
}
#[test]
fn a_style_made_from_the_whole_layer_counts_a_trailing_empty_paragraphs_wrap_style() {
// The first two paragraphs are Balance, the empty one after the final newline keeps the
// layer's Auto: they differ, so the style takes the layer's wrap style, not Balance.
let mut t = TextData::new("a\nb\n");
t.set_para_wrap_styles(0, 3, WrapStyle::Balance);
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1), t.para_wrap_style(2)), (WrapStyle::Balance, WrapStyle::Balance, WrapStyle::Auto));
assert_eq!(t.paragraph_style().wrap_style, WrapStyle::Auto);
t.set_para_wrap_styles(4, 4, WrapStyle::Balance);
assert_eq!(t.paragraph_style().wrap_style, WrapStyle::Balance, "all three Balance");
}
}
+69 -8
View File
@@ -184,6 +184,13 @@ fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usi
}
}
/// Which segments are in a loop, and which segments join each pair of points: the lookups
/// [`VectorNetwork::closing_segment`] reads, built once per pass by [`VectorNetwork::loop_index`].
pub struct LoopIndex {
in_loop: Vec<bool>,
by_ends: std::collections::BTreeMap<(usize, usize), Vec<usize>>,
}
impl VectorNetwork {
/// A network from a polyline / polygon.
pub fn from_points(points: &[Point], closed: bool) -> Self {
@@ -471,16 +478,16 @@ impl VectorNetwork {
// The curves go between the two segments wherever a loop walks through the point, in the
// order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many
// rounded corners isn't quadratic.
let index = self.loop_index();
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let Some(&first_idx) = lp.first() else { continue };
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
(Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b,
(Some(first), _) => first.a,
_ => 0,
};
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()));
if lp.is_empty() {
continue;
}
let start_v = self.loop_start(lp).unwrap_or(0);
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()) + 1);
let mut cur = start_v;
for &x in lp.iter() {
let closing = self.closing_segment(lp, &index);
for &x in lp.iter().chain(closing.iter()) {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
cur = if s.a == cur { s.b } else { s.a };
@@ -497,6 +504,60 @@ impl VectorNetwork {
Some(out)
}
/// The point loop `lp` is walked from: the end of its first segment that its second does not
/// share.
fn loop_start(&self, lp: &[usize]) -> Option<usize> {
let first = self.segments.get(*lp.first()?)?;
match lp.get(1).and_then(|&i| self.segments.get(i)) {
Some(second) if first.a == second.a || first.a == second.b => Some(first.b),
_ => Some(first.a),
}
}
/// The lookups [`Self::closing_segment`] needs, built once for a pass over every loop.
pub fn loop_index(&self) -> LoopIndex {
let mut in_loop = vec![false; self.segments.len()];
for &x in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) {
if let Some(f) = in_loop.get_mut(x) {
*f = true;
}
}
let mut by_ends: std::collections::BTreeMap<(usize, usize), Vec<usize>> = std::collections::BTreeMap::new();
for (i, s) in self.segments.iter().enumerate() {
by_ends.entry((s.a.min(s.b), s.a.max(s.b))).or_default().push(i);
}
LoopIndex { in_loop, by_ends }
}
/// The segment that joins the two ends of an open loop `lp` when it is in no loop of its own
/// (and the only such segment): a corner cut off by a segment drawn before the corner point was
/// deleted leaves the loop open there, and the stroke follows that segment. Only a loop that
/// walks in order as one continuous chain has one. Rounding walks it as the loop's last
/// segment, so the points at the loop's ends are cut by arcs as the stroke's are, and deleting
/// points or segments puts it into the loop (#1951). `index` is this network's [`Self::loop_index`],
/// shared by every loop of one pass.
pub fn closing_segment(&self, lp: &[usize], index: &LoopIndex) -> Option<usize> {
let start_v = self.loop_start(lp)?;
let mut cur = start_v;
for &x in lp {
let s = self.segments.get(x)?;
cur = if s.a == cur {
s.b
} else if s.b == cur {
s.a
} else {
return None;
};
}
if cur == start_v {
return None;
}
let mut joining =
index.by_ends.get(&(cur.min(start_v), cur.max(start_v)))?.iter().copied().filter(|&i| !index.in_loop.get(i).copied().unwrap_or(false));
let first = joining.next()?;
joining.next().is_none().then_some(first)
}
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
/// run on smoothly or fold back. With `smoothing`, a point between two straight segments that
/// are both `on_loop` gets a smoothed corner.
+21
View File
@@ -103,3 +103,24 @@ fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_s
let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark"));
}
#[test]
fn the_invert_zoom_preference_toggles_and_is_saved() {
let mut s = Session::new();
assert!(!s.prefs.invert_zoom, "off by default");
assert_eq!(s.execute("pref.invertZoom", &json!({})).unwrap()["on"], true);
assert!(s.prefs.invert_zoom);
let saved = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(saved["invert_zoom"], true);
let loaded: crate::Prefs = serde_json::from_value(saved).unwrap();
assert!(loaded.invert_zoom);
assert_eq!(s.execute("pref.invertZoom", &json!({"on": false})).unwrap()["on"], false);
assert!(!s.prefs.invert_zoom);
let saved = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(saved["invert_zoom"], false);
let loaded: crate::Prefs = serde_json::from_value(saved).unwrap();
assert!(!loaded.invert_zoom, "off persists after being on");
// Preferences saved before the setting existed read it as off.
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.invert_zoom);
}
+14 -3
View File
@@ -1,7 +1,8 @@
//! Variables bound inside shared styles. A style's `bound` map names fields of its own value in
//! the keys a layer's bindings use (`fills/<i>/color`, `fills/<i>/stops/<j>/color`,
//! `effects/<i>/<field>`, `layoutGrids/<i>/<field>`, a text style's `fontSize`, `lineHeight`,
//! `letterSpacing`, `fontFamily`, `fontStyle`). Each layer using the style sees those fields
//! `letterSpacing`, `fontWeight`, `fontFamily`, `fontStyle`; a number weight and a style name set the
//! same property, the weight wins). Each layer using the style sees those fields
//! resolved in its own modes; the style itself stores them resolved in the default modes.
use std::borrow::Cow;
@@ -32,7 +33,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
(StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => effect_field_type(&effects.get(i)?.kind, field),
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number),
(StyleKind::Text { .. }, None) => match key {
"fontSize" | "lineHeight" | "letterSpacing" => Some(Number),
"fontSize" | "lineHeight" | "letterSpacing" | "fontWeight" => Some(Number),
"fontFamily" | "fontStyle" => Some(String),
_ => None,
},
@@ -70,6 +71,7 @@ pub fn field_scopes(kind: &StyleKind, key: &str) -> Option<&'static [&'static st
"letterSpacing" => &["LETTER_SPACING"],
"fontFamily" => &["FONT_FAMILY"],
"fontStyle" => &["FONT_STYLE"],
"fontWeight" => &["FONT_WEIGHT"],
_ => return None,
},
_ => return None,
@@ -111,6 +113,8 @@ pub fn from_layer(n: &Node, field: &str, kind: &StyleKind) -> BTreeMap<String, V
/// `st`'s value with its bound fields resolved in `modes`.
fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind {
let mut kind = st.kind.clone();
// `bound` is a BTreeMap, so `fontStyle` applies before `fontWeight` and a weight wins when a file carries both
// (pinned by `font_weight_wins_over_font_style_when_a_file_carries_both`).
for (key, var) in &st.bound {
let Some(val) = vars.resolve(var, modes) else { continue };
match (&mut kind, list_binding(key), &val) {
@@ -134,7 +138,14 @@ fn set_text_field(style: &mut TextStyle, key: &str, val: &VariableValue) {
("lineHeight", VariableValue::Number(x)) if x.is_finite() => style.line_height = LineHeight::Pixels(x.clamp(0.0, 100_000.0)),
("letterSpacing", VariableValue::Number(x)) if x.is_finite() => style.letter_spacing = LetterSpacing::Pixels(x.clamp(-10_000.0, 10_000.0)),
("fontFamily", VariableValue::String(f)) if !f.trim().is_empty() => style.font_family = f.clone(),
("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.font_style = f.clone(),
("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.set_font_style(f.clone()),
("fontWeight", VariableValue::Number(x)) if x.is_finite() => {
// The face nearest the number (a static family) and the `wght` axis (a variable font).
let w = x.clamp(1.0, 1000.0);
let italic = style.italic();
style.set_font_style(crate::cmd::props::styled_name(crate::cmd::props::weight_name(w as u16), italic));
style.variations.insert("wght".to_string(), w);
}
_ => {}
}
}
+8 -1
View File
@@ -1,6 +1,6 @@
//! What a file's number variables measure, so exported tokens carry the right unit: a radius,
//! font size, spacing or other length is in pixels, opacity and font weight are bare numbers.
//! A number's role comes from the layer fields bound to it, else its scopes, and an alias target
//! A number's role comes from the layer fields bound to it (and a text style's weight), else its scopes, and an alias target
//! with no role of its own takes the role of the variables that read it (the target of an opacity
//! alias stays unitless). A number nothing tells about is a length: design tokens mostly are.
@@ -60,6 +60,13 @@ impl Roles {
}
}
}
for st in &doc.styles {
for (field, id) in &st.bound {
if field == "fontWeight" {
roles.entry(id.clone()).or_insert(NumberRole::Unitless);
}
}
}
for v in vars.iter().filter(|v| v.kind == VariableType::Number) {
if let Some(r) = v.scopes.as_ref().and_then(|s| s.iter().find_map(|s| role_of_scope(s))) {
roles.entry(v.id.clone()).or_insert(r);
+9 -2
View File
@@ -57,6 +57,7 @@ pub const NODE_KEYS: &[&str] = &[
"lineHeight",
"lineIndentations",
"lineTypes",
"lineWrapStyles",
"listSpacing",
"listStartOffsets",
"listStyle",
@@ -466,8 +467,14 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
if t.leading_trim != figcraft_doc::LeadingTrim::None {
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
}
if t.wrap_style != figcraft_doc::WrapStyle::Auto {
insert_prop(o, options, "wrapStyle", || json!(t.wrap_style));
// The paragraphs' effective wrap styles: one for the layer when they agree, else each one's.
let wraps: Vec<figcraft_doc::WrapStyle> = (0..t.paragraph_count()).map(|p| t.para_wrap_style(p)).collect();
if let Some(first) = wraps.first().copied().filter(|w| *w != figcraft_doc::WrapStyle::Auto)
&& wraps.iter().all(|w| *w == first)
{
insert_prop(o, options, "wrapStyle", || json!(first));
} else if wraps.iter().any(|w| *w != figcraft_doc::WrapStyle::Auto) {
insert_prop(o, options, "lineWrapStyles", || wraps.iter().map(|w| json!(w)).collect());
}
if !t.runs.is_empty() {
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
+184
View File
@@ -258,3 +258,187 @@ fn template_and_table_commands_survive_junk() {
}
}
}
// ---------------------------------------------------------------------------------------------
// Resizing a table (#2172): a height change goes into its rows, never into blank space.
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
}
/// A selected 3 x 3 table at 0,0 (480 x 122: a line under all but the last row adds 1 to each), with `texts` in the first row.
fn table_3x3(texts: [&str; 3]) -> (Session, NodeId) {
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [texts, ["", "", ""], ["", "", ""]]})).unwrap();
(s, id(&r, "id"))
}
fn rows_of(s: &Session, t: NodeId) -> Vec<NodeId> {
let doc = s.doc().unwrap();
doc.node(t).unwrap().children.iter().copied().filter(|c| !node(s, *c).child_layout.absolute).collect()
}
fn row_heights(s: &Session, t: NodeId) -> Vec<f64> {
rows_of(s, t).into_iter().map(|r| node(s, r).height).collect()
}
fn column_widths(s: &Session, t: NodeId) -> Vec<f64> {
let r = rows_of(s, t)[0];
node(s, r).children.iter().map(|c| node(s, *c).width).collect()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 0.6
}
fn all_near(got: &[f64], want: &[f64]) -> bool {
got.len() == want.len() && got.iter().zip(want).all(|(a, b)| near(*a, *b))
}
fn is_hug(s: &Session, t: NodeId) -> bool {
node(s, t).child_layout.sizing_v == figcraft_doc::Sizing::Hug
}
#[test]
fn a_bottom_drag_grows_only_the_last_row() {
// The programmatic route.
let (mut s, t) = table_3x3(["Alpha", "", ""]);
s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t));
let n = node(&s, t);
assert!(near(n.width, 480.0) && near(n.height, 272.0), "{}x{}", n.width, n.height);
assert!(is_hug(&s, t), "still hugging its rows");
assert!(all_near(&column_widths(&s, t), &[160.0; 3]));
assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t));
// The pointer route: the bottom edge dragged down by 150.
let (mut s, t) = table_3x3(["Alpha", "", ""]);
drag(&mut s, (240.0, 122.0), (240.0, 272.0));
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t));
assert!(near(node(&s, t).height, 272.0) && near(node(&s, t).width, 480.0));
assert!(is_hug(&s, t));
assert!(all_near(&column_widths(&s, t), &[160.0; 3]));
assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t));
// The text keeps its size.
let row0 = rows_of(&s, t)[0];
let cell = node(&s, node(&s, row0).children[0]);
let NodeKind::Text(td) = &node(&s, cell.children[0]).kind else { panic!("text") };
assert_eq!(td.style.font_size, 14.0);
}
#[test]
fn a_corner_drag_shares_the_change_over_every_row_and_column() {
let (mut s, t) = table_3x3(["", "", ""]);
drag(&mut s, (480.0, 122.0), (570.0, 182.0));
let n = node(&s, t);
assert!(near(n.width, 570.0) && near(n.height, 182.0), "{}x{}", n.width, n.height);
assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t));
assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t));
let xs: Vec<f64> = lines(&s, t).iter().map(|l| l.0).collect();
assert!(all_near(&xs, &[189.5, 379.5]), "{xs:?}");
assert!(is_hug(&s, t));
// The same through `transform.resize`.
let (mut s, t) = table_3x3(["", "", ""]);
s.execute("transform.resize", &json!({"ids": [t], "width": 570, "height": 182})).unwrap();
assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t));
assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t));
}
#[test]
fn a_side_drag_changes_no_row() {
let (mut s, t) = table_3x3(["", "", ""]);
s.execute("node.set", &json!({"id": t, "props": {"width": 680}})).unwrap();
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0));
assert!(is_hug(&s, t));
// A pointer drag of the right edge.
let (mut s, t) = table_3x3(["", "", ""]);
drag(&mut s, (480.0, 60.0), (680.0, 60.0));
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0));
assert!(is_hug(&s, t));
}
#[test]
fn a_table_never_shrinks_below_its_cells() {
let (mut s, t) = table_3x3(["Alpha", "Bravo longer text", "Charlie"]);
drag(&mut s, (480.0, 122.0), (50.0, 32.0));
let n = node(&s, t);
assert!(near(n.width, 120.0), "width {}", n.width);
assert!(all_near(&column_widths(&s, t), &[40.0; 3]), "{:?}", column_widths(&s, t));
let rows = row_heights(&s, t);
assert!(rows[0] > 40.0, "row 1 holds its wrapped text: {rows:?}");
assert!(near(rows[1], 41.0) && near(rows[2], 40.0), "{rows:?}");
assert!(near(n.height, rows.iter().sum::<f64>()), "{} vs {rows:?}", n.height);
assert!(is_hug(&s, t));
}
#[test]
fn a_resized_table_still_grows_with_its_text_and_undoes_in_one_step() {
// Text measures only once system fonts are scanned (without craft-fonts), so scan them here
// without depending on another test having scanned them first.
figcraft_text::scan_system_fonts();
let (mut s, t) = table_3x3(["", "", ""]);
drag(&mut s, (240.0, 122.0), (240.0, 272.0));
assert!(near(node(&s, t).height, 272.0));
// Typing in a last-row cell: the table grows once the text needs more than 190.
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 200.0, 1.0)).unwrap();
assert!(s.ds().unwrap().text_edit.is_some(), "typing in the last row's cell");
let long = (0..12).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
crate::textedit::insert(&mut s, &long).unwrap();
crate::textedit::end(&mut s).unwrap();
assert!(node(&s, t).height > 272.0, "grew with its text: {}", node(&s, t).height);
assert!(is_hug(&s, t));
// Undo: the typing, then the resize, to the table as made.
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(node(&s, t).height, 272.0), "{}", node(&s, t).height);
s.execute("edit.undo", &json!({})).unwrap();
let n = node(&s, t);
assert!(near(n.width, 480.0) && near(n.height, 122.0), "{}x{}", n.width, n.height);
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
}
#[test]
fn table_resizes_sync_in_live_sessions() {
let s = board();
let mut room = Room::new(s.doc().unwrap().clone(), 2, 12);
let t = id(&room.s(0).execute("board.table", &json!({"x": 0, "y": 0})).unwrap(), "id");
room.settle();
room.check();
room.s(0).execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
room.settle();
room.check();
let doc = room.server.document();
let heights: Vec<f64> =
doc.node(t).unwrap().children.iter().map(|c| doc.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect();
assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}");
assert!(near(doc.node(t).unwrap().height, 272.0));
assert_eq!(doc.node(t).unwrap().child_layout.sizing_v, figcraft_doc::Sizing::Hug);
}
#[test]
fn resizing_a_table_survives_junk_and_saves_the_rows() {
for h in [json!(1e308), json!(-5), json!("NaN"), json!(null), json!(0)] {
let (mut s, t) = table_3x3(["", "", ""]);
if let Err(EngineError::Internal { cmd, msg }) = s.execute("node.set", &json!({"id": t, "props": {"height": h}})) {
panic!("{cmd} panicked on {h}: {msg}");
}
for p in [json!({"ids": [t], "width": h, "height": h}), json!({"ids": [t], "height": h}), json!({"ids": [t], "width": h})] {
if let Err(EngineError::Internal { cmd, msg }) = s.execute("transform.resize", &p) {
panic!("{cmd} panicked on {p}: {msg}");
}
}
let n = node(&s, t);
assert!(n.width.is_finite() && n.height.is_finite(), "{h}: {}x{}", n.width, n.height);
}
let (mut s, t) = table_3x3(["", "", ""]);
s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
let doc = s.doc().unwrap();
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
let heights: Vec<f64> =
back.node(t).unwrap().children.iter().map(|c| back.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect();
assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}");
}
@@ -14,6 +14,7 @@ pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTree
if !is_board(doc) {
return;
}
crate::board_table::settle_resize(doc, before, touched);
for &id in touched {
let Some(n) = doc.node(id) else { continue };
if let Some(BoardItem::Shape { shape }) = n.board
+66
View File
@@ -3,6 +3,16 @@
//! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws
//! a line under itself and the column lines are thin rectangles over the table that stretch with
//! it (they are rebuilt after every edit, like the first row's header tint).
//!
//! Resizing (measured in FigJam's web app, 2026-11-09, #2172): a table never scales its text or
//! its rows in proportion. A height change goes into the rows ([`settle_resize`]): the bottom or
//! top edge grows the last row, a corner drag shares the change equally over every row (every
//! cell fills its row, so the width is already shared equally), and the table keeps hugging its
//! rows. A cell is at least [`MIN_CELL`] (40; FigJam's floor is about 52) wide and tall, and a row
//! is never shorter than its wrapped text. There are no per-column widths yet: a side drag of
//! the right edge splits the change equally where FigJam grows only the last column.
use std::collections::BTreeSet;
use figcraft_color::{Color, Paint};
use figcraft_doc::{
@@ -22,6 +32,10 @@ pub const MAX_COLUMNS: usize = 20;
/// A new column's width, and a cell's height before its text is measured.
const CELL_W: f64 = 160.0;
const CELL_H: f64 = 40.0;
/// The least a cell is wide or tall (FigJam's is about 52).
pub(crate) const MIN_CELL: f64 = CELL_H;
/// The most a resize may ask one row to be tall.
const MAX_ROW_H: f64 = 100_000.0;
/// Room around a cell's text: [top, right, bottom, left].
const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0];
const TEXT_SIZE: f64 = 14.0;
@@ -125,6 +139,7 @@ fn make_cell(doc: &mut Document, row: NodeId, index: Option<usize>, width: f64,
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.sizing_v = Sizing::Hug;
n.child_layout.min_h = Some(CELL_H);
n.child_layout.min_w = Some(MIN_CELL);
})?;
add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?;
Ok(cell)
@@ -259,6 +274,57 @@ fn table_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// A table resized by its height or a corner (the resize leaves it at a fixed height): the change
/// goes into its rows, as in FigJam, and the table hugs them again. A bottom or top edge (or a
/// height set by command) grows or shrinks the last row; a drag that changed both axes shares the
/// change equally over every row. A row never gets shorter than [`MIN_CELL`] or than its text (the
/// table hugs it), and the table is never narrower than its columns' minimum. A table already
/// hugging (a peer's synced edit, a second pass) is left alone, so the change is made once.
pub(crate) fn settle_resize(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
let mut tables = BTreeSet::new();
for &id in touched {
if let Some(t) = table_of(doc, id) {
tables.insert(t);
}
}
for table in tables {
let (Some(now), Some(was)) = (doc.node(table), before.node(table)) else { continue };
if now.child_layout.sizing_v != Sizing::Fixed {
continue;
}
let (w, h, bw, bh) = (now.width, now.height, was.width, was.height);
let (rows, _) = parts(doc, table);
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
let dh = h - bh;
if dh.is_finite() && dh.abs() > 1e-6 && !rows.is_empty() {
let both = w.is_finite() && bw.is_finite() && (w - bw).abs() > 1e-6;
let targets: Vec<NodeId> = if both { rows.clone() } else { rows.last().copied().into_iter().collect() };
let share = dh / targets.len() as f64;
for row in targets {
let base = row_content(before, row).or_else(|| row_content(doc, row)).unwrap_or(CELL_H);
let min_h = (base + share).clamp(MIN_CELL, MAX_ROW_H);
for cell in cells(doc, row) {
if let Some(c) = doc.node_mut(cell) {
c.child_layout.min_h = Some(min_h);
}
}
}
}
if let Some(t) = doc.node_mut(table) {
let least = MIN_CELL * columns as f64;
if t.width.is_nan() || t.width < least {
t.width = least;
}
t.child_layout.sizing_v = Sizing::Hug;
}
}
}
/// How tall a row's cells are: the row's height less the line under it.
fn row_content(doc: &Document, row: NodeId) -> Option<f64> {
cells(doc, row).into_iter().filter_map(|c| doc.node(c).map(|n| n.height)).reduce(f64::max)
}
/// The table `id` is or is in.
fn table_of(doc: &Document, id: NodeId) -> Option<NodeId> {
std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table)))
+134
View File
@@ -0,0 +1,134 @@
//! A boolean group's corner radius through the engine: it rounds the result (and the SVG export
//! of it), children keep their own radius, Flatten bakes the arcs and clicks follow the result.
use serde_json::json;
use crate::Session;
use crate::booleans_tests::{area, click, ex};
use crate::params::id_of;
use figcraft_doc::{NodeId, NodeKind};
use figcraft_geom::{Point, Shape};
const L_AREA: f64 = 34656.6;
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h}))["id"]).unwrap()
}
fn snapping_off(s: &mut Session) {
ex(s, "select.none", json!({}));
let ds = s.ds_mut().unwrap();
ds.view.snap_objects = false;
ds.view.snap_pixel = false;
}
fn set_radius(s: &mut Session, id: NodeId, r: f64) {
ex(s, "node.set", json!({"ids": [id], "props": {"cornerRadius": r}}));
}
/// The L of two rectangles, united, nothing selected, snapping off.
fn l_group(s: &mut Session) -> NodeId {
let (a, b) = (rect(s, 0.0, 0.0, 200.0, 100.0), rect(s, 150.0, 0.0, 100.0, 200.0));
let g = id_of(&ex(s, "bool.union", json!({"ids": [a, b]}))["id"]).unwrap();
snapping_off(s);
g
}
/// Whether the page point is inside the group's drawn result.
fn covers(s: &Session, g: NodeId, x: f64, y: f64) -> bool {
let doc = s.doc().unwrap();
let (path, _) = figcraft_render::node_outline(doc, doc.node(g).unwrap()).unwrap();
let p = doc.world_transform(g).inverse() * Point::new(x, y);
path.winding(p) % 2 != 0
}
#[test]
fn a_boolean_group_rounds_every_sharp_corner_of_its_result() {
let mut s = Session::new();
let g = l_group(&mut s);
assert!(covers(&s, g, 1.0, 1.0));
set_radius(&mut s, g, 20.0);
assert!(!covers(&s, g, 1.0, 1.0) && covers(&s, g, 148.0, 102.0));
assert!((area(&s, g) - L_AREA).abs() < 1.0, "{}", area(&s, g));
let doc = s.doc().unwrap();
let svg = figcraft_svg::export_svg(doc, &[g], &figcraft_svg::SvgOptions::default()).unwrap();
let d = svg.split("d=\"").nth(1).and_then(|r| r.split('"').next()).unwrap();
let exported = figcraft_geom::svg_path(d).unwrap();
assert!((exported.area().abs() - L_AREA).abs() < 1.0, "{}", exported.area());
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii, [0.0; 4], "one undo step");
assert!((area(&s, g) - 35000.0).abs() < 1.0);
}
/// The notch of the measurement: A (200 by 100 at the origin) minus a 100 square on its top edge,
/// whose own radius is `a_radius`, under a group radius of `radius`.
fn notch(s: &mut Session, a: NodeId, radius: f64) -> NodeId {
let b = rect(s, 50.0, -50.0, 100.0, 100.0);
let g = id_of(&ex(s, "bool.subtract", json!({"ids": [a, b]}))["id"]).unwrap();
snapping_off(s);
set_radius(s, g, radius);
g
}
/// The asserts of the measurement on a notched group at radius 20 under A's own 10.
fn assert_notch_at_20(s: &Session, g: NodeId) {
assert!(covers(s, g, 4.0, 4.0), "A's 10 was not enlarged to 20");
assert!(!covers(s, g, 48.0, 2.0), "the cut's top corner rounds");
assert!(covers(s, g, 52.0, 48.0), "the cut's inner corner fills in");
}
#[test]
fn children_keep_their_radius_and_a_subtraction_rounds_its_cut() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 200.0, 100.0);
set_radius(&mut s, a, 10.0);
let g = notch(&mut s, a, 20.0);
assert_notch_at_20(&s, g);
assert_eq!(s.doc().unwrap().node(a).unwrap().corners.radii, [10.0; 4]);
set_radius(&mut s, g, 500.0);
assert_eq!(s.doc().unwrap().node(g).unwrap().corners.radii[0], 500.0, "the field keeps the value");
assert!(!covers(&s, g, 40.0, 4.0), "25, not the 20 that half the 40-long edge would give");
assert!(covers(&s, g, 27.0, 1.0), "and not more than 25");
assert!(covers(&s, g, 100.0, 51.0) && !covers(&s, g, 100.0, 49.0), "the floor stays straight between 75 and 125");
assert!(covers(&s, g, 4.0, 4.0));
}
#[test]
fn a_nested_group_keeps_its_own_radius() {
let mut s = Session::new();
let (l, r) = (rect(&mut s, 0.0, 0.0, 120.0, 100.0), rect(&mut s, 80.0, 0.0, 120.0, 100.0));
let inner = id_of(&ex(&mut s, "bool.union", json!({"ids": [l, r]}))["id"]).unwrap();
set_radius(&mut s, inner, 10.0);
let g = notch(&mut s, inner, 20.0);
assert_notch_at_20(&s, g);
}
#[test]
fn flatten_bakes_the_arcs_and_the_vector_has_no_radius() {
let mut s = Session::new();
let g = l_group(&mut s);
set_radius(&mut s, g, 20.0);
let flat = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [g]}))["id"]).unwrap();
let doc = s.doc().unwrap();
let n = doc.node(flat).unwrap();
let NodeKind::Vector { network } = &n.kind else { panic!("a vector: {:?}", n.kind) };
assert_eq!(n.corners.radii, [0.0; 4]);
assert!(network.vertices.iter().all(|v| v.radius == 0.0));
let path = figcraft_doc::geometry::shape_path(n).unwrap();
assert!((path.area().abs() - L_AREA).abs() < 1.0, "{}", path.area());
assert!((n.width - 250.0).abs() < 1e-6 && (n.height - 200.0).abs() < 1e-6);
}
#[test]
fn clicks_follow_the_rounded_result() {
let mut s = Session::new();
let g = l_group(&mut s);
set_radius(&mut s, g, 20.0);
click(&mut s, 1.0, 1.0, false);
assert!(s.selection().is_empty(), "the cut-off corner reaches what is behind");
click(&mut s, 148.0, 102.0, false);
assert_eq!(s.selection(), vec![g], "the concave fillet belongs to the group");
ex(&mut s, "select.none", json!({}));
click(&mut s, 100.0, 50.0, false);
assert_eq!(s.selection(), vec![g]);
}
+4 -4
View File
@@ -11,21 +11,21 @@ use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::{BoolOp, NodeId, NodeKind};
use figcraft_geom::Shape;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
pub(crate) fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
pub(crate) fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
PointerEvent { cmd, ..PointerEvent::at(kind, x, y, 1.0) }
}
fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
pub(crate) fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap();
}
/// A layer's filled area (a boolean group's result; none of these have holes).
fn area(s: &Session, id: NodeId) -> f64 {
pub(crate) fn area(s: &Session, id: NodeId) -> f64 {
let doc = s.doc().unwrap();
figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap().0.area().abs()
}
+13 -2
View File
@@ -32,7 +32,7 @@ use handles::DRAG_SLOP_PX;
pub(crate) use handles::scale_factor;
pub use handles::{
PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point,
selection_box, selection_frame,
selection_box, selection_frame, shared_rotation_degrees,
};
pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box};
#[cfg(test)]
@@ -58,6 +58,7 @@ pub enum ToolId {
Text,
TextOnPath,
Comment,
Annotation,
Eyedropper,
ShapeBuilder,
// Board tools.
@@ -71,7 +72,7 @@ pub enum ToolId {
}
impl ToolId {
pub const ALL: [ToolId; 26] = [
pub const ALL: [ToolId; 27] = [
ToolId::Move,
ToolId::Scale,
ToolId::Hand,
@@ -89,6 +90,7 @@ impl ToolId {
ToolId::Text,
ToolId::TextOnPath,
ToolId::Comment,
ToolId::Annotation,
ToolId::Eyedropper,
ToolId::ShapeBuilder,
ToolId::Sticky,
@@ -118,6 +120,7 @@ impl ToolId {
ToolId::Text => "text",
ToolId::TextOnPath => "textOnPath",
ToolId::Comment => "comment",
ToolId::Annotation => "annotation",
ToolId::Eyedropper => "eyedropper",
ToolId::ShapeBuilder => "shapeBuilder",
ToolId::Sticky => "sticky",
@@ -129,6 +132,11 @@ impl ToolId {
ToolId::Stamp => "stamp",
}
}
/// Whether Developer Mode keeps this tool: it only inspects, so the tools that edit leave it.
/// The one list behind the engine's pointer routing and command allowlist and the toolbar.
pub fn works_in_dev_mode(self) -> bool {
matches!(self, ToolId::Move | ToolId::Hand | ToolId::Comment | ToolId::Annotation)
}
pub fn from_id(s: &str) -> Option<ToolId> {
ToolId::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s))
}
@@ -151,6 +159,7 @@ impl ToolId {
ToolId::Text => "Text",
ToolId::TextOnPath => "Text on a path",
ToolId::Comment => "Comment",
ToolId::Annotation => "Annotation",
ToolId::Eyedropper => "Pick color",
ToolId::ShapeBuilder => "Shape builder",
ToolId::Sticky => "Sticky note",
@@ -445,6 +454,8 @@ pub(crate) enum Gesture {
Rotate {
center: Point,
start_angle: f64,
/// The rotation (Design panel convention) the layers share when the drag began, else 0.
base: f64,
ids: Vec<NodeId>,
},
Draw {
+7 -2
View File
@@ -360,8 +360,13 @@ impl Session {
pub(super) fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let doc = ds.doc.clone();
let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 };
let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) };
// One screen pixel centred on the pointer (its top left half a pixel up and to the left).
let scale = ev.zoom.max(0.01);
let view = figcraft_render::View { origin: (ev.x - 0.5 / scale, ev.y - 0.5 / scale), scale, width: 1, height: 1 };
// As the canvas draws it: an image magnified past 2x shows its pixels, so the colour picked
// is the pixel under the pointer, not a blend with its neighbours (#2189).
let opts = figcraft_render::Options { magnified_image_pixels: true, ..Default::default() };
let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &opts) else { return Ok(()) };
let rgba = figcraft_render::to_rgba8(&pix);
let c = figcraft_color::Color::from_rgba8(
rgba.first().copied().unwrap_or(0),
+6 -4
View File
@@ -1,6 +1,6 @@
use super::handles::{
SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame,
shape_hint,
shape_hint, shared_rotation_degrees,
};
use super::{
Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point,
@@ -58,7 +58,8 @@ impl Session {
.moved_origin()
.unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::<f64>() / 4.0, b.iter().map(|c| c.y).sum::<f64>() / 4.0));
self.begin_interaction("Rotate")?;
self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids };
self.tool.gesture =
Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), base: shared_rotation_degrees(&doc, &ids), ids };
return Ok(());
}
let nodes: Vec<(NodeId, Affine, f64, f64)> =
@@ -362,10 +363,11 @@ impl Session {
}
return res;
}
Gesture::Rotate { center, start_angle, ids } => {
Gesture::Rotate { center, start_angle, base, ids } => {
let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees();
if ev.shift {
a = (a / 15.0).round() * 15.0;
// The panel rotation is base - a; Shift lands it on a multiple of 15 degrees.
a = base - ((base - a) / 15.0).round() * 15.0;
}
let (c, ids) = (*center, ids.clone());
self.preview(|doc, _| {
@@ -132,6 +132,14 @@ pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f
Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height()))
}
/// The rotation `ids` share on the page (degrees, counter-clockwise positive), else 0 (the handle
/// box is page-aligned then). Figma's rotation field shows this page rotation, also for a layer
/// inside a rotated frame or group, and Shift lands it on multiples of 15 degrees. Axis-aligned
/// layers give 0, which is the same as 90, 180 and 270 for stepping in 15 degrees.
pub fn shared_rotation_degrees(doc: &Document, ids: &[NodeId]) -> f64 {
shared_rotation(doc, ids).map_or(0.0, |a| -a.to_degrees())
}
/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the
/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction.
fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option<f64> {
+15 -1
View File
@@ -110,7 +110,7 @@ impl Session {
/// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects.
fn acting_tool(&self) -> ToolId {
match self.tool.tool {
t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move,
t if self.tool.dev_mode && !t.works_in_dev_mode() => ToolId::Move,
t => t,
}
}
@@ -176,6 +176,7 @@ impl Session {
self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y}));
Ok(())
}
ToolId::Annotation => self.annotation_down(ev),
ToolId::Eyedropper => self.eyedrop(ev),
t if t.on_boards() => self.board_down(ev),
t if t.draws_shape() => self.draw_down(ev),
@@ -206,6 +207,19 @@ impl Session {
Ok(true)
}
/// The Annotation tool's press: the layer under the pointer is selected and the UI is asked
/// for its note (the engine writes nothing; the note is typed in the Inspect tab). A press on
/// empty canvas does nothing.
fn annotation_down(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let (doc, page) = (ds.doc.clone(), ds.page);
// The same reach as the Move tool's pick (`ev.tol() * 2.0`); never the page itself.
let Some(id) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0).filter(|i| doc.node(*i).is_some_and(|n| !n.is_page())) else { return Ok(()) };
self.set_selection(vec![id]);
self.request_ui("annotation.new", json!({"id": id}));
Ok(())
}
pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> {
let tool = self.acting_tool();
let ds = self.ds()?;
+101 -13
View File
@@ -10,6 +10,8 @@
pub mod ops;
pub(crate) mod point_snap;
mod pointer;
#[cfg(test)]
mod position_tests;
pub mod regions;
#[cfg(test)]
mod regions_tests;
@@ -706,7 +708,61 @@ fn handle_list(p: &Value, key: &str) -> Option<Vec<(usize, bool)>> {
}
}
/// Selection bounds' top-left in the parent's coordinates (what the X/Y fields show).
/// One axis of `vector.edit.position`: left alone, set, or relative to each point's own value.
#[derive(Clone, Copy)]
enum AxisEdit {
Keep,
Set(f64),
Add(f64),
Mul(f64),
Div(f64),
}
impl AxisEdit {
fn apply(self, cur: f64) -> Result<f64> {
let v = match self {
AxisEdit::Keep => cur,
AxisEdit::Set(v) => v,
AxisEdit::Add(n) => cur + n,
AxisEdit::Mul(n) => cur * n,
AxisEdit::Div(n) if n != 0.0 => cur / n,
AxisEdit::Div(_) => return Err(bad("vector.edit.position", "cannot divide by zero")),
};
if v.is_finite() { Ok(v) } else { Err(bad("vector.edit.position", "the result is not a number")) }
}
}
/// Parse `p[key]`: absent is `Keep`, a number is `Set`, `{add|mul|div: n}` is relative; anything else is refused.
fn axis_edit(p: &Value, key: &str) -> Result<AxisEdit> {
let junk = || bad("vector.edit.position", format!("`{key}` must be a number or {{add|mul|div: number}}"));
match get(p, key) {
None => Ok(AxisEdit::Keep),
Some(Value::Object(o)) => {
let (Some((k, v)), 1) = (o.iter().next(), o.len()) else { return Err(junk()) };
let n = match v {
Value::Number(n) => n.as_f64().filter(|n| n.is_finite()),
_ => None,
}
.ok_or_else(junk)?;
match k.as_str() {
"add" => Ok(AxisEdit::Add(n)),
"mul" => Ok(AxisEdit::Mul(n)),
"div" if n != 0.0 => Ok(AxisEdit::Div(n)),
_ => Err(junk()),
}
}
Some(v) => f64_of(v).filter(|_| !v.is_boolean()).map(AxisEdit::Set).ok_or_else(junk),
}
}
/// The selected points in the parent's coordinates (what the X/Y fields show).
pub fn selected_parent_points(doc: &Document, ve: &VectorEdit) -> Vec<Point> {
let Some(net) = network(doc, ve.id) else { return Vec::new() };
let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default();
selected_points(net, ve).into_iter().filter_map(|v| net.vertices.get(v)).map(|v| t * v.point()).collect()
}
/// Selection bounds' top-left in the parent's coordinates (the `x` and `y` of `vector.edit.bounds`).
pub fn selection_position(doc: &Document, ve: &VectorEdit) -> Option<Point> {
let net = network(doc, ve.id)?;
let t = doc.node(ve.id)?.transform;
@@ -935,18 +991,50 @@ pub fn specs() -> Vec<CommandSpec> {
edit_sets(s, sets, |net, v, w| ops::move_vertices(net, v, local_offset(w, d)))?;
Ok(json!({"ok": true}))
}),
spec!("vector.edit.position", "Point position", "{x?, y?} (the selected points' top-left, in the parent's coordinates)", true, |s, p| {
let ve = editing(s)?;
let doc = s.doc()?;
let cur = selection_position(doc, &ve).ok_or_else(|| bad("vector.edit.position", "select points"))?;
let target = Point::new(num_or(p, "x", cur.x), num_or(p, "y", cur.y));
let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default();
let inv = t.inverse();
let d = inv * target - inv * cur;
let v = network(doc, ve.id).map(|n| selected_points(n, &ve)).unwrap_or_default();
edit(s.doc_mut()?, ve.id, |net| ops::move_vertices(net, &v, d))?;
Ok(json!({"ok": true}))
}),
spec!(
"vector.edit.position",
"Point position",
"{x?, y?}: each a number (every selected point's own coordinate becomes it) or {add: n}, {mul: n} or {div: n} (applied to each selected point's own coordinate); in the parent's coordinates",
true,
|s, p| {
let ve = editing(s)?;
let doc = s.doc()?;
let (ax, ay) = (axis_edit(p, "x")?, axis_edit(p, "y")?);
let t = doc.node(ve.id).map(|n| n.transform).unwrap_or_default();
let inv = t.inverse();
let net = network(doc, ve.id).ok_or_else(|| bad("vector.edit.position", "select points"))?;
let mut moves: Vec<(usize, Vec2)> = Vec::new();
for i in selected_points(net, &ve) {
let Some(v) = net.vertices.get(i) else { continue };
let cur = t * v.point();
let target = Point::new(ax.apply(cur.x)?, ay.apply(cur.y)?);
let d = inv * target - v.point();
if !(d.x.is_finite() && d.y.is_finite()) {
return Err(bad("vector.edit.position", "the result is not a number"));
}
moves.push((i, d));
}
if moves.is_empty() {
return Err(bad("vector.edit.position", "select points"));
}
// Points that all share one offset move as one, so moving every point of a layer
// by one offset still carries the regions' paint frames, as a drag of all of them
// does. Otherwise no one move covers every point, so each point moves alone.
let d0 = moves[0].1;
let uniform = moves.iter().all(|(_, d)| (*d - d0).hypot() < 1e-9);
edit(s.doc_mut()?, ve.id, |net| {
if uniform {
let all: Vec<usize> = moves.iter().map(|(i, _)| *i).collect();
ops::move_vertices(net, &all, d0);
} else {
for (i, d) in &moves {
ops::move_vertices(net, &[*i], *d);
}
}
})?;
Ok(json!({"ok": true}))
}
),
spec!("vector.edit.setHandle", "Set handle", "{segment, start (bool), x, y (local), independent?}", true, |s, p| {
let ve = editing(s)?;
let seg = index("vector.edit.setHandle", p, "segment")?;
+209
View File
@@ -312,6 +312,7 @@ fn remove_vertex_index(net: &mut VectorNetwork, vi: usize) {
/// Remove segments (fixing region loops); points left without any segment go too.
pub fn remove_segments(net: &mut VectorNetwork, segs: &[usize]) {
let before = net.segments.len();
let mut touched: Vec<usize> = segs.iter().filter_map(|s| net.segments.get(*s)).flat_map(|s| [s.a, s.b]).collect();
remove_segments_only(net, segs);
touched.sort_unstable();
@@ -322,6 +323,9 @@ pub fn remove_segments(net: &mut VectorNetwork, segs: &[usize]) {
}
}
reopen_loops(net);
if net.segments.len() != before {
close_bridged_loops(net);
}
}
fn remove_segments_only(net: &mut VectorNetwork, segs: &[usize]) {
@@ -349,6 +353,7 @@ fn remove_segments_only(net: &mut VectorNetwork, segs: &[usize]) {
/// Delete vertices, joining their neighbours where a vertex had exactly two segments (`heal`).
pub fn delete_vertices(net: &mut VectorNetwork, idx: &[usize], heal: bool) {
let before = net.vertices.len();
let mut idx: Vec<usize> = idx.to_vec();
idx.sort_unstable();
idx.dedup();
@@ -408,6 +413,9 @@ pub fn delete_vertices(net: &mut VectorNetwork, idx: &[usize], heal: bool) {
}
net.sanitize();
reopen_loops(net);
if net.vertices.len() != before {
close_bridged_loops(net);
}
}
/// Handles for the curve that heals over deleted point `vi` between `(vertex, handle)` ends `o1`
@@ -470,6 +478,24 @@ fn reopen_loops(net: &mut VectorNetwork) {
}
}
/// After points or segments were removed: an open loop whose two ends a segment in no loop already
/// joins (a bridge drawn between two points before what lay between them was deleted) closes
/// through that segment, at the loop's end so its winding is kept, as `connect` closes it (#1951).
/// Every loop takes what [`VectorNetwork::closing_segment`] gives it against one snapshot of the
/// network, which is what rounding walks: two fills that shared the removed border share the
/// bridge, as they shared the border. Only a removal can open a loop at two points a segment
/// already joins (a cut's ends are new points), so only `delete_vertices` and `remove_segments`
/// run this, and only when they removed something.
fn close_bridged_loops(net: &mut VectorNetwork) {
let index = net.loop_index();
let closings: Vec<Option<usize>> = net.regions.iter().flat_map(|r| r.loops.iter()).map(|lp| net.closing_segment(lp, &index)).collect();
for (lp, closing) in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()).zip(closings) {
if let Some(closing) = closing {
lp.push(closing);
}
}
}
/// Cut a segment at `t` into two open ends at the same spot. Returns the two end vertices.
pub fn cut_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<(usize, usize)> {
let mid = split_segment(net, seg, t)?;
@@ -1107,6 +1133,24 @@ mod tests {
assert!(d.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
}
/// A curved bridge between two points of a ring, then the point between them deleted (Delete,
/// no healing): the region's loop closes through the bridge, so the fill follows its curve and
/// is as long as the stroke (#1951).
#[test]
fn deleting_a_point_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::ParamCurveArclen;
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap();
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "the ring kept");
delete_vertices(&mut n, &[2], false);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5), "the loop holds the bridge: {:?}", n.regions);
let length = |p: &BezPath| p.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let (fill, stroke) = (length(&n.fill_path()), length(&n.stroke_path()));
assert!((fill - stroke).abs() < 1e-6, "fill {fill} and stroke {stroke} follow the same outline");
}
#[test]
fn lasso_polygon() {
let pts = [Point::new(5.0, 5.0), Point::new(50.0, 50.0), Point::new(15.0, 5.0)];
@@ -1175,4 +1219,169 @@ mod tests {
assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}");
assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}");
}
/// An open chain of four segments over five points whose two ends a fifth segment (in no
/// loop) joins, as a bridge drawn before the point between them was deleted.
fn bridged_open_chain() -> VectorNetwork {
let corners = [
Point::new(0.0, 0.0),
Point::new(525.0, 525.0),
Point::new(0.0, 525.0),
Point::new(333.0, 0.0),
Point::new(525.0, 183.0),
Point::new(900.0, 900.0),
];
let mut n = VectorNetwork::from_points(&corners, false);
n.segments = [(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)].iter().map(|&(a, b)| Segment::line(a, b)).collect();
n.regions.clear();
for v in &mut n.vertices {
v.radius = 125.0;
}
n
}
#[test]
fn regions_over_one_open_chain_all_close_through_the_joining_segment() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::ParamCurveArclen;
let mut n = bridged_open_chain();
for _ in 0..2 {
n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
delete_vertices(&mut n, &[5], false);
for (i, r) in n.regions.iter().enumerate() {
assert_eq!(r.loops[0], vec![3, 1, 2, 0, 4], "region {i} closes through the segment: {:?}", n.regions);
}
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
let stroke = length(&n.stroke_path());
let rounded = n.rounded().unwrap();
for i in 0..2 {
let fill = length(&rounded.region_path(i).unwrap());
assert!((fill - stroke).abs() < 1e-6, "region {i}: fill {fill} and stroke {stroke} trace the same outline");
}
}
/// Many independent open chains, each with its own closing segment, all close through it.
#[test]
fn many_open_chains_each_close_through_their_own_segment() {
let one = bridged_open_chain();
let (nv, ns) = (one.vertices.len(), one.segments.len());
let mut n = VectorNetwork::default();
for (i, dx) in (0..50usize).zip((0..50u32).map(|k| f64::from(k) * 1000.0)) {
n.vertices.extend(one.vertices.iter().map(|v| Vertex { x: v.x + dx, ..*v }));
n.segments.extend(one.segments.iter().map(|s| Segment { a: s.a + i * nv, b: s.b + i * nv, ..*s }));
let base = i * ns;
n.regions.push(Region {
loops: vec![vec![base + 3, base + 1, base + 2, base]],
winding: WindingRule::NonZero,
fills: None,
..Default::default()
});
}
close_bridged_loops(&mut n);
for (i, r) in n.regions.iter().enumerate() {
assert_eq!(r.loops[0].last(), Some(&(i * ns + 4)), "chain {i} closes through its own segment");
}
}
#[test]
fn a_segment_joining_two_different_chains_closes_both() {
let mut n = bridged_open_chain();
n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
delete_vertices(&mut n, &[5], false);
assert_eq!(n.regions[0].loops[0].len(), 5);
assert_eq!(n.regions[1].loops[0].len(), 5, "the same segment set is the same chain");
let mut m = bridged_open_chain();
m.segments.push(Segment::line(1, 2));
m.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
m.regions.push(Region { loops: vec![vec![3, 5, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let drawn = m.rounded().unwrap();
delete_vertices(&mut m, &[5], false);
assert_eq!(m.regions[0].loops[0], vec![3, 1, 2, 0, 4], "{:?}", m.regions);
assert_eq!(m.regions[1].loops[0], vec![3, 5, 2, 0, 4], "different chains share the segment too: {:?}", m.regions);
let after = m.rounded().unwrap();
for i in 0..2 {
assert_eq!(after.region_path(i).unwrap(), drawn.region_path(i).unwrap(), "region {i} is stored as rounding drew it");
}
}
/// Two fills that shared a border, a bridge drawn between the border's ends, then the border's
/// middle point deleted: both fills close through the bridge (#1951).
#[test]
fn two_fills_that_shared_a_deleted_border_both_close_through_the_bridge() {
use figcraft_geom::Shape;
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (50.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
n.vertices.push(Vertex::new(50.0, 50.0));
n.segments.push(Segment::line(1, 6));
n.segments.push(Segment::line(6, 4));
n.regions.clear();
for lp in [vec![0, 6, 7, 4, 5], vec![1, 2, 3, 7, 6]] {
n.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
connect(&mut n, 1, 4, (30.0, 30.0), (30.0, -30.0)).unwrap();
delete_vertices(&mut n, &[6], false);
assert_eq!(n.regions[0].loops[0], vec![4, 5, 0, 6]);
assert_eq!(n.regions[1].loops[0], vec![1, 2, 3, 6], "the second fill closes through the bridge too");
let area = |i: usize| n.region_path(i).unwrap().area().abs();
assert!((area(0) + area(1) - 10_000.0).abs() < 1e-6, "the fills meet along the bridge");
}
#[test]
fn removing_nothing_leaves_an_open_loop_alone() {
let mut n = bridged_open_chain();
n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let before = n.clone();
remove_segments(&mut n, &[]);
assert_eq!(n, before);
remove_segments(&mut n, &[99]);
assert_eq!(n, before);
delete_vertices(&mut n, &[], false);
assert_eq!(n, before);
delete_vertices(&mut n, &[99], true);
assert_eq!(n, before);
delete_vertices(&mut n, &[5], false);
assert_eq!(n.regions[0].loops[0], vec![3, 1, 2, 0, 4]);
}
/// The same bridge as the curved-bridge test, reached by removing segments.
#[test]
fn removing_segments_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() {
let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y));
let mut n = VectorNetwork::from_points(&pts, true);
connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap();
remove_segments(&mut n, &[1, 2]);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5));
assert_eq!(n.regions[0].loops[0], vec![1, 2, 3, 0, 4]);
}
#[test]
fn many_open_loops_with_no_closing_segment_round_as_before() {
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false);
n.segments.clear();
n.vertices.clear();
for k in 0..40 {
let x = f64::from(k) * 100.0;
let base = n.vertices.len();
for p in [(x, 0.0), (x + 50.0, 0.0), (x + 50.0, 50.0), (x, 50.0)] {
n.vertices.push(Vertex { radius: 8.0, ..Vertex::new(p.0, p.1) });
}
let first = n.segments.len();
// Three sides of a square, an open loop with nothing to close it.
for (a, b) in [(0, 1), (1, 2), (2, 3)] {
n.segments.push(Segment::line(base + a, base + b));
}
n.regions.push(Region {
loops: vec![vec![first, first + 1, first + 2]],
winding: WindingRule::NonZero,
fills: None,
..Default::default()
});
}
let r = n.rounded().unwrap();
assert_eq!(r.regions.len(), 40);
// Each loop gained only its two inner corners' arcs (two corners, one arc each).
assert!(r.regions.iter().all(|reg| reg.loops[0].len() == 5), "{:?}", r.regions[0]);
}
}
@@ -0,0 +1,171 @@
//! `vector.edit.position`: with several points, a number sets every selected point's own
//! coordinate, and `{add}`, `{mul}`, `{div}` apply to each point's own.
use serde_json::json;
use super::tests::{ex, net, rect};
use super::*;
use crate::Session;
/// Every point in the parent's coordinates (the layer re-fits its box after an edit, so its own
/// coordinates shift).
fn xs(s: &Session, id: NodeId) -> Vec<(f64, f64)> {
let t = s.doc().unwrap().node(id).unwrap().transform;
net(s, id).vertices.iter().map(|v| t * v.point()).map(|p| (p.x, p.y)).collect()
}
fn parent_xy(s: &Session) -> Vec<(f64, f64)> {
let ve = s.ds().unwrap().vector_edit.clone().unwrap();
selected_parent_points(s.doc().unwrap(), &ve).iter().map(|p| (p.x, p.y)).collect()
}
fn undo_steps(s: &Session) -> usize {
s.ds().unwrap().history.undo.len()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn a_number_sets_every_selected_points_own_coordinate() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
// The rectangle's points 0 and 1 differ in x; both end at -200 and keep their y.
assert!(!near(before[0].0, before[1].0));
ex(&mut s, "vector.edit.position", json!({"x": -200}));
let after = xs(&s, id);
assert!(near(after[0].0, -200.0) && near(after[1].0, -200.0), "{after:?}");
assert!(near(after[0].1, before[0].1) && near(after[1].1, before[1].1));
assert_eq!(after[2], before[2]);
// One undo step restores.
assert!(s.undo().unwrap());
assert_eq!(xs(&s, id), before);
}
#[test]
fn one_point_sets_its_own_coordinates() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [2]}));
ex(&mut s, "vector.edit.position", json!({"x": 7, "y": 9}));
let after = xs(&s, id);
assert!(near(after[2].0, 7.0) && near(after[2].1, 9.0));
assert_eq!(after[0], before[0]);
}
#[test]
fn relative_edits_apply_to_each_points_own_value() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}}));
let a = xs(&s, id);
assert!(near(a[0].0, before[0].0 + 10.0) && near(a[1].0, before[1].0 + 10.0), "{a:?}");
ex(&mut s, "vector.edit.position", json!({"y": {"mul": 2}}));
let b = xs(&s, id);
assert!(near(b[0].1, a[0].1 * 2.0) && near(b[1].1, a[1].1 * 2.0), "{b:?}");
ex(&mut s, "vector.edit.position", json!({"x": {"div": 2}}));
let c = xs(&s, id);
assert!(near(c[0].0, a[0].0 / 2.0) && near(c[1].0, a[1].0 / 2.0), "{c:?}");
}
#[test]
fn a_point_keeps_its_handles_relative_to_it() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.select", json!({"vertices": [1]}));
ex(&mut s, "vector.edit.smooth", json!({}));
let before = net(&s, id);
assert!(before.segments.iter().any(|g| g.ta != (0.0, 0.0) || g.tb != (0.0, 0.0)));
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
ex(&mut s, "vector.edit.position", json!({"x": {"add": 25}}));
let after = net(&s, id);
let tangents = |n: &VectorNetwork| n.segments.iter().map(|g| (g.ta, g.tb)).collect::<Vec<_>>();
assert_eq!(tangents(&before), tangents(&after));
}
#[test]
fn the_values_are_in_the_parents_coordinates() {
let mut s = Session::new();
let r = ex(&mut s, "shape.rectangle", json!({"x": 40, "y": 30, "width": 100, "height": 100}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
let before = parent_xy(&s);
assert!(near(before[0].0, 40.0), "{before:?}");
ex(&mut s, "vector.edit.position", json!({"x": 10}));
let after = parent_xy(&s);
assert!(near(after[0].0, 10.0) && near(after[1].0, 10.0), "{after:?}");
assert!(near(after[0].1, before[0].1));
}
#[test]
fn moving_every_point_by_one_offset_carries_the_regions_paint_frame() {
// `{add}` on every point is a plain translation: the region's paint frame goes with the
// points, as when they are dragged together.
let mut s = Session::new();
let id = rect(&mut s);
{
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected a vector") };
assert!(!network.regions.is_empty(), "the rectangle has a region");
for region in &mut network.regions {
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::IDENTITY });
}
}
let frame = |s: &Session| {
let world = s.doc().unwrap().world_transform(id);
world * net(s, id).regions[0].paint_space.as_ref().unwrap().transform
};
let before = frame(&s);
let all: Vec<usize> = (0..net(&s, id).vertices.len()).collect();
ex(&mut s, "vector.edit.select", json!({"vertices": all}));
ex(&mut s, "vector.edit.position", json!({"x": {"add": 10}}));
let (want, got) = ((Affine::translate((10.0, 0.0)) * before).as_coeffs(), frame(&s).as_coeffs());
assert!(want.iter().zip(got).all(|(a, b)| near(*a, b)), "{got:?} vs {want:?}");
}
#[test]
fn junk_is_refused_and_changes_nothing() {
let mut s = Session::new();
let id = rect(&mut s);
let before = xs(&s, id);
ex(&mut s, "vector.edit.select", json!({"vertices": [0, 1]}));
let steps = undo_steps(&s);
for junk in [
json!({"x": {"div": 0}}),
json!({"x": {"pow": 2}}),
json!({"x": "a"}),
json!({"x": {"add": 1, "mul": 2}}),
json!({"y": {"add": "x"}}),
json!({"x": {}}),
json!({"x": [1]}),
json!({"x": true}),
json!({"x": {"add": true}}),
// A valid x with a refused y changes neither.
json!({"x": 5, "y": {"div": 0}}),
// Point 1's x of 100 overflows; point 0's 0 alone would not.
json!({"x": {"mul": 1e308}}),
] {
assert!(s.execute("vector.edit.position", &junk).is_err(), "{junk}");
}
assert_eq!(xs(&s, id), before);
assert_eq!(undo_steps(&s), steps, "a refused edit leaves no undo step");
}
#[test]
fn nothing_selected_is_refused() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.select", json!({"vertices": []}));
let (before, steps) = (xs(&s, id), undo_steps(&s));
assert!(parent_xy(&s).is_empty());
let err = s.execute("vector.edit.position", &json!({"x": 5})).unwrap_err();
assert!(err.to_string().contains("select points"), "{err}");
assert_eq!((xs(&s, id), undo_steps(&s)), (before, steps));
}
@@ -659,6 +659,27 @@ fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
fill_traces_stroke(&s, id, "drawn with the pen");
}
/// The reporter's sequence (#1951): a bridge drawn between the two new points while the corner is
/// still there (both have two segments, so the region keeps its ring), then the corner deleted with
/// Delete. The region's loop must close through the bridge, so the rounded fill traces the stroke.
#[test]
fn deleting_the_corner_behind_a_pen_bridge_closes_the_loop_through_the_bridge() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
let n = net(&s, id);
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
let n = net(&s, id);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "bridged, the ring kept: {:?}", n.regions);
let corner = vertex_at(&n, 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.edit.delete", json!({}));
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "bridged, then the corner deleted");
}
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
/// fill that keeps off every sharp point and traces the same outline as the stroke.
+1
View File
@@ -291,6 +291,7 @@ pub static MENU: &[MenuItem] = &[
m!("Tools", "Pencil", "tool.pencil"),
m!("Tools", "Text", "tool.text"),
m!("Tools", "Comment", "tool.comment"),
m!("Tools", "Annotation", "tool.annotation"),
m!("Tools", "Shape builder", "tool.shapeBuilder"),
m!("Tools", "Actions", "app.actions"),
];
+11 -2
View File
@@ -113,8 +113,12 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
// A new rotation for the selection turns it about its moved rotation origin: each layer's
// point under the origin stays put.
let ds = s.ds()?;
let pivot =
ds.moved_origin().filter(|_| props.contains_key("rotation") && !props.contains_key("x") && !props.contains_key("y") && ds.selection == ids);
let pivot = ds.moved_origin().filter(|_| {
(props.contains_key("rotation") || props.contains_key("absoluteRotation"))
&& !props.contains_key("x")
&& !props.contains_key("y")
&& ds.selection == ids
});
let pinned: Vec<(NodeId, figcraft_geom::Point)> = match pivot {
Some(o) => roots(&ds.doc, &ids)
.into_iter()
@@ -161,6 +165,11 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
let doc = s.doc_mut()?;
let before = crate::cmd::styles::bound_before(doc, &ids);
let mut ids = ids;
if props.contains_key("absoluteRotation") {
// A page angle depends on the parents': turn a selected parent before the layers in it.
ids.sort_by_key(|i| doc.ancestors(*i).len());
}
for i in ids {
crate::cmd::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?;
}
+6 -3
View File
@@ -26,8 +26,8 @@ pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS;
use export::add_member;
pub(super) use export::export_setting;
pub use export::{
ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes,
frames_pdf, jobs_for, penpot_bytes, safe_file_name,
ExportJob, backdrop_layers, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers,
fig_bytes, frames_pdf, jobs_for, penpot_bytes, safe_file_name,
};
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
use load::font_report;
@@ -409,7 +409,10 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
}
_ => {
let mut pix = figcraft_render::render_area(doc, &drawn, area, scale)
// Layers exported on their own still read the page beneath them for a background blur
// or glass (#2213); a "contents only" off export already draws the whole page.
let page = if setting.contents_only { backdrop_layers(doc, ids) } else { Vec::new() };
let mut pix = figcraft_render::render_area_over(doc, &drawn, &page, area, scale)
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
// The render is in the document's profile; the setting's profile converts the pixels
// (out-of-gamut colors clip) and tags the file.
+6
View File
@@ -55,6 +55,12 @@ pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) ->
crate::cmd::edit::z_sorted(doc, &crate::cmd::edit::roots(doc, &own))
}
/// The top-level layers of the page of `ids`, bottom to top: what a background blur or glass in an
/// export of `ids` reads from (empty when `ids` are on no page).
pub fn backdrop_layers(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default()
}
/// The frame or group a slice sits in: None on the page or in a section.
fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
let parent = doc.node(slice)?.parent?;
+32 -4
View File
@@ -16,7 +16,7 @@ use crate::{Result, bad};
pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "strokeBottomWeight", "strokeLeftWeight"];
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), absoluteRotation (degrees on the page: the layer's angle plus its parents', what the Design panel's field shows; one of the two per call), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, {$var: variable id or name}, or \"none\"/null: no fill), strokes, stroke (colour, {$var: variable id or name}, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], dashCap (none|round|square; null: dashes take startCap and endCap), widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
@@ -29,7 +29,7 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
(auto|px|\"10%\" of the font size; the offset runs from the baseline down to an underline's top edge), textDecorationSkipInk (underlines break around descenders), \
textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \
listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
listSpacing (px between list items, in place of paragraphSpacing), wrapStyle (auto|balance|pretty; with start/end: the paragraphs those chars touch), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \
path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \
node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \
@@ -456,7 +456,7 @@ fn apply_order(k: &str, v: &Value) -> u8 {
match k {
"path" | "width" | "height" | "w" | "h" => 0,
"layout" | "layoutMode" => 1,
"rotation" => 3,
"rotation" | "absoluteRotation" => 3,
"x" | "y" => 4,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 5,
"boundVariables" => 6,
@@ -595,6 +595,27 @@ fn apply_geometry(
let n = doc.get_mut(id)?;
n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg);
}
"absoluteRotation" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`absoluteRotation` must be degrees"))?;
if props.contains_key("rotation") {
return Err(bad(cmd, "give one of `rotation` and `absoluteRotation`"));
}
let (w, h) = {
let n = doc.get(id)?;
(n.width, n.height)
};
let world = figcraft_geom::set_rotation_about_center(doc.world_transform(id), w, h, deg);
let parent = doc.get(id)?.parent;
let pw = parent.map(|p| doc.world_transform(p)).unwrap_or(figcraft_geom::Affine::IDENTITY);
let local = pw.inverse() * world;
if local.as_coeffs().iter().all(|c| c.is_finite()) {
doc.get_mut(id)?.transform = local;
} else {
// A degenerate parent can't be inverted: turn the local angle instead.
let n = doc.get_mut(id)?;
n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg);
}
}
_ => return Ok(false),
}
Ok(true)
@@ -1008,7 +1029,14 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
}
"leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?,
"wrapStyle" | "textWrap" => {
text_mut(cmd, doc.get_mut(id)?)?.wrap_style = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?
let w: figcraft_doc::WrapStyle = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?;
let t = text_mut(cmd, doc.get_mut(id)?)?;
if props.contains_key("start") || props.contains_key("end") {
let (start, end) = range_of(props, t.char_count());
t.set_para_wrap_styles(start, end, w);
} else {
t.set_wrap_style(w);
}
}
"maxLines" => {
let lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32;
+3 -1
View File
@@ -496,7 +496,9 @@ fn collect_flatten_parts(
}
let part = match &node.kind {
NodeKind::Vector { network } => {
let mut part = network.clone();
// Rounded points bake into the path (the arc's end points joined by curves), drawn the way
// the layer draws them, so the shape looks the same and the box fits the real geometry.
let mut part = figcraft_doc::geometry::drawn_network(network, node.corners.smoothing).into_owned();
if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
WindingRule::EvenOdd
+14 -5
View File
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"style.bind",
"Bind variable in style",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing|fontWeight` (numbers; line height and letter spacing in px, as the reference tool applies them; a weight picks the family's face nearest it and the `wght` axis of a variable font) and `fontFamily|fontStyle` (strings; a weight bound as a number replaces a bound style name and the reverse). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
true,
bind_style
),
@@ -275,6 +275,9 @@ fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
"paragraphSpacing" | "paragraph_spacing" => ps.paragraph_spacing = length(k, x)?,
"paragraphIndent" | "paragraph_indent" => ps.paragraph_indent = length(k, x)?,
"listStyle" | "list" => ps.list = super::props::enum_of(x).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
"wrapStyle" | "textWrap" | "wrap_style" => {
ps.wrap_style = super::props::enum_of(x).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?;
}
"features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => {
ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect();
}
@@ -289,10 +292,11 @@ fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
fn paragraph_style_of(n: &Node, range: Option<(usize, usize)>) -> Option<ParagraphStyle> {
let t = n.kind.text()?;
let mut ps = t.paragraph_style();
if let Some((a, b)) = range
&& let Some(pl) = t.common_para_list(a, b)
{
ps.list = pl.list;
if let Some((a, b)) = range {
if let Some(pl) = t.common_para_list(a, b) {
ps.list = pl.list;
}
ps.wrap_style = t.common_wrap_style(a, b).unwrap_or(t.wrap_style);
}
Some(ps)
}
@@ -520,6 +524,11 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
{
rebind_paint_alpha(paints, &mut [], &key, true);
}
match key.as_str() {
"fontWeight" => style.bound.remove("fontStyle"),
"fontStyle" => style.bound.remove("fontWeight"),
_ => None,
};
style.bound.insert(key, id);
}
None => {
+11 -2
View File
@@ -98,6 +98,15 @@ fn set_direction(s: &mut Session, p: &Value, d: TextDirection) -> Result<Value>
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d))
}
/// The wrap style of the targeted texts: the edited paragraphs while editing (the paragraph
/// level), else the whole layer (the layer level: every paragraph, overrides included).
fn set_wrap_style(s: &mut Session, p: &Value, w: figcraft_doc::WrapStyle) -> Result<Value> {
match textedit::selected_range(s) {
Some((_, a, b)) => para(s, p, |t| t.set_para_wrap_styles(a, b, w)),
None => para(s, p, |t| t.set_wrap_style(w)),
}
}
/// Whether every targeted char has `pred` (to decide toggle direction).
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
let Ok(doc) = s.doc() else { return false };
@@ -445,12 +454,12 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"text.wrapStyle",
"Wrap style",
"{style: auto|balance|pretty, ids?} where lines break when the text wraps: Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
"{style: auto|balance|pretty, ids?} where lines break when the text wraps (while editing: the selected paragraphs; else the whole layer): Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
true,
|s, p| {
let w: figcraft_doc::WrapStyle =
get(p, "style").and_then(super::props::enum_of).ok_or_else(|| bad("text.wrapStyle", "`style`: auto|balance|pretty"))?;
para(s, p, |t| t.wrap_style = w)
set_wrap_style(s, p, w)
}
),
spec!(
+83 -5
View File
@@ -100,12 +100,16 @@ fn zoom_to(s: &mut Session, z: f64) -> Result<Value> {
Ok(json!({"zoom": v.zoom}))
}
/// How far an arrow key pans the view with nothing selected (screen pixels; Shift: ten times).
pub const PAN_STEP_PX: f64 = 20.0;
/// How far an arrow key pans the view with nothing selected (screen pixels). Measured in Figma at
/// 100% zoom: about 64 px (62-64 in two trials, +/-3 px).
pub const PAN_STEP_PX: f64 = 64.0;
/// Shift multiplies the arrow pan by this much (Figma: about 320 px, five steps).
pub const PAN_BIG_STEPS: f64 = 5.0;
/// `view.pan`: the arrow keys with nothing selected move the view by keyboard steps.
fn pan(s: &mut Session, p: &Value) -> Result<Value> {
let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * 10.0 } else { PAN_STEP_PX };
let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * PAN_BIG_STEPS } else { PAN_STEP_PX };
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
let (dx, dy) = (dir("dx"), dir("dy"));
let v = view(s)?;
@@ -235,6 +239,26 @@ pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
Ok(json!({"tool": t.id()}))
}
/// Shift+T and choosing the Annotation tool: with a layer selected (or named by `id`/`ids`) the UI
/// is asked for its note at once; in the tool, a press on a layer selects it and asks (the engine
/// never writes the note). A named target is checked first: one that isn't a layer in the document
/// is an error and the tool stays as it was.
fn annotation(s: &mut Session, p: &Value) -> Result<Value> {
let named = p.get("id").is_some_and(|v| !v.is_null()) || p.get("ids").is_some_and(|v| !v.is_null());
let first = crate::params::targets(s, p).into_iter().find(|i| s.doc().is_ok_and(|d| d.node(*i).is_some_and(|n| !n.is_page())));
if named && first.is_none() {
return Err(bad("tool.annotation", "`id` must name a layer in this document (not a page)".to_string()));
}
let r = tool(s, ToolId::Annotation)?;
if let Some(id) = first {
if named {
crate::cmd::edit::select_and_page(s, vec![id])?;
}
s.request_ui("annotation.new", json!({"id": id}));
}
Ok(r)
}
/// The View menu's commands and their parameters.
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -298,7 +322,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
toggle(s, p, "view.multiplayerCursors")
}),
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (five times as far, like Shift)}", false, pan),
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
toggle(s, p, "view.outlinesSelection")
@@ -410,6 +434,13 @@ pub fn specs() -> Vec<CommandSpec> {
false,
|s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom)
),
spec!(
"pref.invertZoom",
"Invert zoom direction",
"{on?} reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in (a trackpad pinch and panning are unchanged) → {on}",
false,
|s, p| pref(s, p, |x| &mut x.invert_zoom)
),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!(
"pref.uiScale",
@@ -528,7 +559,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"tool.set",
"Choose tool",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
false,
|s, p| {
let t = req_string("tool.set", p, "tool")?;
@@ -536,6 +567,9 @@ pub fn specs() -> Vec<CommandSpec> {
if t.on_boards() {
crate::board::require(s, &format!("tool.{}", t.id()))?;
}
if t == ToolId::Annotation {
return annotation(s, &json!({}));
}
tool(s, t)
}
),
@@ -638,6 +672,9 @@ pub fn specs() -> Vec<CommandSpec> {
tool(s, ToolId::TextOnPath)
}),
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
// Shift+T: with a layer selected (or an `id`) the UI is asked for its note at once; in the
// tool, a press on a layer selects it and asks (the engine never writes the note).
spec!("tool.annotation", "Annotation", "{id?|ids?}", false, annotation),
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
// `ToolState::picked`) and the picker writes the colour to the paint it edits.
@@ -672,4 +709,45 @@ mod start_on_home_tests {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.start_on_home);
}
#[test]
fn the_arrows_pan_64_px_and_320_with_shift_as_measured_in_figma() {
let mut s = crate::Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("view.pan", &json!({"dx": 1})).unwrap();
let v = &s.ds().unwrap().view;
assert_eq!((v.cx, v.cy), (364.0, 200.0), "one press pans 64 px");
s.execute("view.pan", &json!({"dy": -1, "big": true})).unwrap();
let v = &s.ds().unwrap().view;
assert_eq!((v.cx, v.cy), (364.0, -120.0), "Shift pans 320 px");
}
#[test]
fn the_arrow_pan_is_the_same_screen_distance_at_any_zoom_on_both_axes() {
// (zoom, dx, dy, big, expected centre change in document px)
let cases = [
(2.0, 1.0, 0.0, false, 32.0, 0.0),
(0.5, 1.0, 0.0, false, 128.0, 0.0),
(2.0, 0.0, 1.0, true, 0.0, 160.0),
(0.5, 0.0, 1.0, true, 0.0, 640.0),
(1.0, -1.0, 0.0, false, -64.0, 0.0),
(1.0, 1.0, 0.0, true, 320.0, 0.0),
(1.0, -1.0, 0.0, true, -320.0, 0.0),
(1.0, 0.0, -1.0, false, 0.0, -64.0),
(1.0, 0.0, 1.0, false, 0.0, 64.0),
(1.0, 0.0, -1.0, true, 0.0, -320.0),
];
for (zoom, dx, dy, big, ex, ey) in cases {
let mut s = crate::Session::new();
s.execute("view.set", &json!({"zoom": zoom, "cx": 300, "cy": 200})).unwrap();
s.execute("view.pan", &json!({"dx": dx, "dy": dy, "big": big})).unwrap();
let v = &s.ds().unwrap().view;
let got = (v.cx - 300.0, v.cy - 200.0);
assert!(
(got.0 - ex).abs() < 1e-6 && (got.1 - ey).abs() < 1e-6,
"zoom {zoom} dx {dx} dy {dy} big {big}: moved {got:?}, expected {:?}",
(ex, ey)
);
}
}
}
+1
View File
@@ -107,6 +107,7 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() {
(ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"),
(ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"),
(ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"),
(ToolId::Annotation, NONE, ON_SQUARE, Crosshair, "the annotation tool marks the layer it is pressed on"),
(ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"),
];
for (tool, m, at, want, why) in rows {
+6 -4
View File
@@ -302,10 +302,12 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
TextAlignH::Right => put("text-align", "right".into()),
TextAlignH::Justified => put("text-align", "justify".into()),
}
match tx.wrap_style {
figcraft_doc::WrapStyle::Auto => {}
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
// CSS has one `text-wrap` per element: written when every paragraph has the same one.
let wrap = (0..tx.paragraph_count()).map(|p| tx.para_wrap_style(p)).collect::<Vec<_>>();
match wrap.first().copied().filter(|w| wrap.iter().all(|x| x == w)) {
Some(figcraft_doc::WrapStyle::Balance) => put("text-wrap", "balance".into()),
Some(figcraft_doc::WrapStyle::Pretty) => put("text-wrap", "pretty".into()),
_ => {}
}
// A base direction forced on every paragraph (CSS has one per element).
let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d));
+1 -1
View File
@@ -230,7 +230,7 @@ pub fn dev_mode_allows(id: &str) -> bool {
// Edits recorded as one grouped undo step, so their spec says no history of its own.
const GROUPED: [&str; 1] = ["data.fill"];
match id.strip_prefix("tool.") {
Some(t) => matches!(t, "move" | "hand" | "comment"),
Some(t) => crate::tools::ToolId::from_id(t).is_some_and(crate::tools::ToolId::works_in_dev_mode),
None if id == "file.revert" || GROUPED.contains(&id) => false,
None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history),
}
@@ -99,6 +99,102 @@ fn the_dev_mode_canvas_selects_and_pans_but_never_edits() {
assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved");
}
/// The `annotation.new` requests queued so far, as the layer ids they name.
fn note_requests(s: &Session) -> Vec<NodeId> {
s.ui_requests.iter().filter(|r| r.kind == "annotation.new").filter_map(|r| crate::params::id_of(&r.data["id"])).collect()
}
fn click(s: &mut Session, x: f64, y: f64) {
s.pointer(&ev(PointerKind::Down, x, y)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y)).unwrap();
}
#[test]
fn the_annotation_tool_picks_a_layer_and_asks_for_its_note() {
for dev_mode in [true, false] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
s.tool.dev_mode = dev_mode;
ex(&mut s, "select.set", json!({"ids": []}));
ex(&mut s, "tool.annotation", json!({}));
assert_eq!(s.tool.tool, ToolId::Annotation);
assert!(note_requests(&s).is_empty(), "nothing selected: no note is asked for yet");
let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len());
// A press on empty canvas does nothing.
click(&mut s, 1000.0, 1000.0);
assert!(note_requests(&s).is_empty(), "empty canvas asks for nothing (dev_mode {dev_mode})");
// A press on the rectangle selects it and asks for its note.
click(&mut s, 40.0, 40.0);
assert_eq!(s.selection(), vec![r], "dev_mode {dev_mode}");
assert_eq!(note_requests(&s), vec![r], "dev_mode {dev_mode}");
assert_eq!(s.tool.tool, ToolId::Annotation, "the tool stays until V or Escape");
// The tool writes nothing.
assert_eq!(*s.doc().unwrap(), doc_before);
assert_eq!(s.undo_labels(1000).len(), undo_before);
}
// The keyboard flow: with a layer selected, choosing the tool asks at once.
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
ex(&mut s, "tool.annotation", json!({}));
assert_eq!(note_requests(&s), vec![r]);
// And with an `id`.
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "tool.annotation", json!({"id": r}));
assert_eq!(note_requests(&s), vec![r]);
assert_eq!(s.selection(), vec![r]);
}
/// A named target that isn't a layer (a page, an unknown id) is an error, and the tool is left as it
/// was; several targets pick the first layer among them and select it.
#[test]
fn the_annotation_tool_checks_the_layer_it_is_named_for_before_changing_anything() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
let page = s.page().unwrap();
assert!(s.execute("tool.annotation", &json!({"id": page})).is_err(), "a page is not a layer");
assert!(s.execute("tool.annotation", &json!({"id": "9999:9999"})).is_err(), "no such layer");
assert!(s.execute("tool.annotation", &json!({"ids": []})).is_err(), "nothing named");
assert_eq!(s.tool.tool, ToolId::Move, "the tool did not change");
assert!(note_requests(&s).is_empty());
// The page first, then the rectangle: the rectangle is the target, and selected.
ex(&mut s, "tool.annotation", json!({"ids": [page, r]}));
assert_eq!((s.tool.tool, s.selection(), note_requests(&s)), (ToolId::Annotation, vec![r], vec![r]));
}
/// Choosing the tool with `tool.set` (the toolbar) asks for the selected layer's note like Shift+T.
#[test]
fn choosing_the_annotation_tool_from_the_toolbar_asks_for_the_selected_layers_note() {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
let r = rect(&mut s, f, 20.0);
ex(&mut s, "select.set", json!({"ids": [r]}));
ex(&mut s, "tool.set", json!({"tool": "annotation"}));
assert_eq!((s.tool.tool, note_requests(&s)), (ToolId::Annotation, vec![r]));
}
/// A drag with the tool never moves the layer it starts on, in Developer Mode or out of it.
#[test]
fn dragging_with_the_annotation_tool_moves_nothing() {
for dev_mode in [true, false] {
let mut s = Session::new();
let f = frame(&mut s, 0.0, "Screen");
rect(&mut s, f, 20.0);
s.tool.dev_mode = dev_mode;
ex(&mut s, "select.set", json!({"ids": []}));
ex(&mut s, "tool.annotation", json!({}));
let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len());
drag(&mut s, (40.0, 40.0), (140.0, 90.0));
assert_eq!(*s.doc().unwrap(), doc_before, "dev_mode {dev_mode}");
assert_eq!(s.undo_labels(1000).len(), undo_before, "dev_mode {dev_mode}");
}
}
#[test]
fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() {
for id in [
@@ -121,6 +217,7 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts
"tool.move",
"tool.hand",
"tool.comment",
"tool.annotation",
"inspect.code",
] {
assert!(dev_mode_allows(id), "{id} runs in Dev Mode");
+161
View File
@@ -385,6 +385,167 @@ fn copy_as_png_svg_and_code() {
assert!(css.contains("background: #FF0000;"), "Copy as CSS is the layer's CSS from Inspect");
}
/// A red square with a fill-less pane over its right part, holding `effect` (#2213).
fn pane_scene(effect: EffectKind) -> (figcraft_doc::Document, NodeId, NodeId) {
use figcraft_doc::{Document, NodeKind};
let mut d = Document::new("blur-export");
let page = d.first_page().unwrap();
let under = d.create(page, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap();
d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())];
let pane = d.create(page, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap();
d.get_mut(pane).unwrap().fills = vec![];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: effect, visible: true }];
(d, under, pane)
}
/// The PNG export of `ids` as the Export command makes it, and what the canvas draws there.
fn export_vs_canvas(d: &figcraft_doc::Document, ids: &[NodeId]) -> (image::RgbaImage, image::RgbaImage) {
let setting = figcraft_doc::ExportSetting::default();
let page = d.first_page().unwrap();
let area = crate::cmd::file::export_area(d, ids, &setting).unwrap();
let canvas = figcraft_render::render_area(d, &d.get(page).unwrap().children.clone(), area, 1.0).unwrap();
let canvas = image::RgbaImage::from_raw(canvas.width(), canvas.height(), figcraft_render::to_rgba8(&canvas)).unwrap();
let (bytes, _) = crate::cmd::file::export_file(d, ids, &setting, 1.0).unwrap();
(pixels(&bytes), canvas)
}
/// #2213: a layer with a background blur exported on its own shows the same pixels as the canvas
/// shows for that area: the blur reads what lies under it, so the export must draw the layers
/// beneath it as the backdrop (clipped to the layer) rather than blurring nothing.
#[test]
fn exporting_a_background_blur_layer_keeps_the_blurred_backdrop() {
let (d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
// The pane's left half sits over the red square, which ends at x = 120 (60 px into the pane).
assert_ne!(canvas.get_pixel(60, 40).0, [0, 0, 0, 0], "the canvas draws the blurred backdrop");
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "export lost the backdrop the blur reads");
assert_eq!(exported.dimensions(), (120, 80), "the pane's own bounds");
}
/// #2213, as reported and as measured in Figma (2026-11-10): the pane inside a frame, exported on
/// its own, keeps the frame's fill and the blurred layer beneath it, opaque.
#[test]
fn a_blurred_pane_inside_a_frame_keeps_the_frame_and_the_layer_beneath() {
use figcraft_doc::{Document, NodeKind};
let mut d = Document::new("blur-export-frame");
let page = d.first_page().unwrap();
let art = d.create(page, NodeKind::Frame, "Art", 0.0, 0.0, 300.0, 200.0).unwrap();
d.get_mut(art).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FFFFFF").unwrap())];
let under = d.create(art, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap();
d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())];
let pane = d.create(art, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap();
d.get_mut(pane).unwrap().fills = vec![];
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)), visible: true }];
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "the blurred square under the pane");
assert_eq!(exported.get_pixel(100, 40).0[3], 255, "the frame's white behind the pane, opaque");
}
/// #2213: the same for Glass.
#[test]
fn exporting_a_glass_layer_keeps_the_backdrop_it_bends() {
let (d, _, pane) = pane_scene(EffectKind::Glass(figcraft_doc::Glass::default()));
let (exported, canvas) = export_vs_canvas(&d, &[pane]);
assert_ne!(canvas.get_pixel(30, 40).0, [0, 0, 0, 0]);
assert_eq!(exported.get_pixel(30, 40), canvas.get_pixel(30, 40), "export lost the backdrop the glass reads");
assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40));
}
/// Measured in Figma 2026-11-10: inside the pane's bounds the export is opaque where the backdrop
/// is (the blurred red), and the layers over the pane stay out.
#[test]
fn a_blur_export_is_opaque_inside_the_pane_and_leaves_layers_above_out() {
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)));
let page = d.first_page().unwrap();
let above = d.create(page, figcraft_doc::NodeKind::Rectangle, "Above", 0.0, 0.0, 200.0, 200.0).unwrap();
d.get_mut(above).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("0000FF").unwrap())];
let (exported, _) = export_vs_canvas(&d, &[pane]);
let left = exported.get_pixel(10, 40).0;
assert_eq!(left[3], 255, "red under the pane: opaque");
assert!(left[0] > 150 && left[2] < 60, "red, not the blue layer above: {left:?}");
// The far right of the pane has no square under it: nothing to read, nothing drawn.
assert_eq!(exported.get_pixel(110, 40).0[3], 0);
}
/// Guard: a layer without backdrop effects over another still exports alone ("contents only").
#[test]
fn a_layer_without_backdrop_effects_still_leaves_the_layers_below_out() {
let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(0.0)));
d.get_mut(pane).unwrap().effects.clear();
let (exported, _) = export_vs_canvas(&d, &[pane]);
assert!(exported.pixels().all(|p| p.0 == [0, 0, 0, 0]), "a fill-less pane alone exports clear");
}
#[test]
fn node_set_takes_back_the_export_settings_node_get_returns() {
use figcraft_doc::{ExportColorProfile, ExportFormat, ExportSetting, PrintSettings, VdCompat};
// Every multi-word field (and `quality`) away from its default.
let expected = ExportSetting {
format: ExportFormat::Jpg,
suffix: "@3x".into(),
print: PrintSettings { bleed: 9.0, crop_marks: true, bleed_marks: true, cmyk: true },
contents_only: false,
include_bounding_box: true,
svg_outline_text: false,
svg_id_attribute: true,
svg_simplify_stroke: false,
vd_compat: VdCompat::Exact,
color_profile: ExportColorProfile::DisplayP3,
quality: 55,
..Default::default()
};
let default = ExportSetting::default();
for (field, differs) in [
("print", expected.print != default.print),
("contents_only", expected.contents_only != default.contents_only),
("include_bounding_box", expected.include_bounding_box != default.include_bounding_box),
("svg_outline_text", expected.svg_outline_text != default.svg_outline_text),
("svg_id_attribute", expected.svg_id_attribute != default.svg_id_attribute),
("svg_simplify_stroke", expected.svg_simplify_stroke != default.svg_simplify_stroke),
("vd_compat", expected.vd_compat != default.vd_compat),
("color_profile", expected.color_profile != default.color_profile),
("quality", expected.quality != default.quality),
] {
assert!(differs, "{field} must be non-default in the expected setting");
}
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 40.0, 20.0, "#FF0000", None);
let stored = |s: &Session| s.doc().unwrap().node(a).unwrap().exports.clone();
ex(
&mut s,
"export.add",
json!({"ids": [a], "setting": {
"format": "jpg", "suffix": "@3x", "bleed": 9, "cropMarks": true, "bleedMarks": true, "cmyk": true,
"contentsOnly": false, "includeBoundingBox": true, "svgOutlineText": false, "svgIdAttribute": true,
"svgSimplifyStroke": false, "vdCompat": "exact", "colorProfile": "displayP3", "quality": 55,
}}),
);
assert_eq!(stored(&s), vec![expected.clone()], "export.add stores every field");
// What node.get returns goes back through node.set unchanged.
let got = ex(&mut s, "node.get", json!({"id": a}))["exports"].clone();
set(&mut s, &[a], json!({"exports": []}));
assert!(stored(&s).is_empty());
set(&mut s, &[a], json!({"exports": got}));
assert_eq!(stored(&s), vec![expected.clone()], "node.set stores what node.get returned, unchanged");
// The snake_case spelling is taken too.
set(&mut s, &[a], json!({"exports": []}));
set(
&mut s,
&[a],
json!({"exports": [{
"format": "jpg", "suffix": "@3x",
"print": {"bleed": 9, "crop_marks": true, "bleed_marks": true, "cmyk": true},
"contents_only": false, "include_bounding_box": true, "svg_outline_text": false, "svg_id_attribute": true,
"svg_simplify_stroke": false, "vd_compat": "exact", "color_profile": "displayP3", "quality": 55,
}]}),
);
assert_eq!(stored(&s), vec![expected], "node.set takes the snake_case keys");
}
#[test]
fn export_rejects_an_invalid_layer_id_instead_of_exporting_the_page() {
let mut s = Session::new();
@@ -0,0 +1,42 @@
//! #2189: the eyedropper picks the colour the canvas shows under the pointer. A magnified image
//! draws its pixels on the canvas (`magnified_image_pixels`), so the picked colour is that pixel's,
//! not a blend with its neighbours.
use base64::Engine as _;
use serde_json::json;
use crate::Session;
use crate::tools::{PointerEvent, PointerKind};
/// A 9 x 9 image of `back` with a 1 px vertical line of `line` in column 4 (a thin border in a
/// screenshot, as in the report).
fn line_png(back: [u8; 3], line: [u8; 3]) -> Vec<u8> {
let mut p = figcraft_render::Pixmap::new(9, 9).unwrap();
for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() {
let c = if i % 9 == 4 { line } else { back };
px.copy_from_slice(&[c[0], c[1], c[2], 255]);
}
figcraft_render::encode_png(&p).unwrap()
}
/// The colour the eyedropper picks at page point (x, y) at `zoom`.
fn picked(png: &[u8], x: f64, y: f64, zoom: f64) -> String {
let mut s = Session::new();
let data = base64::engine::general_purpose::STANDARD.encode(png);
s.execute("image.place", &json!({"data": data, "x": 0, "y": 0})).unwrap();
s.execute("edit.pickColor", &json!({"apply": false})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, zoom)).unwrap();
s.tool.picked.map(|c| c.to_hex()).unwrap_or_default()
}
#[test]
fn the_eyedropper_picks_a_magnified_images_pixel_not_a_blend_with_its_neighbours() {
// The reporter's colours: a #3D444D border, 1 px wide, beside a darker background.
let png = line_png([0x15, 0x1B, 0x23], [0x3D, 0x44, 0x4D]);
for zoom in [2.0, 8.0, 20.0] {
assert_eq!(picked(&png, 4.5, 4.5, zoom), "3D444D", "on the line at {zoom}x");
assert_eq!(picked(&png, 2.5, 4.5, zoom), "151B23", "beside it at {zoom}x");
}
// At 1:1 the canvas pixel is the image's pixel too.
assert_eq!(picked(&png, 4.5, 4.5, 1.0), "3D444D", "on the line at 1x");
}
+1
View File
@@ -192,6 +192,7 @@ pub static KEYS: &[Binding] = &[
k(Tools, "tool.pencil", &["Shift+P"]),
k(Tools, "tool.text", &["T"]),
k(Tools, "tool.comment", &["C"]),
k(Tools, "tool.annotation", &["Shift+T"]),
k(Tools, "tool.shapeBuilder", &["Shift+M"]),
// Board tools (on boards only), on keys nothing else uses.
k(Tools, "tool.sticky", &["Q"]),
+2
View File
@@ -206,6 +206,8 @@ fn tool_keys_come_from_the_keymap() {
assert_eq!(ToolId::Eyedropper.shortcut(), "I");
assert_eq!(ToolId::Polygon.shortcut(), "");
assert_eq!(crate::catalog::shortcut("tool.text"), Some("T"));
assert_eq!(ToolId::Annotation.shortcut(), "Shift+T");
assert_eq!(crate::catalog::shortcut("tool.annotation"), Some("Shift+T"));
}
#[test]
+4
View File
@@ -227,6 +227,8 @@ mod board_tools_tests;
#[cfg(test)]
mod boolean_cap_tests;
#[cfg(test)]
mod boolean_radius_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
@@ -325,6 +327,8 @@ mod extended_chain_tests;
#[cfg(test)]
mod eyedropper_create_tests;
#[cfg(test)]
mod eyedropper_image_tests;
#[cfg(test)]
mod facilitate_tests;
#[cfg(test)]
mod fig_export_assets_tests;
+4
View File
@@ -36,6 +36,9 @@ pub struct Prefs {
/// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the
/// other way round). Trackpads are unchanged.
pub scroll_wheel_zoom: bool,
/// Wheel zoom goes the other way: Cmd/Ctrl+wheel (and a plain mouse wheel with Scroll wheel
/// zoom on) zooms out where it would zoom in. A trackpad pinch is unchanged.
pub invert_zoom: bool,
/// "system" by default; an explicit "light" or "dark" choice stays saved.
pub theme: String,
/// The widths of the main window's left and right panels (the interface keeps them within its
@@ -136,6 +139,7 @@ impl Default for Prefs {
number_keys_opacity: true,
show_dimensions: true,
scroll_wheel_zoom: false,
invert_zoom: false,
theme: "system".into(),
panel_widths: (240.0, 240.0),
pages_height: None,
@@ -90,6 +90,17 @@ fn rotation_field_and_rotate_commands_turn_about_a_moved_origin() {
assert!(s.execute("transform.setRotationOrigin", &json!({"x": 1})).is_err(), "x without y");
}
#[test]
fn the_absolute_rotation_key_turns_about_a_moved_origin_too() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("transform.setRotationOrigin", &json!({"x": 100, "y": 100})).unwrap();
s.execute("node.set", &json!({"ids": [r], "props": {"absoluteRotation": 30}})).unwrap();
let doc = s.doc().unwrap();
assert!(near(doc.world_transform(r) * Point::new(100.0, 100.0), Point::new(100.0, 100.0)));
assert!((figcraft_geom::rotation_degrees(doc.world_transform(r)) - 30.0).abs() < 1e-6);
}
#[test]
fn origin_lasts_as_long_as_its_selection() {
let mut s = Session::new();
@@ -189,6 +189,7 @@ fn corner_radius_follows_the_documented_rules() {
(NodeKind::Polygon { count: 5 }, true, false, "polygons: one radius for every corner"),
(NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, false, "stars: one radius for every corner"),
(NodeKind::Vector { network: Default::default() }, true, false, "vectors: a radius, and one per point in edit mode"),
(NodeKind::BooleanOp { op: BoolOp::Union }, true, false, "boolean groups: one radius for every sharp corner of the result"),
(NodeKind::Line, false, false, "lines and arrows take no radius"),
];
for (kind, radius, independent, why) in doc_rows {
@@ -241,6 +242,7 @@ fn corner_smoothing_follows_the_documented_rules() {
"vector networks that are closed shapes",
),
(NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"),
(NodeKind::BooleanOp { op: BoolOp::Union }, true, "boolean groups (the plugin API's corner mixin; the look is pinned)"),
(NodeKind::Line, false, "lines take no radius, so no smoothing"),
];
for (kind, smoothing, why) in doc_rows {
+2 -1
View File
@@ -110,6 +110,7 @@ static ROWS: &[Row] = &[
doc(Tools, "Pencil tool", &["Shift+P"], &[], Bound("tool.pencil"), ""),
doc(Tools, "Text tool", &["T"], &[], Bound("tool.text"), ""),
doc(Tools, "Comment mode", &["C"], &[], Bound("tool.comment"), ""),
doc(Tools, "Annotation tool", &["Shift+T"], &[], Bound("tool.annotation"), "Accessibility at Figma, Add an annotation; Developer Mode"),
doc(Tools, "Hand tool while held", &["Space"], &[], Gesture("Pan the canvas"), "held, not pressed"),
doc(Tools, "Pan with the arrows when nothing is selected (Shift: farther)", &["ArrowRight"], &[], Gesture("Pan with nothing selected (Shift: farther)"),
"view.pan; nudges move the selection when there is one"),
@@ -416,7 +417,7 @@ fn counts_match_the_docs() {
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
let pinned = ROWS.len() - documented.len();
// docs/ui-parity.md quotes these.
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (136, 135, 1, 0, 2, 57));
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (137, 136, 1, 0, 2, 57));
}
#[test]
+8 -3
View File
@@ -417,12 +417,16 @@ fn assert_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
assert!(html.contains(&format!(">{expected}</p>")), "slot Label HTML: {html}");
}
fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
fn assert_stored_slot_text(s: &Session, instance: NodeId, expected: &str) {
assert!(
matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Text") == Some(&json!(expected))),
"instance {instance} must store its own Text value {expected:?}: {:?}",
node(s, instance).kind
);
}
fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
assert_stored_slot_text(s, instance, expected);
let properties = props_of(s, instance);
let text = properties.iter().find(|p| p["name"] == "Text").unwrap_or_else(|| panic!("instance {instance} has no Text property: {properties:?}"));
assert_eq!(text["value"], expected, "instance {instance} Text property");
@@ -475,14 +479,15 @@ fn slot_text_properties_do_not_read_an_unbound_base_property() {
ex(&mut s, "component.addProperty", json!({"id": base, "name": "Text", "type": "text", "default": "Wrong owner"}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
let nested_base = kids(&s, i)[0];
assert_local_slot_text(&mut s, nested_base, "Wrong owner");
// Bound to no layer, the base's Text isn't listed on its instance (#2269); its value stays stored.
assert_stored_slot_text(&s, nested_base, "Wrong owner");
let nested = copy_of(&s, i, main_content);
assert_local_slot_text(&mut s, nested, "Default");
assert_slot_text(&mut s, nested, "Default");
ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Text": "Custom"}}));
assert_local_slot_text(&mut s, nested, "Custom");
assert_slot_text(&mut s, nested, "Custom");
assert_local_slot_text(&mut s, nested_base, "Wrong owner");
assert_stored_slot_text(&s, nested_base, "Wrong owner");
assert_slot_text(&mut s, main_content, "Default");
healthy(&s);
}
+117
View File
@@ -160,6 +160,7 @@ fn what_a_style_cannot_bind_is_refused() {
("fills/3/color", "bg", "no field"),
("effects/0/radius", "blur", "no field"),
("fontSize", "size", "no field"),
("fontWeight", "bg", "no field"),
("opacity", "size", "no field"),
("fill", "nope", "no such variable"),
] {
@@ -209,6 +210,7 @@ fn style_fields_offer_their_properties_scopes() {
(&body, "letterSpacing", Some(&["LETTER_SPACING"][..])),
(&body, "fontFamily", Some(&["FONT_FAMILY"][..])),
(&body, "fontStyle", Some(&["FONT_STYLE"][..])),
(&body, "fontWeight", Some(&["FONT_WEIGHT"][..])),
// Grid fields take any number variable, as on layers; fields a style lacks take none.
(&grid, "layoutGrids/0/size", None),
(&sid, "fills/3/color", None),
@@ -244,3 +246,118 @@ fn rest_and_fig_carry_style_bindings() {
assert!(got.bound.is_empty() && got.styles.fill.as_deref() == Some(st.id.as_str()), "{got:?}");
}
}
/// A text style "Body" (made from a layer, or from `value`) used by a layer in the Light frame
/// and one in the Dark one; returns (light layer, dark layer, style id).
fn body_style(s: &mut Session, value: Option<Value>) -> (NodeId, NodeId, String) {
let (_, dark, a, _, _) = two_modes(s);
let light = node(s, a).parent.unwrap();
let text = |s: &mut Session, f: NodeId| id(run(s, "text.create", json!({"text": "Hello", "x": 0, "y": 60, "parent": f})));
let (t1, t2) = (text(s, light), text(s, dark));
let from = match &value {
Some(v) => json!({"type": "text", "name": "Body", "value": v}),
None => json!({"type": "text", "name": "Body", "from": t1}),
};
let body = run(s, "style.create", from)["id"].as_str().unwrap().to_string();
run(s, "style.apply", json!({"ids": [t1, t2], "style": body}));
(t1, t2, body)
}
fn text_of(s: &Session, n: NodeId) -> figcraft_doc::TextStyle {
node(s, n).kind.text().unwrap().style.clone()
}
#[test]
fn a_number_weight_picks_the_face_and_the_wght_axis() {
let mut s = Session::new();
let (t1, t2, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"}));
let face = |s: &Session, n: NodeId| {
let t = text_of(s, n);
(t.font_style, t.variations.get("wght").copied())
};
assert_eq!(face(&s, t1), ("Bold".to_string(), Some(700.0)));
assert_eq!(face(&s, t2), ("Regular".to_string(), Some(400.0)));
run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Dark", "value": 650}));
assert_eq!(face(&s, t2), ("Bold".to_string(), Some(650.0)));
let e = s.execute("style.bind", &json!({"style": body, "field": "fontWeight", "variable": "bg"})).unwrap_err().to_string();
assert!(e.contains("takes a number variable"), "{e}");
// A style name bound afterwards replaces the number binding.
run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]}));
run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontStyle", "variable": "face"}));
let bound = s.doc().unwrap().style(&body).unwrap().bound.clone();
assert!(bound.contains_key("fontStyle") && !bound.contains_key("fontWeight"), "{bound:?}");
// The name replaces the number's weight: no stale `wght` axis keeps drawing 700.
assert_eq!(face(&s, t1), ("Light".to_string(), None));
assert_eq!(text_of(&s, t1).weight(), 300);
}
#[test]
fn junk_weight_values_clamp_to_the_lightest_and_heaviest_face() {
let mut s = Session::new();
let (t1, _, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": -50, "Dark": 5000}}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"}));
let face = |s: &Session| {
let t = text_of(s, t1);
(t.font_style, t.variations.get("wght").copied())
};
assert_eq!(face(&s), ("Thin".to_string(), Some(1.0)));
run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Light", "value": 5000}));
assert_eq!(face(&s), ("Black".to_string(), Some(1000.0)));
}
#[test]
fn font_weight_wins_over_font_style_when_a_file_carries_both() {
let mut s = Session::new();
let (t1, _, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]}));
run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}}));
let doc = s.doc_mut().unwrap();
let (w, f) = (doc.variables.get("weight").unwrap().id.clone(), doc.variables.get("face").unwrap().id.clone());
let st = doc.styles.iter_mut().find(|x| x.id == body).unwrap();
st.bound.insert("fontStyle".into(), f);
st.bound.insert("fontWeight".into(), w);
crate::style_vars::after_load(doc);
assert_eq!(text_of(&s, t1).font_style, "Bold");
}
#[test]
fn a_number_bound_to_a_percent_line_height_applies_as_pixels() {
let mut s = Session::new();
let (t1, _, body) = body_style(&mut s, Some(json!({"fontSize": 24, "lineHeight": "150%"})));
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "lh", "type": "number", "values": {"Light": 150, "Dark": 36}}));
run(&mut s, "style.bind", json!({"style": body, "field": "lineHeight", "variable": "lh"}));
let lh = |s: &Session| match &s.doc().unwrap().style(&body).unwrap().kind {
StyleKind::Text { style, .. } => style.line_height,
k => panic!("{k:?}"),
};
assert_eq!(lh(&s), figcraft_doc::LineHeight::Pixels(150.0));
assert_eq!(text_of(&s, t1).line_height, figcraft_doc::LineHeight::Pixels(150.0));
assert_eq!(node(&s, t1).height, 150.0);
run(&mut s, "variable.set", json!({"variable": "lh", "mode": "Light", "value": 36}));
assert_eq!((lh(&s), node(&s, t1).height), (figcraft_doc::LineHeight::Pixels(36.0), 36.0));
}
#[test]
fn a_text_style_binding_round_trips_through_fig() {
let mut s = Session::new();
let (_, _, body) = body_style(&mut s, None);
run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}}));
for (field, var) in [("fontSize", "size"), ("lineHeight", "size"), ("fontWeight", "weight")] {
run(&mut s, "style.bind", json!({"style": body, "field": field, "variable": var}));
}
let doc = s.doc().unwrap().clone();
let (bytes, warnings) = figcraft_format::fig::export(&doc, None).unwrap();
let (mut back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
crate::style_vars::after_load(&mut back);
let st = back.styles.iter().find(|x| x.name == "Body").unwrap();
assert!(st.bound.contains_key("fontSize") && st.bound.contains_key("lineHeight"), "{st:?}");
// The format has no weight field yet: the settled name comes back, not the binding (the file has no `wght` axis field yet).
assert!(warnings.iter().any(|w| w.contains("no field for")), "{warnings:?}");
let StyleKind::Text { style, .. } = &st.kind else { panic!("{st:?}") };
assert_eq!(style.font_style, "Bold");
}
@@ -418,6 +418,51 @@ fn text_styles_carry_paragraph_spacing_indent_lists_and_features() {
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err());
}
#[test]
fn text_styles_carry_the_wrap_style() {
// Explore text properties: a wrap style can be set on the text layer, a paragraph or a text
// style. A style made from a text takes its wrap style, applying it sets it,
// editing it on a styled text unlinks it.
use figcraft_doc::WrapStyle::{Auto, Balance, Pretty};
let mut s = Session::new();
let td = |s: &Session, n: NodeId| -> TextData { node(s, n).kind.text().unwrap().clone() };
let t = text(&mut s, 0.0);
run(&mut s, "node.set", json!({"id": t, "props": {"characters": "One\nTwo", "wrapStyle": "balance"}}));
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Even", "from": t})));
let listed = run(&mut s, "style.list", json!({"type": "text"}));
let even = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap();
assert_eq!(even["paragraph"]["wrap_style"], json!("balance"), "{even}");
// Applying it sets the layer's wrap style, with no paragraph overrides, and links the layer.
let t2 = text(&mut s, 300.0);
run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB"}}));
run(&mut s, "style.apply", json!({"ids": [t2], "style": st}));
let d = td(&s, t2);
assert_eq!(d.wrap_style, Balance);
assert!(d.paragraphs.iter().all(|p| p.wrap.is_none()), "{:?}", d.paragraphs);
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()));
// Redefining the style moves the layer and keeps the link.
run(&mut s, "style.update", json!({"style": st, "value": {"wrapStyle": "pretty"}}));
let d = td(&s, t2);
assert_eq!((d.para_wrap_style(0), d.para_wrap_style(1)), (Pretty, Pretty));
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update");
// Editing the wrap style on the styled text unlinks it.
run(&mut s, "node.set", json!({"id": t2, "props": {"wrapStyle": "auto"}}));
assert_eq!(td(&s, t2).wrap_style, Auto);
assert_eq!(node(&s, t2).styles.text, None, "wrap style edited: unlinked");
// On a range, the paragraphs touched take it as their own and the layer keeps its style.
let t3 = text(&mut s, 600.0);
run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB"}}));
run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1}));
let d = td(&s, t3);
assert_eq!((d.wrap_style, d.para_wrap_style(0), d.para_wrap_style(1)), (Auto, Pretty, Auto));
// A definition given as a value takes the node.set spellings; junk is refused.
let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": {"wrapStyle": "balance"}})));
let want = ParagraphStyle { wrap_style: Balance, ..Default::default() };
assert!(matches!(&s.doc().unwrap().style(&st2).unwrap().kind, StyleKind::Text { paragraph, .. } if **paragraph == want));
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"wrapStyle": "zigzag"}})).is_err());
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"textWrap": 3}})).is_err());
}
#[test]
fn styles_are_organised_by_slash_names_order_and_duplicates() {
// Documented: "/" in a name groups styles in folders; moving a style to another folder renames
+86
View File
@@ -495,6 +495,92 @@ fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
assert_eq!(lines(&s), [(0, 9), (9, 16), (16, 21)]);
}
#[test]
fn wrap_style_per_paragraph_overrides_the_layers() {
// Explore text properties: a wrap style can be set on the text layer, a paragraph or a text
// style. A paragraph's own wrap style beats the layer's; setting the layer
// sets every paragraph again.
let font = test_mono_font();
let mut s = Session::new();
let t = text(&mut s, "aaa bbb ccc ddd eee\naaa bbb ccc ddd eee", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 136}})).unwrap();
let lines = |s: &Session| -> Vec<(usize, usize)> {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
l.lines.iter().map(|l| (l.char_start, l.char_end)).collect()
};
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
let paragraphs = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().paragraphs.clone();
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 36), (36, 39)], "Auto: four words and one in each paragraph");
// The second paragraph alone, by a range on node.set.
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 20, "end": 20}})).unwrap();
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 32), (32, 39)]);
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance"]));
assert!(inspect(&s).get("wrapStyle").is_none());
assert!(!css(&mut s).contains("text-wrap"), "{}", css(&mut s));
// While editing, the caret's paragraph (the Wrap style control).
s.execute("text.edit.begin", &json!({"id": t, "caret": 0})).unwrap();
s.execute("text.wrapStyle", &json!({"style": "pretty"})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(lines(&s), [(0, 12), (12, 20), (20, 32), (32, 39)]);
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["pretty", "balance"]));
// Undo restores the paragraph's override.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 32), (32, 39)]);
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance"]));
// Enter inside the balanced paragraph carries the style to the new paragraph.
s.execute("text.edit.begin", &json!({"id": t, "caret": 22})).unwrap();
s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "balance"]));
// The layer level sets every paragraph and folds the overrides away.
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
assert!(paragraphs(&s).iter().all(|p| p.wrap.is_none()), "{:?}", paragraphs(&s));
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
assert!(css(&mut s).contains("text-wrap: balance;"), "{}", css(&mut s));
assert!(s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "zigzag", "start": 0, "end": 0}})).is_err());
}
#[test]
fn text_wrap_style_without_editing_sets_the_layer_and_folds_paragraph_overrides() {
let mut s = Session::new();
let t = text(&mut s, "one\ntwo\nthree", json!({}));
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
let td = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().clone();
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 5, "end": 5}})).unwrap();
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty", "start": 9, "end": 9}})).unwrap();
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "pretty"]));
assert!(s.ds().unwrap().text_edit.is_none(), "no edit session");
s.execute("text.wrapStyle", &json!({"ids": [t], "style": "balance"})).unwrap();
let after = td(&s);
assert!(after.paragraphs.iter().all(|p| p.wrap.is_none()), "{:?}", after.paragraphs);
assert_eq!(after.wrap_style, figcraft_doc::WrapStyle::Balance);
assert!((0..3).all(|p| after.para_wrap_style(p) == figcraft_doc::WrapStyle::Balance));
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
assert!(inspect(&s).get("lineWrapStyles").is_none());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "pretty"]), "undo restores the overrides");
}
#[test]
fn inspect_reports_wrap_style_only_when_not_auto_and_counts_a_trailing_empty_paragraph() {
let mut s = Session::new();
let t = text(&mut s, "one\ntwo\n", json!({}));
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
let quiet = |s: &Session| inspect(s).get("wrapStyle").is_none() && inspect(s).get("lineWrapStyles").is_none();
assert!(quiet(&s), "uniform Auto says nothing");
// An override on the empty last paragraph (a caret after the final newline) is listed with the others.
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 8, "end": 8}})).unwrap();
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "auto", "balance"]));
assert!(inspect(&s).get("wrapStyle").is_none());
// Every paragraph the same non-Auto style: the one key; back to Auto: neither.
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
assert!(inspect(&s).get("lineWrapStyles").is_none());
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "auto"}})).unwrap();
assert!(quiet(&s));
}
#[test]
fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
// "Max lines is available only if truncate text is enabled, and: Text resizing is set to
@@ -369,6 +369,73 @@ fn resizing_and_flipping_one_layer() {
assert!(near(rotation(&s, t).abs(), 30.0), "Shift: the nearest 15° step: {}", rotation(&s, t));
}
/// A rotate-handle drag on a layer at `start` degrees, turning it by `turn` degrees in the Design
/// panel's convention (positive is counter-clockwise on screen); returns the rotation it ends at.
fn rotate_drag_from(start: f64, turn: f64, m: Mods) -> f64 {
let mut s = session();
let r = rect(&mut s, 0.0, 200.0, 100.0, 100.0, None);
set(&mut s, &[r], json!({"rotation": start}));
ex(&mut s, "select.set", json!({"ids": [r]}));
// The handle sits just outside the top-left corner of the turned box; the pointer then circles
// the centre by `turn` (the page angle runs the other way round, y being down).
let world = s.doc().unwrap().world_transform(r);
let (c, corner) = (world * Point::new(50.0, 50.0), world * Point::ZERO);
let from = corner + (corner - c) * 0.15;
let a = (from - c).atan2() - turn.to_radians();
let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0;
drag(&mut s, from, to, m);
rotation(&s, r)
}
#[test]
fn shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start() {
// Measured in Figma: a layer at 7° (or -4.27°) Shift-rotated by its corner ends at 15°, so the
// steps are absolute multiples of 15°, not 15° steps from where the drag began.
let got = rotate_drag_from(7.0, 37.0, SHIFT);
assert!(near(got, 45.0), "7° turned by 37° is 44°, nearest multiple of 15 is 45°: {got}");
// Without Shift the turn is free.
let got = rotate_drag_from(7.0, 37.0, NONE);
assert!(near(got, 44.0), "a free turn: {got}");
// A small turn: 7° + 3° = 10°, nearest multiple of 15 is 15° (not 7° or 10°).
let got = rotate_drag_from(7.0, 3.0, SHIFT);
assert!(near(got, 15.0), "10° rounds to 15°: {got}");
let got = rotate_drag_from(-4.27, 2.0, SHIFT);
assert!(near(got, 0.0), "-2.27° rounds to 0°: {got}");
}
/// Rotates a 120 × 80 layer inside a frame turned `frame_rot`, the layer at `start` on the page,
/// by `turn` from its corner, and returns its rotation on the page.
fn rotate_drag_in_frame(frame_rot: f64, start: f64, turn: f64, m: Mods) -> f64 {
let mut s = session();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 400, "height": 400})));
set(&mut s, &[f], json!({"rotation": frame_rot}));
let r = rect(&mut s, 100.0, 100.0, 120.0, 80.0, Some(f));
set(&mut s, &[r], json!({"rotation": start - frame_rot}));
let page = |s: &Session| rotation_degrees(s.doc().unwrap().world_transform(r));
assert!(near(page(&s), start), "the layer starts at {start}° on the page: {}", page(&s));
ex(&mut s, "select.set", json!({"ids": [r]}));
let world = s.doc().unwrap().world_transform(r);
let (c, corner) = (world * Point::new(60.0, 40.0), world * Point::ZERO);
let from = corner + (corner - c) * 0.15;
let a = (from - c).atan2() - turn.to_radians();
let to = c + figcraft_geom::Vec2::from_angle(a) * 80.0;
drag(&mut s, from, to, m);
page(&s)
}
#[test]
fn shift_rotating_inside_a_rotated_frame_lands_the_page_rotation_on_multiples_of_15() {
// Measured in Figma (2026-11-10): the rotation field of a layer inside a frame turned 10° shows
// its rotation on the page, and Shift lands that on multiples of 15°: from 7°, turns of 40°,
// 6° and -10° end at 45°, 15° and 0°; without Shift 6° ends at 13°. Groups behave the same.
for (turn, want) in [(40.0, 45.0), (6.0, 15.0), (-10.0, 0.0)] {
let got = rotate_drag_in_frame(10.0, 7.0, turn, SHIFT);
assert!(near(got, want), "7° on the page turned by {turn}° with Shift: {got}, want {want}");
}
let got = rotate_drag_in_frame(10.0, 7.0, 6.0, NONE);
assert!(near(got, 13.0), "a free turn: {got}");
}
#[test]
fn groups_scale_children_keeping_their_rotation_and_flip() {
let mut s = session();
@@ -466,6 +533,61 @@ fn design_panel_edits_reach_every_selected_layer() {
assert!(near(rotation(&s, a), 30.0) && flipped(&s, a), "{}", rotation(&s, a));
}
#[test]
fn absolute_rotation_sets_the_rotation_on_the_page_keeping_the_centre() {
let mut s = session();
let page = |s: &Session, i: NodeId| rotation_degrees(s.doc().unwrap().world_transform(i));
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 400, "height": 400})));
set(&mut s, &[f], json!({"rotation": 10}));
let r = rect(&mut s, 100.0, 100.0, 120.0, 80.0, Some(f));
assert!(near(page(&s, r), 10.0) && near(rotation(&s, r), 0.0));
let centre = s.doc().unwrap().world_transform(r) * Point::new(60.0, 40.0);
set(&mut s, &[r], json!({"absoluteRotation": 7}));
assert!(near(page(&s, r), 7.0), "{}", page(&s, r));
assert!(near(rotation(&s, r), -3.0), "{}", rotation(&s, r));
let c = s.doc().unwrap().world_transform(r) * Point::new(60.0, 40.0);
assert!((c - centre).hypot() < 1e-6, "the centre stays put");
set(&mut s, &[r], json!({"absoluteRotation": 0}));
assert!(near(page(&s, r), 0.0) && near(rotation(&s, r), -10.0));
// `node.get` still reports the local angle.
let got = ex(&mut s, "node.get", json!({"id": r}));
let rot = got["rotation"].as_f64().unwrap_or(f64::NAN);
assert!(near(rot, -10.0), "{got}");
// Groups: a child reads and sets the page angle, its sibling keeps its own.
let a = rect(&mut s, 0.0, 600.0, 50.0, 50.0, None);
let b = rect(&mut s, 100.0, 600.0, 50.0, 50.0, None);
let g = id(&ex(&mut s, "object.group", json!({"ids": [a, b]})));
set(&mut s, &[g], json!({"rotation": 10}));
assert!(near(page(&s, a), 10.0));
set(&mut s, &[a], json!({"absoluteRotation": 7}));
assert!(near(page(&s, a), 7.0) && near(page(&s, b), 10.0));
// Several layers: one value on the page for all, one undo step.
let top = rect(&mut s, 800.0, 0.0, 50.0, 50.0, None);
let steps = undo_steps(&s);
set(&mut s, &[r, top], json!({"absoluteRotation": 45}));
assert!(near(page(&s, r), 45.0) && near(page(&s, top), 45.0));
assert_eq!(undo_steps(&s), steps + 1);
// A flip is kept.
ex(&mut s, "object.flipH", json!({"ids": [r]}));
set(&mut s, &[r], json!({"absoluteRotation": 30}));
assert!(near(page(&s, r), 30.0) && flipped(&s, r), "{}", page(&s, r));
// One of the two keys per call.
assert!(s.execute("node.set", &json!({"ids": [r], "props": {"rotation": 1, "absoluteRotation": 2}})).is_err());
}
#[test]
fn absolute_rotation_on_a_frame_and_a_layer_inside_it_lands_both_on_the_page_angle() {
let mut s = session();
let page = |s: &Session, i: NodeId| rotation_degrees(s.doc().unwrap().world_transform(i));
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 100, "width": 400, "height": 400})));
set(&mut s, &[f], json!({"rotation": 10}));
let r = rect(&mut s, 100.0, 100.0, 120.0, 80.0, Some(f));
// The layer inside comes first in the ids: the frame's turn must not carry it past 45.
set(&mut s, &[r, f], json!({"absoluteRotation": 45}));
assert!(near(page(&s, f), 45.0), "{}", page(&s, f));
assert!(near(page(&s, r), 45.0), "{}", page(&s, r));
}
#[test]
fn constrain_proportions_keeps_the_other_side_in_ratio() {
let mut s = session();
+13
View File
@@ -562,6 +562,19 @@ fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() {
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}");
}
#[test]
fn css_export_keeps_a_style_weight_bare() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "weight", "type": "number", "value": 700}));
let t = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0})));
let st = ex(&mut s, "style.create", json!({"type": "text", "name": "Body", "from": t}))["id"].as_str().unwrap_or_default().to_string();
ex(&mut s, "style.bind", json!({"style": st, "field": "fontWeight", "variable": "weight"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
// A weight bound only to a text style is still a bare number, not a length.
assert!(css.contains("--weight: 700;") && !css.contains("700px"), "{css}");
}
#[test]
fn review_a_long_alias_chain_keeps_its_role_to_the_end() {
let mut s = Session::new();
@@ -881,3 +881,68 @@ fn vector_points_take_the_requested_size() {
let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string();
assert!(e.contains("positive"), "{e}");
}
/// A vector from `points` with a corner radius per point (`radii`), via `vector.create`.
fn rounded_vector(s: &mut Session, points: &[(f64, f64)], radii: &[f64], closed: bool) -> NodeId {
let pts: Vec<Value> = points.iter().map(|p| json!([p.0, p.1])).collect();
let id = id_of(&ex(s, "vector.create", json!({"points": pts, "closed": closed}))["id"]).unwrap();
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
for (v, r) in network.vertices.iter_mut().zip(radii) {
v.radius = *r;
}
}
id
}
/// #2195: Flatten bakes a vector's point radii into its path: the rounded vertex becomes the
/// arc's end points joined by curves, the layer stays a vector, the box fits the real geometry.
#[test]
fn flatten_bakes_a_vectors_point_radius() {
// An open V with the apex rounded.
let mut s = Session::new();
let v = rounded_vector(&mut s, &[(0.0, 0.0), (184.0, 245.0), (368.0, 0.0)], &[0.0, 60.0, 0.0], false);
let before = net(&s, v);
let drawn = before.rounded().unwrap().stroke_path().perimeter(1e-4);
let h_before = s.doc().unwrap().node(v).unwrap().height;
let out = ex(&mut s, "vector.flatten", json!({"ids": [v]}));
let f = id_of(&out["id"]).unwrap();
let after = net(&s, f);
assert_eq!(after.vertices.len(), 4, "the apex became two points: 3 + 1");
assert!(after.vertices.iter().all(|p| p.radius == 0.0), "no radius left");
let len = after.stroke_path().perimeter(1e-4);
assert!((len - drawn).abs() < 0.5, "the outline is the same: {len} vs {drawn}");
let h = s.doc().unwrap().node(f).unwrap().height;
let bb = after.bounds().unwrap();
assert!(h < h_before && (h - 245.0).abs() > 1.0, "the box shrinks from 245: {h}");
assert!((h - bb.height()).abs() < 1e-6 && bb.y0.abs() < 1e-6, "the box is the baked path's bounds: {h} vs {bb:?}");
println!("open V: points {} -> {}, height {h_before} -> {h}", before.vertices.len(), after.vertices.len());
s.undo().unwrap();
assert!(s.doc().unwrap().node(v).is_some() && s.doc().unwrap().node(f).is_none(), "one undo step");
assert_eq!(net(&s, v).vertices[1].radius, 60.0);
// A closed triangle rounded at every point.
let mut s = Session::new();
let v = rounded_vector(&mut s, &[(0.0, 0.0), (368.0, 0.0), (184.0, 245.0)], &[40.0; 3], true);
let drawn = net(&s, v).rounded().unwrap();
let (len, area) = (drawn.stroke_path().perimeter(1e-4), drawn.fill_path().area().abs());
let f = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [v]}))["id"]).unwrap();
let after = net(&s, f);
assert_eq!(after.vertices.len(), 6, "each corner became two points");
assert!(after.vertices.iter().all(|p| p.radius == 0.0));
assert_eq!(after.regions.len(), 1, "still one filled region");
assert!((after.stroke_path().perimeter(1e-4) - len).abs() < 0.5);
assert!((after.fill_path().area().abs() - area).abs() < 0.5, "same fill area");
let n = s.doc().unwrap().node(f).unwrap().clone();
let bb = after.bounds().unwrap();
assert!(n.height < 245.0 && (n.height - bb.height()).abs() < 1e-6 && (n.width - bb.width()).abs() < 1e-6);
println!("triangle: points 3 -> {}, height 245 -> {}", after.vertices.len(), n.height);
// Guard: no radius, nothing changes.
let mut s = Session::new();
let v = rounded_vector(&mut s, &[(0.0, 0.0), (184.0, 245.0), (368.0, 0.0)], &[0.0; 3], false);
let before = net(&s, v);
let f = id_of(&ex(&mut s, "vector.flatten", json!({"ids": [v]}))["id"]).unwrap();
assert_eq!(net(&s, f).vertices.len(), 3);
assert_eq!(s.doc().unwrap().node(f).unwrap().height, 245.0);
assert_eq!(net(&s, f).vertices, before.vertices);
}
+1 -1
View File
@@ -148,7 +148,7 @@ fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result
let max_nodes = layer_budget(packed);
drop(message);
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
let (mut json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
let (mut json, mut warnings, networks) = map::to_rest_for(&msg, file_name, max_nodes, board);
// The message goes before the layers are built, so the images it carries are copied out first.
let embedded = embedded_copies(&msg);
drop(msg);
+97 -1
View File
@@ -446,10 +446,106 @@ fn a_jam_file_imports_like_a_fig() {
let (doc, warnings) = import(&bytes, "Synthetic board").unwrap();
assert_eq!(doc.kind, figcraft_doc::FileKind::Board, "a FigJam whiteboard opens as a board");
let note = named(&doc, "Board object");
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 3, "the sticky with its background, note and author");
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 2, "the sticky is native: its note and author");
assert_eq!(note.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() }));
assert!(matches!(named(&doc, "Synthetic table").kind, NodeKind::Frame));
assert!(doc.nodes().all(|n| n.name != "Unknown object"), "left out");
assert!(warnings.iter().any(|w| w.contains("table layers import as")), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("SYNTHETIC_JAM_ONLY")), "a note says what was left out: {warnings:?}");
}
}
/// Import `layer` through the public importer as a FigJam whiteboard (`fig-jam.` header).
fn jam(layer: Kv<'static>) -> (Document, Vec<String>) {
let mut bytes = canvas(&board_schema(), &message(layer));
bytes.splice(..8, *b"fig-jam.");
import(&bytes, "Synthetic board").unwrap()
}
/// On a whiteboard a sticky is a native sticky: its colour, size, text and author come along.
#[test]
fn a_jam_sticky_is_a_native_sticky_with_its_colour_text_and_author() {
let (doc, warnings) = jam(board_node("STICKY"));
assert_eq!(doc.kind, figcraft_doc::FileKind::Board);
let frame = named(&doc, "Board object");
assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() }));
assert_eq!(frame.fills[0].solid_color().unwrap().to_rgba8(), [255, 191, 64, 255]);
assert_eq!((frame.width, frame.height), (240.0, 240.0));
assert_eq!(frame.opacity, 0.5);
let layout = frame.layout.as_ref().expect("sticky auto layout");
assert_eq!(layout.padding, [20.0; 4]);
assert_eq!(frame.child_layout.min_h, Some(240.0));
assert_eq!(frame.children.len(), 2, "the note and its author, no background vector");
let texts: Vec<_> = frame.children.iter().map(|id| doc.node(*id).unwrap()).collect();
assert!(texts.iter().all(|n| n.kind.text().is_some()));
let note = texts[0].kind.text().unwrap();
assert_eq!(note.characters, "Olá\nIdea");
assert_eq!(note.style.font_size, 24.0);
assert!(note.runs.iter().any(|r| r.style.font_size == 18.0), "inline styles survive");
let author = texts[1].kind.text().unwrap();
assert_eq!(author.characters, "Test Author");
assert_eq!(author.style.font_size, 12.0);
assert!(!warnings.iter().any(|w| w.contains("skipped")), "{warnings:?}");
}
/// A sticky whose author line is not saved has an empty author and just its note.
#[test]
fn a_jam_sticky_without_an_author_line_has_no_author() {
let mut layer = board_node("STICKY");
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]);
set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text("Olá\nIdea", 24.0))]))]));
let (doc, _) = jam(layer);
let frame = named(&doc, "Board object");
assert_eq!(frame.children.len(), 1);
let Some(figcraft_doc::BoardItem::Sticky { author }) = &frame.board else { panic!("{:?}", frame.board) };
assert!(author.is_empty());
}
/// A sticky record that isn't complete keeps today's generated layers and no board item.
#[test]
fn a_damaged_jam_sticky_keeps_the_generated_layers() {
let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("visible", Kv::Bool(true))]);
let cases: Vec<Kv<'static>> = {
let mut v = Vec::new();
// Layout version 2.
let mut a = board_node("STICKY");
set(&mut a, "derivedImmutableFrameData", o(vec![("version", u(2))]));
v.push(a);
// Background record missing.
let mut b = board_node("STICKY");
set(&mut b, "nodeGenerationData", o(vec![("overrides", arr(vec![record(1, text("Olá", 24.0))]))]));
v.push(b);
// A stroke on the background.
let mut c = board_node("STICKY");
let stroked = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)])), ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)]))]);
set(&mut c, "nodeGenerationData", o(vec![("overrides", arr(vec![stroked, record(1, text("Olá", 24.0))]))]));
v.push(c);
// A gradient on the background.
let mut d = board_node("STICKY");
let graded = record(0, vec![("fillPaints", arr(vec![gradient]))]);
set(&mut d, "nodeGenerationData", o(vec![("overrides", arr(vec![graded, record(1, text("Olá", 24.0))]))]));
v.push(d);
// Blank note.
let mut f = board_node("STICKY");
let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]);
set(&mut f, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text(" ", 24.0))]))]));
v.push(f);
v
};
for (i, layer) in cases.into_iter().enumerate() {
let (as_fig, _) = imported(layer.clone());
let (doc, _) = jam(layer);
let frame = named(&doc, "Board object");
assert!(frame.board.is_none(), "case {i}");
assert_eq!(frame.children.len(), named(&as_fig, "Board object").children.len(), "case {i}: the generated layers, as in a .fig");
}
}
/// The same sticky in a `.fig` stays the design frame it always was.
#[test]
fn a_fig_sticky_stays_a_design_frame() {
let (doc, _) = imported(board_node("STICKY"));
let frame = named(&doc, "Board object");
assert!(frame.board.is_none());
assert_eq!(frame.children.len(), 3);
}
+15
View File
@@ -811,6 +811,21 @@ fn generated_documents_round_trip() {
}
}
#[test]
fn a_boolean_groups_corner_radius_round_trips() {
let mut doc = Document::new("t");
let page = doc.first_page().unwrap();
let g = doc.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
doc.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap();
doc.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap();
doc.fit_to_children(g).unwrap();
doc.get_mut(g).unwrap().corners = Corners { radii: [20.0; 4], smoothing: 0.6 };
let (back, _) = round_trip(&doc, "boolean radius");
let n = back.nodes().find(|n| matches!(n.kind, NodeKind::BooleanOp { .. })).unwrap();
assert!(n.corners.radii.iter().all(|r| (r - 20.0).abs() < 1e-4), "{:?}", n.corners);
assert!((n.corners.smoothing - 0.6).abs() < 1e-4, "{:?}", n.corners);
}
#[test]
fn round_trip_keeps_the_details() {
let doc = generate(3);
+12
View File
@@ -17,6 +17,7 @@ use serde_json::{Map, Value, json};
use super::kiwi::Kv;
use super::network;
mod board;
mod component;
mod connector;
mod document;
@@ -50,8 +51,16 @@ const MAX_ASSET_PART: usize = 256;
/// The REST API file JSON for message `msg`, with the warnings about what was skipped and the
/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]).
#[cfg(test)]
pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
to_rest_for(msg, name, max_nodes, false)
}
/// The REST JSON (as `to_rest`) for a FigJam whiteboard (`board`) or a design file: a
/// whiteboard's stickies come out as native board items.
pub(super) fn to_rest_for(msg: &Kv, name: &str, max_nodes: usize, board: bool) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
let mut m = Mapper::new(msg, max_nodes);
m.board = board;
let _canon = VarCanon::install(&m);
let doc = m.document();
// Large values are moved in, never passed through `json!`, which serialises its arguments:
@@ -362,6 +371,8 @@ struct Mapper<'a> {
/// Region paint elements (paints and gradient stops) read so far: one budget for the whole
/// file, since layers with region paints don't share their network.
region_paint_elements: usize,
/// The file is a FigJam whiteboard: its objects are built as native board items.
board: bool,
}
impl<'a> Mapper<'a> {
@@ -439,6 +450,7 @@ impl<'a> Mapper<'a> {
max_nodes,
reserved: 0,
networks: Vec::new(),
board: false,
network_ids: HashMap::new(),
region_paint_elements: 0,
}
+88
View File
@@ -0,0 +1,88 @@
//! FigJam whiteboard objects as native board items: what a `.jam` import builds in place of the
//! generated frames a `.fig` keeps. The REST JSON carries them as a `board` field
//! ([`crate::rest`] reads it).
use super::layer::{own_text, valid_size};
use super::paint::paint;
use super::{Affine, Kv, MAX_DEPTH, Mapper, Obj, Value, View, generated_record, json, num};
impl<'a> Mapper<'a> {
/// A sticky as a native board sticky: its frame (size, place, opacity and shadows as saved,
/// the saved colour) holding the note and, when saved, the author line. `None` (nothing
/// changed) unless every generated record is complete: then the caller keeps the generated
/// layers.
pub(super) fn native_sticky(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, depth: usize) -> Option<Vec<Value>> {
if v.get("nodeGenerationData").is_none() || v.get("derivedImmutableFrameData").and_then(|d| num(d, "version")) != Some(1.0) {
return None;
}
let record = |id: u32| {
let (style, bounds) = (generated_record(v, "nodeGenerationData", id)?, generated_record(v, "derivedImmutableFrameData", id)?);
let view = View::of(style).with([(bounds, 1.0)]);
(valid_size(view.get("size")).is_some() && view.transform().is_some()).then_some(view)
};
let background = record(0)?;
let fills = background.list("fillPaints");
let solid = matches!(fills, [p] if p.get("type").and_then(Kv::str) == Some("SOLID") && p.get("visible").and_then(Kv::bool) != Some(false));
if !solid || !background.list("strokePaints").is_empty() {
return None;
}
let note = record(1).filter(|n| own_text(n).is_some())?;
let author = record(2).filter(|a| a.flag("visible") != Some(false) && own_text(a).is_some());
let wanted = 1 + usize::from(author.is_some());
if depth >= MAX_DEPTH || self.count.saturating_add(wanted) > self.budget() {
return None;
}
let (w, h) = v.size().or_else(|| o.get("size").and_then(|s| Some((s.get("x")?.as_f64()?, s.get("y")?.as_f64()?))))?;
let at = note.transform().unwrap_or_default().as_coeffs();
let pad = |x: f64| if x.is_finite() { x.clamp(0.0, (w.min(h) / 2.0).max(0.0)) } else { 0.0 };
let (pad_x, pad_y) = (pad(at[4]), pad(at[5]));
// Everything is valid: build.
let paints: Vec<Value> = fills.iter().filter_map(|p| paint(p, &mut |m| self.warn(m))).collect();
o.insert("fills".into(), Value::Array(paints));
o.insert("strokes".into(), json!([]));
o.insert("clipsContent".into(), json!(false));
match background.len("cornerRadius") {
Some(r) => o.insert("cornerRadius".into(), json!(r)),
None => o.remove("cornerRadius"),
};
for (k, val) in [
("layoutMode", json!("VERTICAL")),
("primaryAxisAlignItems", json!("SPACE_BETWEEN")),
("counterAxisAlignItems", json!("MIN")),
("primaryAxisSizingMode", json!("AUTO")),
("counterAxisSizingMode", json!("FIXED")),
("minHeight", json!(h)),
("itemSpacing", json!(8.0)),
("paddingTop", json!(pad_y)),
("paddingBottom", json!(pad_y)),
("paddingLeft", json!(pad_x)),
("paddingRight", json!(pad_x)),
] {
o.insert(k.into(), val);
}
let mut kids = Vec::new();
let mut written = String::new();
for (local, view) in [(1, Some(note)), (2, author)] {
let Some(view) = view else { continue };
let chars = own_text(&view).unwrap_or_default();
let mut layer = Obj::new();
layer.insert("id".into(), json!(format!("{rest_id};generated{local}")));
layer.insert("type".into(), json!("TEXT"));
layer.insert("name".into(), json!(if local == 1 { chars.chars().take(40).collect::<String>() } else { "Author".to_string() }));
self.common(&mut layer, &view, "TEXT", Affine::IDENTITY, None);
self.text(&mut layer, &view);
if let Some(Value::Object(style)) = layer.get_mut("style") {
style.insert("textAutoResize".into(), json!("HEIGHT"));
style.insert("textAlignHorizontal".into(), json!("LEFT"));
}
layer.insert("layoutSizingHorizontal".into(), json!("FILL"));
if local == 2 {
written = chars.trim().to_string();
}
self.count += 1;
kids.push(Value::Object(layer));
}
o.insert("board".into(), json!({"type": "sticky", "author": written}));
Some(kids)
}
}
+10 -2
View File
@@ -174,7 +174,9 @@ impl<'a> Mapper<'a> {
// A label without a snapshot: the connector becomes a frame holding its line and its label.
let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth);
let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? };
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
// A whiteboard's sticky says it imports as a frame only when it can't be a native one.
let native_sticky = self.board && own == "STICKY";
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH" && !native_sticky) {
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
}
if connector_snapshot {
@@ -262,12 +264,18 @@ impl<'a> Mapper<'a> {
});
kids.splice(0..0, connector_kids);
if stand.is_some_and(|s| s.0 == "FRAME") {
if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
let native = if native_sticky { self.native_sticky(&mut o, &view, &rest_id, depth) } else { None };
if let Some(native) = native {
kids.splice(0..0, native);
} else if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
kids.splice(0..0, snapshot);
} else {
kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
}
if native_sticky && !o.contains_key("board") {
self.warn("sticky layers import as frames with their text");
}
}
if !kids.is_empty() {
o.insert("children".into(), Value::Array(kids));
+2 -1
View File
@@ -1583,7 +1583,8 @@ fn dump_samples() {
std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap();
let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len()));
// A whiteboard's dump shows its native board items as the import makes them.
let (rest, ..) = map::to_rest_for(&msg, &stem, super::layer_budget(message.len()), a.canvas.starts_with(super::JAM_HEADER));
std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap();
}
}
+37
View File
@@ -442,6 +442,8 @@ pub(crate) struct Ctx<'a> {
set_defs: BTreeMap<NodeId, BTreeMap<String, PropDef>>,
/// Prototype starting points: (page, name, REST node id).
flows: Vec<(NodeId, String, String)>,
/// Board connectors and labels, resolved once every layer is imported (their ends name layers).
pending_boards: Vec<(NodeId, &'a Value)>,
/// Layers with prototype interactions, resolved once every layer is imported.
protos: Vec<(NodeId, &'a Value)>,
/// Layers that carry overlay settings (position, background, outside clicks), by REST id.
@@ -478,6 +480,7 @@ impl<'a> Ctx<'a> {
swap_defaults: Vec::new(),
set_defs: BTreeMap::new(),
flows: Vec::new(),
pending_boards: Vec::new(),
protos: Vec::new(),
overlay_meta: BTreeMap::new(),
var_kinds: BTreeMap::new(),
@@ -781,6 +784,9 @@ impl<'a> Ctx<'a> {
self.overlay_meta.entry(rid).or_insert(j);
}
self.bindings(n, j);
if let Some(b) = j.get("board") {
self.board_item(n, b);
}
let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original));
// Styles and bindings were read against the stroke: they follow its paints to the fill.
if outlined && !restored {
@@ -788,6 +794,36 @@ impl<'a> Ctx<'a> {
}
}
/// The `board` field (a FigCraft extension): what the object is on a board. A sticky or shape
/// is set at once; anything else, or junk, is ignored with a warning.
fn board_item(&mut self, n: &mut Node, b: &'a Value) {
// A sticky or shape is a frame holding its layers: on anything else it is ignored.
let frame = n.kind.is_frame_like();
let item = match text(b, "type") {
Some("sticky") if frame => {
let author = text(b, "author").unwrap_or("").trim();
Some(figcraft_doc::BoardItem::Sticky { author: author.chars().filter(|c| !c.is_control()).take(64).collect() })
}
Some("shape") if frame => {
text(b, "shape").and_then(figcraft_doc::BoardShape::from_id).map(|shape| figcraft_doc::BoardItem::Shape { shape })
}
_ => None,
};
match item {
Some(item) => n.board = Some(item),
None => self.warn(format!("unknown board item on {} was ignored", n.name)),
}
}
/// Board items that name other layers (connector ends, a label's connector), once every
/// layer is imported and `ids` can resolve them.
fn resolve_boards(&mut self) {
for (id, _) in std::mem::take(&mut self.pending_boards) {
let name = self.doc.node(id).map(|n| n.name.clone()).unwrap_or_default();
self.warn(format!("unknown board item on {name} was ignored"));
}
}
/// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or
/// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its
/// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints
@@ -1096,6 +1132,7 @@ impl<'a> Ctx<'a> {
}
}
self.resolve_instances();
self.resolve_boards();
if let Some(sources) = self.fig_sources.take() {
let missing = sources.apply(&mut self.doc, &self.ids);
if missing > 0 {
@@ -93,3 +93,29 @@ fn thousands_of_distinct_style_references_import_in_linear_time() {
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert_eq!(doc.styles.len(), n);
}
#[test]
fn a_board_field_becomes_the_nodes_board_item() {
let frame = json!({"id": "1:1", "name": "Note", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 200},
"board": {"type": "sticky", "author": "A\u{7}"}});
let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap();
assert_eq!(named(&doc, "Note").board, Some(figcraft_doc::BoardItem::Sticky { author: "A".into() }));
assert!(!warnings.iter().any(|w| w.contains("board item")), "{warnings:?}");
}
#[test]
fn junk_board_fields_are_ignored_with_a_warning() {
for junk in [json!(5), json!({"type": "nope"}), json!({"type": "shape", "shape": "blob"}), json!({"type": "connector"})] {
let frame =
json!({"id": "1:1", "name": "Junk", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}, "board": junk});
let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap();
assert!(named(&doc, "Junk").board.is_none(), "{junk}");
assert!(warnings.iter().any(|w| w.contains("unknown board item on Junk was ignored")), "{junk}: {warnings:?}");
}
// A sticky is a frame holding its layers: on a rectangle the item is ignored.
let rect = json!({"id": "1:2", "name": "Rect", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20},
"board": {"type": "sticky", "author": "A"}});
let (doc, warnings) = import(&file(json!([rect])), &no_images).unwrap();
assert!(named(&doc, "Rect").board.is_none());
assert!(warnings.iter().any(|w| w.contains("unknown board item on Rect was ignored")), "{warnings:?}");
}
+4
View File
@@ -290,6 +290,8 @@ fn vectors_ellipses_stars_and_booleans() {
counted["pointCount"] = json!(6);
let mut boolean = base("Bool", "BOOLEAN_OPERATION");
boolean["booleanOperation"] = json!("SUBTRACT");
boolean["cornerRadius"] = json!(12);
boolean["cornerSmoothing"] = json!(0.6);
boolean["children"] =
json!([base("A", "RECTANGLE"), {"id": "B", "name": "B", "type": "ELLIPSE", "relativeTransform": at(5.0, 5.0), "size": {"x": 10, "y": 10}}]);
let (doc, warnings) = one(json!([tri, wire, empty, arc, star, plain_star, counted, boolean]));
@@ -311,6 +313,8 @@ fn vectors_ellipses_stars_and_booleans() {
assert!(matches!(b.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }));
assert_eq!(b.children.len(), 2);
assert!(close(b.width, 15.0), "booleans wrap their children");
assert_eq!(b.corners.radii, [12.0; 4], "a boolean group keeps its corner radius");
assert!(close(b.corners.smoothing, 0.6));
}
#[test]
+1
View File
@@ -278,6 +278,7 @@ fn lists(v: &Value, td: &mut TextData) {
Some("RTL") => TextDirection::Rtl,
_ => TextDirection::Auto,
},
wrap: None,
})
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
+29 -8
View File
@@ -674,27 +674,48 @@ fn grid_rules() {
rects: &[(0.0, 0.0, 60.0, 20.0), (60.0, 0.0, 240.0, 20.0)],
},
Case {
rule: "fr tracks behave like the CSS fr unit: a fixed child taller than its share keeps its row",
// Share 50 < 80: row 1 keeps 80, row 2 gets the remaining 20.
rule: "fr tracks as measured in Figma: a fixed child taller than its share keeps its row's share and overflows",
// Share 50 < 80: rows stay 50 / 50, child 1 overflows into row 2.
frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(20.0, 80.0), Kid { sizing: (Fixed, Fill), ..KID }],
want: (100.0, 100.0),
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 80.0, 20.0, 20.0)],
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 50.0, 20.0, 50.0)],
},
Case {
rule: "fr tracks: children bigger than every share keep their sizes (the grid overflows)",
rule: "fr tracks as measured in Figma: children bigger than every share keep their sizes (the grid overflows)",
frame: Frame { mode: Grid, size: (100.0, 100.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(20.0, 80.0), kid(20.0, 70.0)],
want: (100.0, 100.0),
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 80.0, 20.0, 70.0)],
rects: &[(0.0, 0.0, 20.0, 80.0), (0.0, 50.0, 20.0, 70.0)],
},
Case {
rule: "fr columns: a fixed child wider than its share keeps its column",
// Share 100 < 150: column 1 keeps 150; column 2 (2fr alone) gets the other 150.
rule: "fr columns as measured in Figma: a fixed child wider than its share overflows its column",
// Share 100 < 150: columns stay 100 / 200.
frame: Frame { mode: Grid, size: (300.0, 20.0), columns: &[fr(1.0), fr(2.0)], ..FRAME },
kids: &[kid(150.0, 20.0), Kid { sizing: (Fill, Fixed), ..KID }],
want: (300.0, 20.0),
rects: &[(0.0, 0.0, 150.0, 20.0), (150.0, 0.0, 150.0, 20.0)],
rects: &[(0.0, 0.0, 150.0, 20.0), (100.0, 0.0, 200.0, 20.0)],
},
Case {
rule: "fr rows as measured in Figma: 300 x 200, 1fr / 1fr, a 150-tall child does not widen its row",
frame: Frame { mode: Grid, size: (300.0, 200.0), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(100.0, 150.0), kid(100.0, 20.0)],
want: (300.0, 200.0),
rects: &[(0.0, 0.0, 100.0, 150.0), (0.0, 100.0, 100.0, 20.0)],
},
Case {
rule: "fr rows as measured in Figma: the row gap comes off first (95 / 95, second child at 105)",
frame: Frame { mode: Grid, size: (300.0, 200.0), cross_gap: 10.0, columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(100.0, 150.0), kid(100.0, 20.0)],
want: (300.0, 200.0),
rects: &[(0.0, 0.0, 100.0, 150.0), (0.0, 105.0, 100.0, 20.0)],
},
Case {
rule: "fr rows as measured in Figma: with the frame on Hug the rows size to content (150 / 20, frame 170)",
frame: Frame { mode: Grid, size: (300.0, 200.0), sizing: (Fixed, Hug), columns: &[fr(1.0)], rows: &[fr(1.0), fr(1.0)], ..FRAME },
kids: &[kid(100.0, 150.0), kid(100.0, 20.0)],
want: (300.0, 170.0),
rects: &[(0.0, 0.0, 100.0, 150.0), (0.0, 150.0, 100.0, 20.0)],
},
Case {
rule: "placement: left to right, top to bottom; rows grow to fit the children",
+22 -6
View File
@@ -794,9 +794,9 @@ fn track_spec(specs: &[figcraft_doc::GridTrack], i: usize) -> (Sizing, f64) {
}
/// Resolve track sizes: fixed tracks keep their size, hug tracks fit their content, fill tracks
/// share what is left by their fractional units like the CSS `fr` unit (a track never gets less
/// than its content: one whose share is too small keeps its content size and the others share the
/// rest). `avail` is None when the frame hugs that axis (fill tracks then hug their content too).
/// share what is left by their fractional units (a track whose share is below its content, which
/// only a Fill child's minimum or a spanning child asks for, keeps that size and the others share
/// the rest). `avail` is None when the frame hugs that axis (fill tracks then hug their content too).
fn resolve_tracks(specs: &[figcraft_doc::GridTrack], content: &[f64], avail: Option<f64>) -> Vec<f64> {
let mut out: Vec<f64> = (0..content.len())
.map(|i| match track_spec(specs, i) {
@@ -891,13 +891,29 @@ pub fn grid_geometry(doc: &Document, frame: NodeId) -> Option<GridGeometry> {
let gy = finite_or_zero(al.cross_gap).max(0.0);
let hug_w = f.child_layout.sizing_h == Sizing::Hug;
let hug_h = f.child_layout.sizing_v == Sizing::Hug;
// What each child asks of its tracks: its size, or (filling a non-hugging axis) its minimum.
// What each child asks of its tracks: its size, its minimum when it fills a non-hugging axis, or
// nothing when it sits alone in a Fill track of a non-hugging axis.
let (mut ws, mut hs) = (Vec::with_capacity(cells.len()), Vec::with_capacity(cells.len()));
for c in &cells {
let (w, h) = outer_size(doc, c.id);
let cl = doc.node(c.id).map(|n| n.child_layout.clone()).unwrap_or_default();
ws.push(if cl.sizing_h == Sizing::Fill && !hug_w { cl.min_w.unwrap_or(0.0) } else { w });
hs.push(if cl.sizing_v == Sizing::Fill && !hug_h { cl.min_h.unwrap_or(0.0) } else { h });
// Measured in Figma: a Fill track of a fixed-size axis shares the space by its fraction and
// ignores a fixed-size (or hug-size) child in it, which overflows into the next track.
let in_fill = |tracks: &[figcraft_doc::GridTrack], start: usize, span: usize| span == 1 && track_spec(tracks, start).0 == Sizing::Fill;
ws.push(if cl.sizing_h == Sizing::Fill && !hug_w {
cl.min_w.unwrap_or(0.0)
} else if !hug_w && in_fill(&al.column_tracks, c.column, c.column_span) {
0.0
} else {
w
});
hs.push(if cl.sizing_v == Sizing::Fill && !hug_h {
cl.min_h.unwrap_or(0.0)
} else if !hug_h && in_fill(&al.row_tracks, c.row, c.row_span) {
0.0
} else {
h
});
}
let col_content = track_content(&cells, &ws, &al.column_tracks, cols, gx, true, hug_w);
let row_content = track_content(&cells, &hs, &al.row_tracks, rows, gy, false, hug_h);
+2 -2
View File
@@ -208,7 +208,7 @@ fn tools() -> Vec<Value> {
tool(
"set_properties",
"Set properties",
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Auto layout keys (gap, padding, mainAlign, crossAlign, wrap, …) apply to frames, components and instances only: other layers among the ids are skipped and named in `warnings`, and with none that can take them the call is an error. A fontStyle draws in the family's nearest face; `warnings` says when that face has another weight or slant than the name asks for. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), absoluteRotation (degrees on the page), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Auto layout keys (gap, padding, mainAlign, crossAlign, wrap, …) apply to frames, components and instances only: other layers among the ids are skipped and named in `warnings`, and with none that can take them the call is an error. A fontStyle draws in the family's nearest face; `warnings` says when that face has another weight or slant than the name asks for. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
obj(
json!({"ids": ids_schema("Layer ids (default: the selection)"), "props": {"type": "object", "description": "Property values to set"}}),
&["props"],
@@ -336,7 +336,7 @@ fn tools() -> Vec<Value> {
"y": num("Page y"),
"to": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2, "description": "Drag end [x, y] (page)"},
"steps": int("Intermediate move events for drag (default 8)"),
"tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment"},
"tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation"},
"shift": boolean("Shift held"),
"alt": boolean("Alt/Option held"),
"cmd": boolean("Cmd (macOS) / Ctrl held"),
+6
View File
@@ -4,6 +4,8 @@
//! sweep-line topology from `linesweeper`); results use the even-odd rule.
//! - [`stroke_outline`]: a stroke as a fill, with Figma's Inside / Center / Outside alignment,
//! dashes, caps, joins, arrowheads, variable width profiles ([`width`]) and brushes ([`brush`]).
//! - [`round_corners`]: a corner radius (and smoothing) on every corner still sharp in a closed path,
//! as a boolean group draws its result.
//!
//! Every sweep is capped ([`MAX_SEGMENTS`], [`MAX_CROSSINGS`], and [`MAX_FOLD_SEGMENTS`] over a
//! fold): past the caps an operation is refused with [`PathOpsError::TooComplex`].
@@ -24,6 +26,9 @@ mod dot_dash_tests;
#[cfg(test)]
mod ops_tests;
mod points;
mod round;
#[cfg(test)]
mod round_tests;
#[cfg(test)]
mod rounded_side_strokes_tests;
#[cfg(test)]
@@ -36,6 +41,7 @@ use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, Vect
use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2};
pub use budget::{Complexity, FoldBudget, MAX_CROSSINGS, MAX_FOLD_SEGMENTS, MAX_SEGMENTS, complexity};
pub use round::round_corners;
#[derive(Debug, PartialEq)]
pub enum PathOpsError {
+314
View File
@@ -0,0 +1,314 @@
//! A corner radius on a closed path: every vertex that is still sharp rounds with a circular arc,
//! convex and concave alike, the way a boolean group draws its result.
//!
//! What a sweep hands back is noisy: straight edges split at collinear points, pieces a hair long,
//! one real corner cut into two half turns. So each subpath is cleaned first ([`clean`]), then each
//! vertex is judged by the turn it makes, and a corner that does not fit shrinks to what the edges
//! beside it can hold ([`Ctx::corner`]).
use figcraft_geom::kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg, Point, Shape, Vec2};
use std::f64::consts::PI;
/// The smallest turn (radians) that counts as a corner. The sweep moves end points by up to its
/// own 1e-6 accuracy, which can tilt a short piece by more than 1e-6 rad.
const SHARP_TURN: f64 = 1e-3;
/// Accuracy of arc length measurements.
const ACC: f64 = 1e-6;
#[derive(Clone, Copy)]
enum Seg {
Line(Point, Point),
Cubic(CubicBez),
}
impl Seg {
fn start(&self) -> Point {
match self {
Self::Line(a, _) => *a,
Self::Cubic(c) => c.p0,
}
}
fn end(&self) -> Point {
match self {
Self::Line(_, b) => *b,
Self::Cubic(c) => c.p3,
}
}
fn set_start(&mut self, p: Point) {
match self {
Self::Line(a, _) => *a = p,
Self::Cubic(c) => c.p0 = p,
}
}
fn is_line(&self) -> bool {
matches!(self, Self::Line(..))
}
/// As a cubic (a line gets its handles at the thirds).
fn cubic(&self) -> CubicBez {
match self {
Self::Line(a, b) => CubicBez::new(*a, a.lerp(*b, 1.0 / 3.0), a.lerp(*b, 2.0 / 3.0), *b),
Self::Cubic(c) => *c,
}
}
fn len(&self) -> f64 {
match self {
Self::Line(a, b) => (*b - *a).hypot(),
Self::Cubic(c) => c.arclen(ACC),
}
}
/// The length of the control polygon: cheap, and zero only for a point.
fn span(&self) -> f64 {
let c = self.cubic();
(c.p1 - c.p0).hypot() + (c.p2 - c.p1).hypot() + (c.p3 - c.p2).hypot()
}
/// The cubic leaving the start of the segment, or (`from_start` false) leaving its end.
fn outward(&self, from_start: bool) -> CubicBez {
let c = self.cubic();
if from_start { c } else { CubicBez::new(c.p3, c.p2, c.p1, c.p0) }
}
}
/// Unit direction in which `c` leaves its start (skipping coincident handles).
fn start_dir(c: &CubicBez) -> Option<Vec2> {
[c.p1, c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|d| d.hypot() > 1e-9).map(|d| d / d.hypot())
}
/// The point `t` along `c` from its start, the unit direction back toward the start there, and the
/// parameter reached.
fn trim(c: &CubicBez, t: f64, line: bool, len: f64) -> Option<(Point, Vec2, f64)> {
if line {
if !len.is_finite() || len <= 0.0 {
return None;
}
let u = (c.p3 - c.p0) / len;
return Some((c.p0 + u * t, -u, t / len));
}
let k = c.inv_arclen(t, ACC).clamp(0.0, 1.0);
let d = c.deriv().eval(k).to_vec2();
let back = if d.hypot() > 1e-9 { -d / d.hypot() } else { -start_dir(c)? };
Some((c.eval(k), back, k))
}
/// One rounded corner: the curves from the cut on the incoming segment to the cut on the outgoing
/// one, and where those cuts sit on their segments (each segment's own parameter).
struct Corner {
curves: Vec<CubicBez>,
t_in: f64,
t_out: f64,
}
/// `path` with every corner still sharp rounded by `radius` (and corner `smoothing`, 0..=1, between
/// straight edges). A corner shrinks to what its edges can hold: two corners wanting more than
/// half an edge share it half and half, a corner whose far end is smooth may use all of it. A
/// closed subpath with no sharp corner, an open one, and a path with a bad radius or coordinates
/// come back as they are.
pub fn round_corners(path: &BezPath, radius: f64, smoothing: f64) -> BezPath {
if !(radius.is_finite() && radius > 0.0) || path.elements().is_empty() || !crate::finite(path) {
return path.clone();
}
let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 };
let mut out = BezPath::new();
for sub in figcraft_geom::split_subpaths(path) {
// Each contour's own size sets its cleanup tolerance, so a large contour can't weld away
// the real short edges of a small island or hole beside it.
let bb = sub.bounding_box();
let weld = (bb.width().max(bb.height()) * 1e-6).max(1e-9);
let closed = matches!(sub.elements().last(), Some(PathEl::ClosePath));
match closed.then(|| round_subpath(&sub, radius, smoothing, weld)).flatten() {
Some(p) => out.extend(p.elements().iter().copied()),
None => out.extend(sub.elements().iter().copied()),
}
}
out
}
/// The segments of a closed subpath without the sweep's noise: pieces shorter than `weld` dropped,
/// each segment starting where the one before ends, and straight runs merged into one line.
fn clean(sub: &BezPath, weld: f64) -> Vec<Seg> {
let mut segs: Vec<Seg> = sub
.segments()
.map(|s| match s {
PathSeg::Line(l) => Seg::Line(l.p0, l.p1),
other => Seg::Cubic(other.to_cubic()),
})
.filter(|s| s.span() >= weld)
.collect();
let n = segs.len();
for i in 0..n {
let prev_end = segs.get((i + n - 1) % n).map(Seg::end);
if let (Some(p), Some(s)) = (prev_end, segs.get_mut(i)) {
s.set_start(p);
}
}
merge_runs(segs, weld)
}
/// Two lines A-B and B-C become A-C when B lies within `weld` of that line and between A and C
/// (a distance test, so a long edge with a real slight kink is not straightened). Cyclic.
fn merge_runs(segs: Vec<Seg>, weld: f64) -> Vec<Seg> {
let mergeable = |a: &Seg, b: &Seg| match (a, b) {
(Seg::Line(p, q), Seg::Line(_, r)) => {
let (d, e) = (*r - *p, *q - *p);
let l = d.hypot();
l > 1e-12 && (d.cross(e) / l).abs() <= weld && e.dot(*r - *q) > 0.0
}
_ => false,
};
let mut merged: Vec<Seg> = Vec::with_capacity(segs.len());
for s in segs {
match merged.last_mut() {
Some(last) if mergeable(last, &s) => *last = Seg::Line(last.start(), s.end()),
_ => merged.push(s),
}
}
while merged.len() > 2 {
let (Some(first), Some(last)) = (merged.first().copied(), merged.last().copied()) else { break };
if !mergeable(&last, &first) {
break;
}
if let Some(f) = merged.first_mut() {
*f = Seg::Line(last.start(), first.end());
}
merged.pop();
}
merged
}
/// Everything the corner of one vertex needs. Vertex `i` is where segment `i - 1` ends and
/// segment `i` starts.
struct Ctx<'a> {
segs: &'a [Seg],
/// Segment lengths, measured only for segments with a corner at one end.
lens: &'a [Option<f64>],
/// Per vertex: the turn between its segments and the length it wants along each of them.
wants: &'a [Option<(f64, f64)>],
radius: f64,
smoothing: f64,
weld: f64,
}
/// The angle between the segments at each vertex (pi is straight on, 0 folded back) and the
/// length a corner there wants along each; `None` where the vertex is not a corner.
fn wants(segs: &[Seg], radius: f64, smoothing: f64) -> Vec<Option<(f64, f64)>> {
let n = segs.len();
(0..n)
.map(|i| {
let (a, b) = (segs.get((i + n - 1) % n)?, segs.get(i)?);
let (u1, u2) = (start_dir(&a.outward(false))?, start_dir(&b.outward(true))?);
let angle = u1.dot(u2).clamp(-1.0, 1.0).acos();
if angle < 1e-6 || PI - angle < SHARP_TURN {
return None;
}
let t = radius / (angle / 2.0).tan();
let want = if a.is_line() && b.is_line() { (1.0 + smoothing) * t } else { t };
want.is_finite().then_some((angle, want))
})
.collect()
}
impl Ctx<'_> {
fn want(&self, i: usize) -> f64 {
self.wants.get(i % self.segs.len().max(1)).copied().flatten().map_or(0.0, |(_, w)| w)
}
/// What a corner may use of a segment of `len`: half of it, or what the corner at its other
/// end leaves, whichever is more.
fn room(len: f64, other: f64) -> f64 {
(len / 2.0).max(len - other)
}
fn corner(&self, i: usize) -> Option<Corner> {
let n = self.segs.len();
let (angle, _) = self.wants.get(i).copied().flatten()?;
let prev = (i + n - 1) % n;
let (a, b) = (self.segs.get(prev)?, self.segs.get(i)?);
let (la, lb) = (self.lens.get(prev).copied().flatten()?, self.lens.get(i).copied().flatten()?);
let room = Self::room(la, self.want(prev)).min(Self::room(lb, self.want(i + 1)));
if !room.is_finite() || room <= self.weld {
return None;
}
let (c1, c2) = (a.outward(false), b.outward(true));
if a.is_line() && b.is_line() {
self.between_lines(&c1, &c2, room, (la, lb))
} else {
self.beside_curve((&c1, a.is_line(), la), (&c2, b.is_line(), lb), room, angle)
}
}
fn between_lines(&self, c1: &CubicBez, c2: &CubicBez, room: f64, (la, lb): (f64, f64)) -> Option<Corner> {
let (u1, u2) = (start_dir(c1)?, start_dir(c2)?);
let curves = figcraft_geom::smoothed_corner(c1.p0, u1, u2, room, self.radius, self.smoothing)?;
let (first, last) = (curves.first()?, curves.last()?);
let (t_in, t_out) = (1.0 - (first.p0 - c1.p0).hypot() / la, (last.p3 - c1.p0).hypot() / lb);
(t_in.is_finite() && t_out.is_finite() && curves.iter().all(|c| [c.p0, c.p1, c.p2, c.p3].iter().all(|p| p.x.is_finite() && p.y.is_finite())))
.then_some(Corner { curves, t_in, t_out })
}
/// One cubic between the cuts on both sides, its handles along each side's direction at its cut.
fn beside_curve(
&self,
(c1, line1, la): (&CubicBez, bool, f64),
(c2, line2, lb): (&CubicBez, bool, f64),
room: f64,
angle: f64,
) -> Option<Corner> {
let t = (self.radius / (angle / 2.0).tan()).min(room);
let ((p, d1, k1), (q, d2, k2)) = (trim(c1, t, line1, la)?, trim(c2, t, line2, lb)?);
let turn = (-d1.dot(d2)).clamp(-1.0, 1.0).acos();
let chord = (q - p).hypot();
let k = if turn < 1e-9 { chord / 3.0 } else { 4.0 / 3.0 * (turn / 4.0).tan() * chord / (2.0 * (turn / 2.0).sin()) };
let ok = k.is_finite() && [p, q].iter().all(|x| x.x.is_finite() && x.y.is_finite());
ok.then(|| Corner { curves: vec![CubicBez::new(p, p + d1 * k, q + d2 * k, q)], t_in: 1.0 - k1, t_out: k2 })
}
}
/// The subpath with its corners rounded, or `None` when no vertex of it is a corner that rounds
/// (the caller keeps the subpath exactly as it came in).
fn round_subpath(sub: &BezPath, radius: f64, smoothing: f64, weld: f64) -> Option<BezPath> {
let segs = clean(sub, weld);
let n = segs.len();
if n < 2 {
return None;
}
let wants = wants(&segs, radius, smoothing);
let touches = |i: usize| wants.get(i).is_some_and(Option::is_some) || wants.get((i + 1) % n).is_some_and(Option::is_some);
let lens: Vec<Option<f64>> = segs.iter().enumerate().map(|(i, s)| touches(i).then(|| s.len())).collect();
let ctx = Ctx { segs: &segs, lens: &lens, wants: &wants, radius, smoothing, weld };
let corners: Vec<Option<Corner>> = (0..n).map(|i| ctx.corner(i)).collect();
corners.iter().any(Option::is_some).then(|| emit(&segs, &corners))?
}
/// Walk the segments: what is left of each between the corner at its start and the one at its
/// end, then the next corner's curves.
fn emit(segs: &[Seg], corners: &[Option<Corner>]) -> Option<BezPath> {
let n = segs.len();
let mut out = BezPath::new();
let first = segs.first()?;
out.move_to(corners.first().and_then(Option::as_ref).and_then(|c| c.curves.last()).map_or(first.start(), |c| c.p3));
for (i, seg) in segs.iter().enumerate() {
let next = corners.get((i + 1) % n).and_then(Option::as_ref);
let t0 = corners.get(i).and_then(Option::as_ref).map_or(0.0, |c| c.t_out);
let t1 = next.map_or(1.0, |c| c.t_in);
if t1 - t0 > 1e-9 {
match seg {
Seg::Line(a, b) => out.line_to(a.lerp(*b, t1)),
Seg::Cubic(c) => {
let q = c.subsegment(t0..t1);
out.curve_to(q.p1, q.p2, q.p3);
}
}
}
for c in next.iter().flat_map(|c| c.curves.iter()) {
out.curve_to(c.p1, c.p2, c.p3);
}
}
out.close_path();
crate::finite(&out).then_some(out)
}
+256
View File
@@ -0,0 +1,256 @@
//! `round_corners`: every sharp corner of a closed path, convex or concave, rounds with a circular
//! arc; smooth joins and straight-run leftovers of the sweep do not count; a radius larger than the
//! edges allow shrinks to fit.
use figcraft_doc::BoolOp;
use figcraft_geom::kurbo::{Affine, BezPath, PathEl, PathSeg, Point, Shape, Vec2};
use figcraft_geom::{ellipse, rounded_rect, split_subpaths};
use crate::{boolean, boolean_step, round_corners};
fn rect(x: f64, y: f64, w: f64, h: f64) -> BezPath {
kurbo_rect(x, y, w, h).to_path(0.1)
}
fn kurbo_rect(x: f64, y: f64, w: f64, h: f64) -> figcraft_geom::Rect {
figcraft_geom::Rect::new(x, y, x + w, y + h)
}
fn l_shape() -> BezPath {
boolean(&[rect(0.0, 0.0, 200.0, 100.0), rect(150.0, 0.0, 100.0, 200.0)], BoolOp::Union).unwrap()
}
/// A (200 by 100, radius 10) with a 100 square cut out of its top edge: the notch of the measurement.
fn notch() -> BezPath {
let a = rounded_rect(200.0, 100.0, [10.0; 4], 0.0);
boolean_step(&a, &rect(50.0, -50.0, 100.0, 100.0), BoolOp::Subtract).unwrap()
}
/// Every `CurveTo` as `[from, c1, c2, to]`.
fn arcs(path: &BezPath) -> Vec<[Point; 4]> {
let mut cur = Point::ZERO;
let mut out = Vec::new();
for el in path.elements() {
match *el {
PathEl::MoveTo(p) | PathEl::LineTo(p) => cur = p,
PathEl::CurveTo(a, b, c) => {
out.push([cur, a, b, c]);
cur = c;
}
_ => {}
}
}
out
}
fn near(a: Point, b: Point) -> bool {
(a - b).hypot() < 1e-3
}
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
/// Some curve runs through exactly these four points, in either direction.
fn has_arc(path: &BezPath, want: [(f64, f64); 4]) -> bool {
let w = want.map(|(x, y)| pt(x, y));
let rev = [w[3], w[2], w[1], w[0]];
arcs(path).iter().any(|a| a.iter().zip(w).all(|(p, q)| near(*p, q)) || a.iter().zip(rev).all(|(p, q)| near(*p, q)))
}
/// Some curve joins these two end points, in either direction.
fn has_arc_between(path: &BezPath, from: (f64, f64), to: (f64, f64)) -> bool {
let (f, t) = (pt(from.0, from.1), pt(to.0, to.1));
arcs(path).iter().any(|a| (near(a[0], f) && near(a[3], t)) || (near(a[0], t) && near(a[3], f)))
}
fn passes_through(path: &BezPath, p: Point) -> bool {
path.elements().iter().any(|el| match el {
PathEl::MoveTo(q) | PathEl::LineTo(q) | PathEl::CurveTo(_, _, q) => near(*q, p),
_ => false,
})
}
fn unit(v: Vec2) -> Option<Vec2> {
(v.hypot() > 1e-9).then(|| v / v.hypot())
}
fn start_dir(s: PathSeg) -> Option<Vec2> {
let c = s.to_cubic();
[c.p1, c.p2, c.p3].into_iter().find_map(|p| unit(p - c.p0))
}
fn end_dir(s: PathSeg) -> Option<Vec2> {
let c = s.to_cubic();
[c.p2, c.p1, c.p0].into_iter().find_map(|p| unit(c.p3 - p))
}
/// The largest direction change at any vertex of a closed subpath, in radians.
fn sharpest_turn(path: &BezPath) -> f64 {
let mut worst: f64 = 0.0;
for sub in split_subpaths(path) {
let segs: Vec<PathSeg> = sub.segments().collect();
for (i, s) in segs.iter().enumerate() {
let next = segs[(i + 1) % segs.len()];
if let (Some(a), Some(b)) = (end_dir(*s), start_dir(next)) {
worst = worst.max(a.dot(b).clamp(-1.0, 1.0).acos());
}
}
}
worst
}
fn area(p: &BezPath) -> f64 {
p.area().abs()
}
fn all_finite(p: &BezPath) -> bool {
crate::finite(p)
}
#[test]
fn every_sharp_corner_of_the_result_rounds_convex_and_concave() {
let r = round_corners(&l_shape(), 20.0, 0.0);
assert_eq!(arcs(&r).len(), 6);
assert!(has_arc(&r, [(230.0, 0.0), (241.046, 0.0), (250.0, 8.95431), (250.0, 20.0)]));
assert!(has_arc(&r, [(150.0, 120.0), (150.0, 108.954), (141.046, 100.0), (130.0, 100.0)]));
assert!((area(&r) - (35000.0 - 4.0 * 85.84)).abs() < 0.5, "{}", area(&r));
assert_eq!(r.winding(pt(1.0, 1.0)), 0);
assert_ne!(r.winding(pt(148.0, 102.0)), 0);
let bb = r.bounding_box();
assert!((bb.width() - 250.0).abs() < 1e-6 && (bb.height() - 200.0).abs() < 1e-6);
}
#[test]
fn a_childs_rounded_corners_are_kept_and_do_not_stack() {
let r = round_corners(&notch(), 20.0, 0.0);
assert_eq!(arcs(&r).len(), 8);
assert!(has_arc(&r, [(30.0, 0.0), (41.0457, 0.0), (50.0, 8.954), (50.0, 20.0)]));
assert!(has_arc(&r, [(50.0, 30.0), (50.0, 41.0457), (58.954, 50.0), (70.0, 50.0)]));
assert!(has_arc_between(&r, (130.0, 50.0), (150.0, 30.0)));
assert!(has_arc_between(&r, (150.0, 20.0), (170.0, 0.0)));
assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)]));
}
#[test]
fn a_radius_larger_than_the_edges_clamps_to_what_they_hold() {
let r = round_corners(&notch(), 500.0, 0.0);
assert!(has_arc(&r, [(25.0, 0.0), (38.8071, 0.0), (50.0, 11.1929), (50.0, 25.0)]));
assert!(has_arc(&r, [(50.0, 25.0), (50.0, 38.807), (61.193, 50.0), (75.0, 50.0)]));
assert!(has_arc_between(&r, (125.0, 50.0), (150.0, 25.0)));
assert!(has_arc_between(&r, (150.0, 25.0), (175.0, 0.0)));
assert!(has_arc(&r, [(0.0, 10.0), (0.0, 4.477), (4.477, 0.0), (10.0, 0.0)]));
assert!(all_finite(&r));
let l = round_corners(&l_shape(), 500.0, 0.0);
assert!(has_arc_between(&l, (0.0, 50.0), (50.0, 0.0)));
assert!(has_arc_between(&l, (150.0, 0.0), (250.0, 100.0)));
assert!((area(&round_corners(&l_shape(), 1e300, 0.0)) - area(&l)).abs() < 1e-6);
}
#[test]
fn nothing_sharp_nothing_changes() {
let round = rounded_rect(100.0, 60.0, [20.0; 4], 0.0);
let oval = ellipse(100.0, 60.0, 0.0, std::f64::consts::TAU, 0.0);
assert_eq!(round_corners(&round, 20.0, 0.0), round);
assert_eq!(round_corners(&oval, 20.0, 0.0), oval);
let l = l_shape();
for radius in [0.0, -5.0, f64::NAN, f64::INFINITY] {
assert_eq!(round_corners(&l, radius, 0.0), l);
}
let mut open = BezPath::new();
open.move_to((0.0, 0.0));
open.line_to((50.0, 0.0));
open.line_to((50.0, 50.0));
assert_eq!(round_corners(&open, 10.0, 0.0), open);
}
#[test]
fn points_the_sweep_leaves_on_an_edge_are_not_corners() {
let mut p = BezPath::new();
for (i, q) in [(0.0, 0.0), (20.0, 0.0), (100.0, 0.0), (100.0, 0.0), (100.0, 60.0), (100.0 + 1e-11, 60.0), (100.0, 100.0), (0.0, 100.0)]
.into_iter()
.enumerate()
{
if i == 0 {
p.move_to(q);
} else {
p.line_to(q);
}
}
p.close_path();
let r = round_corners(&p, 500.0, 0.0);
assert_eq!(arcs(&r).len(), 4);
assert!(has_arc_between(&r, (0.0, 50.0), (50.0, 0.0)));
}
fn two_circles(op: BoolOp) -> BezPath {
let c = ellipse(100.0, 100.0, 0.0, std::f64::consts::TAU, 0.0);
boolean(&[c.clone(), Affine::translate((70.0, 0.0)) * c], op).unwrap()
}
#[test]
fn corners_beside_curves_round_too() {
for op in [BoolOp::Union, BoolOp::Intersect] {
let sharp = two_circles(op);
assert!(sharpest_turn(&sharp) > 1.0);
let r = round_corners(&sharp, 10.0, 0.0);
assert!(sharpest_turn(&r) < 1e-3, "{}", sharpest_turn(&r));
assert!(all_finite(&r));
match op {
BoolOp::Union => assert!(area(&r) > area(&sharp)),
_ => assert!(area(&r) < area(&sharp)),
}
}
}
#[test]
fn smoothing_draws_the_polygon_profile_between_straight_edges() {
let l = l_shape();
let plain = round_corners(&l, 20.0, 0.0);
let smooth = round_corners(&l, 20.0, 0.6);
assert!(passes_through(&smooth, pt(0.0, 32.0)) && passes_through(&smooth, pt(32.0, 0.0)));
assert!((area(&smooth) - area(&plain)).abs() > 1.0);
let circles = two_circles(BoolOp::Union);
assert_eq!(round_corners(&circles, 10.0, 0.6), round_corners(&circles, 10.0, 0.0));
}
#[test]
fn junk_never_panics() {
let mut bad = BezPath::new();
bad.move_to((f64::NAN, 0.0));
bad.line_to((10.0, f64::INFINITY));
bad.close_path();
assert_eq!(round_corners(&bad, 5.0, 0.0).elements().len(), 3);
assert!(round_corners(&BezPath::new(), 5.0, 0.0).elements().is_empty());
let mut one = BezPath::new();
one.move_to((0.0, 0.0));
one.line_to((10.0, 0.0));
one.close_path();
assert!(all_finite(&round_corners(&one, 5.0, 0.5)));
assert!(all_finite(&round_corners(&rect(0.0, 0.0, 1.0, 0.5), 0.1, 0.0)));
let mut zig = BezPath::new();
zig.move_to((0.0, 0.0));
for i in 1..40_000 {
zig.line_to((f64::from(i) * 0.5, f64::from(i % 2)));
}
zig.close_path();
assert!(all_finite(&round_corners(&zig, 2.0, 0.0)));
}
/// The cleanup tolerance is each contour's own: a contour a million units wide doesn't weld away
/// the half-unit notch of a separate small contour in the same path.
#[test]
fn a_large_contour_does_not_weld_a_small_ones_short_edges() {
let mut p = rect(0.0, 0.0, 1e6, 1e6);
let x = 2e6;
for (i, (dx, y)) in [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (5.5, 10.0), (5.5, 9.5), (5.0, 9.5), (5.0, 10.0), (0.0, 10.0)].into_iter().enumerate()
{
if i == 0 { p.move_to((x + dx, y)) } else { p.line_to((x + dx, y)) }
}
p.close_path();
let out = round_corners(&p, 0.001, 0.0);
let reaches = |q: Point| {
out.elements().iter().any(|el| matches!(el, PathEl::MoveTo(e) | PathEl::LineTo(e) | PathEl::CurveTo(_, _, e) if (*e - q).hypot() < 0.01))
};
assert!(reaches(pt(x + 5.0, 9.5)) && reaches(pt(x + 5.5, 9.5)), "the notch's floor is kept");
}
+29
View File
@@ -7,6 +7,11 @@
//! edited one are never recombined, and groups whose operands didn't change aren't recomputed at
//! all. The caches drop their least recently used half when full, so a long drag doesn't evict
//! every other group's result.
//!
//! The group's own corner radius is applied to the finished fold ([`result_cached`]) and cached in a
//! separate, smaller map keyed by the folded path plus the radius and smoothing: the fold's caches
//! are keyed by the operands alone, so scrubbing the radius never redoes the fold or evicts its
//! prefixes, and a rounded path is never stored where a fold would find it.
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
@@ -109,6 +114,30 @@ pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> Option<BezPath> {
}
}
fn rounded() -> &'static Mutex<PathCache> {
static C: OnceLock<Mutex<PathCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(PathCache::new(512)))
}
/// A boolean group's result as it is drawn: the fold of `operands` ([`fold_cached`]), then the
/// group's corner `radius` and `smoothing` on every corner still sharp.
pub fn result_cached(operands: &[(BezPath, BoolOp)], radius: f64, smoothing: f64) -> Option<BezPath> {
let folded = fold_cached(operands)?;
if !(radius.is_finite() && radius > 0.0) {
return Some(folded);
}
let mut h = DefaultHasher::new();
hash_path(&folded, &mut h);
(radius.to_bits(), smoothing.to_bits(), 0x52u8).hash(&mut h);
let key = h.finish();
if let Some(p) = locked(rounded()).get(key) {
return Some((*p).clone());
}
let out = figcraft_pathops::round_corners(&folded, radius, smoothing);
locked(rounded()).insert(key, Arc::new(out.clone()));
Some(out)
}
/// `keys[i]`: the cache key of the fold of operands `0..=i`.
fn fold_keys(operands: &[(BezPath, BoolOp)]) -> Vec<u64> {
let mut h = DefaultHasher::new();
+3 -3
View File
@@ -7,12 +7,12 @@ use figcraft_color::{Color, Paint};
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, WidthPoint, WidthProfile};
use figcraft_geom::kurbo::{BezPath, Shape};
fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
pub(crate) fn dark(p: &Pixmap, x: u32, y: u32) -> bool {
let i = ((y * p.width() + x) * 4) as usize;
p.data().get(i).is_some_and(|r| *r < 40)
}
fn render(d: &Document, p: NodeId) -> Pixmap {
pub(crate) fn render(d: &Document, p: NodeId) -> Pixmap {
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 200, height: 200 }, &Options::default()).unwrap()
}
@@ -28,7 +28,7 @@ fn row_of_squares(op: BoolOp, n: usize) -> (Document, NodeId, NodeId, Vec<NodeId
(d, p, b, kids)
}
fn outline(d: &Document, id: NodeId) -> BezPath {
pub(crate) fn outline(d: &Document, id: NodeId) -> BezPath {
node_outline(d, d.node(id).unwrap()).unwrap().0
}
+60
View File
@@ -0,0 +1,60 @@
//! A boolean group's corner radius: it rounds the result the canvas draws, and keys a cache of its
//! own so the fold underneath is not redone.
use crate::boolean::boolean_steps;
use crate::boolean_cache_tests::{dark, outline, render};
use figcraft_color::{Color, Paint};
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind};
use figcraft_geom::kurbo::Shape;
/// The L of the measurement: a union of 200 by 100 and 100 by 200 rectangles, on a page of its own.
fn l_group() -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new("t");
let p = d.first_page().unwrap();
let g = d.create(p, NodeKind::BooleanOp { op: BoolOp::Union }, "G", 0.0, 0.0, 0.0, 0.0).unwrap();
d.get_mut(g).unwrap().fills = vec![Paint::solid(Color::BLACK)];
d.create(g, NodeKind::Rectangle, "A", 0.0, 0.0, 200.0, 100.0).unwrap();
let b = d.create(g, NodeKind::Rectangle, "B", 150.0, 0.0, 100.0, 200.0).unwrap();
d.fit_to_children(g).unwrap();
(d, p, g, b)
}
fn set_radius(d: &mut Document, g: NodeId, r: f64) {
d.get_mut(g).unwrap().corners.radii = [r; 4];
}
fn area(d: &Document, g: NodeId) -> f64 {
outline(d, g).area().abs()
}
#[test]
fn a_boolean_groups_radius_rounds_what_the_canvas_draws() {
let (mut d, p, g, _) = l_group();
let pix = render(&d, p);
assert!(dark(&pix, 1, 1) && !dark(&pix, 148, 102));
set_radius(&mut d, g, 20.0);
let pix = render(&d, p);
assert!(!dark(&pix, 1, 1), "the corner is cut off");
assert!(dark(&pix, 148, 102), "the concave corner is filled in");
assert!(dark(&pix, 100, 50));
assert!((area(&d, g) - 34656.6).abs() < 1.0, "{}", area(&d, g));
}
#[test]
fn the_radius_keys_the_result_and_the_fold_is_not_redone() {
let (mut d, _, g, b) = l_group();
let sharp = outline(&d, g);
set_radius(&mut d, g, 20.0);
let _ = outline(&d, g);
let before = boolean_steps();
set_radius(&mut d, g, 40.0);
// Four net corners of 343.36 each, plus the cubic arcs' own slight excess over a circle.
assert!((area(&d, g) - 33626.6).abs() < 2.0, "{}", area(&d, g));
assert_eq!(boolean_steps(), before, "a new radius reuses the fold");
set_radius(&mut d, g, 0.0);
assert_eq!(outline(&d, g), sharp, "radius 0 draws the fold itself");
set_radius(&mut d, g, 20.0);
let rounded = area(&d, g);
d.get_mut(b).unwrap().transform = figcraft_geom::Affine::translate((10.0, 0.0)) * d.node(b).unwrap().transform;
assert!((area(&d, g) - rounded).abs() > 1.0, "moving an operand does not draw a stale rounded path");
}
+3 -2
View File
@@ -347,8 +347,9 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
Some((p, c.bool_op.unwrap_or(*op)))
})
.collect();
// Past the path operations' caps the group has no outline: it draws without its result.
crate::boolean::fold_cached(&operands).map(|p| (p, FillRule::EvenOdd))
// Past the path operations' caps the group has no outline: it draws without its result. The
// group's corner radius rounds what is still sharp in the fold.
crate::boolean::result_cached(&operands, n.corners.radii[0], n.corners.smoothing).map(|p| (p, FillRule::EvenOdd))
}
NodeKind::Text(_) => {
let (l, dy) = figcraft_text::layout_node(n)?;
+1 -1
View File
@@ -33,7 +33,7 @@ const MAX_CACHED_IMAGES: usize = 256;
/// The largest sides of the copies the canvas draws larger images from while they show smaller
/// (device pixels): two 16 000 × 10 000 images take 640 MB each in full, past the cache's budget
/// together, and 42 MB each at 4096 (168 MB at 8192).
pub const DISPLAY_SIDES: [u32; 2] = [4096, 8192];
pub const DISPLAY_SIDES: [u32; 6] = [256, 512, 1024, 2048, 4096, 8192];
/// Decoded pixmaps by image, filters and profile, holding at most a byte budget of pixels: adding
/// one past the budget (or past [`MAX_CACHED_IMAGES`]) drops the least recently used first, so
+7 -6
View File
@@ -8,7 +8,7 @@ use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind};
use tiny_skia::Pixmap;
use crate::images::{self, DISPLAY_SIDES, MAX_CACHED_BYTES};
use crate::images::{self, MAX_CACHED_BYTES};
use crate::{Options, View, render_view};
const W: u32 = 6000;
@@ -63,12 +63,11 @@ fn sharp_stripes(p: &Pixmap, y: u32, black_first: bool, each: u32) -> bool {
#[test]
fn zoomed_out_the_canvas_draws_both_images_from_copies_that_stay_decoded() {
let (d, page, keys) = doc("zoomed-out");
// At 25% each shows 1500 px long: drawn from 4096 px copies, not decoded in full.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
let first = keys.each_ref().map(|k| shown(&d, k, 1500.0));
for (k, p) in keys.iter().zip(&first) {
assert_eq!((p.width(), p.height()), (DISPLAY_SIDES[0], 5), "{k}");
assert_eq!(images::cached_sides(k), vec![DISPLAY_SIDES[0]], "{k}: only its copy is decoded");
assert_eq!((p.width(), p.height()), (2048, 3), "{k}");
assert_eq!(images::cached_sides(k), vec![2048], "{k}: only its copy is decoded");
}
// Drawn again (a drag, a pan), both are still the copies decoded the first time.
render_view(&d, page, &view(0.25), &canvas()).unwrap();
@@ -93,13 +92,15 @@ fn exports_and_close_ups_draw_from_the_image_itself() {
#[test]
fn the_copy_is_the_smallest_that_holds_what_shows() {
let side = |long, shown| images::display_side(long, shown);
assert_eq!(side(6000, 1500.0), 4096);
assert_eq!(side(6000, 1500.0), 2048);
assert_eq!(side(16_000, 4096.0), 4096);
assert_eq!(side(16_000, 4097.0), 8192);
assert_eq!(side(4096, 100.0), 256);
assert_eq!(side(3000, 20.0), 256);
// Shown at more than the largest copy, or an image no larger than the copy: the image itself.
assert_eq!(side(16_000, 9000.0), 16_384);
assert_eq!(side(6000, 5000.0), 16_384);
assert_eq!(side(4096, 100.0), 16_384);
assert_eq!(side(200, 100.0), 16_384);
// An unknown size or a meaningless scale: the image itself.
assert_eq!(side(0, 100.0), 16_384);
assert_eq!(side(16_000, f64::NAN), 16_384);
+41 -7
View File
@@ -266,11 +266,11 @@ fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Opti
/// Render several nodes (in z-order) into an existing pixmap through `view`.
pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) {
nodes_into(doc, ids, view, pix, 0.0);
nodes_into(doc, ids, view, pix, 0.0, None);
}
/// [`render_nodes_into`] `time` seconds into animated GIFs.
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64, under: Option<&backdrop::Under>) {
let bounds = draw::Bounds::default();
let ctx = draw::Ctx {
doc,
@@ -282,7 +282,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
lod: false,
preview: false,
magnified_image_pixels: false,
under: None,
under,
time,
bounds: &bounds,
};
@@ -353,16 +353,24 @@ pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option<Pixmap> {
/// [`render_node`] `time` seconds into animated GIFs (the presenter plays them).
pub fn render_node_at(doc: &Document, id: NodeId, scale: f64, time: f64) -> Option<Pixmap> {
area_at(doc, &[id], export_bounds(doc, id)?, scale, time)
area_at(doc, &[id], None, export_bounds(doc, id)?, scale, time)
}
/// Rasterise `ids` (bottom to top) over page area `b` at `scale` (export): the image starts at the
/// area's top-left and its sides round up to whole pixels. `None` past 16384 pixels a side.
pub fn render_area(doc: &Document, ids: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
area_at(doc, ids, b, scale, 0.0)
area_at(doc, ids, None, b, scale, 0.0)
}
fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
/// [`render_area`] for an export of layers that sit among `page_layers` (the page's top-level
/// layers, in z-order): a background blur or glass in `ids` reads what lies under the first of
/// them on that page, clipped to the area (#2213). Only `ids` are drawn; the layers above stay
/// out. Without such an effect it is [`render_area`].
pub fn render_area_over(doc: &Document, ids: &[NodeId], page_layers: &[NodeId], b: Rect, scale: f64) -> Option<Pixmap> {
area_at(doc, ids, Some(page_layers), b, scale, 0.0)
}
fn area_at(doc: &Document, ids: &[NodeId], page_layers: Option<&[NodeId]>, b: Rect, scale: f64, time: f64) -> Option<Pixmap> {
let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 };
let w = (b.width() * scale - 1e-6).ceil().max(1.0);
let h = (b.height() * scale - 1e-6).ceil().max(1.0);
@@ -371,7 +379,31 @@ fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Op
}
let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 };
let mut pix = new_pixmap(view.width, view.height)?;
nodes_into(doc, ids, &view, &mut pix, time);
// What the backdrop effects read: the page under the first layer, with the margin they reach.
let reach = page_layers.and_then(|_| {
ids.iter()
.flat_map(|i| doc.subtree(*i))
.filter_map(|i| doc.node(i))
.filter(|n| n.visible && backdrop::has_effects(n))
.map(|n| backdrop::pad(n, scale))
.max()
});
let beneath = match (page_layers, ids.first(), reach) {
(Some(layers), Some(first), Some(m)) => {
let m = i32::try_from(m).unwrap_or(0);
let p = f64::from(m) / scale;
let around = View {
origin: (b.x0 - p, b.y0 - p),
scale,
width: view.width.saturating_add(m.unsigned_abs() * 2),
height: view.height.saturating_add(m.unsigned_abs() * 2),
};
backdrop::render_beneath(doc, layers, *first, &around).map(|back| (back, m))
}
_ => None,
};
let under = beneath.as_ref().map(|(back, m)| backdrop::Under::new(back, (*m, *m), None));
nodes_into(doc, ids, &view, &mut pix, time, under.as_ref());
Some(pix)
}
@@ -518,6 +550,8 @@ mod boolean_budget_tests;
#[cfg(test)]
mod boolean_cache_tests;
#[cfg(test)]
mod boolean_radius_tests;
#[cfg(test)]
mod bounds_memo_tests;
#[cfg(test)]
mod dots_tests;
+4 -4
View File
@@ -869,7 +869,7 @@ fn line_width(items: &[Item], (start, end, _, _): LineRange) -> f64 {
/// to give a word to the lines after it ("keeps each line full, but adjusts the last four lines to
/// avoid an orphan"). `rebreak` lays the text out again with a width per paragraph and line.
fn restyle_breaks(
style: WrapStyle,
style_of: &dyn Fn(usize) -> WrapStyle,
w: f64,
lines: Vec<LineRange>,
items: &[Item],
@@ -905,7 +905,7 @@ fn restyle_breaks(
}
// Words wider than the box break where they must; the re-breaking adds no such break.
let base = emergency_breaks(items, word_breaks, &para);
match style {
match style_of(p) {
WrapStyle::Auto => {}
WrapStyle::Balance => {
// The narrowest width that still gives `n` lines, to half a pixel.
@@ -992,8 +992,8 @@ fn wrapped_lines(
let lines = from_saved.or_else(|| breaks(&|_, _| wrap, None)).unwrap_or_default();
// Balance and Pretty re-break the paragraphs at narrower widths (never saved breaks).
match wrap {
Some(w) if !followed && text.wrap_style != WrapStyle::Auto => {
restyle_breaks(text.wrap_style, w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
Some(w) if !followed && (0..text.paragraph_count()).any(|p| text.para_wrap_style(p) != WrapStyle::Auto) => {
restyle_breaks(&|p| text.para_wrap_style(p), w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
breaks(wrap_of, None).unwrap_or_default()
})
}
+6
View File
@@ -122,6 +122,7 @@ name = "English"
"Android Vector Drawable" = "Android Vector Drawable"
"Angular" = "Angular"
"Animation" = "Animation"
"Annotation" = "Annotation"
"Annotations" = "Annotations"
"Annotations shown on the canvas" = "Annotations shown on the canvas"
"Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences."
@@ -534,6 +535,9 @@ name = "English"
"Edit padding on all sides" = "Edit padding on all sides"
"Edit style" = "Edit style"
"Edit variable" = "Edit variable"
"Edit variables" = "Edit variables"
"Scope" = "Scope"
"No scope is shared by all the selected variables" = "No scope is shared by all the selected variables"
"Edit with the builder" = "Edit with the builder"
"Edited {when}" = "Edited {when}"
"Effect settings" = "Effect settings"
@@ -883,6 +887,7 @@ name = "English"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}"
"Remove these results from the file" = "Remove these results from the file"
"Scroll wheel zoom" = "Scroll wheel zoom"
"Invert zoom direction" = "Invert zoom direction"
"Shared by your team on {server}" = "Shared by your team on {server}"
"Slot name" = "Slot name"
"Mar" = "Mar"
@@ -1886,6 +1891,7 @@ name = "English"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
"stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count"
"sticky layers import as frames with their text" = "sticky layers import as frames with their text"
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke"
"text path underline or strikethrough decoration is not rendered" = "text path underline or strikethrough decoration is not rendered"
"text style overrides refer to missing table entries" = "text style overrides refer to missing table entries"
+4
View File
@@ -22,6 +22,7 @@ status = "draft — needs native review"
"Along the top" = "A lo largo del borde superior"
"An earlier vote" = "Una votación anterior"
"Android Vector Drawable" = "Android Vector Drawable"
"Annotation" = "Anotación"
"Best effort" = "Mejor aproximación"
"Between the selected layers" = "Entre las capas seleccionadas"
"Brainstorm" = "Lluvia de ideas"
@@ -35,6 +36,7 @@ status = "draft — needs native review"
"Couldn't download the variables" = "No se pudieron descargar las variables"
"Couldn't download {name}" = "No se pudo descargar {name}"
"Create from color" = "Crear a partir del color"
"Invert zoom direction" = "Invertir la dirección del zoom"
"New collection" = "Nueva colección"
"Delete results" = "Eliminar resultados"
"Pasted design layers" = "Capas de diseño pegadas"
@@ -2215,6 +2217,7 @@ status = "draft — needs native review"
"plugins.runLast" = "Ejecutar el último plugin"
"pref.autoSave" = "Guardar archivos automáticamente"
"pref.highlightHover" = "Resaltar capas al pasar el cursor"
"pref.invertZoom" = "Invertir la dirección del zoom"
"pref.keepTool" = "Mantener la herramienta tras usarla"
"pref.language" = "Idioma de la interfaz"
"pref.nudge" = "Cantidad de desplazamiento"
@@ -2366,6 +2369,7 @@ status = "draft — needs native review"
"text.underline" = "Subrayado"
"text.weightDown" = "Reducir peso de fuente"
"text.weightUp" = "Aumentar peso de fuente"
"tool.annotation" = "Anotación"
"tool.arrow" = "Flecha"
"tool.comment" = "Comentario"
"tool.connector" = "Conector"
+69
View File
@@ -128,6 +128,7 @@ name = "Svenska"
"Another server, or one that needs its key. The usual server is set in Preferences." = "En annan server, eller en som kräver sin nyckel. Standardservern anges i Inställningar."
"Any status" = "Valfri status"
"Anyone on your network with this code can edit the file, while this app stays open." = "Alla i ditt nätverk som har den här koden kan redigera filen så länge appen är öppen."
"Anyone on your network with this code can look at the file, not change it, while this app stays open." = "Alla i ditt nätverk som har den här koden kan titta på filen, men inte ändra den, så länge appen är öppen."
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Alla med redigeringslänken kan ändra filen; visningslänken låter bara andra titta. Länkarna innehåller sina hemligheter: dela dem som lösenord. `figcraft-server links` skapar fler och återkallar dem."
"Anyone with this code can edit the file." = "Alla med den här koden kan redigera filen."
"Anyone with this code can look at the file, not change it." = "Alla med den här koden kan titta på filen, men inte ändra den."
@@ -388,6 +389,8 @@ name = "Svenska"
"Crop marks" = "Skärmärken"
"Ctrl+arrows" = "Ctrl+piltangenter"
"Ctrl+click" = "Ctrl+klick"
"Ctrl+click a padding field" = "Ctrl+klicka på ett padding-fält"
"⌘ click a padding field" = "⌘ klicka på ett padding-fält"
"Ctrl+scroll" = "Ctrl+rullning"
"Current name" = "Aktuellt namn"
"Current version" = "Aktuell version"
@@ -407,6 +410,40 @@ name = "Svenska"
"Decoration style" = "Dekorationsstil"
"Decoration" = "Dekoration"
"Deep select" = "Djupmarkering"
"Scrub a field faster (2x) or slower (1/2, 1/4)" = "Dragjustera ett fält snabbare (2x) eller långsammare (1/2, 1/4)"
"Move up or down while dragging its label" = "Flytta upp eller ned medan du drar dess etikett"
"Scrubbing at 2x" = "Dragjustering med 2x hastighet"
"Scrubbing at 1x" = "Dragjustering med 1x hastighet"
"Scrubbing at 1/2" = "Dragjustering med 1/2 hastighet"
"Scrubbing at 1/4" = "Dragjustering med 1/4 hastighet"
"Delete {n} variables" = { one = "Radera {n} variabel", other = "Radera {n} variabler" }
"Move out of any group" = "Flytta ut ur alla grupper"
"Move to group {group}" = "Flytta till gruppen {group}"
"Scope: all supported properties" = "Omfång: alla egenskaper som stöds"
"Scope: hidden from pickers" = "Omfång: dold i väljare"
"Scope: only {scope}" = "Omfång: endast {scope}"
"Disconnected. Reconnecting…" = "Frånkopplad. Återansluter…"
"Disconnected. Your edits stay here and sync when the connection returns. Reconnecting…" = "Frånkopplad. Dina redigeringar finns kvar här och synkas när anslutningen är tillbaka. Återansluter…"
"Dismiss" = "Stäng"
"Not connected: {reason}. Your work is still in this tab." = "Inte ansluten: {reason}. Ditt arbete finns kvar i den här fliken."
"Retry now" = "Försök igen nu"
"Save a copy…" = "Spara en kopia…"
"Wrap style" = "Radbrytningsstil"
"Text direction" = "Textriktning"
"Auto: by the first letter with a direction" = "Auto: efter den första bokstaven som har en riktning"
"Left to right" = "Vänster till höger"
"Right to left" = "Höger till vänster"
"Balance" = "Balans"
"Pretty" = "Snygg"
"Auto: as many words as fit on each line" = "Auto: så många ord som får plats på varje rad"
"Balance: every line close to the same length" = "Balans: alla rader ungefär lika långa"
"Pretty: full lines, no one word alone on the last" = "Snygg: fulla rader, inget ensamt ord på den sista"
"Set hug contents" = "Ställ in Omslut innehåll"
"Set fill container" = "Ställ in Fyll behållaren"
"Double-click an edge of an auto layout frame" = "Dubbelklicka på en kant av en auto layout-frame"
"{name}, disconnected from its live session" = "{name}, frånkopplad från sin livesession"
"⌥ double-click an edge of an auto layout child" = "⌥ dubbelklicka på en kant av ett underordnat objekt i auto layout"
"Alt+double-click an edge of an auto layout child" = "Alt+dubbelklicka på en kant av ett underordnat objekt i auto layout"
"Default" = "Standard"
"Default content" = "Standardinnehåll"
"Default value (the top-left variant)" = "Standardvärde (varianten längst upp till vänster)"
@@ -494,6 +531,7 @@ name = "Svenska"
"Edit dev resource" = "Redigera utvecklarresurs"
"Edit link" = "Redigeringslänk"
"Edit link copied" = "Redigeringslänken har kopierats"
"Edit padding on all sides" = "Redigera padding på alla sidor"
"Edit style" = "Redigera stil"
"Edit variable" = "Redigera variabel"
"Edit with the builder" = "Redigera med byggaren"
@@ -531,6 +569,7 @@ name = "Svenska"
"Exclusion" = "Uteslutning"
"Exit focus view (Esc)" = "Lämna fokusvyn (Esc)"
"Expand" = "Expandera"
"Expand the interface" = "Expandera gränssnittet"
"Export" = "Exportera"
"Export all" = "Exportera alla"
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exportera frames och lager som PNG, JPG, SVG eller PDF"
@@ -1073,6 +1112,7 @@ name = "Svenska"
"Pages: {pages}" = "Sidor: {pages}"
"Paint bucket" = "Färghink"
"Pan the canvas" = "Panorera arbetsytan"
"Show and move the placement crosshair (its tool chosen, nothing selected)" = "Visa och flytta placeringshårkorset (med dess verktyg valt och inget markerat)"
"Pan with nothing selected (Shift: farther)" = "Panorera när inget är markerat (Shift: längre)"
"Panels" = "Paneler"
"Paper" = "Papper"
@@ -1297,6 +1337,7 @@ name = "Svenska"
"See all templates" = "Se alla mallar"
"Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras"
"SHA-256: {sha}…" = "SHA-256: {sha}…"
"Same as file ({profile})" = "Samma som filen ({profile})"
"Same as layer" = "Samma som lagret"
"Saturation" = "Mättnad"
"Save" = "Spara"
@@ -1969,6 +2010,17 @@ name = "Svenska"
"⌘ click" = "⌘ klicka"
"⌘ scroll" = "⌘ rulla"
"⌥ drag" = "⌥ dra"
"Selection box: switch to pick select" = "Markeringsruta: växla till plockmarkering"
"Pick select: next or previous layer" = "Plockmarkering: nästa eller föregående lager"
"Pick select: into a layer, out to its parent" = "Plockmarkering: in i ett lager, ut till dess överordnade"
"Pick select: select the picked layer" = "Plockmarkering: markera det valda lagret"
"⇥ or ⌥ Space again" = "⇥ eller ⌥ Blanksteg igen"
"Tab or Ctrl+Space again" = "Tab eller Ctrl+Blanksteg igen"
"↵ / ⇧↵" = "↵ / ⇧↵"
"Enter / Shift+Enter" = "Enter / Shift+Enter"
"Space" = "Blanksteg"
"Nothing to pick: the page is empty" = "Inget att välja: sidan är tom"
"Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels" = "Plockmarkering: Tab och Shift+Tab flyttar mellan lager, Enter går in i ett, Shift+Enter ut, Blanksteg markerar, Esc avbryter"
[commands]
@@ -2005,6 +2057,7 @@ name = "Svenska"
"board.label" = "Kopplingsetikett"
"board.nextSticky" = "Nästa lapp"
"board.reattach" = "Koppla om koppling"
"board.reshape" = "Omforma koppling"
"board.setColor" = "Whiteboardfärg"
"board.setStyle" = "Whiteboardstil"
"board.shape" = "Form med text"
@@ -2047,6 +2100,7 @@ name = "Svenska"
"collab.openFile" = "Öppna onlinefil"
"collab.peers" = "Personer i den här filen"
"collab.publish" = "Publicera på server"
"collab.reconnect" = "Återanslut livesessionen"
"collab.recoverFile" = "Återställ onlinefil"
"collab.registerLinks" = "Öppna inbjudningslänkar i FigCraft"
"collab.removePerson" = "Ta bort från session"
@@ -2195,6 +2249,7 @@ name = "Svenska"
"image.deletePreset" = "Radera förinställning"
"image.place" = "Placera bild"
"image.presets" = "Bildförinställningar"
"image.replace" = "Ersätt bild"
"image.savePreset" = "Spara förinställning"
"inspect.assets" = "Resurser"
"inspect.code" = "Kod"
@@ -2212,6 +2267,7 @@ name = "Svenska"
"layers.collapseAll" = "Fäll ihop alla"
"layout.add" = "Lägg till auto layout"
"layout.remove" = "Ta bort auto layout"
"layout.removeAll" = "Ta bort all auto layout"
"layout.setTrack" = "Ange spår"
"layout.suggest" = "Föreslå layout"
"libraries.open" = "Öppna bibliotek"
@@ -2306,6 +2362,9 @@ name = "Svenska"
"pref.showDimensions" = "Visa dimensioner på objekt"
"pref.startOnHome" = "Börja på Start"
"pref.uiScale" = "Gränssnittsstorlek"
"pref.uiScaleDown" = "Mindre gränssnitt"
"pref.uiScaleReset" = "Standardstorlek för gränssnittet"
"pref.uiScaleUp" = "Större gränssnitt"
"pref.userName" = "Ditt namn"
"proto.action.add" = "Lägg till prototypåtgärd"
"proto.action.move" = "Ändra ordning på prototypåtgärd"
@@ -2353,6 +2412,7 @@ name = "Svenska"
"selection.applyColorStyle" = "Använd färgformatmall på markeringen"
"selection.bindColorVariable" = "Använd färgvariabel på markeringen"
"selection.colors" = "Markeringsfärger"
"selection.createColorStyle" = "Skapa färgstil från markeringen"
"selection.detachColor" = "Koppla bort markeringsfärg"
"selection.selectColor" = "Markera lager med den här färgen"
"selection.setPaint" = "Ändra markeringens färg"
@@ -2399,6 +2459,10 @@ name = "Svenska"
"text.caseUpper" = "Versaler"
"text.create" = "Text"
"text.decoration" = "Dekoration"
"text.direction" = "Textriktning"
"text.directionAuto" = "Textriktning: auto"
"text.directionLtr" = "Textriktning: vänster till höger"
"text.directionRtl" = "Textriktning: höger till vänster"
"text.edit.begin" = "Redigera text"
"text.edit.caret" = "Placera markören"
"text.edit.end" = "Sluta redigera text"
@@ -2423,6 +2487,7 @@ name = "Svenska"
"text.missingFonts" = "Saknade typsnitt"
"text.numberList" = "Numrerad lista"
"text.onPath" = "Text på bana"
"text.openLink" = "Öppna länk"
"text.paragraphIndent" = "Styckeindrag"
"text.paragraphSpacing" = "Styckeavstånd"
"text.pathOptions" = "Alternativ för textbana"
@@ -2442,9 +2507,11 @@ name = "Svenska"
"text.underline" = "Understrykning"
"text.weightDown" = "Minska teckenvikten"
"text.weightUp" = "Öka teckenvikten"
"text.wrapStyle" = "Radbrytningsstil"
"tool.arrow" = "Pil"
"tool.comment" = "Kommentar"
"tool.connector" = "Koppling"
"tool.crosshair" = "Flytta placeringshårkorset"
"tool.ellipse" = "Ellips"
"tool.eraser" = "Suddgummi"
"tool.frame" = "Frame"
@@ -2488,6 +2555,7 @@ name = "Svenska"
"variable.deleteCollection" = "Radera samling"
"variable.duplicate" = "Duplicera variabel"
"variable.export" = "Exportera variabler"
"variable.extendCollection" = "Utöka samlingen"
"variable.import" = "Importera variabler"
"variable.list" = "Lista variabler"
"variable.moveMode" = "Flytta läge"
@@ -2561,6 +2629,7 @@ name = "Svenska"
"view.layoutGrids" = "Layoutrutnät"
"view.leftSidebar" = "Visa/dölj vänster sidofält"
"view.maskOutlines" = "Konturlinjer för masker"
"view.minimize" = "Minimera gränssnittet"
"view.multiplayerCursors" = "Samarbetsmarkörer"
"view.nextFrame" = "Zooma till nästa frame"
"view.nextPage" = "Nästa sida"
-1
View File
@@ -1575,7 +1575,6 @@ status = "draft — needs native review"
"Texts" = "உரைகள்"
"Texture" = "அமைப்பு"
"The bars are hidden. {shortcut} shows them again" = "பட்டைகள் மறைக்கப்பட்டுள்ளன. {shortcut} அவற்றை மீண்டும் காட்டும்"
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "கேன்வாஸ் நிறங்களை sRGB இல் காட்டுகிறது; ஏற்றுமதி செய்த PNG, JPG, WebP கோப்புகள் Display P3 எனக் குறிக்கப்படும்."
"The destination document is unavailable. Nothing was imported." = "இலக்கு ஆவணம் கிடைக்கவில்லை. எதுவும் இறக்குமதி செய்யப்படவில்லை."
"The destination document was closed. Nothing was imported." = "இலக்கு ஆவணம் மூடப்பட்டது. எதுவும் இறக்குமதி செய்யப்படவில்லை."
"The destination page was removed. Nothing was imported." = "இலக்குப் பக்கம் அகற்றப்பட்டது. எதுவும் இறக்குமதி செய்யப்படவில்லை."
+9 -1
View File
@@ -883,9 +883,17 @@ pub fn tab(resp: &Response, label: &str, selected: bool) {
}
pub fn checkbox(resp: &Response, label: &str, on: bool) {
checkbox_tri(resp, label, Some(on));
}
/// A checkbox that can be mixed (`None`, announced as "mixed" rather than unchecked).
pub fn checkbox_tri(resp: &Response, label: &str, state: Option<bool>) {
node(resp, label, |n| {
n.set_role(Role::CheckBox);
n.set_toggled(toggled(on));
n.set_toggled(match state {
Some(on) => toggled(on),
None => Toggled::Mixed,
});
});
}
+77
View File
@@ -431,3 +431,80 @@ fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() {
}
assert!(!seen.contains(&left) && !seen.contains(&right), "Tab never stops on a panel edge: {seen:?}");
}
#[test]
fn a_mixed_checkbox_is_announced_as_mixed_and_a_checked_one_as_true() {
let ctx = egui::Context::default();
ctx.enable_accesskit();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
crate::widgets::checkbox_tri(ui, None, "Mixed one");
crate::widgets::checkbox_tri(ui, Some(true), "Checked one");
crate::widgets::checkbox_tri(ui, Some(false), "Plain one");
});
out.textures_delta.clear();
let nodes: Vec<Node> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect();
assert_eq!(find(&nodes, Role::CheckBox, "Mixed one").toggled(), Some(Toggled::Mixed));
assert_eq!(find(&nodes, Role::CheckBox, "Checked one").toggled(), Some(Toggled::True));
assert_eq!(find(&nodes, Role::CheckBox, "Plain one").toggled(), Some(Toggled::False));
}
/// Runs one headless frame of a lone tri-state checkbox; returns whether it was clicked.
fn tri_frame(ctx: &egui::Context, state: Option<bool>, events: Vec<Event>) -> bool {
let mut clicked = false;
let input = egui::RawInput { events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
clicked = crate::widgets::checkbox_tri(ui, state, "Keyed one");
});
out.textures_delta.clear();
clicked
}
fn key(key: Key, pressed: bool) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
}
#[test]
fn a_checkbox_is_keyboard_operable_tab_to_it_then_space_or_enter_toggles_it() {
for activate in [Key::Space, Key::Enter] {
let ctx = egui::Context::default();
tri_frame(&ctx, Some(false), vec![]);
tri_frame(&ctx, Some(false), vec![key(Key::Tab, true), key(Key::Tab, false)]);
assert!(ctx.memory(|m| m.focused()).is_some(), "Tab focuses the checkbox");
tri_frame(&ctx, Some(false), vec![]);
let clicked = tri_frame(&ctx, Some(false), vec![key(activate, true), key(activate, false)]);
assert!(clicked, "{activate:?} on the focused checkbox toggles it");
}
}
/// The marks (paths, meshes, segments) painted inside the 14-point box of a lone checkbox.
fn box_marks(state: Option<bool>) -> Vec<egui::Rect> {
fn walk(s: &egui::Shape, out: &mut Vec<egui::Rect>) {
match s {
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } => out.push(s.visual_bounding_rect()),
_ => {}
}
}
let ctx = egui::Context::default();
let mut out = Vec::new();
for _ in 0..2 {
let mut o = ctx.run_ui(egui::RawInput::default(), |ui| {
crate::widgets::checkbox_tri(ui, state, "Painted one");
});
o.textures_delta.clear();
out.clear();
o.shapes.iter().for_each(|c| walk(&c.shape, &mut out));
}
out
}
#[test]
fn a_mixed_checkbox_paints_a_dash_and_a_checked_one_a_tick() {
// Icon::Minus is one flat bar; Icon::Check is a taller path.
let mixed = box_marks(None);
assert!(mixed.iter().any(|r| r.width() >= 5.0 && r.height() <= 2.5), "a flat dash is painted for the mixed state: {mixed:?}");
let checked = box_marks(Some(true));
assert!(checked.iter().any(|r| r.width() >= 5.0 && r.height() > 3.5), "a tick is painted for the checked state: {checked:?}");
assert!(!checked.iter().any(|r| r.width() >= 5.0 && r.height() <= 2.5), "no dash on a checked box: {checked:?}");
assert!(box_marks(Some(false)).is_empty(), "an empty box paints no mark");
}
+6
View File
@@ -22,7 +22,11 @@ impl FigApp {
/// state.
pub fn new(session: Session, services: Box<dyn Services>) -> Self {
// The main window's panels open as wide as they were left.
// Widths saved under other limits are clamped to the current ones, not rejected; one that
// isn't finite falls back to the default.
let (left_width, right_width) = session.prefs.panel_widths;
let within = |w: f32, range: std::ops::RangeInclusive<f32>| if w.is_finite() { w.clamp(*range.start(), *range.end()) } else { PANEL_DEFAULT };
let (left_width, right_width) = (within(left_width, LEFT_PANEL), within(right_width, RIGHT_PANEL));
let pages_height = session.prefs.pages_height;
Self {
ui: UiState { left_width, right_width, pages_height, ..UiState::default() },
@@ -302,6 +306,8 @@ impl FigApp {
self.ui.narrow = narrow;
// A narrow window shows the right panel when its button asks for it, a board's too.
let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right);
// A highlighted fill, stroke or effect row goes with the panel it's in, before it draws.
crate::property_rows::reconcile(self, &ctx, right_open);
// A right-to-left UI language mirrors the window: the "left" rail and panel go on the
// right, the "right" (Design) panel on the left. Their ids and widths stay as they are.
let mirrored = self.ui.mirrored;
+12
View File
@@ -266,6 +266,18 @@ impl FigApp {
self.ui.comments.show(id);
}
}
"annotation.new" => {
// The Annotation tool: show the Inspect tab (never toggle it away) and put the
// caret in the layer's new-note field.
// Only a selected layer's field can show; a request for anything else would wait
// and take the keyboard later.
let shown =
|id| self.session.selection().contains(&id) && self.session.doc().is_ok_and(|d| d.node(id).is_some_and(|n| !n.is_page()));
if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of).filter(|id| shown(*id)) {
self.ui.right_tab = RightTab::Inspect;
self.ui.dev.focus_new_note = Some(id);
}
}
"comment.new" => {
let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
+9 -3
View File
@@ -14,6 +14,7 @@ use crate::i18n::{tr, trf, trn};
use crate::render_worker::{Done, Job, Worker};
use crate::theme::{COMPONENT, Tokens, doc_c32, regular, semibold};
mod background;
mod builder;
mod input;
mod outline;
@@ -339,15 +340,20 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
app.canvas.tex_seq = done.seq;
}
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, bg);
crate::theme::set_canvas_page(&ctx, bg);
if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) {
let image = if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) {
// Re-project the last render onto the current view.
let tl = app.to_screen(v.origin.0, v.origin.1);
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
let pixel_preview = app.session.ds().ok().and_then(|ds| ds.view.pixel_preview_scale()) == Some(v.scale);
let image_rect = presented_image_rect(tl, v, zoom, ppp, pixel_preview);
painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
Some((tex.id(), image_rect))
} else {
None
};
background::paint(&painter, rect, bg, image.map(|(_, r)| r));
if let Some((texture, image_rect)) = image {
painter.image(texture, image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
crate::dev_mode::refresh_measurements(app);
app.session.tool.labels = overlays(app, &painter, &t);
+230
View File
@@ -0,0 +1,230 @@
//! The transparency grid behind the page; rendered pixels already contain the page colour.
use egui::{Color32, CornerRadius, Id, Painter, Rect, TextureHandle, pos2};
/// Reuse the checkerboard texture, updating its pixels when the active theme changes.
fn grid_texture(ctx: &egui::Context) -> TextureHandle {
let id = Id::unique("canvas_transparency_grid");
let tokens = crate::theme::Tokens::get(ctx);
let colors = [tokens.panel, tokens.canvas];
let cached = ctx.data(|data| data.get_temp::<(TextureHandle, [Color32; 2])>(id));
if let Some((texture, old_colors)) = &cached
&& *old_colors == colors
{
return texture.clone();
}
let image = egui::ColorImage::new([2, 2], vec![colors[0], colors[1], colors[1], colors[0]]);
let texture = match cached {
Some((mut texture, _)) => {
texture.set(image, egui::TextureOptions::NEAREST_REPEAT);
texture
}
None => ctx.load_texture("canvas-transparency", image, egui::TextureOptions::NEAREST_REPEAT),
};
ctx.data_mut(|data| data.insert_temp(id, (texture.clone(), colors)));
texture
}
/// Draw the transparency grid and apply page colour only outside the rendered image.
pub(super) fn paint(painter: &Painter, rect: Rect, background: Color32, rendered: Option<Rect>) {
if background.a() < 255 {
// Repeat four pixels instead of allocating one shape per square on a large canvas.
let texture = grid_texture(painter.ctx());
let uv = Rect::from_min_size(egui::Pos2::ZERO, rect.size() / 16.0);
painter.image(texture.id(), rect, uv, Color32::WHITE);
}
// The render contains the page colour and its alpha. Painting that colour beneath it
// applies alpha twice (50% becomes 75%). Only uncovered areas need the fallback colour.
let covered = rendered.map(|r| r.intersect(rect)).filter(|r| r.is_positive());
let areas = match covered {
Some(r) => [
Rect::from_min_max(rect.min, pos2(rect.right(), r.top())),
Rect::from_min_max(pos2(rect.left(), r.bottom()), rect.max),
Rect::from_min_max(pos2(rect.left(), r.top()), r.left_bottom()),
Rect::from_min_max(r.right_top(), pos2(rect.right(), r.bottom())),
],
None => [rect, Rect::NOTHING, Rect::NOTHING, Rect::NOTHING],
};
for area in areas.into_iter().filter(|r| r.is_positive()) {
painter.rect_filled(area, CornerRadius::ZERO, background);
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Mesh, TextureId, vec2};
/// Paint a small canvas and consume its texture uploads as the UI harness does.
fn shapes(alpha: u8, rendered: Option<Rect>) -> egui::FullOutput {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| {
paint(ui.painter(), canvas(), Color32::from_rgba_unmultiplied(255, 0, 0, alpha), rendered);
});
out.textures_delta.clear();
out
}
/// Fixed canvas bounds make clipping and uncovered-area checks independent of UI layout.
fn canvas() -> Rect {
Rect::from_min_size(egui::Pos2::ZERO, vec2(100.0, 100.0))
}
/// Check the actual checkerboard quad, including clipping and its screen-sized UV mapping.
fn assert_grid(out: &egui::FullOutput, tile: TextureId, rect: Rect) -> usize {
let meshes: Vec<_> = out
.shapes
.iter()
.enumerate()
.filter_map(|(index, shape)| match &shape.shape {
egui::Shape::Mesh(mesh) if mesh.texture_id == tile => Some((index, shape.clip_rect, mesh)),
_ => None,
})
.collect();
assert_eq!(meshes.len(), 1, "one checkerboard covers the canvas");
let (index, clip, mesh) = meshes[0];
assert_eq!(mesh.calc_bounds(), rect);
assert!(clip.contains_rect(rect), "the whole checkerboard is visible");
assert_eq!(mesh.vertices.len(), 4);
for vertex in &mesh.vertices {
let expected = (vertex.pos - rect.min) / 16.0;
assert!((vertex.uv.to_vec2() - expected).length() < 0.001, "UVs keep the squares at a fixed screen size");
}
index
}
/// Assert strips have the correct colour and partition exactly the area outside the render.
fn assert_partition(alpha: u8, rendered: Option<Rect>) {
let out = shapes(alpha, rendered);
let fills: Vec<_> = out
.shapes
.iter()
.enumerate()
.filter_map(|(index, s)| match &s.shape {
egui::Shape::Rect(r) => Some((index, r)),
_ => None,
})
.collect();
let covered = rendered.map(|r| r.intersect(canvas())).filter(|r| r.is_positive());
let background = Color32::from_rgba_unmultiplied(255, 0, 0, alpha);
for (i, (_, strip)) in fills.iter().enumerate() {
assert_eq!(strip.fill.a(), alpha, "each fallback strip applies alpha exactly once");
assert_eq!(strip.fill, background, "the fallback keeps the page colour too");
assert!(strip.rect.is_positive() && canvas().contains_rect(strip.rect));
if let Some(covered) = covered {
assert!(!strip.rect.intersect(covered).is_positive(), "no fallback beneath rendered pixels");
}
for (_, other) in fills.iter().skip(i + 1) {
assert!(!strip.rect.intersect(other.rect).is_positive(), "fallback strips do not overlap");
}
}
let area: f32 = fills.iter().map(|(_, strip)| strip.rect.area()).sum();
assert_eq!(area, canvas().area() - covered.map_or(0.0, |r| r.area()), "no uncovered gaps");
if covered == Some(canvas()) {
assert!(fills.is_empty(), "full coverage needs no fallback fills");
}
let meshes: Vec<&Mesh> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Mesh(mesh) => Some(mesh.as_ref()),
_ => None,
})
.collect();
if alpha < 255 {
assert_eq!(meshes.len(), 1);
let index = assert_grid(&out, meshes[0].texture_id, canvas());
assert!(fills.iter().all(|(fill_index, _)| *fill_index > index), "the grid is behind every fallback strip");
} else {
assert!(meshes.is_empty(), "opaque pages need no checkerboard");
}
}
/// Exercise the real canvas with a render present, checking the grid covers it and draws first.
#[test]
fn the_canvas_draws_the_transparency_grid_for_a_transparent_page() {
let mut ui = crate::layers_drag_tests::Ui::new(figcraft_engine::Session::new());
ui.app.run("page.setBackground", serde_json::json!({"color": "#FF000000"}));
let ds = ui.app.session.ds().unwrap();
let shown = (ds.uid, ds.page);
// Seed an image so this test does not depend on when the asynchronous worker finishes.
ui.app.canvas.texture =
Some(ui.ctx.load_texture("test-page-render", egui::ColorImage::filled([16, 16], Color32::TRANSPARENT), egui::TextureOptions::NEAREST));
ui.app.canvas.tex_view = Some(figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 16, height: 16 });
ui.app.canvas.tex_of = Some(shown);
let out = ui.frame_output(vec![]);
let (tile, _) =
ui.ctx.data(|d| d.get_temp::<(TextureHandle, [Color32; 2])>(Id::unique("canvas_transparency_grid"))).expect("the canvas checkerboard");
let grid_index = assert_grid(&out, tile.id(), ui.app.canvas.rect);
let rendered = ui.app.canvas.texture.as_ref().unwrap().id();
let render_index =
out.shapes.iter().position(|s| matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id == rendered)).expect("the rendered page");
assert!(grid_index < render_index, "the checkerboard is behind the rendered page");
}
/// Theme changes must reach drawing, update the pixels used by its mesh and reuse the texture.
#[test]
fn the_grid_updates_its_colours_when_the_theme_changes() {
let ctx = egui::Context::default();
let mut previous_id = None;
for tokens in [crate::theme::Tokens::light(), crate::theme::Tokens::dark(), crate::theme::Tokens::light()] {
crate::theme::apply(&ctx, tokens);
let mut out = ctx.run_ui(Default::default(), |ui| paint(ui.painter(), canvas(), Color32::TRANSPARENT, None));
let (tile, _) =
ctx.data(|d| d.get_temp::<(TextureHandle, [Color32; 2])>(Id::unique("canvas_transparency_grid"))).expect("drawn checkerboard");
let grid_index = assert_grid(&out, tile.id(), canvas());
assert!(out.shapes.iter().enumerate().all(|(index, s)| !matches!(&s.shape, egui::Shape::Rect(_)) || index > grid_index));
if let Some(previous_id) = previous_id {
assert_eq!(tile.id(), previous_id, "changing theme updates the same texture");
}
let delta = out.textures_delta.set.get(&tile.id()).and_then(|d| d.first()).expect("updated checkerboard pixels");
let egui::ImageData::Color(image) = &delta.image;
assert_eq!(image.pixels, [tokens.panel, tokens.canvas, tokens.canvas, tokens.panel]);
previous_id = Some(tile.id());
out.textures_delta.clear();
}
}
/// Interior, edge-straddling and fully covering renders must all apply the page alpha once.
#[test]
fn translucent_pages_show_the_grid_without_applying_page_alpha_twice() {
let renders = [
Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 80.0)),
Rect::from_min_max(pos2(-20.0, 20.0), pos2(80.0, 80.0)),
Rect::from_min_max(pos2(20.0, -20.0), pos2(80.0, 80.0)),
Rect::from_min_max(pos2(20.0, 20.0), pos2(120.0, 80.0)),
Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 120.0)),
Rect::from_min_max(pos2(-20.0, -20.0), pos2(80.0, 80.0)),
canvas(),
Rect::from_min_max(pos2(-20.0, -20.0), pos2(120.0, 120.0)),
];
for alpha in [0, 128, 255] {
for rendered in renders {
assert_partition(alpha, Some(rendered));
}
}
}
/// Missing, offscreen and merely edge-touching images leave exactly one fallback page fill.
#[test]
fn absent_or_offscreen_renders_fill_the_page_once() {
for rendered in
[None, Some(Rect::from_min_size(pos2(200.0, 200.0), vec2(10.0, 10.0))), Some(Rect::from_min_max(pos2(-20.0, 20.0), pos2(0.0, 80.0)))]
{
for alpha in [0, 128, 255] {
assert_partition(alpha, rendered);
let out = shapes(alpha, rendered);
let fills: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) => Some(r),
_ => None,
})
.collect();
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].rect, canvas());
}
}
}
}
+24 -4
View File
@@ -1,7 +1,7 @@
//! The pointer on the canvas: each engine cursor ([`figcraft_engine::tools::Cursor`]) as one of
//! the system's cursors, or as a small glyph FigCraft draws at the pointer with the system's
//! hidden (a rotate cursor turned to its corner, the pen's states, the eyedropper, the comment
//! pin). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images.
//! pin, the crosshair of the shape and Text tools). The glyphs are original, drawn from lines and the code-drawn icons: no cursor images.
use egui::{Color32, CursorIcon, Painter, Pos2, Shape, Stroke, pos2, vec2};
use figcraft_engine::tools::{Cursor, PenCursor};
@@ -17,6 +17,9 @@ pub enum Glyph {
Pen(PenCursor),
Eyedropper,
Comment,
/// A plus open at the centre, for the shape and Text tools (the system's follows the
/// system's pointer colour, and a white one is lost on a white frame).
Crosshair,
}
/// What the pointer shows.
@@ -46,7 +49,7 @@ pub fn of(cursor: Cursor) -> Shown {
use Shown::{Drawn, System};
match cursor {
Cursor::Default => System(CursorIcon::Default),
Cursor::Crosshair => System(CursorIcon::Crosshair),
Cursor::Crosshair => Drawn(Glyph::Crosshair),
Cursor::Move => System(CursorIcon::Move),
Cursor::Resize(a) if a.is_finite() => System(resize_icon(a)),
Cursor::Resize(_) => System(CursorIcon::Default),
@@ -85,7 +88,7 @@ impl Shown {
/// A name for it (`ui.inspect`): the CSS cursor names where there is one ("default",
/// "nwse-resize", "copy"), and "rotate" (with its angle), "pen", "pen-close", "pen-add",
/// "pen-bend", "eyedropper" and "comment" for the drawn ones.
/// "pen-bend", "eyedropper", "comment" and "crosshair" for the drawn ones.
pub fn name(self) -> String {
match self {
Shown::System(i) => match i {
@@ -114,6 +117,7 @@ impl Shown {
Shown::Drawn(Glyph::Pen(PenCursor::Bend)) => "pen-bend".into(),
Shown::Drawn(Glyph::Eyedropper) => "eyedropper".into(),
Shown::Drawn(Glyph::Comment) => "comment".into(),
Shown::Drawn(Glyph::Crosshair) => "crosshair".into(),
}
}
@@ -127,7 +131,7 @@ impl Shown {
}
/// Dark lines over a light edge: readable on any page and artwork.
const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E);
pub(crate) const INK: Color32 = Color32::from_rgb(0x1E, 0x1E, 0x1E);
const EDGE: Color32 = Color32::WHITE;
/// A line through `pts` with the light edge under it.
@@ -167,6 +171,22 @@ pub fn paint(painter: &Painter, at: Pos2, glyph: Glyph) {
line(painter, &[tip - t * 4.5 + n * 3.5, tip + t * 1.0, tip - t * 4.5 - n * 3.5]);
}
}
Glyph::Crosshair => {
// A plus centred on the pointer, open at the centre so its pixel stays visible; the
// light edge goes under both arms of each line first, then the dark line on top.
let arms = [
(vec2(-8.0, 0.0), vec2(-2.0, 0.0)),
(vec2(2.0, 0.0), vec2(8.0, 0.0)),
(vec2(0.0, -8.0), vec2(0.0, -2.0)),
(vec2(0.0, 2.0), vec2(0.0, 8.0)),
];
for (a, b) in arms {
painter.add(Shape::line_segment([at + a, at + b], Stroke::new(3.0, EDGE)));
}
for (a, b) in arms {
painter.add(Shape::line_segment([at + a, at + b], Stroke::new(1.0, INK)));
}
}
Glyph::Pen(state) => {
// A cross open at the centre (the point it lands on stays visible), and what a click
// does at its lower right.
+10 -2
View File
@@ -81,10 +81,18 @@ fn wheel_zoom(app: &mut FigApp, ctx: &egui::Context, zoom: f32, shift: bool) ->
if let Some(mouse) = latest {
app.canvas.wheel_is_mouse = mouse;
}
let invert = app.session.prefs.invert_zoom && !pinch;
if !app.session.prefs.scroll_wheel_zoom || !app.canvas.wheel_is_mouse || pinch {
return (wheel, zoom);
return (wheel, if invert { invert_zoom_factor(zoom) } else { zoom });
}
swap_wheel(wheel, zoom, shift, speed)
let (scroll, zoom) = swap_wheel(wheel, zoom, shift, speed);
(scroll, if invert { invert_zoom_factor(zoom) } else { zoom })
}
/// The zoom factor for the opposite direction (Preferences ▸ Invert zoom direction): its
/// reciprocal. A factor that is zero, negative or not finite is left as it is.
pub(crate) fn invert_zoom_factor(zoom: f32) -> f32 {
if zoom.is_finite() && zoom > 0.0 { 1.0 / zoom } else { zoom }
}
/// Whether a scroll event came from a mouse wheel. Only macOS says which device scrolled; elsewhere
+153 -1
View File
@@ -209,6 +209,61 @@ fn layout_grids(app: &FigApp, painter: &egui::Painter, doc: &Document, c: NodeId
}
}
/// The solid colour of the fill the next typed character gets (the character before the caret,
/// the first one's at 0, the base style for an empty text): the style's own fills when it has
/// them, else the layer's. `None` when the topmost visible paint isn't a solid (gradient, image,
/// no visible fill), so the caller uses [`caret_fallback`]. Opaque on purpose: Figma's caret
/// is one colour, and the fill's and paint's opacity weren't measured, so it must stay visible.
fn caret_fill(n: &figcraft_doc::Node, caret: usize) -> Option<figcraft_color::Color> {
let t = n.kind.text()?;
let style = t.style_at(caret.saturating_sub(1));
let paints = style.fills.as_ref().unwrap_or(&n.fills);
paints.iter().rev().find(|p| p.visible).and_then(|p| match &p.kind {
figcraft_color::PaintKind::Solid { color } => Some(color.with_alpha(1.0)),
_ => None,
})
}
/// [`caret_fill`] as a screen colour.
fn caret_color(ctx: &egui::Context, n: &figcraft_doc::Node, caret: usize) -> Option<Color32> {
caret_fill(n, caret).map(|c| doc_c32(ctx, c))
}
/// The caret of a text without a solid fill (a gradient, an image, no visible fill): black on a
/// light surface, white on a dark one. The surface is the nearest layer around the text whose
/// topmost visible fill is a solid at least half opaque as drawn (its colour's alpha times its paint's
/// opacity times its own and its containers' layer opacity), else the page; a layer whose top fill is a gradient or an
/// image is passed over. Not the interface's text colour, which is white in the dark theme and
/// vanishes on the default light page (#2190).
fn caret_fallback(doc: &Document, id: NodeId) -> Color32 {
let chain = doc.ancestors(id);
// Each layer's opacity as drawn: its own times every container's above it.
let drawn: Vec<f32> = {
let mut acc = 1.0f32;
let mut out: Vec<f32> = chain
.iter()
.rev()
.map(|a| {
acc *= doc.node(*a).map_or(1.0, |n| if n.opacity.is_finite() { n.opacity.clamp(0.0, 1.0) } else { 1.0 });
acc
})
.collect();
out.reverse();
out
};
let under = chain.iter().zip(drawn).find_map(|(a, alpha)| {
let n = doc.node(*a)?;
match &n.kind {
NodeKind::Page { background, .. } => Some(*background),
_ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| match &p.kind {
figcraft_color::PaintKind::Solid { color } if color.a * p.opacity * alpha >= 0.5 => Some(*color),
_ => None,
}),
}
});
if under.is_some_and(|c| c.is_dark()) { Color32::WHITE } else { Color32::BLACK }
}
fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, te: &figcraft_engine::TextEdit, zoom: f64) {
let preedit = app.preedit_view();
let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(doc);
@@ -242,7 +297,8 @@ fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Do
let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
if blink || a != b {
painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
let col = edoc.node(te.id).and_then(|n| caret_color(painter.ctx(), n, caret)).unwrap_or_else(|| caret_fallback(edoc, te.id));
painter.line_segment(text.caret(app, caret, zoom), stroke(col, 1.0));
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
}
@@ -782,3 +838,99 @@ fn pick_outline(app: &FigApp, painter: &egui::Painter, doc: &Document) {
draw_outline(app, painter, doc, p, stroke(crate::handles::PINK, 2.0));
}
}
#[cfg(test)]
mod caret_tests {
use super::*;
use figcraft_color::{Color, Paint};
use figcraft_doc::{Node, NodeId, NodeKind, StyleRun, TextData};
fn text(chars: &str, fills: Vec<Paint>) -> Node {
let mut n = Node::new(NodeId(1), NodeKind::Text(TextData { characters: chars.into(), ..TextData::default() }), "Text", 100.0, 20.0);
n.fills = fills;
n
}
fn hex(s: &str) -> Color {
Color::from_hex(s).unwrap()
}
#[test]
fn red_text_has_a_red_caret() {
let n = text("Caret", vec![Paint::solid(hex("#FF0000"))]);
assert_eq!(caret_fill(&n, 2), Some(hex("#FF0000")));
assert_eq!(caret_color(&egui::Context::default(), &n, 2), Some(Color32::from_rgb(255, 0, 0)));
}
#[test]
fn light_grey_text_keeps_its_own_colour_whatever_is_behind_it() {
// Inside a #222222 frame: the frame's fill plays no part.
let n = text("Caret", vec![Paint::solid(hex("#DDDDDD"))]);
assert_eq!(caret_fill(&n, 5), Some(hex("#DDDDDD")));
assert_eq!(caret_color(&egui::Context::default(), &n, 0), Some(Color32::from_rgb(0xDD, 0xDD, 0xDD)));
}
#[test]
fn the_caret_follows_the_run_of_the_character_before_it() {
let mut n = text("ab cd", vec![Paint::solid(hex("#000000"))]);
if let Some(t) = n.kind.text_mut() {
let mut style = t.style.clone();
style.fills = Some(vec![Paint::solid(hex("#FF0000"))]);
t.runs.push(StyleRun { start: 3, end: 5, style });
}
assert_eq!(caret_fill(&n, 2), Some(hex("#000000")), "after a black character");
assert_eq!(caret_fill(&n, 4), Some(hex("#FF0000")), "after a red character");
assert_eq!(caret_fill(&n, 0), Some(hex("#000000")), "at the start: the first character's");
}
#[test]
fn the_caret_is_opaque_whatever_the_fill_opacity() {
let mut p = Paint::solid(hex("#336699"));
p.opacity = 0.2;
assert_eq!(caret_fill(&text("x", vec![p]), 1).map(|c| c.a), Some(1.0));
}
/// #2190: a caret without a solid fill reads on the page: black on the default light page,
/// white on a dark one (the interface's text colour was white in the dark theme).
#[test]
fn without_a_solid_fill_the_caret_contrasts_with_the_page() {
let mut s = figcraft_engine::Session::new();
let r = s.execute("text.create", &serde_json::json!({"text": "Caret", "x": 0, "y": 0})).unwrap();
let id = figcraft_engine::params::id_of(&r["id"]).unwrap();
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::BLACK, "the default light page");
s.execute("page.setBackground", &serde_json::json!({"color": "#1E1E1E"})).unwrap();
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::WHITE, "a dark page");
}
/// CodeRabbit on #2163: a dark frame on a light page is the surface under its text.
#[test]
fn without_a_solid_fill_the_caret_contrasts_with_the_frame_around_it() {
let mut s = figcraft_engine::Session::new();
let ex = |s: &mut figcraft_engine::Session, c: &str, p: serde_json::Value| s.execute(c, &p).unwrap();
let f = ex(&mut s, "frame.create", serde_json::json!({"x": 0, "y": 0, "width": 300, "height": 200}));
let f = figcraft_engine::params::id_of(&f["id"]).unwrap();
ex(&mut s, "node.set", serde_json::json!({"id": f, "props": {"fill": "#222222"}}));
let r = ex(&mut s, "text.create", serde_json::json!({"text": "Caret", "x": 20, "y": 20, "parent": f}));
let id = figcraft_engine::params::id_of(&r["id"]).unwrap();
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::WHITE, "inside a dark frame on the light page");
ex(&mut s, "node.set", serde_json::json!({"id": f, "props": {"fill": "#F0F0F0"}}));
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::BLACK, "inside a light frame");
// A dark frame drawn at 20% opacity shows the light page through it.
ex(&mut s, "node.set", serde_json::json!({"id": f, "props": {"fill": "#222222", "opacity": 0.2}}));
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::BLACK, "a faint dark frame on the light page");
// The same through the colour's own alpha.
ex(&mut s, "node.set", serde_json::json!({"id": f, "props": {"fill": "#22222233", "opacity": 1.0}}));
assert_eq!(caret_fallback(s.doc().unwrap(), id), Color32::BLACK, "a dark fill at 20% alpha");
}
#[test]
fn gradient_or_missing_fills_have_no_fill_colour() {
let mut g = Paint::solid(hex("#FF0000"));
g.kind = figcraft_color::PaintKind::Gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, hex("#FF0000")));
assert_eq!(caret_fill(&text("Caret", vec![g]), 2), None);
assert_eq!(caret_fill(&text("Caret", vec![]), 2), None);
let mut hidden = Paint::solid(hex("#FF0000"));
hidden.visible = false;
assert_eq!(caret_fill(&text("Caret", vec![hidden]), 2), None);
}
}
+66 -1
View File
@@ -6,7 +6,7 @@ use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, R
use figcraft_engine::Session;
use serde_json::json;
use super::input::{is_mouse_wheel, swap_wheel};
use super::input::{invert_zoom_factor, is_mouse_wheel, swap_wheel};
use crate::{FigApp, NoServices};
/// The view's zoom, cx and cy.
@@ -25,8 +25,13 @@ fn wheel(unit: MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Event {
impl Canvas {
fn new(scroll_wheel_zoom: bool) -> Self {
Self::with_invert(scroll_wheel_zoom, false)
}
fn with_invert(scroll_wheel_zoom: bool, invert_zoom: bool) -> Self {
let mut c = Self { ctx: egui::Context::default(), app: FigApp::new(Session::new(), Box::new(NoServices)), time: 0.0 };
c.app.run("pref.scrollWheelZoom", json!({"on": scroll_wheel_zoom}));
c.app.run("pref.invertZoom", json!({"on": invert_zoom}));
for _ in 0..3 {
c.frame(vec![], Modifiers::NONE);
}
@@ -126,3 +131,63 @@ fn the_swap_keeps_egui_rates() {
assert_eq!(swap_wheel(vec2(40.0, 0.0), 1.0, true, speed), (vec2(40.0, 0.0), 1.0));
assert_eq!(swap_wheel(Vec2::ZERO, 1.0, false, speed), (Vec2::ZERO, 1.0));
}
#[test]
fn invert_zoom_reverses_cmd_or_ctrl_wheel_by_the_same_factor() {
for mods in [Modifiers::COMMAND, Modifiers::CTRL] {
let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true));
let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods);
let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods);
assert!(b.0 > a.0 * 1.05, "{mods:?} off zooms in: {a:?} -> {b:?}");
assert!(d.0 < c.0 / 1.05, "{mods:?} on zooms out: {c:?} -> {d:?}");
assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "{mods:?} by the same factor");
}
}
#[test]
fn invert_zoom_reverses_a_plain_mouse_wheel_with_scroll_wheel_zoom_on() {
let (mut off, mut on) = (Canvas::with_invert(true, false), Canvas::with_invert(true, true));
let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE);
let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE);
assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}");
assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}");
assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor");
}
/// Panning is never inverted: the scroll vector is the same with the preference on and off.
#[test]
fn invert_zoom_leaves_panning_unchanged() {
// (Scroll wheel zoom, modifiers): a plain wheel without it, Cmd/Ctrl+wheel with it, Shift+wheel.
for (swz, mods) in [(false, Modifiers::NONE), (true, Modifiers::COMMAND), (true, Modifiers::CTRL), (true, Modifiers::SHIFT)] {
let (mut off, mut on) = (Canvas::with_invert(swz, false), Canvas::with_invert(swz, true));
let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods);
let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods);
assert!((b.0 - a.0).abs() < 1e-9 && (d.0 - c.0).abs() < 1e-9, "{mods:?} no zoom");
assert!(b.1 != a.1 || b.2 != a.2, "{mods:?} off pans: {a:?} -> {b:?}");
assert!(
((b.1 - a.1) - (d.1 - c.1)).abs() < 1e-9 && ((b.2 - a.2) - (d.2 - c.2)).abs() < 1e-9,
"{mods:?} same pan: {a:?} -> {b:?} vs {c:?} -> {d:?}"
);
}
}
#[test]
fn a_pinch_zooms_the_same_with_invert_zoom_on_and_off() {
for (swz, invert, factor) in
[(false, false, 1.5), (false, true, 1.5), (true, false, 1.5), (true, true, 1.5), (true, true, 0.5), (false, true, 0.5)]
{
let mut c = Canvas::with_invert(swz, invert);
let (a, b, _) = c.gesture(Event::Zoom(factor), Modifiers::NONE);
assert!((b.0 / a.0 - f64::from(factor)).abs() < 1e-3, "scroll wheel zoom {swz}, invert {invert}, x{factor}: {a:?} -> {b:?}");
}
}
#[test]
fn the_inverted_factor_is_the_reciprocal_and_guards_bad_values() {
assert!((invert_zoom_factor(2.0) - 0.5).abs() < 1e-6);
assert_eq!(invert_zoom_factor(1.0), 1.0);
for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] {
let r = invert_zoom_factor(bad);
assert!(r == bad || (r.is_nan() && bad.is_nan()), "{bad} left alone");
}
}
+33 -16
View File
@@ -166,25 +166,42 @@ fn polygons_and_stars_take_corner_smoothing() {
for cmd in ["shape.polygon", "shape.star"] {
let mut s = Session::new();
let id = NodeId(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 120, "height": 120})).unwrap()["id"].as_u64().unwrap());
s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let mut u = Ui::new(s);
// With no corners of their own, the button is named for what it opens.
let t = named_button(&mut u, "Corner smoothing");
click(&mut u, t);
assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields");
let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center();
let history = u.app.session.undo_depth().0;
click(&mut u, smoothing);
u.frame(vec![Event::Text("60".into())]);
enter(&mut u);
u.frame(vec![]);
let n = u.app.session.doc().unwrap().node(id).unwrap().clone();
assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{cmd}: {}", n.corners.smoothing);
assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step");
takes_corner_smoothing(s, id, cmd);
}
}
/// A boolean group has one radius for its whole result (#2222): like a polygon, its button opens
/// corner smoothing, with no field per corner.
#[test]
fn a_boolean_group_opens_corner_smoothing_and_has_no_corner_fields() {
let mut s = Session::new();
let rect = |s: &mut Session, x: i32| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone();
let (a, b) = (rect(&mut s, 0), rect(&mut s, 60));
let g = NodeId(s.execute("bool.union", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap());
takes_corner_smoothing(s, g, "bool.union");
}
/// With `id` rounded at 8 and selected: the panel's button is named Corner smoothing and opens
/// the smoothing field with no field per corner, and typing 60 sets the smoothing in one undo step.
fn takes_corner_smoothing(mut s: Session, id: NodeId, what: &str) {
s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let mut u = Ui::new(s);
// With no corners of their own, the button is named for what it opens.
let t = named_button(&mut u, "Corner smoothing");
click(&mut u, t);
assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{what}: one radius, no corner fields");
let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center();
let history = u.app.session.undo_depth().0;
click(&mut u, smoothing);
u.frame(vec![Event::Text("60".into())]);
enter(&mut u);
u.frame(vec![]);
let n = u.app.session.doc().unwrap().node(id).unwrap().clone();
assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{what}: {}", n.corners.smoothing);
assert_eq!(u.app.session.undo_depth().0, history + 1, "{what}: one undo step");
}
/// A closed vector shows the same Corner smoothing control, and its iOS button sets 60% in one
/// undo step; an open vector, which has no closed shape to smooth, shows no smoothing control.
#[test]
+43 -5
View File
@@ -22,7 +22,7 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() {
// (engine cursor, what shows, its name in `ui.inspect`)
let rows: Vec<(Cursor, Shown, &str)> = vec![
(Cursor::Default, System(CursorIcon::Default), "default"),
(Cursor::Crosshair, System(CursorIcon::Crosshair), "crosshair"),
(Cursor::Crosshair, Drawn(Glyph::Crosshair), "crosshair"),
(Cursor::Move, System(CursorIcon::Move), "move"),
(Cursor::Resize(0.0), System(CursorIcon::ResizeHorizontal), "ew-resize"),
(Cursor::Resize(90.0), System(CursorIcon::ResizeVertical), "ns-resize"),
@@ -59,6 +59,44 @@ fn every_engine_cursor_has_a_system_cursor_or_a_glyph() {
assert_eq!(of(Cursor::Rotate(f64::INFINITY)), Drawn(Glyph::Rotate(0.0)));
}
#[test]
fn the_crosshair_is_drawn_dark_with_a_white_edge_so_it_shows_on_white() {
use egui::epaint::Shape;
let shown = of(Cursor::Crosshair);
assert_eq!(shown, Shown::Drawn(Glyph::Crosshair));
// The system's pointer is hidden under it, whatever its colour.
assert_eq!(shown.icon(), CursorIcon::None);
let at = pos2(50.0, 40.0);
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let ctx = ui.ctx().clone();
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("crosshair_test"));
crate::cursors::paint(&ctx.layer_painter(layer), at, Glyph::Crosshair);
});
out.textures_delta.clear();
let mut strokes = Vec::new();
for clipped in &out.shapes {
if let Shape::LineSegment { points, stroke } = &clipped.shape {
strokes.push((points.to_vec(), stroke.color, stroke.width));
}
}
let dark: Vec<_> = strokes.iter().filter(|s| s.1 == crate::cursors::INK).collect();
let white: Vec<_> = strokes.iter().filter(|s| s.1 == egui::Color32::WHITE).collect();
assert!(!dark.is_empty() && !white.is_empty(), "{strokes:?}");
// The white edge is wider than the dark line and is painted first.
assert!(white.iter().all(|w| w.2 > dark[0].2), "{strokes:?}");
assert_eq!(strokes[0].1, egui::Color32::WHITE);
// The arms are centred on the pointer and leave its pixel clear.
for (pts, ..) in &dark {
for p in pts {
let d = (p.x - at.x).abs().max((p.y - at.y).abs());
assert!((2.0..=8.0).contains(&d), "{p:?}");
assert!((p.x - at.x).abs() < 0.01 || (p.y - at.y).abs() < 0.01, "{p:?}");
}
}
assert_eq!(dark.len(), 4);
}
#[test]
fn resize_arrows_follow_the_angle_in_eighths() {
// (degrees, the two-headed arrow): each covers 45° about its own direction, any turn.
@@ -134,10 +172,10 @@ fn the_window_asks_for_the_cursor_of_the_tool_under_the_pointer() {
("move", edge, &[], no, CursorIcon::ResizeHorizontal, "ew-resize"),
("move", on, &[], alt, CursorIcon::Copy, "copy"),
("hand", empty, &[], no, CursorIcon::Grab, "grab"),
("rectangle", empty, &[], no, CursorIcon::Crosshair, "crosshair"),
("frame", on, &[], no, CursorIcon::Crosshair, "crosshair"),
("slice", empty, &[], no, CursorIcon::Crosshair, "crosshair"),
("text", empty, &[], no, CursorIcon::Crosshair, "crosshair"),
("rectangle", empty, &[], no, CursorIcon::None, "crosshair"),
("frame", on, &[], no, CursorIcon::None, "crosshair"),
("slice", empty, &[], no, CursorIcon::None, "crosshair"),
("text", empty, &[], no, CursorIcon::None, "crosshair"),
("pen", empty, &[], no, CursorIcon::None, "pen"),
("comment", empty, &[], no, CursorIcon::None, "comment"),
("move", empty, &[Key::Space], no, CursorIcon::Grab, "grab"),
+44
View File
@@ -196,3 +196,47 @@ fn the_playground_swaps_an_instance_in_the_preview_only() {
let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap();
assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}");
}
#[test]
fn the_annotation_tool_opens_the_inspect_tab_with_the_note_field_focused() {
let (mut u, _, r) = setup();
u.app.ui.right_tab = crate::RightTab::Design;
u.frames(2, Vec::new);
u.run("tool.annotation", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect, "the request shows the Inspect tab");
let field = egui::Id::unique(("annot_new", r)).with("te");
assert!(u.ctx.memory(|m| m.has_focus(field)), "the layer's new-note field has the caret");
assert_eq!(u.app.ui.dev.focus_new_note, None, "the focus request is spent");
// Asking again while already on the Inspect tab does not toggle it away.
u.run("tool.annotation", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect);
assert!(u.ctx.memory(|m| m.has_focus(field)), "the field has the caret again");
assert_eq!(u.app.ui.dev.focus_new_note, None, "and the request is spent");
}
/// A request for a layer that isn't selected (or is gone) does not wait to take the keyboard later.
#[test]
fn a_note_request_for_a_layer_that_is_not_selected_is_dropped() {
let (mut u, _, r) = setup();
u.app.session.execute("select.set", &json!({"ids": []})).unwrap();
u.frames(2, Vec::new);
u.app.session.request_ui("annotation.new", json!({"id": r}));
u.frames(2, Vec::new);
assert_eq!(u.app.ui.dev.focus_new_note, None);
}
/// Choosing the Annotation tool from the toolbar on the Inspect tab keeps the tab (an editing tool
/// would leave it for Design) and asks for the selected layer's note.
#[test]
fn the_toolbar_keeps_the_inspect_tab_for_the_annotation_tool() {
let (mut u, _, r) = setup();
u.app.session.execute("select.set", &json!({"ids": [r]})).unwrap();
crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Annotation);
u.frames(2, Vec::new);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect);
assert!(u.ctx.memory(|m| m.has_focus(egui::Id::unique(("annot_new", r)).with("te"))), "the note field has the caret");
crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Rectangle);
assert_eq!(u.app.ui.right_tab, crate::RightTab::Design, "a drawing tool leaves it");
}
+1
View File
@@ -246,6 +246,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
(tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"),
(tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"),
(tr("Scroll wheel zoom"), prefs.scroll_wheel_zoom, "pref.scrollWheelZoom"),
(tr("Invert zoom direction"), prefs.invert_zoom, "pref.invertZoom"),
(tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"),
] {
if crate::widgets::checkbox(ui, val, label) {
+119 -3
View File
@@ -59,6 +59,10 @@ pub struct Editor {
pub(crate) anchor: Option<String>,
/// The variable ids the table shows now, in row order (the range a Shift+click covers).
pub(crate) order: Vec<String>,
/// The variables the compact "Edit variables" editor is open on (empty = closed).
bulk_edit: Vec<String>,
/// That editor's tab: 0 Details, 1 Scope.
bulk_tab: usize,
}
/// A click on row `id`: plain selects only it, Shift extends from the anchor to it through the
@@ -113,8 +117,13 @@ fn bulk_run(app: &mut FigApp, label: &str, vars: &[&Variable], step: impl Fn(&Va
/// The context menu of a row that is part of a selection of several: delete, move to a group and
/// scope act on all of them.
fn bulk_menu(app: &mut FigApp, ui: &mut Ui, coll_groups: &[String], vars: &[&Variable]) {
fn bulk_menu(app: &mut FigApp, ui: &mut Ui, st: &mut Editor, coll_groups: &[String], vars: &[&Variable]) {
let n = vars.len();
if menu_item(ui, tr("Edit variables"), "") {
st.bulk_edit = vars.iter().map(|v| v.id.clone()).collect();
st.bulk_tab = 0;
ui.close();
}
if menu_item(ui, &trn("Delete {n} variables", n, &[]), "") {
bulk_run(app, "Delete variables", vars, |v| Some(("variable.delete", json!({"variable": v.id}))));
ui.close();
@@ -277,6 +286,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
});
});
details(app, ctx, &doc.variables, &mut st);
bulk_details(app, ctx, &doc.variables, &mut st);
app.ui.variables = open.then_some(st);
}
@@ -874,7 +884,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va
crate::right::context_popup(&resp).show(|ui| {
if let Some(vars) = bulk_ids(view, st, v) {
let coll_groups = groups(view.in_collection(&coll.id).map(|x| x.name.as_str()));
bulk_menu(app, ui, &coll_groups, &vars);
bulk_menu(app, ui, st, &coll_groups, &vars);
return;
}
if menu_item(ui, tr("Edit variable"), "") {
@@ -1204,9 +1214,115 @@ fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
}
}
/// Whether `f` holds for every variable (Some(true)), for none (Some(false)) or only some (None).
/// With no variables nothing is on: Some(false), never a checked box for an empty list.
fn tri<T>(vars: &[&T], f: impl Fn(&T) -> bool) -> Option<bool> {
if vars.is_empty() {
return Some(false);
}
let on = vars.iter().filter(|v| f(v)).count();
if on == vars.len() {
Some(true)
} else if on == 0 {
Some(false)
} else {
None
}
}
/// The compact editor of several selected variables: Details (only Hide from publishing) and Scope
/// (the scopes every selected variable's type has), each box mixed where the selection differs; a
/// click sets or clears it on all of them in one undo step.
fn bulk_details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
if st.bulk_edit.is_empty() {
return;
}
let list: Vec<&Variable> = st.bulk_edit.iter().filter_map(|id| vars.get_by_id(id)).filter(|v| v.extends.is_none()).collect();
// The menu offers Edit variables only for two or more editable, non-inherited variables of a
// file collection (`bulk_ids`), so this closes only when they have since gone (deleted,
// undone) or a library was opened: there is nothing left to edit.
if list.is_empty() || st.library.is_some() {
st.bulk_edit.clear();
return;
}
let t = Tokens::get(ctx);
let mut open = true;
egui::Window::new(tr("Edit variables"))
.id(Id::unique("variables_bulk_details"))
.open(&mut open)
.collapsible(false)
.resizable(false)
.default_width(280.0)
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(16)))
.show(ctx, |ui| {
ui.set_width(260.0);
if let Some(i) = widgets::segmented_text(ui, &[tr("Details"), tr("Scope")], st.bulk_tab) {
st.bulk_tab = i;
}
ui.add_space(8.0);
if st.bulk_tab == 0 {
let hidden = tri(&list, |v| v.hidden_from_publishing);
if widgets::checkbox_tri(ui, hidden, tr("Hide from publishing")) {
let to = hidden != Some(true);
bulk_run(app, "Edit variables", &list, |v| Some(("variable.update", json!({"variable": v.id, "hiddenFromPublishing": to}))));
}
return;
}
// The scopes every selected variable's type has.
let first = list.first().map_or(&[][..], |v| scopes_for(v.kind));
let common: Vec<(&'static str, &'static str)> =
first.iter().filter(|(id, _)| list.iter().all(|v| scopes_for(v.kind).iter().any(|(k, _)| k == id))).copied().collect();
if common.is_empty() {
widgets::label(ui, tr("No scope is shared by all the selected variables"), t.text_secondary);
return;
}
let all = tri(&list, |v| v.scopes.is_none());
if widgets::checkbox_tri(ui, all, tr("All supported properties")) {
let to_all = all != Some(true);
bulk_run(app, "Edit variables", &list, |v| {
Some(("variable.update", json!({"variable": v.id, "scopes": if to_all { Value::Null } else { json!([]) }})))
});
}
for (sid, label) in common {
let state = tri(&list, |v| v.in_scope(sid));
let clicked = ui
.horizontal(|ui| {
ui.add_space(16.0);
widgets::checkbox_tri(ui, state, tr(label))
})
.inner;
if clicked {
let to = state != Some(true);
bulk_run(app, "Edit variables", &list, |v| {
let next: Vec<&str> =
scopes_for(v.kind).iter().map(|(id, _)| *id).filter(|id| if *id == sid { to } else { v.in_scope(id) }).collect();
Some(("variable.update", json!({"variable": v.id, "scopes": next})))
});
}
}
});
if !open {
st.bulk_edit.clear();
}
}
#[cfg(test)]
mod tests {
use super::groups;
use super::{groups, tri};
#[test]
fn tri_is_checked_empty_or_mixed() {
let (a, b) = (true, false);
assert_eq!(tri(&[&a, &a], |v| *v), Some(true));
assert_eq!(tri(&[&b, &b], |v| *v), Some(false));
assert_eq!(tri(&[&a, &b], |v| *v), None);
}
#[test]
fn tri_of_no_variables_is_not_checked() {
let none: [&bool; 0] = [];
assert_eq!(tri(&none, |v| *v), Some(false));
}
#[test]
fn groups_include_parents_once_and_sorted() {
+172 -4
View File
@@ -189,7 +189,7 @@ fn setup() -> (Ui, NodeId, NodeId) {
/// The topmost field named `name` in the Design panel: its value centre and displayed text.
/// Layer opacity precedes the equally named opacity fields in paint rows.
fn field(u: &mut Ui, name: &str) -> (Pos2, String) {
pub(crate) fn field(u: &mut Ui, name: &str) -> (Pos2, String) {
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
@@ -234,7 +234,7 @@ fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) {
(b.x0, b.y0, b.width(), b.height())
}
fn undo_steps(u: &Ui) -> usize {
pub(crate) fn undo_steps(u: &Ui) -> usize {
u.app.session.ds().unwrap().history.undo.len()
}
@@ -252,7 +252,7 @@ fn click(u: &mut Ui, p: Pos2) {
}
/// Click into field `name`, type `text` over what it shows and press `end`.
fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
pub(crate) fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
let (p, _) = field(u, name);
click(u, p);
u.frame(vec![Event::Text(text.into())]);
@@ -261,7 +261,7 @@ fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
}
/// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go.
fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
pub(crate) fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
let from = label(u, name);
drag(u, from, dx, modifiers);
}
@@ -441,6 +441,68 @@ fn the_arrow_keys_step_by_the_nudge_amounts_and_fractional_fields_by_their_own()
assert!((rot.abs() - 11.0).abs() < 1e-6, "rotation {rot}: the steps stay after Escape");
}
#[test]
fn the_rotation_field_shows_and_sets_the_rotation_on_the_page() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 400, "height": 400})).unwrap());
s.execute("node.set", &json!({"ids": [f], "props": {"rotation": 10}})).unwrap();
let r = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 120, "height": 80, "parent": f})).unwrap());
s.execute("node.set", &json!({"ids": [r], "props": {"rotation": -10}})).unwrap();
let b = id(s.execute("shape.rectangle", &json!({"x": 600, "y": 0, "width": 50, "height": 50})).unwrap());
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
let page = |u: &Ui, n: NodeId| figcraft_geom::rotation_degrees(u.app.session.doc().unwrap().world_transform(n));
let local = |u: &Ui, n: NodeId| figcraft_geom::rotation_degrees(u.app.session.doc().unwrap().node(n).unwrap().transform);
assert_eq!(field(&mut u, "Rotation").1, "0°");
type_into(&mut u, "Rotation", "7", Key::Enter);
assert!((page(&u, r) - 7.0).abs() < 1e-6 && (local(&u, r) + 3.0).abs() < 1e-6, "{} {}", page(&u, r), local(&u, r));
assert_eq!(field(&mut u, "Rotation").1, "7°");
// Arrows step the shown value.
let shift = Modifiers { shift: true, ..Modifiers::NONE };
let (p, _) = field(&mut u, "Rotation");
click(&mut u, p);
key(&mut u, Key::ArrowUp, Modifiers::NONE);
key(&mut u, Key::ArrowUp, shift);
key(&mut u, Key::Escape, Modifiers::NONE);
assert!((page(&u, r) - 18.0).abs() < 1e-6, "{}", page(&u, r));
assert_eq!(field(&mut u, "Rotation").1, "18°");
// Scrubbing is one undo step.
let steps = undo_steps(&u);
scrub(&mut u, "Rotation", 15.0, Modifiers::NONE);
assert!((page(&u, r) - 33.0).abs() < 1e-6, "{}", page(&u, r));
assert_eq!(undo_steps(&u), steps + 1);
// Mixed: layers in differently turned parents agree when their page angles do.
u.app.session.execute("node.set", &json!({"ids": [b], "props": {"rotation": 33}})).unwrap();
u.app.session.execute("select.set", &json!({"ids": [r, b]})).unwrap();
assert_eq!(field(&mut u, "Rotation").1, "33°");
u.app.session.execute("node.set", &json!({"ids": [b], "props": {"rotation": 20}})).unwrap();
assert_eq!(field(&mut u, "Rotation").1, "Mixed");
let steps = undo_steps(&u);
type_into(&mut u, "Rotation", "+10", Key::Enter);
assert!((page(&u, r) - 43.0).abs() < 1e-6 && (page(&u, b) - 30.0).abs() < 1e-6, "{} {}", page(&u, r), page(&u, b));
assert_eq!(undo_steps(&u), steps + 1);
}
#[test]
fn a_relative_rotation_edit_turns_a_selected_parent_before_its_child_whatever_the_selection_order() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 400})).unwrap());
let c = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 120, "height": 80, "parent": f})).unwrap());
s.execute("node.set", &json!({"ids": [f], "props": {"rotation": 10}})).unwrap();
s.execute("node.set", &json!({"ids": [c], "props": {"absoluteRotation": 30}})).unwrap();
// The child first, then its parent.
s.execute("select.set", &json!({"ids": [c, f]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
u.frame(vec![]);
let page = |u: &Ui, n: NodeId| figcraft_geom::rotation_degrees(u.app.session.doc().unwrap().world_transform(n));
assert_eq!(field(&mut u, "Rotation").1, "Mixed");
type_into(&mut u, "Rotation", "+10", Key::Enter);
assert!((page(&u, f) - 20.0).abs() < 1e-6 && (page(&u, c) - 40.0).abs() < 1e-6, "{} {}", page(&u, f), page(&u, c));
}
#[test]
fn arithmetic_commits_tab_moves_on_and_escape_restores() {
let (mut u, a, _) = setup();
@@ -1485,6 +1547,80 @@ fn sidebar_field_text_stays_in_place_on_focus() {
}
}
/// The shared paint control shows the same precision as Fill and keeps the picker toggle.
#[test]
fn page_background_shows_fractional_opacity_like_fill_and_keeps_it_when_opening_the_picker() {
let mut u = Ui::new(Session::new());
u.ctx.enable_accesskit();
u.app.run("page.setBackground", json!({"color": "#FF000080"}));
u.frame(vec![]);
assert_eq!(field(&mut u, "Opacity").1, "50.2%");
let page = u.app.session.page().unwrap();
let background = |u: &Ui| match u.app.session.doc().unwrap().node(page).unwrap().kind {
figcraft_doc::NodeKind::Page { background, .. } => background,
_ => panic!("the page"),
};
let original = background(&u);
let steps = undo_steps(&u);
let swatch = u.ctx.read_response(Id::unique("pagebg_swatch")).expect("shared page swatch").rect.center();
click(&mut u, swatch);
assert!(matches!(u.app.ui.picker, Some(crate::colorpicker::PickerTarget::PageBackground)));
assert_eq!(undo_steps(&u), steps, "opening the picker does not edit the colour");
assert_eq!(background(&u), original, "opening preserves the stored alpha");
assert_eq!(field(&mut u, "Opacity").1, "50.2%");
click(&mut u, swatch);
assert!(u.app.ui.picker.is_none(), "the swatch toggles the picker closed");
assert_eq!(background(&u), original, "closing preserves the stored alpha");
assert_eq!(field(&mut u, "Opacity").1, "50.2%");
assert_eq!(undo_steps(&u), steps, "neither toggle creates an edit");
}
/// The shared page colour row uses the sidebar's width without hiding either field.
#[test]
fn page_background_colour_and_opacity_fit_a_resized_sidebar() {
// Find the whole galley, rather than its already-clipped bounding box.
fn hex_text(shape: &egui::Shape) -> Option<&egui::epaint::TextShape> {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().find_map(hex_text),
egui::Shape::Text(text) if text.galley.text() == "123ABC" => Some(text),
_ => None,
}
}
let mut widths = Vec::new();
for width in [240.0, 500.0] {
let mut session = Session::new();
session.prefs.panel_widths.1 = width;
session.execute("page.setBackground", &json!({"color": "#123ABC80"})).unwrap();
let mut u = Ui::new(session);
let mut previous = None;
let mut settled = None;
// Stop at stable geometry, with a bound so a layout regression cannot hang the test.
for _ in 0..16 {
let out = u.frame_output(vec![]);
let hex = u.ctx.read_response(Id::unique("pagebg").with("click")).unwrap().rect;
let alpha = u.ctx.read_response(Id::unique("pagebg_a").with("field")).unwrap().rect;
let panel = u.app.ui.right_panel_rect.unwrap();
if previous == Some((hex, alpha, panel)) {
settled = Some((out, hex, alpha, panel));
break;
}
previous = Some((hex, alpha, panel));
}
let (out, hex, alpha, panel) = settled.expect("the page row settles");
assert!(hex.left() >= u.app.canvas.rect.right(), "the colour stays in the sidebar");
assert!(hex.right() < alpha.left(), "the colour and opacity never overlap");
// Half a point permits device-pixel rounding, not an arbitrary gap from the panel edge.
assert!((alpha.right() - (panel.right() - crate::widgets::PAD_RIGHT)).abs() <= 0.5, "opacity stays at the right edge");
assert!((hex.center().y - alpha.center().y).abs() <= 0.5, "both fields stay on the same row");
let (text, clip) = out.shapes.iter().find_map(|s| hex_text(&s.shape).map(|text| (text, s.clip_rect))).expect("the HEX text");
assert!(!text.galley.elided, "the whole HEX value fits at {width} pt");
assert!(hex.expand(0.5).contains_rect(text.visual_bounding_rect()), "the HEX text fits its field");
assert!(clip.expand(0.5).contains_rect(text.visual_bounding_rect()), "the HEX text is not clipped");
widths.push(hex.width());
}
assert!(widths[1] > widths[0], "a wider sidebar gives the colour field more room");
}
/// The Page section's opacity field edits the page background's alpha (#1748; it was a label),
/// one undo step per commit, and the hex field keeps that opacity unless the typed colour
/// carries its own.
@@ -1596,3 +1732,35 @@ fn alt_held_over_a_field_scrubs_it() {
u.frame(vec![]);
assert_eq!(geom(&u, a).0, 65.0, "ten more");
}
#[test]
fn a_boolean_group_has_one_corner_radius_field_and_a_mixed_selection_reads_mixed() {
let (mut u, a, b) = setup();
let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
let r = id(u.app.session.execute("shape.rectangle", &json!({"x": 400, "y": 0, "width": 50, "height": 50})).unwrap());
u.app.run("select.set", json!({"ids": [g]}));
assert_eq!(field(&mut u, "Corner radius").1, "0");
let steps = undo_steps(&u);
type_into(&mut u, "Corner radius", "20", Key::Enter);
let radii = |u: &Ui, n: NodeId| u.app.session.doc().unwrap().node(n).unwrap().corners.radii;
assert_eq!(radii(&u, g), [20.0; 4]);
assert_eq!(undo_steps(&u), steps + 1, "one undo step");
u.app.run("select.set", json!({"ids": [g, r]}));
assert_eq!(field(&mut u, "Corner radius").1, "Mixed");
type_into(&mut u, "Corner radius", "8", Key::Enter);
assert_eq!((radii(&u, g), radii(&u, r)), ([8.0; 4], [8.0; 4]));
}
/// A boolean group draws its first radius; per-corner values a script or a file left behind don't
/// make its field read Mixed, and a relative edit starts from the radius it draws.
#[test]
fn a_boolean_groups_field_shows_the_radius_it_draws() {
let (mut u, a, b) = setup();
let g = id(u.app.session.execute("bool.union", &json!({"ids": [a, b]})).unwrap());
let corners = json!({"topRightRadius": 30, "bottomRightRadius": 30, "bottomLeftRadius": 30});
u.app.session.execute("node.set", &json!({"ids": [g], "props": corners})).unwrap();
u.app.run("select.set", json!({"ids": [g]}));
assert_eq!(field(&mut u, "Corner radius").1, "0", "the radius it draws");
type_into(&mut u, "Corner radius", "+10", Key::Enter);
assert_eq!(u.app.session.doc().unwrap().node(g).unwrap().corners.radii, [10.0; 4], "0 + 10, not 30 + 10");
}
@@ -0,0 +1,52 @@
//! A section header's icon buttons (Appearance, Fill, Stroke, Effects...) draw their glyphs at the
//! same size as the panel's other 24 pt icon buttons (#2227).
use egui::{Context, Rect, pos2, vec2};
use crate::icons::{self, Icon};
use crate::widgets::{icon_button_at, icon_glyph_rect, section_header};
/// Bounding box of everything painted in one frame of `add`.
fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
let ctx = Context::default();
let mut add = Some(add);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(f) = add.take() {
f(ui);
}
});
out.textures_delta.clear();
out.shapes
.iter()
.filter(|s| matches!(s.shape, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_)))
.map(|s| s.shape.visual_bounding_rect())
.fold(Rect::NOTHING, |a, b| a.union(b))
}
/// The header's eye, `icon_button_at`'s and a fill row's (`icons::button`, the reporter's
/// reference) are drawn at one size.
#[test]
fn header_eye_draws_as_large_as_a_fill_rows_eye() {
let header = painted_box(|ui| {
section_header(ui, "Appearance", &[(Icon::Eye, "Hide")], false);
});
let at = painted_box(|ui| {
let r = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0));
icon_button_at(ui, ui.scope_id().with("b"), r, Icon::Eye, "Hide");
});
let row = painted_box(|ui| {
icons::button(ui, Icon::Eye, 24.0, false, "Toggle visibility");
});
for (name, b) in [("icon_button_at", at), ("icons::button", row)] {
assert!(header.is_finite() && b.is_finite(), "{header:?} {b:?}");
assert!((header.width() - b.width()).abs() < 0.5, "{name}: widths {} vs {}", header.width(), b.width());
assert!((header.height() - b.height()).abs() < 0.5, "{name}: heights {} vs {}", header.height(), b.height());
}
}
#[test]
fn glyph_rect_is_the_shared_inset_of_the_button() {
let b = Rect::from_min_size(pos2(0.0, 0.0), vec2(24.0, 24.0));
assert_eq!(icon_glyph_rect(b).size(), vec2(16.0, 16.0));
}
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff. Show more