Merge main into #1913

This commit is contained in:
bflatastic committed 2026-11-07 10:57:11 +07:00
commit f4f16f1ba1
225 files changed
+6686 -681

No files matched your search

+9 -2
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-06 · **Change:** minor (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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not eachLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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 analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no lonLine 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
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
| Stability | 55% (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, #1125) | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts; the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian, #2014); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
@@ -133,6 +133,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-11-05** · M3, #2002: the Local variables window selects several variables with a click, Shift+click (range) and Cmd/Ctrl+click (toggle), cleared by Escape or a click on the empty space; delete, move to a group and scope act on the selected local variables as one undo step (`command.batch`); inherited variables can be selected but are left out, since they change in their own collection, and the bulk menu needs two or more local variables selected (otherwise the row shows its own menu). Rename and value edits stay single-row.
- **2026-11-05** · M10, #2014: 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 (`bidi::script_fallbacks`: one sample letter per script, the system's families for each; Bengali and Thai keep `crate::cjk`'s choice), so a layer name or a String variable's Amharic value draws in the panels instead of boxes. Test: `bidi::tests::other_scripts_take_an_installed_system_face`.
- **2026-11-05** · M4, #1830: a paragraph's base direction can be forced (Text ▸ Text direction: Auto, Left to right, Right to left) as Add right-to-left text says ("controls are available to override text direction per paragraph"). `ParaList::direction`, `text.direction {direction, ids?}` and the three menu commands (the edited paragraphs while editing, else the layer's), `node.set textDirection`, the Type settings control once a right-to-left script is in the text, `textDirection` / `lineDirections` in inspect, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` per paragraph). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests`.
- **2026-11-05** · M1, #2003: the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code from rectangles and polygons (two offset squares, the result filled at reduced alpha); each entry still runs its command.
- **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged.
- **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged.
@@ -361,6 +364,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-11-06 | patch | M5, #2027: `proto.settings` refuses an unknown device name with the list of devices (it saved any string, and the presenter showed no device), takes known names in any case, and answers the page's prototype settings, so `proto.settings {}` reads them back over MCP. Test: `proto_present_tests::unknown_devices_are_refused_and_settings_read_back`. Numbers unchanged |
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes asks Don't Save / Cancel / Save, as closing the window does, instead of a toast that left the tab open; Alt+close still discards at once. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Numbers unchanged |
| 2026-11-06 | patch | M8, #1906: under Wayland a minimised window answers control-channel and attached-MCP requests from its logic passes once it has drawn nothing for 0.2 s while a request waits (about half a second each; every request timed out after 60 s before). Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Numbers unchanged |
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Numbers unchanged |
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged |
| 2026-11-06 | patch | M9, #2026: a branch merge counts one conflict per auto layout field changed both ways, not one for every size and place auto layout works out from it; values an instance takes from its main no longer conflict, and auto layout settings changed one way survive a conflict on another field. Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`. Numbers unchanged |
+3 -1
View File
@@ -68,7 +68,9 @@ impl Backend for Store {
Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
}
}
log.entries += journal.len();
// A refusal changes nothing in the document: it counts toward the log's size (bytes), but
// not toward the entries that make a snapshot rewrite worthwhile.
log.entries += journal.iter().filter(|j| !matches!(j, figcraft_collab::Journal::Refused { .. })).count();
log.dirty = true;
e.meta.updated = now();
if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
+18
View File
@@ -155,6 +155,24 @@ fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
}
}
#[test]
fn a_viewers_refused_batches_do_not_rewrite_the_snapshot() {
let dir = TempDir::new("refusals");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut v = join(&mut server, Access::View);
store.changed(&id, &mut server);
let generation = || disk::check_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap();
assert_eq!(generation(), 1);
for i in 0..10 {
rename(&mut server, &mut v, &format!("P{i}"));
store.changed(&id, &mut server);
}
assert_eq!(generation(), 1, "refusals are logged, not compacted");
}
#[test]
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
+17 -11
View File
@@ -217,7 +217,8 @@ impl Recovery {
return false;
}
let _ = match online {
Some(text) => std::fs::write(&note, text),
// The note holds the secret the client resumes with: owner-only, as the copy.
Some(text) => write_private(&note, text.as_bytes()),
None => std::fs::remove_file(&note),
};
true
@@ -326,24 +327,29 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
let tmp = path.with_extension("tmp");
if write_private(&tmp, &bytes).is_err() {
let _ = std::fs::remove_file(&tmp);
return false;
}
std::fs::rename(&tmp, path).is_ok()
}
/// Write `bytes` to `path`, created (or cut back) readable by its owner only on Unix.
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true).mode(0o600);
let Ok(mut f) = opts.open(&tmp) else { return false };
if std::io::Write::write_all(&mut f, &bytes).is_err() {
let _ = std::fs::remove_file(&tmp);
return false;
}
let mut f = opts.open(path)?;
// `mode` applies to a new file only: one that was there keeps its mode unless set.
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
std::io::Write::write_all(&mut f, bytes)
}
#[cfg(not(unix))]
{
if std::fs::write(&tmp, bytes).is_err() {
return false;
}
std::fs::write(path, bytes)
}
std::fs::rename(&tmp, path).is_ok()
}
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
+3
View File
@@ -112,3 +112,6 @@ display_name = "Denis Gomes"
[people.Hamza0101]
real_name = "Hamza Naveed"
display_name = "Hamza0101"
[people.ashokram-si]
real_name = "Ashok Ram"
+3
View File
@@ -737,6 +737,9 @@ impl<B: Backend> Hub<B> {
continue;
}
let Some(&conn) = room.ids.get(&id) else { continue };
if matches!(msg, ServerMsg::Presence { .. }) && self.conns.get(&conn).is_some_and(|c| !c.out.takes_presence()) {
continue;
}
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
if !deliver(&self.conns, conn, &b, &mut held, &mut stuck) {
stuck.push(conn);
@@ -74,6 +74,8 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
let out = Line {
tx,
queued: Arc::default(),
waiting: Arc::default(),
max_presence: 32,
block: Arc::new(AtomicUsize::new(0)),
all: Arc::new(AtomicUsize::new(0)),
max: 1 << 20,
+36
View File
@@ -51,6 +51,12 @@ fn health_check(stream: &mut TcpStream, until: Instant) -> bool {
let mut req = Vec::new();
let mut chunk = [0u8; 1024];
while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 {
// The same deadline as the request line: a peer dripping header bytes would otherwise
// hold this thread and its connection slot for as long as it liked.
let left = until.saturating_duration_since(Instant::now());
if left.is_zero() || stream.set_read_timeout(Some(left)).is_err() {
break;
}
match stream.read(&mut chunk) {
Ok(0) | Err(_) => break,
Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()),
@@ -235,6 +241,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
let out = Line {
tx,
queued: Arc::default(),
waiting: Arc::default(),
max_presence: limits.queue.max(4) / 2,
block: block.queued.clone(),
all: shared.queued.clone(),
max: limits.max_queued_bytes,
@@ -407,3 +415,31 @@ fn linger(ws: &mut WebSocket<Duplex>) {
}
}
}
#[cfg(test)]
mod health_check_tests {
use super::*;
#[test]
fn a_health_check_dripping_headers_ends_at_the_deadline() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let client = std::thread::spawn(move || {
let mut c = TcpStream::connect(addr).unwrap();
c.write_all(b"GET /healthz HTTP/1.1\r\n").unwrap();
// One header byte every 20 ms, far past the deadline, until the server hangs up.
for _ in 0..200 {
if c.write_all(b"X").is_err() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
});
let (mut stream, _) = listener.accept().unwrap();
let start = Instant::now();
assert!(health_check(&mut stream, start + Duration::from_millis(300)));
assert!(start.elapsed() < Duration::from_secs(2), "{:?}", start.elapsed());
drop(stream);
client.join().unwrap();
}
}
+17 -1
View File
@@ -48,6 +48,7 @@ pub(crate) struct Charges {
pub(super) struct Charge {
n: usize,
mine: Arc<AtomicUsize>,
waiting: Arc<AtomicUsize>,
_all: Arc<Held>,
_block: Option<Arc<Held>>,
}
@@ -55,6 +56,7 @@ pub(super) struct Charge {
impl Drop for Charge {
fn drop(&mut self) {
self.mine.fetch_sub(self.n, Ordering::Relaxed);
self.waiting.fetch_sub(1, Ordering::Relaxed);
}
}
@@ -76,6 +78,10 @@ pub(crate) enum Push {
pub(crate) struct Line {
pub(crate) tx: SyncSender<Out>,
pub(crate) queued: Arc<AtomicUsize>,
/// Messages waiting in the queue.
pub(crate) waiting: Arc<AtomicUsize>,
/// With this many waiting, presence is left out (see [`Line::takes_presence`]).
pub(crate) max_presence: usize,
pub(crate) block: Arc<AtomicUsize>,
pub(crate) all: Arc<AtomicUsize>,
pub(crate) max: usize,
@@ -84,6 +90,15 @@ pub(crate) struct Line {
}
impl Line {
/// Whether presence goes in the queue now. A client still taking in a long message (the welcome
/// of a large file on a slow link) would otherwise have its queue filled by everyone's cursors
/// and be dropped, again on every try. Presence is latest-wins, so what's left out is made up
/// for by the next update, and this keeps room for what can't be: other people's edits. A client
/// that reads nothing at all still ends at the write timeout.
pub(crate) fn takes_presence(&self) -> bool {
self.waiting.load(Ordering::Relaxed) < self.max_presence
}
/// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file
/// gets out). `charges` is what the message counts against, shared by its recipients;
/// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer
@@ -118,7 +133,8 @@ impl Line {
}
};
self.queued.fetch_add(n, Ordering::Relaxed);
let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block };
self.waiting.fetch_add(1, Ordering::Relaxed);
let charge = Charge { n, mine: self.queued.clone(), waiting: self.waiting.clone(), _all: all, _block: block };
self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e {
TrySendError::Full(_) => Push::Full,
TrySendError::Disconnected(_) => Push::Gone,
+38 -2
View File
@@ -1,7 +1,8 @@
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
//! broadcast counts once against the server's budget however many copies are queued, a block of
//! addresses can't take more than its share, and when the server's budget is used up the
//! connection holding the most is shed, not the next client that needs a little.
//! connection holding the most is shed, not the next client that needs a little. Presence stays out
//! of a busy queue.
use std::collections::HashMap;
use std::net::{IpAddr, TcpListener, TcpStream};
@@ -16,7 +17,20 @@ use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
/// A line with its own queue counter, the given block counter and the server's.
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
let (tx, rx) = sync_channel(64);
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
(
Line {
tx,
queued: Arc::default(),
waiting: Arc::default(),
max_presence: 32,
block: block.clone(),
all: all.clone(),
max: 1 << 20,
max_block,
max_all,
},
rx,
)
}
fn socket() -> TcpStream {
@@ -120,3 +134,25 @@ fn blocks_nobody_uses_are_forgotten() {
let _c = blocks.get("203.0.113.3".parse().unwrap());
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
}
#[test]
fn a_busy_queue_leaves_presence_out_and_keeps_room_for_edits() {
let (l, rx) = line(&Arc::default(), &Arc::default(), usize::MAX, usize::MAX);
let msg = Bytes::from_static(b"x");
for _ in 0..32 {
assert!(l.takes_presence());
l.push(&msg, &mut Charges::default(), false).unwrap();
}
assert!(!l.takes_presence(), "half full");
// Edits still fit in the half kept for them.
for _ in 0..32 {
l.push(&msg, &mut Charges::default(), false).unwrap();
}
assert_eq!(l.push(&msg, &mut Charges::default(), false), Err(Push::Full), "a client that takes in nothing still ends");
// It catches up: presence goes in again.
for _ in 0..33 {
drop(rx.recv().unwrap());
}
assert!(l.takes_presence());
assert_eq!(l.waiting.load(Ordering::Relaxed), 31);
}
+31
View File
@@ -206,7 +206,17 @@ fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
/// The next answer `pick` takes, or why there is none.
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
// One deadline for the whole answer: pings, pongs and a message trickling in each renew the
// read timeout, so without it a server could keep a request (and the refresh permit the
// caller holds) waiting for as long as it liked.
// The request-reply bound, not the session idle one: a publish uploads a whole file and can
// legitimately take minutes to be answered (see `connect`).
let until = std::time::Instant::now() + limits.request_reply_timeout;
ws.get_mut().read_until(Some(until));
loop {
if std::time::Instant::now() >= until {
return Err("no answer from the server (it took too long)".into());
}
return match ws.read() {
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
Ok(ServerMsg::Refused { reason }) => Err(format!("{REFUSED}{reason}")),
@@ -251,6 +261,27 @@ mod tests {
rx.recv_timeout(Duration::from_secs(3)).is_ok()
}
/// A server that keeps pinging and never answers ends the wait at the request-reply bound.
#[test]
fn reply_gives_up_at_the_request_reply_deadline_against_a_pinging_server() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
if let Ok(mut ws) = tungstenite::accept(s) {
while ws.send(Message::Ping(Bytes::new())).is_ok() {
std::thread::sleep(Duration::from_millis(10));
}
}
});
let limits = NetLimits { request_reply_timeout: Duration::from_millis(400), ..NetLimits::default() };
let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false };
let mut ws = connect(&server, &limits, Duration::from_secs(2)).unwrap();
let started = std::time::Instant::now();
assert!(reply::<()>(&mut ws, &limits, |_| None).is_err());
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
}
/// A server that keeps pinging and never answers the close must not pin `close` forever.
#[test]
fn close_returns_against_a_server_that_keeps_pinging() {
+4 -1
View File
@@ -73,7 +73,10 @@ fn css_hue_units_and_none() {
assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}");
}
assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex());
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]);
// A missing alpha is zero like any other missing component (CSS Color 4 §4.4), so it is
// transparent; only an *omitted* alpha defaults to opaque.
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 0]);
assert_eq!(Color::parse_css("oklch(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
}
#[test]
+55 -24
View File
@@ -158,14 +158,9 @@ impl Color {
}
if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) {
let parts = css_args(inner)?;
let num = |i: usize| -> Option<f32> {
let p = parts.get(i)?;
if let Some(pc) = p.strip_suffix('%') {
pc.trim().parse::<f32>().ok().map(|v| v / 100.0)
} else {
p.parse::<f32>().ok().map(|v| v / 255.0)
}
};
// A channel is a number of 255, a percentage or `none`; out-of-range values (a number
// too big for f32 included) clamp in `Color::new`, as CSS Color 4 §5.1 asks.
let num = |i: usize| css_fraction(parts.get(i)?, 255.0);
let alpha = match parts.get(3) {
Some(a) => parse_alpha(a)?,
None => 1.0,
@@ -177,10 +172,12 @@ impl Color {
}
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
let parts = css_args(inner)?;
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
if !(h.is_finite() && s.is_finite() && l.is_finite()) {
// The hue takes every CSS angle unit; saturation and lightness are percentages, numbers
// read as percentages (CSS Color 4 §7) or `none`.
let h = css_hue(parts.first()?)?;
let s = css_fraction(parts.get(1)?, 100.0)?;
let l = css_fraction(parts.get(2)?, 100.0)?;
if !(s.is_finite() && l.is_finite()) {
return None;
}
let alpha = match parts.get(3) {
@@ -209,14 +206,23 @@ impl Color {
/// CSS `rgba(...)` / hex text for copy-as-CSS.
pub fn to_css(self) -> String {
let [r, g, b, _] = self.to_rgba8();
if self.a >= 0.999 {
let [r, g, b, a8] = self.to_rgba8();
let a = clamp01(self.a);
if a >= 0.999 {
return self.to_css_hex();
}
// Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours).
let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8;
let short = format!("{:.2}", self.a);
let alpha = if short.parse::<f32>().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) };
// The fewest decimals that read back as the same 8-bit alpha: two, else three, else three
// of the 8-bit value itself (within half a step of it, so it always reads back).
let keeps = |s: &str| s.parse::<f32>().is_ok_and(|v| (v * 255.0).round() as u8 == a8);
let two = format!("{a:.2}");
let three = format!("{a:.3}");
let alpha = if keeps(&two) {
two
} else if keeps(&three) {
three
} else {
format!("{:.3}", f32::from(a8) / 255.0)
};
format!("rgba({r}, {g}, {b}, {alpha})")
}
@@ -268,19 +274,42 @@ fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
let l = css_number(parts.first()?, l_full)?;
let c = css_number(parts.get(1)?, c_full)?;
let h = css_hue(parts.get(2)?)?;
let alpha = match parts.get(3).map(String::as_str) {
None | Some("none") => 1.0,
let alpha = match parts.get(3) {
None => 1.0,
Some(a) => parse_alpha(a)?,
};
Some(([l, c, h], alpha))
}
/// A CSS `<number>` as f32: a sign, digits, a decimal point and an exponent only. Rust's float
/// parser also reads `inf`, `infinity` and `nan`, which are identifiers in CSS, not numbers. A
/// value too big for f32 comes back as an infinity for the caller to clamp or refuse.
fn css_f32(s: &str) -> Option<f32> {
if !s.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.' | b'e' | b'E')) {
return None;
}
s.parse::<f32>().ok()
}
/// A CSS number, percentage or `none` as a fraction of one: a percentage is of 100, a bare number
/// of `number_full` (255 for an rgb() channel, 100 for hsl() saturation and lightness, 1 for
/// alpha) and `none` is 0 (CSS Color 4 §4.4).
fn css_fraction(p: &str, number_full: f32) -> Option<f32> {
if p == "none" {
return Some(0.0);
}
match p.strip_suffix('%') {
Some(pc) => Some(css_f32(pc)? / 100.0),
None => Some(css_f32(p)? / number_full),
}
}
/// A CSS number, a percentage of `full` or `none` (0); finite only.
fn css_number(p: &str, full: f32) -> Option<f32> {
let v = match p.strip_suffix('%') {
_ if p == "none" => 0.0,
Some(pc) => pc.trim().parse::<f32>().ok()? / 100.0 * full,
None => p.parse::<f32>().ok()?,
Some(pc) => css_f32(pc)? / 100.0 * full,
None => css_f32(p)?,
};
v.is_finite().then_some(v)
}
@@ -294,12 +323,14 @@ fn css_hue(p: &str) -> Option<f32> {
.into_iter()
.find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k)))
.unwrap_or((p, 1.0));
let v = num.parse::<f32>().ok()? / per_degree;
let v = css_f32(num)? / per_degree;
v.is_finite().then_some(v)
}
/// A CSS `<alpha-value>`: a number, a percentage or `none` (0: a missing alpha is transparent, CSS
/// Color 4 §4.4); finite only. An *omitted* alpha is the caller's default of 1.
fn parse_alpha(a: &str) -> Option<f32> {
let v = if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }?;
let v = css_fraction(a, 1.0)?;
v.is_finite().then_some(v)
}
+99
View File
@@ -195,3 +195,102 @@ fn css_rejects_empty_arguments() {
assert!(Color::parse_css("rgb(255 0 0 / 50%)").is_some());
assert!(Color::parse_css("hsl(120deg 50% 50%)").is_some());
}
#[test]
fn css_rgb_rejects_non_numeric_channels() {
// `nan`, `inf` and `infinity` are identifiers, not CSS numbers; the hsl(), lch() and oklch()
// branches and the alpha already refuse them, and so must rgb() channels (which previously read
// `rgb(nan, 0, 0)` as black and `rgb(inf, 0, 0)` as red).
for s in ["rgb(nan, 0, 0)", "rgb(inf, 0, 0)", "rgb(0, 0, infinity)", "rgb(-inf, 0, 0)", "rgb(nan%, 0, 0)", "rgb(NaN 0 0)", "rgba(0, 0, 0, inf)"] {
assert_eq!(Color::parse_css(s), None, "{s}");
}
// A genuine number that overflows f32 is still a number and clamps (CSS Color 4 §5.1).
assert_eq!(Color::parse_css("rgb(1e40, -1e40, 0)").unwrap().to_rgba8(), [255, 0, 0, 255]);
assert_eq!(Color::parse_css("rgb(+255, .5%, 1E2)").unwrap().to_rgba8(), [255, 1, 100, 255]);
}
#[test]
fn css_hsl_takes_every_hue_unit() {
// CSS Color 4 §4.3: `<hue> = <number> | <angle>`, so turn, grad and rad are as valid as deg.
let cyan = Color::parse_css("hsl(180, 100%, 50%)").unwrap();
assert_eq!(cyan.to_hex(), "00FFFF");
let units =
["hsl(0.5turn, 100%, 50%)", "hsl(200grad 100% 50%)", "hsl(3.1415927rad 100% 50%)", "hsl(180deg 100% 50%)", "hsla(-0.5turn, 100%, 50%, 1)"];
for s in units {
assert_eq!(Color::parse_css(s).map(|c| c.to_hex()), Some(cyan.to_hex()), "{s}");
}
// One unit only: a repeated or stray unit is not a dimension.
for s in ["hsl(120degdeg, 100%, 50%)", "hsl(120, 100%%, 50%)", "hsl(0.5turndeg 100% 50%)", "hsl(deg 100% 50%)"] {
assert_eq!(Color::parse_css(s), None, "{s}");
}
}
#[test]
fn css_none_is_zero_in_every_function() {
// CSS Color 4 §4.4: every modern-syntax component may be `none`, and outside interpolation a
// missing component "behaves as a zero value" -- including a missing alpha, which renders
// fully transparent.
assert_eq!(Color::parse_css("rgb(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
assert_eq!(Color::parse_css("rgb(255 none none)").unwrap().to_rgba8(), [255, 0, 0, 255]);
assert_eq!(Color::parse_css("rgb(255 0 0 / none)").unwrap().to_rgba8(), [255, 0, 0, 0]);
assert_eq!(Color::parse_css("hsl(none 100% 50%)").unwrap().to_hex(), "FF0000");
assert_eq!(Color::parse_css("hsl(120 none 50%)").unwrap().to_hex(), "808080");
assert_eq!(Color::parse_css("hsl(120 100% none)").unwrap().to_hex(), "000000");
assert_eq!(Color::parse_css("hsl(120 100% 50% / none)").unwrap().to_rgba8(), [0, 255, 0, 0]);
assert_eq!(Color::parse_css("oklch(0.5 0.1 20 / none)").unwrap().a, 0.0);
assert_eq!(Color::parse_css("lch(50 50 50 / none)").unwrap().a, 0.0);
// An omitted alpha still defaults to opaque.
assert_eq!(Color::parse_css("rgb(none none none)").unwrap().a, 1.0);
}
#[test]
fn to_css_keeps_the_8_bit_alpha_for_any_alpha() {
// Three decimals of the alpha can still round across an 8-bit step (0.0055 -> "0.006" reads
// back as 2/255, not 1/255); the text must always read back to the same 8-bit alpha.
for i in 0..=10_000u32 {
let a = i as f32 / 10_000.0;
let c = Color::new(0.1, 0.2, 0.3, a);
let css = c.to_css();
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some(c.to_rgba8()), "alpha {a}: {css}");
}
let c = Color::new(0.1, 0.2, 0.3, 0.0055);
assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8()[3], 1, "{}", c.to_css());
// Still the short forms where they are exact.
assert_eq!(Color::new(1.0, 0.0, 0.0, 0.5).to_css(), "rgba(255, 0, 0, 0.50)");
assert_eq!(Color::from_rgba8(255, 0, 0, 85).to_css(), "rgba(255, 0, 0, 0.333)");
}
#[test]
fn to_css_sanitizes_an_unclamped_alpha() {
// The RGB channels go through `to_rgba8` and are sanitized; the alpha must be too, or a colour
// built field-by-field prints `rgba(0, 0, 0, NaN)` / `rgba(0, 0, 0, -0.50)`, which is not CSS.
for a in [f32::NAN, -0.5, f32::NEG_INFINITY] {
let c = Color { r: 0.0, g: 0.0, b: 0.0, a };
let css = c.to_css();
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some([0, 0, 0, 0]), "alpha {a}: {css}");
}
assert_eq!(Color { r: 1.0, g: 0.0, b: 0.0, a: f32::INFINITY }.to_css(), "#FF0000");
}
#[test]
fn css_hue_wraps_a_whole_turn_and_calc_is_not_a_number() {
// A hue is an angle (CSS Color 4 §4.3): a whole turn (360, 1turn, 400grad, 2 pi rad) is 0 and a
// negative hue counts back from it, in hsl() and in the polar spaces alike. `calc()` is not
// read, so `calc(NaN)` and `calc(infinity)` are refused like any other non-number rather than
// read as 0 or clamped.
let red = Color::parse_css("hsl(0 100% 50%)").unwrap().to_rgba8();
for s in ["hsl(360 100% 50%)", "hsl(1turn 100% 50%)", "hsl(400grad 100% 50%)", "hsl(6.2831855rad 100% 50%)", "hsl(-1turn 100% 50%)"] {
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(red), "{s}");
}
let violet = Color::parse_css("hsl(270 100% 50%)").unwrap().to_rgba8();
for s in ["hsl(-90deg 100% 50%)", "hsl(-0.25turn 100% 50%)", "hsl(-100grad 100% 50%)", "hsl(630 100% 50%)"] {
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(violet), "{s}");
}
assert_eq!(Color::parse_css("oklch(0.5 0.1 1turn)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 0)").map(|c| c.to_rgba8()));
assert_eq!(Color::parse_css("lch(50 50 400grad)").map(|c| c.to_rgba8()), Color::parse_css("lch(50 50 0)").map(|c| c.to_rgba8()));
assert_eq!(Color::parse_css("oklch(0.5 0.1 -90deg)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 270)").map(|c| c.to_rgba8()));
for s in ["hsl(calc(NaN) 100% 50%)", "rgb(calc(infinity) 0 0)", "rgb(0 0 0 / calc(1))", "oklch(0.5 0.1 calc(20deg))", "hsl(calc(1turn) 100% 50%)"]
{
assert_eq!(Color::parse_css(s), None, "{s}");
}
}
+9 -2
View File
@@ -72,6 +72,12 @@ pub fn attached_sides(from: RouteEnd, to: RouteEnd) -> (Option<Side>, Option<Sid
(attach(from, to.centre()).1, attach(to, from.centre()).1)
}
/// Where a connector's ends attach, as routed now: the points [`ConnectorPath::Runs`] is measured
/// from the middle of.
pub fn attach_points(from: RouteEnd, to: RouteEnd) -> (Point, Point) {
(attach(from, to.centre()).0, attach(to, from.centre()).0)
}
/// The route's path handles: one for each run of an elbow and one for a curve, with the portion
/// of the path each moves and where it sits.
pub fn path_handles(pts: &[Point], routing: Routing) -> Vec<(usize, Point)> {
@@ -96,8 +102,9 @@ pub fn reshaped(from: RouteEnd, to: RouteEnd, routing: Routing, path: Option<&Co
}
}
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another.
const MAX_RUNS: usize = 64;
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another, and an
/// importer leaves a saved one to the router.
pub const MAX_RUNS: usize = 64;
/// The runs of the right-angled route `pts` with run `portion` moved to `p`.
fn reshaped_runs(pts: &[Point], portion: usize, p: Point) -> Option<ConnectorPath> {
+1 -1
View File
@@ -79,7 +79,7 @@ impl Style {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
match &mut self.kind {
StyleKind::Paint { paints } => paints.iter_mut().for_each(|p| p.for_each_color_mut(f)),
StyleKind::Text { style } => style.for_each_color_mut(f),
StyleKind::Text { style, .. } => style.for_each_color_mut(f),
StyleKind::Effect { effects } => effects.iter_mut().for_each(|e| e.for_each_color_mut(f)),
StyleKind::Grid { grids } => grids.iter_mut().for_each(|g| f(&mut g.color)),
}
+19 -7
View File
@@ -28,8 +28,9 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
p
}
NodeKind::Vector { network } => {
let f = network.fill_path();
if f.elements().is_empty() { network.stroke_path() } else { f }
let net = drawn_network(network, c.smoothing);
let f = net.fill_path();
if f.elements().is_empty() { net.stroke_path() } else { f }
}
NodeKind::Text(_) => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0),
NodeKind::Page { .. } | NodeKind::Group | NodeKind::BooleanOp { .. } => return None,
@@ -39,11 +40,21 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
/// The open/closed path strokes follow (vector networks stroke every segment, filled or not).
pub fn stroke_geometry(n: &Node) -> Option<BezPath> {
match &n.kind {
NodeKind::Vector { network } => Some(network.stroke_path()),
NodeKind::Vector { network } => Some(drawn_network(network, n.corners.smoothing).stroke_path()),
_ => shape_path(n),
}
}
/// A vector's `network` as the layer draws it: rounded points drawn in with the layer's corner
/// `smoothing` (see [`VectorNetwork::rounded_smoothed`](crate::VectorNetwork::rounded_smoothed)),
/// or the network itself when no point is rounded.
pub fn drawn_network(network: &crate::VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, crate::VectorNetwork> {
match network.rounded_smoothed(smoothing) {
Some(r) => std::borrow::Cow::Owned(r),
None => std::borrow::Cow::Borrowed(network),
}
}
/// Whether the shape's path is closed (fills apply).
pub fn is_closed(n: &Node) -> bool {
match &n.kind {
@@ -75,8 +86,8 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
if !outline {
for (i, region) in net.regions.iter().enumerate().rev() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
@@ -96,10 +107,11 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
}
/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it).
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains) on the network as drawn: even-odd when any
/// region asks for it).
fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool {
match &n.kind {
NodeKind::Vector { network } => network.fill_contains(lp),
NodeKind::Vector { network } => drawn_network(network, n.corners.smoothing).fill_contains(lp),
_ => path.winding(lp) != 0,
}
}
+23 -4
View File
@@ -14,6 +14,8 @@
//! space 0. The session server accepts a new object only with an id in its creator's space or
//! space 0, so two people never make the same id.
use std::collections::BTreeSet;
use crate::{CollectionId, Document, ModeId, NodeId, StyleId, VariableId};
/// What style ids start with.
@@ -74,25 +76,42 @@ impl Document {
/// A new style id.
pub fn new_style_id(&mut self) -> StyleId {
let taken: Vec<u64> = self.styles.iter().filter_map(|s| id_number(&s.id, STYLE_ID_PREFIX)).collect();
self.new_style_id_avoiding(&BTreeSet::new())
}
/// A new style id that is none of `avoid` either: ids of styles about to come in with a file
/// being imported.
pub fn new_style_id_avoiding(&mut self, avoid: &BTreeSet<StyleId>) -> StyleId {
let taken: Vec<u64> = self.styles.iter().map(|s| &s.id).chain(avoid).filter_map(|id| id_number(id, STYLE_ID_PREFIX)).collect();
if let Some(n) = self.session_number(taken) {
return format!("{STYLE_ID_PREFIX}{n}");
}
(self.styles.len() + 1..)
.map(|k| format!("{STYLE_ID_PREFIX}{k}"))
.find(|id| self.style(id).is_none())
.find(|id| self.style(id).is_none() && !avoid.contains(id))
.unwrap_or_else(|| format!("{STYLE_ID_PREFIX}0"))
}
/// A new variable id.
pub fn new_variable_id(&mut self) -> VariableId {
let taken: Vec<u64> = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)).collect();
self.new_variable_id_avoiding(&BTreeSet::new())
}
/// A new variable id that is none of `avoid` either (see [`Self::new_style_id_avoiding`]).
pub fn new_variable_id_avoiding(&mut self, avoid: &BTreeSet<VariableId>) -> VariableId {
let taken = self.variables.variables.iter().map(|v| &v.id).chain(avoid).filter_map(|id| id_number(id, VARIABLE_ID_PREFIX)).collect();
self.new_numbered_id(VARIABLE_ID_PREFIX, taken)
}
/// A new variable collection id.
pub fn new_collection_id(&mut self) -> CollectionId {
let taken: Vec<u64> = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)).collect();
self.new_collection_id_avoiding(&BTreeSet::new())
}
/// A new variable collection id that is none of `avoid` either (see
/// [`Self::new_style_id_avoiding`]).
pub fn new_collection_id_avoiding(&mut self, avoid: &BTreeSet<CollectionId>) -> CollectionId {
let taken = self.variables.collections.iter().map(|c| &c.id).chain(avoid).filter_map(|id| id_number(id, COLLECTION_ID_PREFIX)).collect();
self.new_numbered_id(COLLECTION_ID_PREFIX, taken)
}
+16
View File
@@ -1,6 +1,8 @@
//! Ids of styles, variables, collections, modes and comments: unchanged outside a live session,
//! from the client's own space during one.
use std::collections::BTreeSet;
use crate::ids::{COLLECTION_ID_PREFIX, id_number, id_space, mode_number, space_of};
use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Style, StyleKind};
@@ -93,6 +95,20 @@ fn ids_of_the_space_already_in_the_document_stay_taken() {
assert_eq!(e.new_style_id(), format!("S:{}", C3 | 1));
}
#[test]
fn new_ids_can_avoid_ids_about_to_come_in() {
let mut d = Document::new("a");
d.styles.push(style("S:1"));
let avoid: BTreeSet<String> = ["S:2", "S:3", "VariableID:4", "VariableCollectionId:2", "lib:VariableID:9"].map(String::from).into();
assert_eq!(d.new_style_id_avoiding(&avoid), "S:4");
assert_eq!(d.new_variable_id_avoiding(&avoid), "VariableID:5");
assert_eq!(d.new_collection_id_avoiding(&avoid), "VariableCollectionId:3");
// In a session the numbers come from the client's space, above those of it to avoid.
let avoid: BTreeSet<String> = [format!("VariableID:{}", C3 | 7)].into();
d.set_client_id(3);
assert_eq!(d.new_variable_id_avoiding(&avoid), format!("VariableID:{}", C3 | 8));
}
#[test]
fn the_highest_space_counts_every_kind_of_id() {
let mut d = Document::new("a");
+13 -9
View File
@@ -27,7 +27,7 @@ pub mod vector_faces;
pub mod vector_regions;
pub use board::*;
pub use board_route::{END_GAP, RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points};
pub use board_route::{END_GAP, MAX_RUNS, RouteEnd, attach_points, attached_sides, path_handles, reshaped, route_along, route_points};
pub use colors::*;
pub use comments::*;
pub use facilitation::*;
@@ -767,16 +767,20 @@ impl Document {
/// children only within their mask). A group, or an unclipped frame past its box, is what its
/// children show grown by its effects, which apply to all of it.
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
self.render_bounds_at(id, 0)
// A tree visits each node once; a child list deserialized with repeats (a graph) is cut off
// after twice the document's node count rather than walked in full.
self.render_bounds_at(id, 0, &std::cell::Cell::new(self.nodes.len().saturating_mul(2).saturating_add(8)))
}
fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option<Rect> {
fn render_bounds_at(&self, id: NodeId, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
let n = self.node(id)?;
let own = n.own_paint_bounds(self.world_transform(id));
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH {
let visit = left.get();
left.set(visit.saturating_sub(1));
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH || visit == 0 {
return Some(own);
}
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
Some(match (self.children_render_bounds(n, depth + 1, left), &n.kind) {
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
// An unclipped frame's effects apply to its children too, past its box included.
(Some(kids), _) => own.union(n.grow_by_effects(kids)),
@@ -785,15 +789,15 @@ impl Document {
}
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
fn children_render_bounds(&self, n: &Node, depth: usize) -> Option<Rect> {
fn children_render_bounds(&self, n: &Node, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
let mut out: Option<Rect> = None;
for run in self.paint_runs(n) {
let ids = n.children.get(run).unwrap_or(&[]);
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth, left)).reduce(|a, b| a.union(b));
let r = match mask {
Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
Some(m) => r.zip(self.render_bounds_at(m, depth, left)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
None => r,
};
out = match (out, r) {
@@ -1230,7 +1234,7 @@ impl Document {
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())),
_ => None,
})
.collect();
+23 -7
View File
@@ -416,9 +416,13 @@ impl NodeKind {
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
}
/// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it).
/// 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).
pub fn has_corner_smoothing(&self) -> bool {
self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. })
match self {
NodeKind::Vector { network } => !network.regions.is_empty(),
_ => self.has_corner_radius(),
}
}
pub fn text(&self) -> Option<&TextData> {
@@ -944,7 +948,7 @@ impl Node {
if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() || point_arrows { w.max(self.stroke.weight) * 3.0 } else { w };
// Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown
// box; an arc's corners don't.
let miter = if self.grows_by_exactly_its_spread() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
let miter = if self.outline_mitres_end_on_its_box() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
} else {
0.0
@@ -960,9 +964,10 @@ impl Node {
!(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke()))
}
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners). An arc
/// (an ellipse that doesn't go all the way round) has corners where its ends meet its sides.
fn grows_by_exactly_its_spread(&self) -> bool {
/// Whether this node's outline has no sharp corners (right-angled ones aside), so mitres along
/// it end on its box grown by as much: a box or a whole ellipse. An arc (an ellipse that doesn't
/// go all the way round) has corners where its ends meet its sides.
fn outline_mitres_end_on_its_box(&self) -> bool {
match self.kind {
NodeKind::Rectangle | NodeKind::Section { .. } => true,
NodeKind::Ellipse { sweep, .. } => figcraft_geom::full_sweep(sweep),
@@ -970,9 +975,20 @@ impl Node {
}
}
/// Whether a spread grows this node by exactly the spread: its outline mitres end on its box
/// (see [`Node::outline_mitres_end_on_its_box`]), and so does its stroke. A dashed stroke is
/// cut into dashes with corners wherever they fall round the outline, and a width profile or a
/// brush can give the stroke corners of its own, which the spread mitres out.
fn grows_by_exactly_its_spread(&self) -> bool {
let s = &self.stroke;
let cornered = self.has_visible_stroke() && (!s.dashes.is_empty() || s.profile().is_some() || s.brush.is_some());
!cornered && self.outline_mitres_end_on_its_box()
}
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
/// spread moves the edges out in parallel and they meet in a mitre (see
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread.
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread,
/// unless their stroke has corners (see [`Node::grows_by_exactly_its_spread`]).
fn spread_miter_reach(&self) -> f64 {
if self.grows_by_exactly_its_spread() {
return 0.0;
+17 -5
View File
@@ -1,7 +1,7 @@
//! Shared styles and variables.
use crate::props::{Effect, LayoutGrid};
use crate::text::TextStyle;
use crate::text::{ParagraphStyle, TextStyle};
use figcraft_color::{Color, Paint};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -14,10 +14,22 @@ pub type ModeId = String;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum StyleKind {
Paint { paints: Vec<Paint> },
Text { style: TextStyle },
Effect { effects: Vec<Effect> },
Grid { grids: Vec<LayoutGrid> },
Paint {
paints: Vec<Paint>,
},
Text {
style: TextStyle,
/// What the style sets at the paragraph and layer level (#1829); boxed to keep the enum
/// small.
#[serde(default, skip_serializing_if = "crate::node::is_default")]
paragraph: Box<ParagraphStyle>,
},
Effect {
effects: Vec<Effect>,
},
Grid {
grids: Vec<LayoutGrid>,
},
}
impl StyleKind {
+221
View File
@@ -223,6 +223,7 @@ fn text_case() {
assert_eq!(style_weight("Semi Bold Italic"), 600);
assert_eq!(style_weight("Black"), 900);
assert_eq!(style_weight("Regular"), 400);
assert_eq!(style_weight("Hairline"), 100);
}
#[test]
@@ -596,6 +597,22 @@ fn is_ancestor_holds_for_very_deep_chains() {
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
}
#[test]
fn render_bounds_of_a_doubled_child_list_end_at_once() {
// A file can list a child twice at every level: 2^40 visits if each is walked.
let mut d = Document::new("x");
let mut parent = page(&d);
let mut groups = Vec::new();
for _ in 0..40 {
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
groups.push(parent);
}
for w in groups.windows(2) {
d.node_mut(w[0]).unwrap().children.push(w[1]);
}
assert!(d.world_render_bounds(groups[0]).is_some());
}
#[test]
fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
// A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while
@@ -679,6 +696,210 @@ fn two_segment_closed_loop_rounded_corners() {
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
let path = net.fill_path();
assert!(!path.elements().is_empty());
// Both points sit next to the curved segment, so smoothing keeps their plain arcs.
assert_eq!(net.rounded_smoothed(0.6), net.rounded(), "a corner next to a curve keeps the plain arc");
}
/// A closed 100 × 100 square whose corners (top left, top right, bottom right, bottom left) have
/// radii `radii`.
fn rounded_square(radii: [f64; 4]) -> VectorNetwork {
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
let mut net = VectorNetwork::from_points(&pts, true);
for (v, r) in net.vertices.iter_mut().zip(radii) {
v.radius = r;
}
net
}
/// The area a closed path encloses, whichever way it runs.
fn area_of(p: &figcraft_geom::BezPath) -> f64 {
use figcraft_geom::Shape;
p.area().abs()
}
#[test]
fn closed_vectors_smooth_each_rounded_point_with_its_own_radius() {
use figcraft_geom::Shape;
use figcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
// (point radii, smoothing, derivation): a closed square draws the smoothed rectangle's corners,
// each point keeping its own radius and a point without one staying sharp.
let rows: Vec<([f64; 4], f64, &str)> = vec![
([20.0; 4], 0.6, "one radius on every point"),
([30.0, 0.0, 10.0, 0.0], 0.6, "each point keeps its own radius; points without one stay sharp"),
([12.0; 4], 1.0, "full smoothing"),
([40.0; 4], 0.6, "a corner too large for its edges loses smoothing first"),
];
for (radii, s, why) in rows {
let net = rounded_square(radii);
let smoothed = net.rounded_smoothed(s).unwrap().fill_path();
// The rectangle's corners take [top left, top right, bottom right, bottom left] radii.
let want = area_of(&figcraft_geom::rounded_rect(100.0, 100.0, radii, s));
let got = area_of(&smoothed);
assert!((got - want).abs() < 0.1, "{why}: {got} vs the smoothed rectangle's {want}");
assert!((got - area_of(&net.fill_path())).abs() > 1.0, "{why}: smoothing changes the outline");
let b = smoothed.bounding_box();
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
}
// The sharp points stay on the outline, and the outline has no kinks at the rounded points.
let smoothed = rounded_square([30.0, 0.0, 10.0, 0.0]).rounded_smoothed(0.6).unwrap();
let path = smoothed.fill_path();
for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0)] {
let d = path.segments().map(|seg| seg.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min);
assert!(d < 1e-12, "the sharp point {p:?} stays a corner");
}
let segs: Vec<_> = path.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect();
let mut kinks = 0;
for (i, seg) in segs.iter().enumerate() {
let next = segs[(i + 1) % segs.len()];
let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize();
let into = (next.eval(1e-6) - next.eval(0.0)).normalize();
if out.dot(into) < 0.999 {
kinks += 1;
}
}
assert_eq!(kinks, 2, "only the two sharp points turn without a curve");
assert_eq!(smoothed.regions[0].loops[0].len(), smoothed.segments.len(), "the loop walks every curve once");
}
#[test]
fn corner_smoothing_leaves_open_paths_and_zero_alone() {
let square = rounded_square([20.0; 4]);
assert_eq!(square.rounded_smoothed(0.0), square.rounded(), "no smoothing draws the plain rounded points");
assert_eq!(square.rounded_smoothed(f64::NAN), square.rounded(), "an unusable smoothing draws no smoothing");
// An open path (no closed region) keeps its plain arcs.
let pts = [Point::new(200.0, 0.0), Point::new(250.0, 0.0), Point::new(250.0, 50.0)];
let mut open = VectorNetwork::from_points(&pts, false);
for v in &mut open.vertices {
v.radius = 20.0;
}
assert!(open.regions.is_empty());
assert_eq!(open.rounded_smoothed(0.6), open.rounded(), "an open path takes no smoothing");
// In a network with a closed square and a separate open branch, only the square smooths.
let mut mixed = square.clone();
let base = mixed.vertices.len();
mixed.vertices.extend(open.vertices.iter().copied());
mixed.segments.extend(open.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
let runs = mixed.rounded_smoothed(0.6).unwrap().stroke_runs();
let open_run = runs.iter().find(|r| !r.closed).expect("the open branch");
assert_eq!(open_run.path, open.rounded().unwrap().stroke_runs()[0].path, "the open branch keeps its plain arc");
let closed_run = runs.iter().find(|r| r.closed).expect("the square");
assert_eq!(closed_run.path, square.rounded_smoothed(0.6).unwrap().stroke_runs()[0].path, "the square smooths");
assert_ne!(closed_run.path, square.rounded().unwrap().stroke_runs()[0].path, "the square's smoothed corners differ from its arcs");
}
#[test]
fn smoothed_vector_corners_stay_finite_on_awkward_networks() {
use figcraft_geom::Shape;
// A concave L, a square with a square hole, points with unusable radii and a loop that names a
// missing segment.
let l = [(0.0, 0.0), (100.0, 0.0), (100.0, 40.0), (40.0, 40.0), (40.0, 100.0), (0.0, 100.0)];
let mut concave = VectorNetwork::from_points(&l.map(|(x, y)| Point::new(x, y)), true);
for v in &mut concave.vertices {
v.radius = 1000.0;
}
let mut holed = rounded_square([10.0; 4]);
let hole = rounded_square([5.0; 4]);
let (base, seg_base) = (holed.vertices.len(), holed.segments.len());
holed.vertices.extend(hole.vertices.iter().map(|v| Vertex { x: 25.0 + v.x / 2.0, y: 25.0 + v.y / 2.0, ..*v }));
holed.segments.extend(hole.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
holed.regions[0].loops.push((seg_base..seg_base + hole.segments.len()).collect());
holed.regions[0].winding = WindingRule::EvenOdd;
let mut odd = rounded_square([f64::INFINITY, 10.0, f64::NAN, 10.0]);
odd.regions[0].loops[0].push(99);
for (net, s, why) in [(&concave, 1.0, "concave"), (&holed, 0.6, "holed"), (&odd, 0.6, "odd radii")] {
let drawn = net.rounded_smoothed(s).unwrap();
let b = drawn.fill_path().bounding_box();
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{why}: {b:?}");
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
assert!(!drawn.stroke_path().elements().is_empty(), "{why}");
}
let concave_path = concave.rounded_smoothed(1.0).unwrap().fill_path();
assert_ne!(concave_path, concave.fill_path(), "the concave L smooths");
assert!(concave_path.winding(Point::new(20.0, 60.0)) != 0, "the L stays filled");
let holed_net = holed.rounded_smoothed(0.6).unwrap();
assert!(!holed_net.fill_contains(Point::new(50.0, 50.0)) && holed_net.fill_contains(Point::new(12.0, 50.0)), "the hole stays a hole");
}
#[test]
fn clicks_on_a_smoothed_vector_corner_follow_the_drawn_fill() {
// A closed square with rounded points and smoothing: a click between the plain arc and the
// smoothed corner selects the layer only where the smoothed fill is drawn.
let pts = [Point::new(0.0, 0.0), Point::new(1000.0, 0.0), Point::new(1000.0, 1000.0), Point::new(0.0, 1000.0)];
let mut net = VectorNetwork::from_points(&pts, true);
for v in &mut net.vertices {
v.radius = 200.0;
}
let smoothed = net.rounded_smoothed(1.0).unwrap();
let mut d = Document::new("x");
let p = page(&d);
let v = d.create(p, NodeKind::Vector { network: net.clone() }, "V", 0.0, 0.0, 1000.0, 1000.0).unwrap();
let n = d.node_mut(v).unwrap();
n.corners.smoothing = 1.0;
n.strokes.clear();
// Walk the row 20 units below the top edge inward to where each outline starts covering it.
let first_inside = |inside: &dyn Fn(Point) -> bool| (0..5_000).map(|i| f64::from(i) * 0.1).find(|&x| inside(Point::new(x, 20.0)));
let plain = first_inside(&|pt| net.fill_contains(pt)).unwrap();
let drawn = first_inside(&|pt| smoothed.fill_contains(pt)).unwrap();
assert!((plain - drawn).abs() > 5.0, "smoothing moves the corner: plain {plain}, smoothed {drawn}");
let pt = Point::new((plain + drawn) / 2.0, 20.0);
assert_ne!(net.fill_contains(pt), smoothed.fill_contains(pt));
assert_eq!(d.hit_test(p, pt, 0.0, None).is_some(), smoothed.fill_contains(pt), "the click follows the smoothed corner at {pt:?}");
assert_eq!(d.hit_test(p, Point::new(500.0, 500.0), 0.0, None), Some(v));
}
/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point
/// on the top edge and one on the right edge), then rounded: the fill, which the fill and the
/// selection outline draw, is cut by an arc at every point just as the stroke is, however the loop
/// lists its segments and whichever way each one runs (#1951, #1506).
#[test]
fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let corners = [Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 50.0), Point::new(100.0, 100.0), Point::new(0.0, 100.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>();
for first in 0..corners.len() {
for backwards in [false, true] {
for flipped in 0..(1u32 << corners.len()) {
let mut net = VectorNetwork::from_points(&corners, true);
for v in &mut net.vertices {
v.radius = 43.0;
}
for (i, s) in net.segments.iter_mut().enumerate() {
if flipped & (1 << i) != 0 {
std::mem::swap(&mut s.a, &mut s.b);
std::mem::swap(&mut s.ta, &mut s.tb);
}
}
let lp = &mut net.regions[0].loops[0];
lp.rotate_left(first);
if backwards {
lp.reverse();
}
let why = format!("loop from segment {first}{}, segments flipped {flipped:05b}", if backwards { " backwards" } else { "" });
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, "{why}: the stroke is cut at point {i} ({:.2} from it)", keeps_off(&stroke, *p));
assert!(keeps_off(&fill, *p) > 2.0, "{why}: the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
assert!(keeps_off(&region, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(&region, *p));
}
assert!(
(length(&fill) - length(&stroke)).abs() < 1e-6,
"{why}: fill {} and stroke {} trace the same outline",
length(&fill),
length(&stroke)
);
assert!(
(length(&region) - length(&stroke)).abs() < 1e-6,
"{why}: region {} and stroke {} trace the same outline",
length(&region),
length(&stroke)
);
}
}
}
}
#[test]
+121 -7
View File
@@ -259,10 +259,24 @@ pub enum ListStyle {
Numbered,
}
/// A paragraph's base direction: by its letters, or forced ("controls are available to override
/// text direction per paragraph", Add right-to-left text).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum TextDirection {
/// The first letter with a direction decides (Unicode rules P2 and P3).
#[default]
Auto,
/// Left to right, whatever the letters.
Ltr,
/// Right to left, whatever the letters.
Rtl,
}
/// Deepest list nesting (levels 0..=MAX_LIST_INDENT).
pub const MAX_LIST_INDENT: u8 = 8;
/// One paragraph's list settings.
/// One paragraph's list settings and base direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ParaList {
@@ -276,6 +290,9 @@ pub struct ParaList {
/// (items that go on counting ignore it; other paragraphs are always 0).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub start_offset: u32,
/// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters.
#[serde(skip_serializing_if = "crate::node::is_default")]
pub direction: TextDirection,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -307,6 +324,23 @@ pub struct TextStyle {
pub style_id: Option<String>,
}
/// What a text style sets at the paragraph and layer level, beside its character style
/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features
/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType
/// features", Create and apply text styles).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ParagraphStyle {
#[serde(skip_serializing_if = "crate::node::is_default")]
pub paragraph_spacing: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub paragraph_indent: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub list: ListStyle,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub features: std::collections::BTreeMap<String, bool>,
}
/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`,
/// for a distribution whose users mostly write a script Inter lacks); empty means Inter.
pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") {
@@ -405,8 +439,9 @@ pub fn style_weight(style: &str) -> u16 {
{
return n;
}
let table: [(&str, u16); 10] = [
let table: [(&str, u16); 11] = [
("thin", 100),
("hairline", 100),
("extralight", 200),
("ultralight", 200),
("light", 300),
@@ -835,6 +870,11 @@ impl TextData {
fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) {
let (a, b) = self.para_span(start, end);
self.set_para_lists_between(a, b, keep_levels, f);
}
/// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly.
fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) {
self.fill_paragraphs();
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
f(pl);
@@ -863,6 +903,61 @@ impl TextData {
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
}
/// What a text style made from this text sets at the paragraph and layer level: the paragraph
/// spacing and indent, the list style (the one every paragraph shares; the base one when they
/// differ) and the OpenType features.
pub fn paragraph_style(&self) -> ParagraphStyle {
let list = match self.paragraphs.first() {
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() }
}
/// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and
/// features on the layer, the list style on every paragraph.
pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) {
self.paragraph_spacing = ps.paragraph_spacing;
self.paragraph_indent = ps.paragraph_indent;
self.features.clone_from(&ps.features);
// 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);
}
/// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the
/// paragraphs chars `start..end` touch only; the spacing, indent and features are the
/// layer's, as in Figma.
pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) {
self.paragraph_spacing = ps.paragraph_spacing;
self.paragraph_indent = ps.paragraph_indent;
self.features.clone_from(&ps.features);
self.set_para_lists(start, end, |pl| pl.list = ps.list);
}
/// Follow text style `id`'s paragraph-level definition wherever chars link to it.
pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) {
let linked: Vec<(usize, usize)> =
self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect();
for (a, b) in linked {
self.take_paragraph_style_in(a, b, ps);
}
}
/// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer
/// level: the layer's spacing, indent and features, and their paragraphs' list style.
pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool {
self.paragraph_spacing == ps.paragraph_spacing
&& self.paragraph_indent == ps.paragraph_indent
&& self.features == ps.features
&& self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list)
}
/// Whether the whole text shows what text style `ps` sets at the paragraph and layer level.
pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool {
self.shows_paragraph_style_in(0, self.char_count(), ps)
}
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
/// style `id`, defined as `def`.
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
@@ -991,20 +1086,20 @@ mod case_tests {
// Files written before start numbers load with none, and write the same bytes.
let old = r#"{"list":"numbered","indent":1}"#;
let pl: ParaList = serde_json::from_str(old).unwrap();
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0, ..ParaList::default() });
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
let mut t = TextData { characters: "Next".into(), ..Default::default() };
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
// Enter copies it to the new item; different starts don't make the list mixed.
t.replace_range(4, 4, "\nMore");
assert_eq!(t.para_list(1).start_offset, 1);
t.set_para_lists(5, 5, |p| p.start_offset = 0);
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
// Bullets have no number, so no start.
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4, ..ParaList::default() });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() });
// A plain paragraph has none.
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
@@ -1039,3 +1134,22 @@ mod title_case_tests {
assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s");
}
}
#[cfg(test)]
mod whole_text_paragraph_style_tests {
use super::*;
#[test]
fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() {
let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() };
let mut t = TextData::new("a\nb\n");
t.take_paragraph_style(&ps);
assert_eq!(t.paragraph_count(), 3);
assert_eq!(t.para_list(2).list, ListStyle::Bulleted);
assert_eq!(t.list, ListStyle::Bulleted);
assert!(t.paragraphs.is_empty());
let mut plain = TextData::new("a\nb");
plain.take_paragraph_style(&ps);
assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len()));
}
}
+76 -25
View File
@@ -129,9 +129,10 @@ impl Region {
}
}
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
/// at `b`, and an arc with handles `ta`/`tb` joins them. `cut1`/`cut2`: where each segment is cut,
/// in its own parameter (curved segments keep the rest of their curve).
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), and the curves of `chain`
/// lead from there to where segment `s2` starts again (one arc, or a smoothed corner's easing
/// curves and arc). `cut1`/`cut2`: where each segment is cut, in its own parameter (curved
/// segments keep the rest of their curve).
struct Corner {
v: usize,
s1: usize,
@@ -139,13 +140,19 @@ struct Corner {
s2: usize,
s2_at_a: bool,
a: Point,
b: Point,
ta: (f64, f64),
tb: (f64, f64),
chain: Vec<CornerCurve>,
cut1: f64,
cut2: f64,
}
/// One curve of a rounded point: its handles at its start (`ta`) and its end (`tb`), relative to
/// those ends, and the point it ends at.
struct CornerCurve {
ta: (f64, f64),
tb: (f64, f64),
end: Point,
}
/// Accuracy of arc lengths along curved segments (local units).
const ARCLEN_ACC: f64 = 1e-6;
@@ -382,9 +389,19 @@ impl VectorNetwork {
/// the rest of its curve, the arc leaving along its direction there. `None` when no point is
/// rounded.
pub fn rounded(&self) -> Option<VectorNetwork> {
self.rounded_smoothed(0.0)
}
/// [`Self::rounded`] with the layer's corner smoothing (0..=1): a rounded point between two
/// straight segments that both belong to a region's loops gets a smoothed corner, drawn with
/// the profile of a smoothed polygon's corners (`figcraft_geom::smoothed_corner`). Other
/// rounded points (on open branches, or next to a curved segment) keep the plain arc, and a
/// point without a radius stays sharp. Without smoothing this is [`Self::rounded`].
pub fn rounded_smoothed(&self, smoothing: f64) -> Option<VectorNetwork> {
if !self.vertices.iter().any(|v| v.radius > 0.0) {
return None;
}
let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 };
let mut incident: Vec<Vec<(usize, bool)>> = vec![Vec::new(); self.vertices.len()];
for (i, s) in self.segments.iter().enumerate() {
for (v, at_a) in [(s.a, true), (s.b, false)] {
@@ -393,7 +410,14 @@ impl VectorNetwork {
}
}
}
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?)).collect();
// The segments that bound a closed region, where corners take smoothing.
let mut on_loop = vec![false; if smoothing > 0.0 { self.segments.len() } else { 0 }];
for &i in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) {
if let Some(x) = on_loop.get_mut(i) {
*x = true;
}
}
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?, smoothing, &on_loop)).collect();
let mut out = self.clone();
for v in &mut out.vertices {
v.radius = 0.0;
@@ -420,26 +444,33 @@ impl VectorNetwork {
s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y);
s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y);
}
let mut by_vertex: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
// Each rounded point's curves in order from `s1`'s side, by point.
let mut by_vertex: std::collections::BTreeMap<usize, (usize, Vec<usize>)> = std::collections::BTreeMap::new();
for c in corners {
if let Some(v) = out.vertices.get_mut(c.v) {
(v.x, v.y) = (c.a.x, c.a.y);
}
out.vertices.push(Vertex::new(c.b.x, c.b.y));
let nb = out.vertices.len() - 1;
let mut from = c.v;
let mut arcs = Vec::with_capacity(c.chain.len());
for CornerCurve { ta, tb, end } in c.chain {
out.vertices.push(Vertex::new(end.x, end.y));
let to = out.vertices.len() - 1;
out.segments.push(Segment { a: from, b: to, ta, tb });
arcs.push(out.segments.len() - 1);
from = to;
}
if let Some(s) = out.segments.get_mut(c.s2) {
if c.s2_at_a {
s.a = nb;
s.a = from;
} else {
s.b = nb;
s.b = from;
}
}
out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb });
let arc = out.segments.len() - 1;
by_vertex.insert(c.v, arc);
by_vertex.insert(c.v, (c.s1, arcs));
}
// The arc goes between the two segments wherever a loop walks through the point. Each loop
// is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic.
// 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.
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))) {
@@ -453,8 +484,12 @@ impl VectorNetwork {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
cur = if s.a == cur { s.b } else { s.a };
if let Some(&arc) = by_vertex.get(&cur) {
rebuilt.push(arc);
if let Some((s1, arcs)) = by_vertex.get(&cur) {
if x == *s1 {
rebuilt.extend(arcs.iter().copied());
} else {
rebuilt.extend(arcs.iter().rev().copied());
}
}
}
*lp = rebuilt;
@@ -463,8 +498,9 @@ impl VectorNetwork {
}
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
/// run on smoothly or fold back.
fn corner(&self, v: usize, incident: &[(usize, bool)]) -> Option<Corner> {
/// run on smoothly or fold back. With `smoothing`, a point between two straight segments that
/// are both `on_loop` gets a smoothed corner.
fn corner(&self, v: usize, incident: &[(usize, bool)], smoothing: f64, on_loop: &[bool]) -> Option<Corner> {
let r = self.vertices.get(v)?.radius;
let [(s1, a1), (s2, a2)] = *incident else { return None };
if !(r > 0.0 && r.is_finite()) || s1 == s2 {
@@ -480,6 +516,24 @@ impl VectorNetwork {
if !(l1 > 1e-9 && l2 > 1e-9) {
return None;
}
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
let closed = |s: usize| on_loop.get(s).copied().unwrap_or(false);
if smoothing > 0.0
&& is_line(&c1)
&& is_line(&c2)
&& closed(s1)
&& closed(s2)
&& let Some(curves) = figcraft_geom::smoothed_corner(c1.p0, u1, u2, l1.min(l2) / 2.0, r, smoothing)
&& let (Some(first), Some(last)) = (curves.first(), curves.last())
{
let (a, b) = (first.p0, last.p3);
let chain = curves
.iter()
.map(|q| CornerCurve { ta: (q.p1.x - q.p0.x, q.p1.y - q.p0.y), tb: (q.p2.x - q.p3.x, q.p2.y - q.p3.y), end: q.p3 })
.collect();
let (cut1, cut2) = ((a - c1.p0).hypot() / l1, (b - c2.p0).hypot() / l2);
return Some(Corner { v, s1, s1_at_a: a1, s2, s2_at_a: a2, a, chain, cut1: own(a1, cut1), cut2: own(a2, cut2) });
}
let t = (r / (angle / 2.0).tan()).min(l1.min(l2) / 2.0);
let ((a, d1, k1), (b, d2, k2)) = (trim(&c1, t, l1), trim(&c2, t, l2));
// A cubic arc: handles of 4/3·tan(φ/4)·ρ along each end's direction, φ the turn between
@@ -490,7 +544,6 @@ impl VectorNetwork {
if !(k.is_finite() && a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
return None;
}
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
Some(Corner {
v,
s1,
@@ -498,9 +551,7 @@ impl VectorNetwork {
s2,
s2_at_a: a2,
a,
b,
ta: (d1.x * k, d1.y * k),
tb: (d2.x * k, d2.y * k),
chain: vec![CornerCurve { ta: (d1.x * k, d1.y * k), tb: (d2.x * k, d2.y * k), end: b }],
cut1: own(a1, k1),
cut2: own(a2, k2),
})
+2 -2
View File
@@ -121,7 +121,7 @@ fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind {
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, field)), VariableValue::Number(x)) => {
crate::cmd::lists::set_grid_field(grids, i, field, *x);
}
(StyleKind::Text { style }, None, _) => set_text_field(style, key, &val),
(StyleKind::Text { style, .. }, None, _) => set_text_field(style, key, &val),
_ => {}
}
}
@@ -214,7 +214,7 @@ pub fn refresh(doc: &mut Document, before: Option<&Document>, only: Option<&BTre
&& was.is_none_or(|w| w.kind == n.kind)
{
for st in doc.styles.iter().filter(|s| !s.bound.is_empty()) {
if let StyleKind::Text { style } = kind_for(doc, st, id).as_ref() {
if let StyleKind::Text { style, .. } = kind_for(doc, st, id).as_ref() {
t.update_text_style(&st.id, style);
}
}
+12 -1
View File
@@ -28,6 +28,11 @@ pub fn openable(url: &str) -> bool {
if url.is_empty() || url.chars().any(char::is_control) {
return false;
}
// Never valid in a URL unencoded, and read as syntax by the shells some openers go through
// (webbrowser on WSL hands the address to `cmd.exe /c start`, escaping only `&` and `^`).
if url.chars().any(|c| c.is_whitespace() || matches!(c, '|' | '<' | '>' | '"' | '`' | '\\' | '^')) {
return false;
}
let Some((scheme, rest)) = url.split_once(':') else { return false };
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
let mut chars = scheme.chars();
@@ -44,5 +49,11 @@ pub fn openable(url: &str) -> bool {
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
// and `http:///p` has no host.
let Some(after) = rest.strip_prefix("//") else { return false };
!after.is_empty() && !after.starts_with('/')
if after.is_empty() || after.starts_with('/') {
return false;
}
// The authority is made of host characters only (an opener that can't parse the address may
// fall back to treating it as a file path).
let authority = after.split(['/', '?', '#']).next().unwrap_or_default();
authority.chars().all(|c| !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~:@[]%!$&'()*+,;=".contains(c))
}
@@ -277,6 +277,14 @@ pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max))));
}
let f = std::fs::File::open(p).map_err(io(path))?;
// What was opened, not what was looked at: the path could have been swapped in between.
let meta = f.metadata().map_err(io(path))?;
if !meta.is_file() {
return Err(refuse("isn't a regular file (a device or a pipe?), so it isn't read".into()));
}
if meta.len() > max {
return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max))));
}
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(io(path))?;
// It grew after it was looked at.
+12
View File
@@ -53,6 +53,7 @@ pub const NODE_KEYS: &[&str] = &[
"layout",
"leadingTrim",
"letterSpacing",
"lineDirections",
"lineHeight",
"lineIndentations",
"lineTypes",
@@ -96,6 +97,7 @@ pub const NODE_KEYS: &[&str] = &[
"textDecorationSkipInk",
"textDecorationStyle",
"textDecorationThickness",
"textDirection",
"textPath",
"textStyleRanges",
"type",
@@ -432,6 +434,16 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
}
}
// The paragraphs' base direction: one for the layer when they agree, else each one's (Auto
// throughout says nothing).
let dirs: Vec<figcraft_doc::TextDirection> = t.paragraphs.iter().map(|p| p.direction).collect();
if let Some(first) = dirs.first().copied().filter(|d| *d != figcraft_doc::TextDirection::Auto)
&& dirs.iter().all(|d| *d == first)
{
insert_prop(o, options, "textDirection", || json!(first));
} else if dirs.iter().any(|d| *d != figcraft_doc::TextDirection::Auto) {
insert_prop(o, options, "lineDirections", || dirs.iter().map(|d| json!(d)).collect());
}
if t.list_spacing != 0.0 {
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
}
@@ -104,3 +104,31 @@ fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_wer
assert!(r.get("connector").is_none(), "{r}");
assert!(matches!(node(&s, id(&r, "id")).board, Some(BoardItem::Sticky { .. })));
}
/// `at` (#1919; "drag the preview to any location on your board", Build faster with quick create):
/// the next one is centred on that point, wherever it is, and the connector takes the sides facing
/// each other; `at` must be two finite numbers.
#[test]
fn the_next_one_goes_where_it_is_dropped() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap(), "id");
let r = s.execute("board.addConnected", &json!({"id": a, "side": "below", "at": [700, 60]})).unwrap();
let (b, c) = (id(&r, "id"), id(&r, "connector"));
assert_eq!(at(&s, b), (600.0, 0.0), "centred on the point");
let k = connector(&s, c);
assert_eq!(
(k.from, k.to),
(End::Node { node: a, side: Side::Bottom, at: None }, End::Node { node: b, side: Side::Auto, at: None }),
"leaves by the handle's side"
);
// Inside a frame at (1000, 500): the page point lands in the frame's coordinates.
let f = id(&s.execute("frame.create", &json!({"x": 1000, "y": 500, "width": 800, "height": 600})).unwrap(), "id");
let inner = id(&s.execute("board.shape", &json!({"shape": "ellipse", "x": 0, "y": 0, "width": 200, "height": 120, "parent": f})).unwrap(), "id");
let r = s.execute("board.addConnected", &json!({"id": inner, "at": [1300, 700]})).unwrap();
let placed = id(&r, "id");
assert_eq!(node(&s, placed).parent, Some(f), "beside it, in the frame");
assert_eq!(at(&s, placed), (200.0, 140.0), "centred on the page point, in the frame's coordinates");
for wrong in [json!([1]), json!("x"), json!([0, "y"]), json!({"x": 1, "y": 2})] {
assert!(s.execute("board.addConnected", &json!({"id": a, "at": wrong})).is_err(), "{wrong}");
}
}
+31 -1
View File
@@ -33,6 +33,36 @@ pub use connectors::{END_HANDLE_PX, selected_connector_ends};
use connectors::{connector_cmd, reattach};
pub use derived::route;
pub(crate) use derived::{settle_routes, settle_stamps, settle_text, stamp_target};
/// The topmost sticky or shape at `p` on `page`, other than `except`, sections and frames looked
/// into: what a "+" handle dragged onto it joins (#1919).
pub fn sticky_or_shape_at(doc: &Document, page: NodeId, p: Point, except: Option<NodeId>) -> Option<NodeId> {
fn find(doc: &Document, id: NodeId, p: Point, except: Option<NodeId>, depth: usize) -> Option<NodeId> {
let n = doc.node(id)?;
for c in n.children.iter().rev() {
let Some(k) = doc.node(*c) else { continue };
if !k.visible {
continue;
}
match k.board {
Some(BoardItem::Sticky { .. } | BoardItem::Shape { .. }) => {
if Some(*c) != except && doc.world_bounds(*c).is_some_and(|b| b.contains(p)) {
return Some(*c);
}
}
// Not a board object of its own (a section, a frame): what is inside it may be.
None if depth < 32 => {
if let Some(hit) = find(doc, *c, p, except, depth + 1) {
return Some(hit);
}
}
_ => {}
}
}
None
}
find(doc, page, p, except, 0)
}
pub use edit::label_of;
pub(crate) use edit::{copied_items, copy_map, rejoin};
use edit::{label_cmd, set_color, set_style};
@@ -114,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"board.addConnected",
"Add connected shape",
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector",
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), at? ([x, y] on the page: the next one's centre, anywhere, instead of its place on that side; the connector then leaves by `side` and arrives by the side facing it), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector",
true,
add_connected
),
+23
View File
@@ -223,6 +223,19 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
let color = color.unwrap_or(s.tool.board.sticky);
let (to_at, from_side, to_side) = connected_place((at.x, at.y), (n.width, n.height), next, &side)
.ok_or_else(|| bad(cmd, format!("`side` is right, below, left or above, not `{side}`")))?;
// Dropped somewhere by hand ("drag the preview to any location on your board", #1919): the
// next one is centred on that point of the page (placed in the parent's coordinates), and the
// connector leaves by the handle's side and arrives by the side facing it.
let (to_at, from_side, to_side) = match drop_point(cmd, p)? {
Some((x, y)) => {
let local = doc.world_transform(parent).inverse() * figcraft_geom::Point::new(x, y);
if !(local.x.is_finite() && local.y.is_finite()) {
return Err(bad(cmd, "`at` can't be placed in that parent"));
}
((local.x - next.0 / 2.0, local.y - next.1 / 2.0), from_side, Side::Auto)
}
None => (to_at, from_side, to_side),
};
let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?);
if editing.is_some() {
textedit::end(s)?;
@@ -246,6 +259,16 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id, "text": text, "connector": line}))
}
/// `at?` of `board.addConnected`: a point on the page, `[x, y]`, or none.
fn drop_point(cmd: &str, p: &Value) -> Result<Option<(f64, f64)>> {
let Some(v) = get(p, "at") else { return Ok(None) };
let pair = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
match pair {
Some((x, y)) if x.is_finite() && y.is_finite() => Ok(Some((x, y))),
_ => Err(bad(cmd, "`at` is [x, y] on the page, two finite numbers")),
}
}
/// A shape with text's layers: the frame, its outline and its text.
fn make_shape(
doc: &mut Document,
+2 -2
View File
@@ -134,8 +134,8 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
}
match (&n.kind, part.region) {
(NodeKind::Vector { network }, Some(i)) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
let region = net.regions.get(i)?;
let path = net.region_path(i)?;
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
+8
View File
@@ -201,6 +201,7 @@ impl ToolId {
| ToolId::Star
| ToolId::Line
| ToolId::Arrow
| ToolId::BoardShape
)
}
}
@@ -591,6 +592,13 @@ pub struct ToolState {
pub picked: Option<figcraft_color::Color>,
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
pub(crate) widget_hover: Vec<NodeId>,
/// The placement crosshair: where Enter puts the next shape or frame once the arrows have
/// shown it (in the middle of the view) and moved it (`tool.crosshair`), in page coordinates
/// of the document and page in `place_on` (elsewhere it isn't shown: `Session::crosshair`).
/// Placing, Escape and a change of tool take it away.
pub place_at: Option<Point>,
/// The document (uid) and page the crosshair was shown on.
pub(crate) place_on: Option<(u64, NodeId)>,
}
impl ToolState {
+12 -5
View File
@@ -193,6 +193,7 @@ impl Session {
}
// New text at the click (Figma puts the baseline-ish top-left there).
let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page);
let defaults = self.default_props.text.clone();
let ds = self.ds_mut()?;
let d = ds.doc_mut();
let id = d.alloc_id();
@@ -204,12 +205,18 @@ impl Session {
if let Some(fills) = readable_text_fills(d, parent) {
n.fills = fills;
}
if let Some((w, h)) = figcraft_text::auto_size(&n) {
n.width = w.max(1.0);
n.height = h;
}
let line = n.height;
d.insert(n, parent, None)?;
// Edit ▸ Set default properties from a text layer: its fills and font (best effort).
if let Some(defaults) = &defaults {
let _ = crate::cmd::edit::apply_copied_props("edit.setDefaultProperties", d, id, defaults);
}
let line = d.node_mut(id).map_or(14.0, |n| {
if let Some((w, h)) = figcraft_text::auto_size(n) {
n.width = w.max(1.0);
n.height = h;
}
n.height
});
ds.selection = vec![id];
textedit::begin(self, id, Some(0), true)?;
self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line };
+13
View File
@@ -42,6 +42,13 @@ impl Session {
}
let mut created = id;
let page = self.page()?;
// Edit ▸ Set default properties: what new shapes take (not frames, sections, slices, lines
// or arrows, which keep their own look).
let defaults = if matches!(tool, ToolId::Rectangle | ToolId::Ellipse | ToolId::Polygon | ToolId::Star) {
self.default_props.shape.clone()
} else {
None
};
self.preview(|doc, sel| {
let (mut a, b) = (start, p);
if pixel {
@@ -92,6 +99,10 @@ impl Session {
n.transform = pw.inverse() * Affine::translate((r.x0, r.y0));
}
doc.insert(n, parent, None)?;
if let Some(d) = &defaults {
// Best effort: a default the shape can't take leaves it as drawn.
let _ = crate::cmd::edit::apply_copied_props("edit.setDefaultProperties", doc, nid, d);
}
*sel = vec![nid];
created = Some(nid);
let _ = page;
@@ -212,6 +223,8 @@ impl Session {
crate::normalize(ds.doc_mut(), &before);
}
self.commit_interaction()?;
// A placement by the pointer takes the placement crosshair with it, as Enter does.
self.tool.place_at = None;
if !self.prefs.keep_tool {
self.set_tool(ToolId::Move);
}
+26 -3
View File
@@ -84,9 +84,14 @@ impl Session {
if self.tool.busy() {
return refuse("finish the drag first");
}
// The crosshair the arrows moved goes with the placement, which lands on it when no point
// is given.
let crosshair = self.crosshair();
self.tool.place_at = None;
let v = &self.ds()?.view;
let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
let (w, h) = tool.click_size();
let (c, zoom) = (at.or(crosshair).unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
// The board Shapes tool centres its own shape on the press; the others get half a click's size.
let (w, h) = if matches!(tool, ToolId::BoardShape) { (0.0, 0.0) } else { tool.click_size() };
let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom);
let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up)));
if r.is_err() {
@@ -111,6 +116,16 @@ impl Session {
self.tool.overlay = Overlay::default();
return self.cancel_interaction();
}
// The placement crosshair goes first; the tool stays. One left on another page or
// document goes quietly.
let shown = self.crosshair().is_some();
self.tool.place_at = None;
if shown {
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
return Ok(());
}
if self.end_origin_edit() {
return Ok(());
}
@@ -174,6 +189,14 @@ impl Session {
/// Snapshot of tool state for the UI / inspect.
pub fn tool_info(&self) -> serde_json::Value {
let shapes: Vec<serde_json::Value> = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect();
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes})
let crosshair = self.crosshair().map(|p| [p.x, p.y]);
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes, "crosshair": crosshair})
}
/// The placement crosshair, while the document and page it was shown on are the ones in
/// front (`ToolState::place_at`).
pub fn crosshair(&self) -> Option<Point> {
let here = self.ds().ok().map(|d| (d.uid, d.page));
self.tool.place_at.filter(|_| here.is_some() && here == self.tool.place_on)
}
}
@@ -76,6 +76,10 @@ impl Session {
if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
let _ = crate::vectoredit::end(self);
}
// The placement crosshair goes with a change of tool, not with the same tool chosen again.
if self.tool.tool != t {
self.tool.place_at = None;
}
self.tool.tool = t;
self.tool.gesture = Gesture::None;
self.tool.pick_only = false;
+65 -2
View File
@@ -658,8 +658,28 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
net.segments.push(Segment { a, b, ta: finite_t(ta), tb: finite_t(tb) });
let si = net.segments.len() - 1;
if ends && let Some(mut lp) = path_between(net, b, a, si) {
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if unfilled {
let filled = net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if filled {
// A loop broken open at these ends (a point of it deleted, say) closes through the new
// segment, so the fill follows it instead of a straight line across the gap (#1951). The
// segment goes in at the gap, in the loop's own order, so the loop keeps its direction: a
// hole wound against its outline stays a hole whichever end was clicked first. Only a
// loop the segment makes one continuous chain is closed; one listed out of walking order
// is left as it was.
let segs = &net.segments;
let shares = |x: usize, y: usize| segs.get(x).zip(segs.get(y)).is_some_and(|(p, q)| p.a == q.a || p.a == q.b || p.b == q.a || p.b == q.b);
for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) {
let gap = (1..l.len()).find(|&k| l.get(k - 1).zip(l.get(k)).is_some_and(|(&x, &y)| !shares(x, y))).unwrap_or(l.len());
let mut closed = l.clone();
closed.insert(gap, si);
let n = closed.len();
if (0..n).all(|k| closed.get(k).zip(closed.get((k + 1) % n)).is_some_and(|(&x, &y)| shares(x, y))) {
*l = closed;
}
}
}
} else {
lp.insert(0, si);
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
@@ -997,6 +1017,49 @@ mod tests {
assert!(connect(&mut n, 1, 99, (0.0, 0.0), (0.0, 0.0)).is_none());
}
#[test]
fn connecting_the_ends_of_a_hole_keeps_its_winding() {
// A ring: the hole's loop walks the other way round from the outline's, so the non-zero
// rule leaves it empty.
let mut n = square();
for (x, y) in [(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)] {
n.vertices.push(Vertex::new(x, y));
}
for (a, b) in [(4, 5), (5, 6), (6, 7), (7, 4)] {
n.segments.push(Segment::line(a, b));
}
n.regions[0].loops.push(vec![7, 6, 5, 4]);
let (hole, ring, cut) = (Point::new(40.0, 60.0), Point::new(10.0, 50.0), Point::new(70.0, 30.0));
assert!(!n.fill_contains(hole) && n.fill_contains(ring) && !n.fill_contains(cut), "a ring to start with");
// Delete a corner of the hole (the path opens; its fill closes across the gap, so the cut-off
// corner is filled), then connect the two open ends again, either end first: the hole is a
// triangle, still empty.
let at = |m: &VectorNetwork, (x, y): (f64, f64)| m.vertices.iter().position(|v| v.x == x && v.y == y).unwrap();
for (first, second) in [((25.0, 25.0), (75.0, 75.0)), ((75.0, 75.0), (25.0, 25.0))] {
let mut m = n.clone();
delete_vertices(&mut m, &[5], false);
assert!(!m.fill_contains(hole) && m.fill_contains(cut), "opened: {:?}", m.regions);
let (a, b) = (at(&m, first), at(&m, second));
connect(&mut m, a, b, (0.0, 0.0), (0.0, 0.0)).unwrap();
let loops = &m.regions[0].loops;
assert_eq!((m.regions.len(), loops.len(), loops[1].len()), (1, 2, 3), "{first:?} first: {:?}", m.regions);
assert!(!m.fill_contains(hole), "{first:?} first: the hole is filled, {loops:?}");
assert!(m.fill_contains(ring) && m.fill_contains(cut), "{first:?} first: the ring is filled");
}
}
#[test]
fn connecting_leaves_a_loop_out_of_walking_order_alone() {
// The open chain A-B-C-D-E with its loop listed out of walking order: no place for the new
// segment makes it one continuous chain, so the loop is left as it was, and no region is added.
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0), Point::new(-50.0, 50.0)];
let mut n = VectorNetwork::from_points(&pts, false);
n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let s = connect(&mut n, 0, 4, (0.0, 0.0), (0.0, 0.0)).unwrap();
assert_eq!(n.regions.len(), 1);
assert_eq!(n.regions[0].loops, vec![vec![0, 2, 1, 3]], "left as it was, without segment {s}");
}
#[test]
fn join_merges_overlapping_points_and_connects_ends() {
let mut n = open_line();
@@ -56,12 +56,10 @@ fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool
net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
}
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
fn drawn(net: &VectorNetwork) -> std::borrow::Cow<'_, VectorNetwork> {
match net.rounded() {
Some(r) => std::borrow::Cow::Owned(r),
None => std::borrow::Cow::Borrowed(net),
}
/// The network as its fill draws it: rounded points drawn in with the layer's corner `smoothing`
/// (region indexes are kept).
fn drawn(net: &VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, VectorNetwork> {
figcraft_doc::geometry::drawn_network(net, smoothing)
}
/// The region of the active layer under page point `p` at `zoom`: none when a point is within
@@ -90,7 +88,7 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
return None;
}
let drawn = drawn(net);
let drawn = drawn(net, node.corners.smoothing);
(0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
}
@@ -221,7 +219,7 @@ pub fn region_hatch(doc: &Document, ve: &VectorEdit, regions: &[usize], view: Af
let zoom = view.determinant().abs().sqrt();
let Some(spacing) = hatch_spacing(zoom).filter(|_| !regions.is_empty()) else { return Vec::new() };
let Some(net) = network(doc, ve.id) else { return Vec::new() };
let net = drawn(net);
let net = drawn(net, doc.node(ve.id).map_or(0.0, |n| n.corners.smoothing));
let m = view * doc.world_transform(ve.id);
let anchor = m * Point::ZERO;
let mut out = Vec::new();
@@ -597,3 +597,85 @@ fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() {
assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point");
assert_eq!(ve(&s).anchor, None, "the click ended the pen's path");
}
/// A rectangle's corner cut off in the editor (a point added on each edge next to it, the corner
/// deleted with Shift+Delete so the two join) and the shape rounded: the fill, which the fill and
/// the selection outline draw, traces the stroke's outline, cut at both ends of the diagonal (#1951).
#[test]
fn a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs() {
for top_first in [true, false] {
let mut s = Session::new();
let id = rect(&mut s);
// Splitting keeps the split edge's index, so the other edge's stays: top 0, right 1.
let (first, second) = if top_first { (0, 1) } else { (1, 0) };
ex(&mut s, "vector.edit.addVertex", json!({"segment": first, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": second, "t": 0.5}));
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.deleteHeal", json!({}));
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "top first {top_first}: a pentagon");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, &format!("top first {top_first}"));
// A region with a fill of its own is drawn through the region path and the planar normaliser.
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
fill_traces_stroke(&s, id, &format!("top first {top_first}, region fill"));
}
}
/// The chamfer made other ways: the corner deleted plainly, which opens the path between the two
/// new points, and the two connected again; or the pentagon drawn with the pen (#1951).
#[test]
fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
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 corner = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.edit.delete", json!({}));
let n = net(&s, id);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (4, 4), "the path opened: {:?}", n.regions);
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}));
assert_eq!(net(&s, id).segments.len(), 5, "connected again");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "deleted and connected");
// Drawn with the pen: continue from the end of an open two-point path and close on its start.
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
click(&mut s, 50.0, 0.0);
for (x, y) in [(100.0, 50.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)] {
click(&mut s, x, y);
}
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "closed by the pen");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "drawn with the pen");
}
/// 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.
fn fill_traces_stroke(s: &Session, id: NodeId, why: &str) {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let n = net(s, id);
assert_eq!(n.regions.len(), 1, "{why}: one region, {:?}", n.regions);
assert_eq!(n.regions[0].loops[0].len(), n.segments.len(), "{why}: every segment in the loop, {:?}", n.regions[0].loops);
let doc = s.doc().unwrap();
let (fill, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
let stroke = n.stroke_path();
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>();
for p in [pt(50.0, 0.0), pt(100.0, 50.0), pt(0.0, 0.0), pt(100.0, 100.0), pt(0.0, 100.0)] {
assert!(keeps_off(&stroke, p) > 2.0, "{why}: the stroke is cut at {p:?} ({:.2} from it)", keeps_off(&stroke, p));
assert!(keeps_off(&fill, p) > 2.0, "{why}: the fill runs into {p:?} ({:.2} from it)", keeps_off(&fill, p));
}
assert!((length(&fill) - length(&stroke)).abs() < 1e-3, "{why}: fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
}
+6 -1
View File
@@ -216,6 +216,11 @@ pub static MENU: &[MenuItem] = &[
m!("Text/Case", "Uppercase", "text.caseUpper"),
m!("Text/Case", "Lowercase", "text.caseLower"),
m!("Text/Case", "Title case", "text.caseTitle"),
// "Go to Text > Text direction. Select Left to right or Right to left." (Add right-to-left
// text); Auto, the way back to detection, is FigCraft's own (in `OWN`).
m!("Text/Text direction", "Auto", "text.directionAuto"),
m!("Text/Text direction", "Left to right", "text.directionLtr"),
m!("Text/Text direction", "Right to left", "text.directionRtl"),
m!("Text", SEP, ""),
m!("Text", "Missing fonts…", "text.missingFonts"),
// Arrange
@@ -292,7 +297,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"];
const OWN: [&str; 6] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop", "text.directionAuto"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+21 -6
View File
@@ -17,6 +17,7 @@ mod clipboard;
mod props;
mod select;
pub(crate) use clipboard::apply_copied_props;
pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into};
use clipboard::{copy, copy_props, copy_props_of, cut, paste, paste_design_layers, paste_over, paste_props, paste_replace};
use props::{
@@ -142,12 +143,26 @@ pub fn specs() -> Vec<CommandSpec> {
true,
find_replace
),
spec!("edit.setDefaultProperties", "Set default properties", "{id?}", false, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?;
let v = copy_props_of(s.doc()?, id);
s.clipboard.props = Some(v);
ok()
}),
spec!(
"edit.setDefaultProperties",
"Set default properties",
"{id?} the layer's fills, strokes, effects, opacity and corners (a text layer's: also its font) become what new rectangles, ellipses, polygons and stars (or, from a text layer, new text) drawn with the tools take → {for: shapes|text, properties}",
false,
|s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.setDefaultProperties", "select a layer"))?;
let doc = s.doc()?;
let text = matches!(doc.get(id)?.kind, NodeKind::Text(_));
let v = copy_props_of(doc, id);
let kind = if text {
s.default_props.text = Some(v.clone());
"text"
} else {
s.default_props.shape = Some(v.clone());
"shapes"
};
Ok(json!({"for": kind, "properties": v}))
}
),
spec!("select.set", "Select", "{ids: [..]} (replace the selection)", false, |s, p| {
let ids = match get(p, "ids") {
Some(Value::Array(a)) => a.iter().filter_map(id_of).collect(),
+16 -10
View File
@@ -357,21 +357,27 @@ pub(super) fn copy_props(s: &mut Session, p: &Value) -> Result<Value> {
Ok(v)
}
/// Give layer `id` the properties [`copy_props_of`] copied (`props`), those it can take: the text
/// properties only to text, the corners only to a layer with corners.
pub(crate) fn apply_copied_props(cmd: &str, doc: &mut Document, id: NodeId, props: &Value) -> Result<()> {
let mut m = props.as_object().cloned().unwrap_or_default();
let is_text = matches!(doc.get(id)?.kind, NodeKind::Text(_));
if !is_text {
m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing");
}
if !doc.get(id)?.kind.has_corner_radius() {
m.remove("cornerRadius");
m.remove("cornerSmoothing");
}
crate::cmd::props::apply(cmd, doc, id, &m)
}
pub(super) fn paste_props(s: &mut Session, p: &Value) -> Result<Value> {
let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?;
let ids = req_targets("edit.pasteProperties", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
let mut m = props.as_object().cloned().unwrap_or_default();
let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_));
if !is_text {
m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing");
}
if !doc.get(i)?.kind.has_corner_radius() {
m.remove("cornerRadius");
m.remove("cornerSmoothing");
}
crate::cmd::props::apply("edit.pasteProperties", doc, i, &m)?;
apply_copied_props("edit.pasteProperties", doc, i, &props)?;
}
ok()
}
+17 -1
View File
@@ -267,6 +267,18 @@ pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) ->
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
// A removal that names no paint among the rows shown changes nothing, the characters' own
// fills included; characters coloured differently show no rows at all ("Click + to
// replace mixed content").
if !add {
let shown = if fills { crate::cmd::lists::character_fills(n).map_or(0, |f| f.len()) } else { n.strokes.len() };
if index.unwrap_or(shown.saturating_sub(1)) >= shown {
continue;
}
}
if fills {
crate::cmd::lists::take_character_fills(n);
}
let frameish = n.kind.is_frame_like();
let list = if fills { &mut n.fills } else { &mut n.strokes };
if add {
@@ -298,6 +310,9 @@ pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value
let doc = s.doc_mut()?;
for i in ids {
let n = doc.get_mut(i)?;
if fills {
crate::cmd::lists::take_character_fills(n);
}
let list = if fills { &mut n.fills } else { &mut n.strokes };
let at = idx.unwrap_or(list.len().saturating_sub(1));
if list.is_empty() {
@@ -344,7 +359,8 @@ pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Valu
let (paint, stroke) = {
let doc = s.doc()?;
let n = doc.get(id)?;
let list = if fills { &n.fills } else { &n.strokes };
// A text's fills are what its characters show (the list the Design panel's rows are).
let list = if fills { crate::cmd::lists::character_fills(n).unwrap_or_default() } else { n.strokes.clone() };
let at = at.unwrap_or(list.len().saturating_sub(1));
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
(paint, (!fills).then(|| n.stroke.clone()))
+1
View File
@@ -15,6 +15,7 @@ use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_strin
use crate::{DocState, EngineError, Result, Session, bad, spec};
mod export;
mod import_ids;
mod load;
mod open;
mod save;
+254
View File
@@ -0,0 +1,254 @@
//! Ids a file being imported brings that the document already uses for something else.
//!
//! Style, variable and collection ids are numbered per file (`S:1`, `VariableID:1`,
//! `VariableCollectionId:1`; see [`figcraft_doc::ids`]), so two files made apart use the same ones
//! for unrelated things. Kept as they were, the imported layers would show the document's
//! variables and styles instead of their own.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::ids::MODE_ID_INFIX;
use figcraft_doc::proto::{Action, Expr, MAX_EXPR_DEPTH, walk_mut};
use figcraft_doc::{Document, ModeId, NodeId, NodeKind, VariableValue, Variables};
/// A style, variable or collection of the file being imported.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Asset<'a> {
Style(&'a str),
Variable(&'a str),
Collection(&'a str),
}
/// New ids for styles, variables and collections of a file being imported (its id → the new one).
#[derive(Debug, Default)]
pub(super) struct Renames {
styles: BTreeMap<String, String>,
variables: BTreeMap<String, String>,
collections: BTreeMap<String, String>,
}
impl Renames {
/// The new ids `other`'s styles, variables and collections take when appended to `doc`.
///
/// One whose id `doc` doesn't use keeps it. One `doc` already has is shared and keeps its id:
/// the same content under the same id, naming only things that are shared too (the same file
/// imported again). A collection and its variables are shared only all together, so variables
/// of one file never join a collection of another. Library copies keep their ids and are shared
/// with the document's copies, as before (`merge_store` records them). Every other id `doc`
/// already uses is replaced by a new one from `doc`'s allocators, clear of `other`'s ids too.
pub(super) fn new(doc: &mut Document, other: &Document) -> Renames {
let (theirs, libs) = (&other.variables, &other.libraries);
// Each with whether `doc` has the same under its id (None: nothing there), and whether
// it's a library copy.
let styles = other.styles.iter().map(|s| {
let here = doc.styles.iter().find(|m| m.id == s.id);
(Asset::Style(&s.id), here.map(|m| m == s), libs.style_import(&s.id).is_some())
});
let colls = theirs.collections.iter().map(|c| {
let here = doc.variables.collection_by_id(&c.id);
(Asset::Collection(&c.id), here.map(|m| m == c), libs.collection_import(&c.id).is_some())
});
let vars = theirs.variables.iter().map(|v| {
let here = doc.variables.get_by_id(&v.id);
(Asset::Variable(&v.id), here.map(|m| m == v), libs.variable_import(&v.id).is_some())
});
// `taken`: ids `doc` uses; `differs`: what `doc` doesn't have the same of.
let (mut taken, mut copies, mut differs) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new());
for (a, same, copy) in styles.chain(colls).chain(vars) {
match same {
None => {
differs.insert(a);
}
Some(same) => {
taken.insert(a);
if copy {
copies.insert(a);
} else if !same {
differs.insert(a);
}
}
}
}
// What differs makes what names it differ (library copies aside), and a collection and its
// variables differ together.
let mut named_by: BTreeMap<Asset, Vec<Asset>> = BTreeMap::new();
for v in &theirs.variables {
let (var, coll) = (Asset::Variable(&v.id), Asset::Collection(&v.collection));
named_by.entry(coll).or_default().push(var);
named_by.entry(var).or_default().push(coll);
for r in v.values.values().flat_map(VariableValue::references).chain(&v.extends) {
named_by.entry(Asset::Variable(r)).or_default().push(var);
}
}
for c in &theirs.collections {
if let Some(e) = &c.extends {
named_by.entry(Asset::Collection(e)).or_default().push(Asset::Collection(&c.id));
}
}
for s in &other.styles {
for v in s.bound.values() {
named_by.entry(Asset::Variable(v)).or_default().push(Asset::Style(&s.id));
}
}
let mut todo: Vec<Asset> = differs.iter().copied().collect();
while let Some(a) = todo.pop() {
for &n in named_by.get(&a).into_iter().flatten() {
if !copies.contains(&n) && differs.insert(n) {
todo.push(n);
}
}
}
let renamed = |a: Asset| taken.contains(&a) && differs.contains(&a);
Renames {
collections: fresh(
theirs.collections.iter().map(|c| &c.id),
|id| renamed(Asset::Collection(id)),
|avoid| doc.new_collection_id_avoiding(avoid),
),
variables: fresh(theirs.variables.iter().map(|v| &v.id), |id| renamed(Asset::Variable(id)), |avoid| doc.new_variable_id_avoiding(avoid)),
styles: fresh(other.styles.iter().map(|s| &s.id), |id| renamed(Asset::Style(id)), |avoid| doc.new_style_id_avoiding(avoid)),
}
}
pub(super) fn is_empty(&self) -> bool {
self.styles.is_empty() && self.variables.is_empty() && self.collections.is_empty()
}
/// `other` with the renamed styles, variables and collections under their new ids, and what
/// names them following: layers' style links (text ranges too), bindings, explicit modes and
/// prototype actions; styles' bindings; variables' collections, aliases and calculations; what
/// extended collections and their variables extend; and the ids of the renamed collections'
/// modes, which name their collection (`<collection>/mode<k>`).
pub(super) fn applied(&self, other: &Document) -> Document {
let theirs = &other.variables;
let mut doc = other.clone();
let (styles, vars, colls) = (&self.styles, &self.variables, &self.collections);
for s in &mut doc.styles {
rename(styles, &mut s.id);
s.bound.values_mut().for_each(|v| rename(vars, v));
}
for c in &mut doc.variables.collections {
for m in &mut c.modes {
self.mode(theirs, &c.id, &mut m.id);
}
for id in std::iter::once(&mut c.id).chain(&mut c.extends) {
rename(colls, id);
}
}
for v in &mut doc.variables.variables {
let values = std::mem::take(&mut v.values).into_iter().map(|(mut mode, mut val)| {
self.mode(theirs, &v.collection, &mut mode);
val.references_mut().into_iter().for_each(|r| rename(vars, r));
(mode, val)
});
v.values = values.collect();
let overrides = std::mem::take(&mut v.overrides).into_iter().map(|mut mode| {
self.mode(theirs, &v.collection, &mut mode);
mode
});
v.overrides = overrides.collect();
rename(colls, &mut v.collection);
for id in std::iter::once(&mut v.id).chain(&mut v.extends) {
rename(vars, id);
}
}
let ids: Vec<NodeId> = doc.ids().collect();
for id in ids {
let Some(n) = doc.node_mut(id) else { continue };
let links = [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text];
for s in links.into_iter().flatten() {
rename(styles, s);
}
if let NodeKind::Text(t) = &mut n.kind {
t.map_text_styles(|s| Some(styles.get(s).cloned().unwrap_or_else(|| s.to_string())));
}
n.bound.values_mut().for_each(|v| rename(vars, v));
n.var_modes = std::mem::take(&mut n.var_modes)
.into_iter()
.map(|(mut c, mut mode)| {
self.mode(theirs, &c, &mut mode);
rename(colls, &mut c);
(c, mode)
})
.collect();
for it in &mut n.interactions {
walk_mut(&mut it.actions, &mut |a| self.action(theirs, a));
}
}
doc
}
/// Rename mode `mode` of collection `of` (`theirs`: the imported file's variables as they were).
/// A mode id names the collection it was made in: `of`, or one `of` extends (an extended
/// collection has the modes of the one it extends). Library copies keep their library's mode
/// ids, which may name one of the file's own collections.
fn mode(&self, theirs: &Variables, of: &str, mode: &mut ModeId) {
let mut at = Some(of);
for _ in 0..=theirs.collections.len() {
let Some(c) = at else { return };
if let Some(k) = mode.strip_prefix(c).and_then(|rest| rest.strip_prefix(MODE_ID_INFIX)) {
if let Some(new) = self.collections.get(c) {
*mode = format!("{new}{MODE_ID_INFIX}{k}");
}
return;
}
at = theirs.collection_by_id(c).and_then(|c| c.extends.as_deref());
}
}
/// Rename what one prototype action names (not the actions nested in it).
fn action(&self, theirs: &Variables, a: &mut Action) {
match a {
Action::SetVariable { variable, expr, .. } => {
rename(&self.variables, variable);
if let Some(e) = expr {
self.expr(theirs, e, 0);
}
}
Action::SetVariableMode { collection, mode } => {
self.mode(theirs, collection, mode);
rename(&self.collections, collection);
}
Action::Conditional { blocks } => blocks.iter_mut().filter_map(|b| b.condition.as_mut()).for_each(|c| self.expr(theirs, c, 0)),
_ => {}
}
}
fn expr(&self, theirs: &Variables, e: &mut Expr, depth: usize) {
match e {
Expr::Value(v) => v.references_mut().into_iter().for_each(|r| rename(&self.variables, r)),
Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter_mut().for_each(|a| self.expr(theirs, a, depth + 1)),
Expr::Op { .. } => {}
Expr::Mode { collection } => rename(&self.collections, collection),
Expr::InMode { variable, mode } => {
if let Some(v) = theirs.get_by_id(variable) {
self.mode(theirs, &v.collection, mode);
}
rename(&self.variables, variable);
}
}
}
}
/// New ids, from `new_id`, for those of `ids` that `pick` picks; each clear of all of `ids`.
fn fresh<'a>(
ids: impl Iterator<Item = &'a String> + Clone,
pick: impl Fn(&str) -> bool,
mut new_id: impl FnMut(&BTreeSet<String>) -> String,
) -> BTreeMap<String, String> {
let mut avoid: BTreeSet<String> = ids.clone().cloned().collect();
let mut out = BTreeMap::new();
for id in ids.filter(|id| pick(id)) {
let new = new_id(&avoid);
avoid.insert(new.clone());
out.insert(id.clone(), new);
}
out
}
/// `id` as `map` renames it.
fn rename(map: &BTreeMap<String, String>, id: &mut String) {
if let Some(new) = map.get(id.as_str()) {
*id = new.clone();
}
}
+3 -3
View File
@@ -250,7 +250,7 @@ fn match_font_families(doc: &mut Document) {
}
// Shared text styles too, so they stay like the layers linked to them.
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
if let StyleKind::Text { style, .. } = &mut st.kind {
fix(style);
}
}
@@ -294,7 +294,7 @@ fn platform_tracking(doc: &mut Document) {
}
}
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
if let StyleKind::Text { style, .. } = &mut st.kind {
track(style);
}
}
@@ -365,7 +365,7 @@ fn sketch_line_heights(doc: &mut Document) {
}
}
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
if let StyleKind::Text { style, .. } = &mut st.kind {
set(style);
}
}
+12 -1
View File
@@ -1,3 +1,4 @@
use super::import_ids::Renames;
use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param};
use super::*;
@@ -218,8 +219,18 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to
/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers
/// their source and interactions and flows their targets wherever those are. Styles, variables and
/// collections whose id the document already has are kept as they are.
/// collections the document already has (the same under the same id, as when a file comes in
/// again) and library copies are shared; others whose id the document uses for something else get
/// new ids, and what comes in names them by those ([`Renames`]).
pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
let renames = Renames::new(doc, other);
let renamed;
let other = if renames.is_empty() {
other
} else {
renamed = renames.applied(other);
&renamed
};
for (k, v) in &other.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
}
+10 -3
View File
@@ -184,9 +184,10 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"image": image, "width": width, "height": height, "changed": changed}))
}
/// The index of a layer's top visible image fill (fills paint bottom to top).
/// The index of a layer's top visible image fill among the fills it shows (fills paint bottom to
/// top): a text's characters' shared list, so none while they are coloured differently.
pub fn top_image_fill(n: &Node) -> Option<usize> {
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
crate::cmd::lists::character_fills(n)?.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
}
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
@@ -197,11 +198,15 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) };
let doc = s.doc()?;
let n = doc.get(id)?;
// A text's fills are its characters' (the Design panel's rows): the crop edits that list, and
// characters coloured differently have no row to crop ("Click + to replace mixed content").
let shown =
crate::cmd::lists::character_fills(n).ok_or_else(|| refuse("the characters have different fills, so there is no one fill to crop"))?;
let index = match get(p, "index") {
None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?,
Some(_) => req_index(cmd, p, "index")?,
};
let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
let mut paint = shown.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) };
// An image already in Crop mode only enters crop mode: no edit, no undo step.
if img.scale_mode != ImageScaleMode::Crop {
@@ -209,6 +214,8 @@ fn crop(s: &mut Session, p: &Value) -> Result<Value> {
img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height));
img.scale_mode = ImageScaleMode::Crop;
let n = s.doc_mut()?.get_mut(id)?;
// The characters' shared list becomes the layer's, so the crop shows on every one of them.
crate::cmd::lists::take_character_fills(n);
if let Some(f) = n.fills.get_mut(index) {
*f = paint;
}
+24
View File
@@ -215,6 +215,30 @@ mod tests {
assert_eq!(x(&s, small).0, x(&s, big).0);
}
/// "for baseline alignment, the bottoms of the icon and the word home are aligned on the red
/// line" (Use the horizontal and vertical flows in auto layout, the figure's caption; #1825): a
/// layer without text sits on the text's baseline with its bottom edge, not at the top.
#[test]
fn baseline_alignment_puts_an_icons_bottom_on_the_text_baseline() {
let mut s = Session::new();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "props": {"layout": "horizontal", "gap": 8}})));
let icon = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 16, "height": 16, "parent": f})));
let home = id(&ex(&mut s, "text.create", json!({"text": "Home", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 48}})));
ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "baseline"}}));
let base = figcraft_text::layout_node(&node(&s, home)).and_then(|(l, dy)| l.lines.first().map(|line| line.baseline + dy));
// Only meaningful when the text was measured (fonts available) and its baseline is below
// the icon's height.
if let Some(b) = base
&& b > 16.0
{
let bottom = x(&s, icon).1 + 16.0;
assert!((bottom - (x(&s, home).1 + b)).abs() < 1e-6, "icon bottom {bottom}, text baseline {}", x(&s, home).1 + b);
}
// Top alignment puts it back at the top.
ex(&mut s, "node.set", json!({"id": f, "props": {"crossAlign": "min"}}));
assert_eq!(x(&s, icon).1, x(&s, home).1);
}
#[test]
fn grid_tracks_cells_and_undo() {
let mut s = Session::new();
+56 -2
View File
@@ -6,7 +6,7 @@
//! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top;
//! the Design panel shows them in reverse, the topmost row first.
use figcraft_doc::{Document, LayoutGrid, Node, NodeId};
use figcraft_doc::{Document, LayoutGrid, Node, NodeId, NodeKind};
use serde_json::Value;
use super::{CommandSpec, ok};
@@ -189,6 +189,52 @@ fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, fi
}
}
/// The fills a text's characters show when every character shows the same list: the layer's
/// fills, or the one list that styling all of them while editing left on them. None when the
/// characters differ, as the layer's fill is then mixed ("Text nodes can have multiple sets of
/// fills if some characters are colored differently than others", the developer documentation's
/// `fills`). Any other layer: its fills.
pub fn character_fills(n: &Node) -> Option<Vec<figcraft_color::Paint>> {
let NodeKind::Text(t) = &n.kind else { return Some(n.fills.clone()) };
let mut spans = t.spans().into_iter().map(|(_, _, st)| st.fills.as_ref().unwrap_or(&n.fills));
let first = spans.next().unwrap_or(&n.fills).clone();
spans.all(|f| *f == first).then_some(first)
}
/// Whether any character of a text carries fills of its own (on its base style or a run).
pub(crate) fn has_character_fills(n: &Node) -> bool {
match &n.kind {
NodeKind::Text(t) => t.style.fills.is_some() || t.runs.iter().any(|r| r.style.fills.is_some()),
_ => false,
}
}
/// Before a layer-level edit of a text's fills (the Fill section, `fill.*`, `node.set`): the
/// characters' own fills go, so the edit shows on every one of them. One list they all shared
/// becomes the layer's first, so the edit starts from what the characters showed; characters
/// that differed fall back to the layer's fills, which the edit then replaces ("Click + to
/// replace mixed content"). Not a text, or no character with fills of its own: nothing changes.
pub(crate) fn take_character_fills(n: &mut Node) {
if !has_character_fills(n) {
return;
}
if let Some(shared) = character_fills(n)
&& shared != n.fills
{
let old = std::mem::replace(&mut n.fills, shared);
for (i, p) in old.iter().enumerate() {
rebind_edited_paint(n, true, i, p);
}
}
if let NodeKind::Text(t) = &mut n.kind {
t.style.fills = None;
for r in &mut t.runs {
r.style.fills = None;
}
t.normalize();
}
}
/// Where item `k` lands when the item at `from` moves to `to`.
pub fn moved_index(k: usize, from: usize, to: usize) -> usize {
if k == from {
@@ -250,11 +296,19 @@ fn move_item(s: &mut Session, p: &Value, cmd: &str, list: BoundList) -> Result<V
let from = req_index(cmd, p, "from")?;
let to = req_index(cmd, p, "to")?;
for id in req_targets(cmd, s, p)? {
let len = s.doc()?.node(id).map(|n| list.len(n)).unwrap_or(0);
// A text's characters' shared fills are the list the Design panel shows and reorders.
let len = s
.doc()?
.node(id)
.map(|n| if list == BoundList::Fills { character_fills(n).map_or(n.fills.len(), |f| f.len()) } else { list.len(n) })
.unwrap_or(0);
if from >= len || to >= len || from == to {
continue;
}
let n = s.doc_mut()?.get_mut(id)?;
if list == BoundList::Fills {
take_character_fills(n);
}
let moved = match list {
BoundList::Fills => reorder(&mut n.fills, from, to),
BoundList::Strokes => reorder(&mut n.strokes, from, to),
+18 -1
View File
@@ -109,10 +109,18 @@ pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
Ok(Some(effect))
}
/// Most keys an effect object may carry (the widest effect takes about a dozen).
const MAX_EFFECT_KEYS: usize = 32;
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
let Some(fields) = given.as_object() else { return Ok(()) };
// Each key is probed by reparsing the whole object without it: bounded, or a long list of
// unknown keys costs its length squared before it is refused.
if fields.len() > MAX_EFFECT_KEYS {
return Err(bad(cmd, format!("{at}: more than {MAX_EFFECT_KEYS} properties")));
}
let parsed = parse(given);
let ignored: Vec<&str> = fields
.iter()
@@ -405,6 +413,9 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
if fills {
super::lists::take_character_fills(n);
}
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
for (i, p) in old.iter().enumerate() {
super::lists::rebind_edited_paint(n, fills, i, p);
@@ -983,6 +994,12 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8);
}
}
"textDirection" | "direction" => {
let d: figcraft_doc::TextDirection = enum_of(v).ok_or_else(|| bad(cmd, "textDirection: auto|ltr|rtl"))?;
let t = text_mut(cmd, doc.get_mut(id)?)?;
let (start, end) = range_of(props, t.char_count());
t.set_para_lists(start, end, |p| p.direction = d);
}
"listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0),
"hangingPunctuation" | "hangingList" => {
let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?;
@@ -1237,7 +1254,7 @@ pub fn override_group(k: &str) -> &'static str {
"name" => "name",
"characters" | "text" => "characters",
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle",
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" | "textDirection" | "direction" => "textStyle",
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
"width" | "height" | "w" | "h" => "size",
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
+44 -4
View File
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.settings",
"Prototype settings",
"{device? (a device name, \"Custom size\" or \"None\"), background?, rotation? portrait|landscape (or 0|90; phones and tablets turn), color? (one of the device's colours by name), width?, height? (the custom size's screen, 1–10000 points; picks \"Custom size\"), responsive? bool (frames resize to the screen and their constraints and auto layout respond)}",
"{device? (a device name, \"Custom size\" or \"None\"; any case; another name is refused with the list), background?, rotation? portrait|landscape (or 0|90; phones and tablets turn), color? (one of the device's colours by name), width?, height? (the custom size's screen, 1–10000 points; picks \"Custom size\"), responsive? bool (frames resize to the screen and their constraints and auto layout respond)} → the page's settings after the call (none given: reads them)",
true,
settings
),
@@ -173,12 +173,51 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// `proto.settings`: everything is checked before anything changes.
/// The device setting `name` stands for, as the device picker names it: "None", "Custom size" or a
/// model of [`PrototypeSettings::DEVICES`], whatever its case and the spaces around it.
fn known_device(name: &str) -> Option<&'static str> {
let name = name.trim();
["None", PrototypeSettings::CUSTOM_DEVICE]
.into_iter()
.chain(PrototypeSettings::DEVICES.iter().map(|d| d.name))
.find(|d| d.eq_ignore_ascii_case(name))
}
/// What `proto.settings` answers: the page's prototype settings (the device as the picker names it,
/// the colour shown, the screen as presented, `null` without a device).
fn settings_json(ps: &PrototypeSettings) -> Value {
let screen = ps.device().map(|d| json!({"width": d.width, "height": d.height}));
json!({
"ok": true,
"device": ps.device,
"background": format!("#{}", ps.background.to_hex()),
"rotation": ps.rotation,
"color": ps.device_color().map(|c| c.name),
"screen": screen,
"customSize": {"width": ps.custom_size[0], "height": ps.custom_size[1]},
"responsive": ps.responsive,
})
}
/// `proto.settings`: everything is checked before anything changes; an unknown device is refused
/// with the devices to pick from, as an unknown colour is. Answers the settings as they are after
/// the call, so a call without parameters reads them.
fn settings(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "proto.settings";
let page = s.page()?;
let bg = color(p, "background");
let dev = string(p, "device");
let dev = match p.get("device") {
None | Some(Value::Null) => None,
Some(v) => {
let name = v.as_str().unwrap_or_default();
let found = known_device(name).ok_or_else(|| {
let names: Vec<&str> =
["None", PrototypeSettings::CUSTOM_DEVICE].into_iter().chain(PrototypeSettings::DEVICES.iter().map(|d| d.name)).collect();
bad(cmd, format!("unknown device {}: device is one of {}", figcraft_format::clip(&v.to_string(), 60), names.join("|")))
})?;
Some(found.to_string())
}
};
let rot = match p.get("rotation") {
None | Some(Value::Null) => None,
Some(v) => Some(rotation_of(v).ok_or_else(|| bad(cmd, "rotation: portrait|landscape"))?),
@@ -231,12 +270,13 @@ fn settings(s: &mut Session, p: &Value) -> Result<Value> {
None if !colors.iter().any(|x| x.name == next.color) => next.color.clear(),
None => {}
}
let answer = settings_json(&next);
if next != *prototype
&& let NodeKind::Page { prototype, .. } = &mut s.doc_mut()?.get_mut(page)?.kind
{
*prototype = next;
}
ok()
Ok(answer)
}
/// Resize top-level `frame` to a screen of `(w, h)` as a responsive prototype does: its children
+9 -4
View File
@@ -777,8 +777,11 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
for i in req_targets("vector.simplify", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
let smoothing = n.corners.smoothing;
if let NodeKind::Vector { network } = &mut n.kind {
let source = network.clone();
// The regions as the layer draws them: rounded points with the layer's corner smoothing.
let drawn = figcraft_doc::geometry::drawn_network(&source, smoothing);
let mut net = VectorNetwork::default();
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
if legacy && !source.regions.is_empty() {
@@ -787,7 +790,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
} else {
WindingRule::NonZero
};
let path = source.fill_path();
let path = drawn.fill_path();
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
Ok(path) => (path, WindingRule::EvenOdd),
@@ -797,7 +800,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
append_region_path(&mut net, &path, &combined, output_winding);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let Some(path) = drawn.region_path(index) else { continue };
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
append_region_path(&mut net, &path, region, region.winding);
@@ -885,8 +888,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
nm.height = bb.height();
continue;
}
// The regions as the layer draws them: rounded points with the layer's corner smoothing.
let drawn = figcraft_doc::geometry::drawn_network(source, n.corners.smoothing);
if legacy {
let path = source.fill_path();
let path = drawn.fill_path();
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
@@ -906,7 +911,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
append_network(&mut net, part);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let Some(path) = drawn.region_path(index) else { continue };
let mut paint_region = region.clone();
let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
+85 -14
View File
@@ -3,6 +3,7 @@
use std::collections::BTreeMap;
use figcraft_color::{Color, Paint, PaintKind};
use figcraft_doc::ParagraphStyle;
use figcraft_doc::{
Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle,
Variable, VariableId, VariableType, VariableValue, Variables, trim_num,
@@ -261,6 +262,41 @@ fn text_style_of(n: &Node, at: Option<usize>) -> Option<TextStyle> {
Some(at.map_or(&t.style, |a| t.style_at(a)).as_text_style())
}
/// What a text style definition from JSON sets at the paragraph level, in the `node.set`
/// spellings (`paragraphSpacing`, `paragraphIndent`, `listStyle`: none|bulleted|numbered,
/// `features` or `openTypeFeatures`: `{tag: bool}`); keys left out are the defaults.
fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
let Value::Object(m) = v else { return Ok(ParagraphStyle::default()) };
let mut ps = ParagraphStyle::default();
let length =
|k: &str, x: &Value| f64_of(x).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 10_000.0)).ok_or_else(|| bad(cmd, format!("{k}: a number")));
for (k, x) in m {
match k.as_str() {
"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"))?,
"features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => {
ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect();
}
_ => {}
}
}
Ok(ps)
}
/// What a layer's text sets at the paragraph level, the list style being that of the paragraphs
/// chars `range` touch when given (the layer's when they differ).
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;
}
Some(ps)
}
fn detach(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "style.detach";
let which = req_string(cmd, p, "which")?;
@@ -295,7 +331,7 @@ fn kind_from_node(t: &str, n: &Node) -> Option<StyleKind> {
Some(match t {
"paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() },
"stroke" => StyleKind::Paint { paints: n.strokes.clone() },
"text" => StyleKind::Text { style: text_style_of(n, None)? },
"text" => StyleKind::Text { style: text_style_of(n, None)?, paragraph: Box::new(paragraph_style_of(n, None)?) },
"effect" => StyleKind::Effect { effects: n.effects.clone() },
"grid" => StyleKind::Grid { grids: n.grids.clone() },
_ => return None,
@@ -317,13 +353,17 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
let kind = if let Some(v) = get(p, "value") {
match t.as_str() {
"paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
"grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => return Err(bad(cmd, "type: paint|text|effect|grid")),
}
} else if let Some((tid, a, _)) = range {
StyleKind::Text { style: doc.node(tid).and_then(|n| text_style_of(n, Some(a))).ok_or_else(|| bad(cmd, "not a text layer"))? }
} else if let Some((tid, a, b)) = range {
let n = doc.node(tid).ok_or_else(|| bad(cmd, "not a text layer"))?;
StyleKind::Text {
style: text_style_of(n, Some(a)).ok_or_else(|| bad(cmd, "not a text layer"))?,
paragraph: Box::new(paragraph_style_of(n, Some((a, b))).ok_or_else(|| bad(cmd, "not a text layer"))?),
}
} else {
let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?;
kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))?
@@ -343,7 +383,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
};
doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone(), bound });
// The source layer (or the selected chars) becomes linked to the new style.
if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) {
if let (Some((tid, a, b)), StyleKind::Text { style, .. }) = (range, &kind) {
if let NodeKind::Text(td) = &mut doc.get_mut(tid)?.kind {
td.link_text_style(a, b, style, &sid);
}
@@ -387,11 +427,13 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option<
n.grids = grids.clone();
n.styles.grid = Some(id.clone());
}
("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => {
// Every char takes the style's typography (their fills and links stay).
("textStyle", Some(Style { kind: StyleKind::Text { style, paragraph }, id, .. })) => {
// Every char takes the style's typography (their fills and links stay), and the layer
// what the style sets at the paragraph level (#1829).
if let NodeKind::Text(t) = &mut n.kind {
let len = t.char_count();
t.link_text_style(0, len, style, id);
t.take_paragraph_style(paragraph);
}
n.styles.text = Some(id.clone());
}
@@ -416,7 +458,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let st = req_string(cmd, p, "style")?;
let style = s.doc()?.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?;
// Text styles go on chars: a range, the selected chars of the edited text, or whole layers.
if let StyleKind::Text { style: def } = &style.kind {
if let StyleKind::Text { style: def, paragraph } = &style.kind {
// A text style is no property of other layers: they're left alone.
let doc = s.doc()?;
let targets: Vec<TextTarget> =
@@ -430,6 +472,8 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
Some((a, b)) => {
if let NodeKind::Text(t) = &mut doc.get_mut(i)?.kind {
t.link_text_style(a, b, def, &style.id);
// The list goes to the paragraphs touched; the rest is the layer's.
t.take_paragraph_style_in(a, b, paragraph);
}
}
None => apply_style_ref(doc, i, "textStyle", Some(&style.id))?,
@@ -506,7 +550,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
Some(match cur {
"paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
})
@@ -651,22 +695,23 @@ fn rename_folder(s: &mut Session, p: &Value) -> Result<Value> {
/// Re-apply style `sid` to every node (and text chars) linked to it.
pub fn propagate_style(doc: &mut Document, sid: &str) {
let text_def = match doc.style(sid).map(|s| &s.kind) {
Some(StyleKind::Text { style }) => Some(style.clone()),
Some(StyleKind::Text { style, paragraph }) => Some((style.clone(), paragraph.clone())),
_ => None,
};
let ids: Vec<NodeId> = doc.ids().collect();
for i in ids {
let Some(n) = doc.node(i) else { continue };
if let Some(def) = &text_def {
if let Some((def, paragraph)) = &text_def {
if n.kind.text().is_some_and(|t| t.links_text_style(sid)) {
let def = match doc.style(sid).map(|st| crate::style_vars::kind_for(doc, st, i).into_owned()) {
Some(StyleKind::Text { style }) => style,
Some(StyleKind::Text { style, .. }) => style,
_ => def.clone(),
};
if let Ok(n) = doc.get_mut(i)
&& let NodeKind::Text(t) = &mut n.kind
{
t.update_text_style(sid, &def);
t.update_paragraph_style(sid, paragraph);
}
}
continue;
@@ -695,7 +740,16 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())),
_ => None,
})
.collect();
// What each text style sets at the paragraph level (#1829); no variable binds those.
let paras: BTreeMap<&str, &ParagraphStyle> = doc
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Text { paragraph, .. } => Some((s.id.as_str(), paragraph.as_ref())),
_ => None,
})
.collect();
@@ -707,7 +761,7 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
// Styles with bound fields read as this layer sees them.
let mut defs = std::borrow::Cow::Borrowed(&defs);
for st in &varying {
if let StyleKind::Text { style } = crate::style_vars::kind_for(doc, st, id).into_owned() {
if let StyleKind::Text { style, .. } = crate::style_vars::kind_for(doc, st, id).into_owned() {
defs.to_mut().insert(st.id.clone(), style);
}
}
@@ -717,11 +771,28 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
&& t2.text_style_link() == Some(None)
&& let Some(def) = defs.get(sid)
&& t2.spans().iter().all(|(_, _, s)| s.shows_text_style(def))
&& paras.get(sid.as_str()).is_some_and(|ps| t2.shows_paragraph_style(ps))
{
let len = t2.char_count();
t2.restyle(0, len, |s| s.style_id = Some(sid.clone()));
}
t2.check_text_styles(|sid| defs.get(sid));
// Chars whose paragraphs left their style's list, and every char of a layer whose
// paragraph spacing, indent or features were edited, unlink too (#1829).
let gone: Vec<(usize, usize)> = t2
.spans()
.iter()
.filter(|(a, b, s)| {
// A style with no paragraph values (every style in older files) asks nothing of them.
s.style_id
.as_deref()
.is_some_and(|sid| paras.get(sid).is_none_or(|ps| **ps != ParagraphStyle::default() && !t2.shows_paragraph_style_in(*a, *b, ps)))
})
.map(|(a, b, _)| (*a, *b))
.collect();
for (a, b) in gone {
t2.unlink_text_style(a, b);
}
let link = t2.text_style_link().flatten().map(str::to_string);
if t2 != *t || link != n.styles.text {
updates.push((id, link, t2));
+159 -5
View File
@@ -1,8 +1,8 @@
//! Text menu and text editing commands.
use figcraft_doc::{
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle,
VariableId,
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextPath,
TextStyle, VariableId,
};
use serde_json::{Map, Value, json};
@@ -91,6 +91,13 @@ fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result<Va
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.list = l))
}
/// The base direction of the targeted paragraphs: the edited ones while editing, else every
/// paragraph of the targeted texts ("controls are available to override text direction per
/// paragraph", Add right-to-left text, #1830).
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))
}
/// 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 };
@@ -122,11 +129,138 @@ fn toggle_italic(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Hiragino's weight names: "W3" is 300.
fn w_number(word: &str) -> Option<u16> {
let n = word.strip_prefix('w')?.parse::<u16>().ok()?;
(1..=9).contains(&n).then_some(n * 100)
}
/// A weight number as some families name styles ("Condensed 600"). Below 100 it is something else
/// (a width grade, an optical size: "Condensed 2", "Caption 12").
fn number_weight(word: &str) -> Option<u16> {
let n = word.parse::<u16>().ok().filter(|_| word.bytes().all(|b| b.is_ascii_digit()))?;
(100..=1000).contains(&n).then_some(n)
}
/// Style-name words that give a weight or a slant.
const WEIGHT_WORDS: [&str; 25] = [
"thin",
"hairline",
"extralight",
"ultralight",
"light",
"semilight",
"demilight",
"regular",
"normal",
"roman",
"book",
"medium",
"semibold",
"demibold",
"bold",
"extrabold",
"ultrabold",
"black",
"extrablack",
"ultrablack",
"heavy",
"italic",
"oblique",
"plain",
"standard",
];
/// Words that grade the weight word after them ("Extra Bold") but a width otherwise ("Extra Condensed").
const GRADES: [&str; 4] = ["extra", "ultra", "semi", "demi"];
/// The width a Univers number leading a style name gives, as Univers, Frutiger and Helvetica Neue
/// LT name styles ("45 Light", "55 Roman", "57 Condensed", "67 Bold Condensed"): the first digit
/// grades the weight, the second the width (3 or 4 extended, 5 or 6 normal, 7 or 8 condensed, 9
/// ultra condensed; even is italic, which the name says too). Only before weight, slant and width
/// words, so another leading number ("12 Regular", "55 Rounded") stays part of the width.
fn univers_width(words: &[&str]) -> Option<u8> {
const WIDTHS: [&str; 6] = ["condensed", "compressed", "narrow", "extended", "expanded", "wide"];
let (code, rest) = words.split_first()?;
let &[b'1'..=b'9', d @ b'3'..=b'9'] = code.as_bytes() else { return None };
let known = |w: &&str| WEIGHT_WORDS.contains(w) || GRADES.contains(w) || WIDTHS.contains(w);
(!rest.is_empty() && rest.iter().all(known)).then_some((d - b'1') / 2)
}
/// What a style name says besides its weight and slant ("Narrow Bold Italic" → "narrow",
/// "Extra Condensed Bold" → "extra condensed", "67 Bold Condensed" → its Univers width and "condensed").
fn style_rest(style: &str) -> String {
let s = style.to_ascii_lowercase().replace('-', " ");
let words: Vec<&str> = s.split_whitespace().collect();
let weight = |w: &str| WEIGHT_WORDS.contains(&w) || w_number(w).or_else(|| number_weight(w)).is_some();
let rest = words.iter().enumerate().filter(|(i, w)| {
let graded = GRADES.contains(w) && words.get(i + 1).is_some_and(|next| weight(next) && !matches!(*next, "italic" | "oblique"));
!weight(w) && !graded
});
let univers = univers_width(&words).map(|d| format!("univers width {d}"));
// The Univers number (the first word kept) gives way to its width.
let rest = rest.map(|(_, w)| *w).skip(usize::from(univers.is_some()));
univers.as_deref().into_iter().chain(rest).collect::<Vec<_>>().join(" ")
}
/// A style name that slants: italic, or oblique as some families name it.
fn slanted(style: &str) -> bool {
let s = style.to_ascii_lowercase();
s.contains("italic") || s.contains("oblique")
}
/// The weight a style name says ("W3", "W6 Italic", "Condensed W7", "Condensed 600" and "45 Light" too).
pub(crate) fn name_weight(style: &str) -> u16 {
let s = style.to_ascii_lowercase().replace('-', " ");
let words: Vec<&str> = s.split_whitespace().collect();
// A Univers number's weight digit has no fixed CSS weight ("65" is Medium in Helvetica Neue,
// Bold in Univers): the words after it say it.
if let Some((_, rest)) = words.split_first().filter(|_| univers_width(&words).is_some()) {
return figcraft_doc::style_weight(&rest.join(" "));
}
words.iter().find_map(|w| w_number(w).or_else(|| number_weight(w))).unwrap_or_else(|| figcraft_doc::style_weight(style))
}
/// The next heavier (`up`) or lighter style of `styles` (a family's with the weight each is listed
/// by, lightest first, as the style menu lists them) after the chars' style, with the same slant
/// and width. The chars' style is found in the list by name and its neighbour taken; a style the
/// family doesn't have, or a custom weight axis value, goes to the listed style of nearest weight
/// that way. None when the family has nothing further.
pub(crate) fn next_style(styles: &[(String, u16)], st: &TextStyle, up: bool) -> Option<String> {
let italic = st.italic() || slanted(&st.font_style);
let rest = style_rest(&st.font_style);
let mut same: Vec<&(String, u16)> = styles.iter().filter(|(x, _)| slanted(x) == italic && style_rest(x) == rest).collect();
let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], "");
let here = same.iter().position(|(x, _)| norm(x) == norm(&st.font_style));
let axis = st.variations.contains_key("wght");
if let Some(i) = here.filter(|_| !axis) {
let next = if up { i.checked_add(1) } else { i.checked_sub(1) };
return next.and_then(|n| same.get(n)).map(|(x, _)| x.clone());
}
let w = i32::from(if axis { st.weight() } else { name_weight(&st.font_style) });
// Of equal weights, the one nearest in the family's order: the first going up, the last going down.
if !up {
same.reverse();
}
same.into_iter()
.map(|(x, xw)| (i32::from(*xw) - w, x))
.filter(|(d, _)| if up { *d > 0 } else { *d < 0 })
.min_by_key(|(d, _)| d.abs())
.map(|(_, x)| x.clone())
}
/// `text.weightUp` / `text.weightDown`: each targeted run moves to the next style of its family.
fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
style(s, p, |st| {
let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16;
let italic = st.italic();
st.set_font_style(styled_name(weight_name(w), italic));
let styles = figcraft_text::family_style_weights(&st.font_family);
if styles.is_empty() {
// A family that isn't installed: step the weight by 100 under its canonical name.
let w = (i32::from(st.weight()) + if up { 100 } else { -100 }).clamp(100, 900) as u16;
let italic = st.italic();
st.set_font_style(styled_name(weight_name(w), italic));
} else if let Some(next) = next_style(&styles, st, up) {
st.set_font_style(next);
}
})
}
@@ -319,6 +453,26 @@ pub fn specs() -> Vec<CommandSpec> {
para(s, p, |t| t.wrap_style = w)
}
),
spec!(
"text.direction",
"Text direction",
"{direction: auto|ltr|rtl, ids?} the base direction of the paragraphs (while editing: the selected ones): auto goes by the first letter with a direction, ltr runs them left to right and rtl right to left whatever their letters (Text ▸ Text direction)",
true,
|s, p| {
let d: TextDirection =
get(p, "direction").and_then(super::props::enum_of).ok_or_else(|| bad("text.direction", "`direction`: auto|ltr|rtl"))?;
set_direction(s, p, d)
}
),
spec!("text.directionAuto", "Text direction: auto", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Auto)
}),
spec!("text.directionLtr", "Text direction: left to right", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Ltr)
}),
spec!("text.directionRtl", "Text direction: right to left", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Rtl)
}),
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_indent = v)
+36
View File
@@ -539,6 +539,42 @@ pub fn specs() -> Vec<CommandSpec> {
tool(s, t)
}
),
spec!(
"tool.crosshair",
"Move the placement crosshair",
"{dx?, dy? (arrow-key steps, e.g. 1 or -1), big? (the large nudge, like Shift)} — the arrows with a shape or frame tool chosen and nothing selected: the first shows the crosshair in the middle of the view, the next move it by the nudge amount; Enter (`tool.place`) puts the object there",
false,
|s, p| {
if !s.tool.tool.draws_shape() {
return Err(EngineError::Disabled("tool.crosshair".into(), "choose a shape or frame tool first".into()));
}
let step = if boolean(p, "big").unwrap_or(false) { s.prefs.nudge_big } else { s.prefs.nudge_small };
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 (uid, page, centre, half) = {
let ds = s.ds()?;
let z = ds.view.zoom.max(1e-6);
(ds.uid, ds.page, figcraft_geom::Point::new(ds.view.cx, ds.view.cy), (ds.view.vw / z / 2.0, ds.view.vh / z / 2.0))
};
// "It will appear in the middle of the viewport": the first arrow shows it there (a
// crosshair left on another page or document starts over).
let at = match s.crosshair() {
Some(c) => figcraft_geom::Point::new(c.x + dx, c.y + dy),
None => centre,
};
s.tool.place_at = Some(at);
s.tool.place_on = Some((uid, page));
if let Ok(ds) = s.ds_mut() {
// "Move the crosshair around the canvas": nudged past an edge of the view, the
// view pans by the overshoot to keep it in sight.
let v = &mut ds.view;
v.cx += (at.x - (v.cx + half.0)).max(0.0) + (at.x - (v.cx - half.0)).min(0.0);
v.cy += (at.y - (v.cy + half.1)).max(0.0) + (at.y - (v.cy - half.1)).min(0.0);
ds.revision += 1;
}
Ok(json!({"x": at.x, "y": at.y}))
}
),
spec!(
"tool.place",
"Place with the tool",
+3
View File
@@ -260,6 +260,9 @@ fn vue_links_are_a_default_import_with_quoted_bindings() {
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\""}}));
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card title=\"{title} {title}\" />"}));
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title=\"Say &quot;hi&quot; Say &quot;hi&quot;\" />\n");
// Literal text before the first placeholder keeps it inside the attribute value.
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card title=\"Say: {title}\" />"}));
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title=\"Say: Say &quot;hi&quot;\" />\n");
// Text after a completed tag is text, even when it contains `=`.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Buy"}}));
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<p>Total = {title}</p>"}));
+2 -2
View File
@@ -154,7 +154,7 @@ fn auto_layout(doc: &mut Document, id: NodeId, mode: LayoutMode, gap: f64, pad:
fn styled_text(doc: &mut Document, parent: NodeId, chars: &str, name: &str, style: &str) -> DocResult<NodeId> {
let id = doc.create(parent, text(chars), name, 0.0, 0.0, 60.0, 16.0)?;
let def = doc.styles.iter().find(|s| s.id == style).and_then(|s| match &s.kind {
StyleKind::Text { style } => Some(style.clone()),
StyleKind::Text { style, .. } => Some(style.clone()),
_ => None,
});
set_node(doc, id, |n| {
@@ -242,7 +242,7 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) {
id: format!("ds:text:{name}"),
name: name.into(),
description: String::new(),
kind: StyleKind::Text { style },
kind: StyleKind::Text { style, paragraph: Default::default() },
bound: Default::default(),
});
}
+117
View File
@@ -0,0 +1,117 @@
//! Edit ▸ Set default properties (#2028): new rectangles, ellipses, polygons and stars drawn with
//! the tools take the properties of the layer it was set from, and new text takes a text layer's;
//! it no longer only copied them for Paste properties.
use figcraft_color::Color;
use figcraft_doc::{Node, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind, ToolId};
/// Run `cmd`, failing the test on an error.
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// The layer `id` as it is now.
fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().node(id).unwrap().clone()
}
/// Draw with `tool` from `a` to `b` (a click when they're equal); the new layer.
fn draw(s: &mut Session, tool: ToolId, a: (f64, f64), b: (f64, f64)) -> NodeId {
s.set_tool(tool);
s.pointer(&PointerEvent::at(PointerKind::Down, a.0, a.1, 1.0)).unwrap();
if a != b {
s.pointer(&PointerEvent::at(PointerKind::Move, (a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, b.0, b.1, 1.0)).unwrap();
}
s.pointer(&PointerEvent::at(PointerKind::Up, b.0, b.1, 1.0)).unwrap();
*s.selection().first().unwrap()
}
/// The first solid fill's colour of layer `n`.
fn fill(n: &Node) -> Option<Color> {
n.fills.first().and_then(|p| p.solid_color())
}
/// A red, half-transparent rectangle with a 4 pt blue stroke and 12 pt corners.
fn styled(s: &mut Session) -> NodeId {
let id = id_of(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
ex(s, "node.set", json!({"id": id, "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 4, "opacity": 0.5, "cornerRadius": 12}}));
id
}
/// Rectangles, ellipses, polygons and stars drawn or clicked with their tools take the shape
/// defaults (corners only where a shape has them, its own drawn size); frames, lines and layers made
/// by commands keep their own look; undo takes a drawn shape away whole.
#[test]
fn new_shapes_drawn_with_the_tools_take_the_default_properties() {
let mut s = Session::new();
let source = styled(&mut s);
let set = ex(&mut s, "edit.setDefaultProperties", json!({"id": source}));
assert_eq!(set["for"], "shapes");
let red = Some(Color::from_hex("#FF0000").unwrap());
// Dragged and clicked (a click places a 100×100 shape) alike.
for (tool, a, b) in
[(ToolId::Rectangle, (200.0, 0.0), (300.0, 80.0)), (ToolId::Ellipse, (400.0, 0.0), (400.0, 0.0)), (ToolId::Star, (600.0, 0.0), (660.0, 60.0))]
{
let id = draw(&mut s, tool, a, b);
let n = node(&s, id);
assert_eq!(fill(&n), red, "{tool:?}");
assert_eq!((n.stroke.weight, n.opacity), (4.0, 0.5), "{tool:?}");
assert_eq!(n.strokes.first().and_then(|p| p.solid_color()), Some(Color::from_hex("#0000FF").unwrap()), "{tool:?}");
}
let rect = draw(&mut s, ToolId::Rectangle, (0.0, 200.0), (80.0, 260.0));
assert_eq!(node(&s, rect).corners.radii, [12.0; 4], "corners go to layers with corners");
assert_eq!((node(&s, rect).width, node(&s, rect).height), (80.0, 60.0), "the drawn size, not the source's");
// Frames and lines keep their own look; a shape made by a command too.
let frame = draw(&mut s, ToolId::Frame, (0.0, 400.0), (100.0, 500.0));
assert_ne!(fill(&node(&s, frame)), red);
let line = draw(&mut s, ToolId::Line, (0.0, 600.0), (100.0, 600.0));
assert_ne!(node(&s, line).stroke.weight, 4.0);
let made = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 700, "width": 10, "height": 10}))["id"]).unwrap();
assert_ne!(fill(&node(&s, made)), red);
// Undo takes a drawn shape away whole, defaults included.
let drawn = draw(&mut s, ToolId::Polygon, (800.0, 0.0), (860.0, 60.0));
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(drawn).is_none());
}
/// Setting defaults no longer overwrites what Copy properties copied.
#[test]
fn setting_defaults_leaves_the_copied_properties_alone() {
let mut s = Session::new();
let copied = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": copied, "props": {"fill": "#00FF00"}}));
ex(&mut s, "edit.copyProperties", json!({"id": copied}));
let source = styled(&mut s);
ex(&mut s, "edit.setDefaultProperties", json!({"id": source}));
let target = id_of(&ex(&mut s, "shape.ellipse", json!({"x": 0, "y": 100, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "edit.pasteProperties", json!({"ids": [target]}));
assert_eq!(fill(&node(&s, target)), Some(Color::from_hex("#00FF00").unwrap()), "Paste properties pastes what was copied");
}
/// A text layer sets the defaults of new text (its font and fills, laid out at that size), and the
/// shapes' defaults stay as they were.
#[test]
fn new_text_takes_a_text_layers_defaults_and_shapes_keep_theirs() {
let mut s = Session::new();
let shape = styled(&mut s);
ex(&mut s, "edit.setDefaultProperties", json!({"id": shape}));
let text = id_of(&ex(&mut s, "text.create", json!({"text": "Title", "x": 0, "y": 200}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": text, "props": {"fontSize": 32, "fill": "#008000"}}));
let set = ex(&mut s, "edit.setDefaultProperties", json!({"id": text}));
assert_eq!(set["for"], "text");
let id = draw(&mut s, ToolId::Text, (300.0, 300.0), (300.0, 300.0));
let n = node(&s, id);
let NodeKind::Text(t) = &n.kind else { panic!("a text layer") };
assert_eq!(t.style.font_size, 32.0);
assert_eq!(fill(&n), Some(Color::from_hex("#008000").unwrap()));
assert!(n.height > 30.0, "laid out at the default size: {}", n.height);
// The shapes' defaults are still the rectangle's.
let rect = draw(&mut s, ToolId::Rectangle, (0.0, 500.0), (80.0, 560.0));
assert_eq!(fill(&node(&s, rect)), Some(Color::from_hex("#FF0000").unwrap()));
}
+16 -12
View File
@@ -288,41 +288,45 @@ fn pieces(t: &str) -> Vec<Piece<'_>> {
checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)])
}
/// Whether `s` ends inside an unclosed opening tag (no `>` outside quotes since the last `<`).
fn inside_open_tag(s: &str) -> bool {
/// Whether `s` ends inside an unclosed opening tag (no `>` outside quotes since the last `<`), and
/// the quote (with its byte offset) still open there.
fn tag_state(s: &str) -> (bool, Option<(char, usize)>) {
let mut in_tag = false;
let mut quote = None;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
let mut chars = s.char_indices().peekable();
while let Some((i, c)) = chars.next() {
match quote {
Some(q) => {
Some((q, _)) => {
if c == q {
quote = None;
}
}
None => match c {
'<' if !in_tag && chars.peek().is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(*ch, '/' | '!' | '?')) => {
'<' if !in_tag && chars.peek().is_some_and(|(_, ch)| ch.is_ascii_alphabetic() || matches!(*ch, '/' | '!' | '?')) => {
in_tag = true;
}
'>' if in_tag => in_tag = false,
'"' | '\'' if in_tag => quote = Some(c),
'"' | '\'' if in_tag => quote = Some((c, i)),
_ => {}
},
}
}
in_tag
(in_tag, quote)
}
/// Where a placeholder sits in a Vue template: whether the attribute is bound, and the quote the
/// template already opened (if any). `None` when it isn't in an attribute at all.
fn vue_attribute(out: &str) -> Option<(bool, Option<char>)> {
let end = out.trim_end();
if !inside_open_tag(end) {
let (in_tag, open) = tag_state(out);
if !in_tag {
return None;
}
let (after, quote) = match end.chars().last() {
Some(q @ ('"' | '\'')) => (&end[..end.len() - q.len_utf8()], Some(q)),
_ => (end, None),
// A quote still open, with literal text after it (`title="Buy {x}"`), puts the placeholder in
// that attribute's value as much as one directly after the quote does.
let (after, quote) = match open {
Some((q, at)) => (&out[..at], Some(q)),
None => (end, None),
};
let after = after.trim_end();
let eq = after.rfind('=')?;
+13
View File
@@ -307,6 +307,19 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
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));
match dir {
Some(figcraft_doc::TextDirection::Ltr) => put("direction", "ltr".into()),
Some(figcraft_doc::TextDirection::Rtl) => {
put("direction", "rtl".into());
// Left alignment is absolute on the canvas; `start` would be the right edge here.
if tx.align_h == TextAlignH::Left {
put("text-align", "left".into());
}
}
_ => {}
}
match s.case {
TextCase::Original => {}
TextCase::Upper => put("text-transform", "uppercase".into()),
+34
View File
@@ -46,3 +46,37 @@ fn a_plugin_gets_names_but_no_paths() {
assert!(out.contains("Front"), "{out}");
assert!(!out.contains(SECRET) && !out.contains("path"), "{out}");
}
#[test]
fn a_plugin_gets_library_names_but_not_their_links() {
const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret";
let mut s = Session::new();
s.doc_mut().unwrap().libraries_mut().enabled.push(figcraft_doc::LibraryLink {
id: "lib".into(),
name: "Kit".into(),
path: LINK.into(),
version: 3,
});
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
install(&mut s, manifest("org.test.libs", "read", &[]), &script(&[("library.list", json!({}))], OnRefusal::Fail, tail));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.libs"}))["output"].to_string();
assert!(out.contains("Kit"), "{out}");
assert!(!out.contains("per-file-secret") && !out.contains("\\\"path\\\""), "{out}");
}
#[test]
fn a_plugin_gets_a_fixed_message_for_library_update_failures() {
const LINK: &str = "/home/someone/private/kits/kit.figcraft";
let mut s = Session::new();
s.doc_mut().unwrap().libraries_mut().enabled.push(figcraft_doc::LibraryLink {
id: "lib".into(),
name: "Kit".into(),
path: LINK.into(),
version: 3,
});
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
install(&mut s, manifest("org.test.upd", "read", &[]), &script(&[("library.updates", json!({}))], OnRefusal::Fail, tail));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.upd"}))["output"].to_string();
assert!(out.contains("could not be read"), "{out}");
assert!(!out.contains("someone") && !out.contains("private"), "{out}");
}
+22
View File
@@ -85,3 +85,25 @@ fn a_scheme_is_required_and_control_characters_are_refused() {
assert!(!openable(url), "{url:?} must be refused");
}
}
#[test]
fn shell_syntax_and_malformed_authorities_are_refused() {
for url in [
// Never valid unencoded, and command syntax to the `cmd.exe /c start` an opener may use.
"https://example.com/?a=b|calc",
"https://example.com/<x>",
"https://example.com/\"x\"",
"https://example.com/a b",
"https://example.com/`x`",
"https://example.com/x^y",
"https://example.com\\x",
// An authority an opener can't parse may be reopened as a file path.
"http://exam ple.com/",
"http://a{b}/",
] {
assert!(!openable(url), "{url:?} must be refused");
}
for url in ["https://example.com/?a=b&c=d%20e", "https://[::1]:8080/x", "https://bücher.example/", "https://a.b-c_d.example:443/"] {
assert!(openable(url), "{url:?} should open");
}
}
@@ -127,6 +127,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
if manifest.id != entry.id || manifest.version != entry.version {
return Err(fail("its manifest doesn't match the directory's entry"));
}
// What was shown and approved is the entry's access and commands: a bundle whose manifest
// asks for more is refused, since the manifest is what the plugin then runs under.
if manifest.permissions.access == figcraft_plugin::manifest::Access::Write && !entry.access.trim().eq_ignore_ascii_case("write") {
return Err(fail("its manifest asks to write the document, but the directory showed it as read-only"));
}
if !entry.commands.is_empty()
&& (manifest.permissions.commands.is_empty() || manifest.permissions.commands.iter().any(|c| !entry.commands.contains(c)))
{
return Err(fail("its manifest asks for commands the directory didn't show"));
}
let source = address(&server);
let path = save(s, &mut manifest, &bundle).map_err(|e| fail(&e))?;
let out = json!({
+42 -3
View File
@@ -180,12 +180,21 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) {
return Some("plugins can't read files: pass the variables JSON itself".into());
}
// A saved version is the file's history, which plugins can't read (`version.` is denied).
if command == "dev.compare" && get(params, "version").is_some_and(|v| !v.is_null()) {
return Some("plugins can't read saved versions: leave out `version`".into());
}
if !m.permissions.allows(command) {
return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name));
}
if m.permissions.access == Access::Read && find_command(command).is_some_and(|c| c.history) {
return Some(format!("`{command}` edits the document, and {} may only read it", m.name));
}
// Branches live beside the document (not in its undo history or its `Arc`), so the write
// checks below don't see them change.
if m.permissions.access == Access::Read && matches!(command, "branch.create" | "branch.updateFromMain" | "branch.merge" | "branch.archive") {
return Some(format!("`{command}` changes the file's branches, and {} may only read it", m.name));
}
None
}
@@ -263,10 +272,23 @@ impl figcraft_plugin::Host for EngineHost<'_> {
Ok(v)
});
r.map(|mut v| {
// A plugin gets the open files' names, not where they are on disk (#1117).
if command == "doc.list" {
for e in v.as_array_mut().into_iter().flatten().filter_map(Value::as_object_mut) {
// A plugin gets the open files' and libraries' names, not where they are on disk or
// the share links (with their tokens) that reach them (#1117).
if matches!(command, "doc.list" | "library.list" | "library.updates") {
// `doc.list` is a list; `library.list` keeps its list under `libraries`, and
// `library.updates` its failures under `errors`.
let list = match command {
"library.list" => v.get_mut("libraries"),
"library.updates" => v.get_mut("errors"),
_ => Some(&mut v),
};
for e in list.and_then(Value::as_array_mut).into_iter().flatten().filter_map(Value::as_object_mut) {
e.remove("path");
// A failure's text may quote the path, a token or another form of it: the
// plugin gets a fixed message.
if let Some(err) = e.get_mut("error").filter(|v| v.is_string()) {
*err = json!("the library could not be read");
}
}
}
v.to_string()
@@ -708,6 +730,23 @@ mod refusal_tests {
let r = refusal(&m, "library.publishToServer", &json!({}));
assert!(r.is_some(), "library.publishToServer uploads the document and must be refused");
}
#[test]
fn read_only_plugin_cannot_change_branches() {
let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm"}"#).unwrap();
for cmd in ["branch.archive", "branch.updateFromMain", "branch.merge", "branch.create"] {
assert!(refusal(&m, cmd, &json!({})).is_some(), "{cmd} changes persisted branch state");
}
assert!(refusal(&m, "branch.list", &json!({})).is_none());
}
#[test]
fn plugins_cannot_compare_against_a_saved_version() {
let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm","permissions":{"access":"write"}}"#).unwrap();
assert!(refusal(&m, "dev.compare", &json!({"version": "v1"})).is_some());
assert!(refusal(&m, "dev.compare", &json!({"version": null})).is_none());
assert!(refusal(&m, "dev.compare", &json!({})).is_none());
}
}
#[cfg(test)]
@@ -122,6 +122,27 @@ fn variables_styles_and_instance_sizes_come_back() {
assert!(after.iter().any(|l| l.contains("\"In the dark\"") && l.contains("FFFFFF")), "{after:#?}");
}
/// #1829: a text style's paragraph spacing and indent and its list style go through a `.fig`
/// file with it (the style's sample layer carries them, as a Figma file's does).
#[test]
fn a_text_styles_paragraph_values_come_back_from_a_fig_file() {
let mut s = Session::new();
let t = id(&run(&mut s, "text.create", json!({"text": "One\nTwo", "x": 0, "y": 0})));
run(&mut s, "node.set", json!({"ids": [t], "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted"}));
run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t}));
let path = std::env::temp_dir().join(format!("figcraft-fig-text-style-{}.fig", std::process::id()));
run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()}));
run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
let doc = s.doc().unwrap();
let st = doc.styles.iter().find(|x| x.name == "Audit").unwrap();
let figcraft_doc::StyleKind::Text { paragraph, .. } = &st.kind else { panic!("{:?}", st.kind) };
assert_eq!((paragraph.paragraph_spacing, paragraph.paragraph_indent, paragraph.list), (12.0, 8.0, figcraft_doc::ListStyle::Bulleted));
// The layer the style was made from is linked to it again, showing the same values.
let layer = doc.nodes().find(|n| n.kind.text().is_some()).unwrap();
assert_eq!(layer.styles.text.as_deref(), Some(st.id.as_str()));
}
#[test]
fn every_example_keeps_its_variables_and_styles() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
+1 -1
View File
@@ -54,7 +54,7 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
}
}
for st in &doc.styles {
if let StyleKind::Text { style } = &st.kind {
if let StyleKind::Text { style, .. } = &st.kind {
add(&style.font_family, false);
}
if let Some(var) = st.bound.get("fontFamily") {
+4 -1
View File
@@ -37,7 +37,10 @@ fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
id: "S:1".into(),
name: "Heading".into(),
description: String::new(),
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
kind: StyleKind::Text {
style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() },
paragraph: Default::default(),
},
bound: Default::default(),
});
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
+172
View File
@@ -0,0 +1,172 @@
//! `file.import` of a file whose style, variable and collection ids the document already uses for
//! other things: ids are numbered per file, so two files made apart share them. The imported
//! layers keep their own variables, modes and styles, and a file imported again still shares what
//! it brought the first time.
use base64::Engine as _;
use figcraft_doc::proto::Action;
use figcraft_doc::{Document, Node, NodeId, VariableValue};
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::ex;
use crate::params::id_of;
fn idv(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn var_id(s: &Session, name: &str) -> String {
s.doc().unwrap().variables.get(name).unwrap().id.clone()
}
fn fill(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
/// The document's file: collection A (Light, Dark) with colours `a1`…`a4`, paint style `Host`
/// and a frame filled with `a1`.
fn host() -> (Session, NodeId) {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "A", "modes": ["Light", "Dark"]}));
for i in 1..=4 {
ex(&mut s, "variable.create", json!({"collection": "A", "name": format!("a{i}"), "type": "color", "value": format!("#A{i}0000")}));
}
ex(&mut s, "style.create", json!({"type": "paint", "name": "Host", "value": "#FF0000"}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": "Host frame"})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "a1"}));
(s, f)
}
/// Another file made apart: collection B (Light, Dark) with `b1` (green in each mode), `b2` (an
/// alias of `b1`) and `size`; paint style `Brand` bound to `b1`. Frame `Card` shows B in Dark, its
/// corners bound to `size`; in it `Swatch` is filled with `b2` and on click sets B to Dark and
/// `size` to `size + 1`, and `Styled` uses `Brand`.
fn other() -> Session {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "B", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b1", "type": "color", "values": {"Light": "#00B000", "Dark": "#004000"}}));
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b2", "type": "color", "value": {"alias": "b1"}}));
ex(&mut s, "variable.create", json!({"collection": "B", "name": "size", "type": "number", "value": 8}));
let brand = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "value": "#00B000"}))["id"].clone();
ex(&mut s, "style.bind", json!({"style": brand, "field": "fill", "variable": "b1"}));
let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
ex(&mut s, "variable.setMode", json!({"ids": [card], "collection": "B", "mode": "Dark"}));
ex(&mut s, "variable.bind", json!({"ids": [card], "field": "cornerRadius", "variable": "size"}));
let swatch = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Swatch"})));
ex(&mut s, "variable.bind", json!({"ids": [swatch], "field": "fill", "variable": "b2"}));
let actions = json!([
{"type": "setVariableMode", "collection": "B", "mode": "Dark"},
{"type": "setVariable", "variable": "size", "expression": "size + 1"}
]);
ex(&mut s, "proto.interaction.add", json!({"id": swatch, "actions": actions}));
let styled = idv(&ex(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Styled"})));
ex(&mut s, "style.apply", json!({"ids": [styled], "style": "Brand"}));
s
}
fn import(s: &mut Session, file: &Session) -> Value {
let bytes = figcraft_format::save_bytes(file.doc().unwrap(), None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
ex(s, "file.import", json!({"data": data, "name": "other.figcraft"}))
}
/// The layers on the pages after the first `kept` pages.
fn layers_after(doc: &Document, kept: usize) -> Vec<&Node> {
doc.pages[kept..].iter().flat_map(|p| doc.subtree(*p).into_iter().skip(1)).filter_map(|id| doc.node(id)).collect()
}
fn layer<'a>(doc: &'a Document, kept: usize, name: &str) -> &'a Node {
layers_after(doc, kept).into_iter().find(|n| n.name == name).unwrap()
}
#[test]
fn imported_layers_keep_their_own_variables_and_styles_when_the_ids_are_taken() {
let (mut s, frame) = host();
let file = other();
assert_eq!(var_id(&file, "b1"), var_id(&s, "a1"), "the ids must collide for this test");
assert_eq!(file.doc().unwrap().styles[0].id, s.doc().unwrap().styles[0].id);
let before = s.doc().unwrap().clone();
import(&mut s, &file);
let doc = s.doc().unwrap();
assert_eq!(doc.variables.variables.len(), 4 + 3);
assert_eq!(doc.variables.collections.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["A", "B"]);
assert_eq!(doc.styles.iter().map(|st| st.name.as_str()).collect::<Vec<_>>(), ["Host", "Brand"]);
// The document's own are as they were.
assert_eq!(doc.variables.variables[..4], before.variables.variables[..]);
assert_eq!(doc.variables.collections[0], before.variables.collections[0]);
assert_eq!(doc.styles[0], before.styles[0]);
// Everything imported names B's variables, B and Brand.
let b = doc.variables.collection("B").unwrap().id.clone();
let imported = layers_after(doc, before.pages.len());
assert!(imported.iter().any(|n| !n.bound.is_empty()));
for n in imported {
for v in n.bound.values() {
assert_eq!(doc.variables.get_by_id(v).unwrap().collection, b, "{}: {v}", n.name);
}
assert!(n.var_modes.keys().all(|c| *c == b), "{}: {:?}", n.name, n.var_modes);
if let Some(st) = &n.styles.fill {
assert_eq!(doc.styles.iter().find(|x| x.id == *st).unwrap().name, "Brand");
}
}
let brand = doc.styles.iter().find(|st| st.name == "Brand").unwrap();
assert_eq!(brand.bound.values().map(|v| doc.variables.get_by_id(v).unwrap().name.as_str()).collect::<Vec<_>>(), ["b1"]);
// Bindings applied again leave the imported layers alone.
let swatch = layer(doc, before.pages.len(), "Swatch").id;
let a2 = var_id(&s, "a2");
ex(&mut s, "variable.set", json!({"variable": a2, "value": "#123456"}));
assert_eq!(fill(&s, swatch), "004000");
assert_eq!(fill(&s, frame), "A10000");
assert!(s.doc().unwrap().validate().is_empty());
}
#[test]
fn an_alias_a_frame_mode_and_prototype_actions_follow_the_renamed_collection() {
let (mut s, _) = host();
let file = other();
let kept = s.doc().unwrap().pages.len();
import(&mut s, &file);
let doc = s.doc().unwrap();
let vars = &doc.variables;
let b = vars.collection("B").unwrap();
let in_b = |name: &str| vars.variables.iter().find(|v| v.name == name && v.collection == b.id).unwrap();
let (b1, b2, size) = (in_b("b1"), in_b("b2"), in_b("size"));
assert!(b2.values.values().all(|v| *v == VariableValue::Alias(b1.id.clone())), "{:?}", b2.values);
// B's mode ids name B's new id, and its variables' values are by those.
let modes: Vec<&str> = b.modes.iter().map(|m| m.id.as_str()).collect();
assert_eq!(modes, [format!("{}/mode1", b.id), format!("{}/mode2", b.id)]);
assert!([b1, b2, size].iter().all(|v| v.values.keys().all(|m| modes.contains(&m.as_str()))));
// The frame shows B in Dark: its mode is one of B's.
let card = layer(doc, kept, "Card");
let dark = b.find_mode("Dark").unwrap().id.clone();
assert_eq!(card.var_modes.iter().collect::<Vec<_>>(), [(&b.id, &dark)]);
assert_eq!(card.bound.get("cornerRadius"), Some(&size.id));
let swatch = layer(doc, kept, "Swatch");
assert_eq!(swatch.bound.get("fill"), Some(&b2.id));
let shown = vars.resolve(&b2.id, &doc.modes_for(swatch.id));
assert!(matches!(shown, Some(VariableValue::Color(c)) if c.to_hex() == "004000"), "{shown:?}");
let actions = &swatch.interactions[0].actions;
assert_eq!(actions[0], Action::SetVariableMode { collection: b.id.clone(), mode: dark });
let Action::SetVariable { variable, expr: Some(e), .. } = &actions[1] else { panic!("{actions:?}") };
assert_eq!((variable, e.variables()), (&size.id, vec![size.id.as_str()]));
}
#[test]
fn importing_the_same_file_again_shares_what_it_brought() {
let file = other();
let mut s = other();
let before = s.doc().unwrap().clone();
import(&mut s, &file);
let doc = s.doc().unwrap();
assert_eq!(doc.variables, before.variables);
assert_eq!(doc.styles, before.styles);
let swatch = layer(doc, before.pages.len(), "Swatch");
assert_eq!(swatch.bound.get("fill"), Some(&var_id(&s, "b2")));
// A file imported into an empty one, then again: one copy of its variables.
let mut t = Session::new();
import(&mut t, &file);
import(&mut t, &file);
let doc = t.doc().unwrap();
assert_eq!((doc.variables.variables.len(), doc.variables.collections.len(), doc.styles.len()), (3, 1, 1));
assert!(layers_after(doc, 1).iter().flat_map(|n| n.bound.values()).all(|v| doc.variables.get_by_id(v).is_some()));
}
+1
View File
@@ -354,6 +354,7 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
(Tools, "Pan with nothing selected (Shift: farther)", "← ↑ → ↓", "← ↑ → ↓"),
(Transform, "Rotate a degree (Shift: 15)", "⌥⌘ ← ↑ → ↓", "Ctrl+Alt+← ↑ → ↓"),
(Tools, "Place a shape or frame (its tool chosen)", "↵", "Enter"),
(Tools, "Show and move the placement crosshair (its tool chosen, nothing selected)", "← ↑ → ↓", "← ↑ → ↓"),
(Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"),
(Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"),
(Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"),
+11 -1
View File
@@ -110,7 +110,7 @@ mod view_state;
pub use derived::{changed_nodes, normalize};
pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview};
pub use error::{EngineError, Result, bad};
pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_SIZES, UiRequest, clamp_ui_scale};
pub use prefs::{Clipboard, DefaultProps, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_SIZES, UiRequest, clamp_ui_scale};
pub use session::Forward;
pub use view_state::ViewState;
@@ -119,6 +119,8 @@ pub struct Session {
pub docs: Vec<DocState>,
pub active: usize,
pub clipboard: Clipboard,
/// Edit ▸ Set default properties: what new shapes and text drawn with the tools take.
pub default_props: DefaultProps,
pub prefs: Prefs,
/// A command changed a preference that is kept at once (not only when the app quits): the app
/// saves the preferences and clears this.
@@ -275,6 +277,8 @@ mod dash_cap_tests;
#[cfg(test)]
mod datafill_tests;
#[cfg(test)]
mod default_props_tests;
#[cfg(test)]
mod design_paste_tests;
#[cfg(test)]
mod dev_measurements_tests;
@@ -367,6 +371,8 @@ mod images_left_in_file_tests;
#[cfg(test)]
mod import_export_tests;
#[cfg(test)]
mod import_ids_tests;
#[cfg(test)]
mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
@@ -467,6 +473,8 @@ mod penpot_export_tests;
#[cfg(test)]
mod pixel_preview_tests;
#[cfg(test)]
mod place_crosshair_tests;
#[cfg(test)]
mod placed_image_size_tests;
#[cfg(test)]
mod playground_tests;
@@ -581,6 +589,8 @@ mod text_style_picker_tests;
#[cfg(test)]
mod text_tail_tests;
#[cfg(test)]
mod text_weight_step_tests;
#[cfg(test)]
mod textfit_tests;
#[cfg(test)]
mod theme_pages_tests;
@@ -385,6 +385,34 @@ fn library_components_keep_their_variable_bindings() {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_file_bringing_older_copies_of_the_same_library_variables_shares_them() {
let d = dir("older-copies");
let lib_path = path_str(&d.join("kit.figcraft"));
let mut lib = Session::new();
theme(&mut lib);
let lid = publish_to(&mut lib, &lib_path);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": lib_path}));
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10, "name": "Swatch"})));
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [r]}));
let old_path = path_str(&d.join("old.figcraft"));
ex(&mut s, "file.saveAs", json!({"path": old_path}));
// The copies take an update; the saved file keeps the older ones under the same ids.
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#444444"}));
republish(&mut lib);
ex(&mut s, "library.acceptUpdates", json!({}));
let before = s.doc().unwrap().clone();
ex(&mut s, "file.import", json!({"path": old_path}));
let doc = s.doc().unwrap();
assert_eq!((&doc.variables, &doc.libraries.variables), (&before.variables, &before.libraries.variables), "shared by id, as before");
let swatches: Vec<&Node> = doc.nodes().filter(|n| n.name == "Swatch").collect();
assert_eq!(swatches.len(), 2);
assert!(swatches.iter().all(|n| n.bound.get("fill") == Some(&copy_of(&s, "primary").id)));
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn library_variable_commands_fail_cleanly() {
let mut s = Session::new();
+3
View File
@@ -592,4 +592,7 @@ fn same_server_ignores_the_default_port_before_a_path() {
assert!(same_server("wss://share.example:443/figcraft/", "wss://share.example/figcraft/"));
assert!(same_server("ws://host:80/x", "ws://host/x"));
assert!(!same_server("wss://share.example:8443/figcraft/", "wss://share.example/figcraft/"));
// The path is a route: owner tokens for /Team/ must never be sent to /team/.
assert!(same_server("WSS://Share.Example/team/", "wss://share.example/team"));
assert!(!same_server("wss://share.example/Team/", "wss://share.example/team/"));
}
+122
View File
@@ -0,0 +1,122 @@
//! The placement crosshair: the arrows with a shape or frame tool chosen show it in the middle of
//! the view and move it, and Enter places the object there ("Use the arrow keys to activate the
//! crosshair cursor. It will appear in the middle of the viewport indicating where the new object
//! will be placed." / "Use the arrow keys to move the crosshair around the canvas or board, then
//! press Return/Enter to place the object.", Accessibility at Figma, #1839).
use serde_json::json;
use crate::Session;
use crate::tools::{PointerEvent, PointerKind};
#[test]
fn the_arrows_show_and_move_the_crosshair_and_enter_places_there() {
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
assert!(s.execute("tool.crosshair", &json!({"dx": 1})).is_err(), "a shape or frame tool first");
s.execute("tool.rectangle", &json!({})).unwrap();
// The first arrow shows the crosshair in the middle of the view, unmoved; the next move it by
// the nudge amounts.
let r = s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(300.0), Some(200.0)));
s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
let r = s.execute("tool.crosshair", &json!({"dx": 0, "dy": -1, "big": true})).unwrap();
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(301.0), Some(190.0)), "a nudge, then a big nudge");
assert_eq!(s.tool_info()["crosshair"], json!([301.0, 190.0]));
let v = &s.ds().unwrap().view;
assert_eq!((v.cx, v.cy), (300.0, 200.0), "the view hasn't panned");
// Enter: the rectangle is centred on the crosshair, which goes.
let placed = s.execute("tool.place", &json!({})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.center().x, b.center().y), (301.0, 190.0));
assert_eq!(s.tool.place_at, None);
assert!(s.tool_info()["crosshair"].is_null());
// Escape takes a shown crosshair away before anything else, the tool staying; a tool change
// takes it away too.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
assert!(s.tool.place_at.is_some());
s.escape().unwrap();
assert_eq!((s.tool.place_at, s.tool.tool), (None, crate::ToolId::Rectangle));
s.execute("tool.crosshair", &json!({})).unwrap();
s.execute("tool.ellipse", &json!({})).unwrap();
assert_eq!(s.tool.place_at, None);
}
#[test]
fn the_crosshair_keeps_its_page_and_goes_with_a_pointer_placement_but_not_the_same_tool_again() {
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
// The tool already chosen named again keeps the crosshair: `tool.place {tool}` lands on it.
let placed = s.execute("tool.place", &json!({"tool": "rectangle"})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(id).unwrap().center().x, 301.0);
// A click places too (the tool kept by the preference) and takes the crosshair with it.
s.prefs.keep_tool = true;
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 600.0, 600.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 600.0, 1.0)).unwrap();
assert_eq!((s.tool.tool, s.tool.place_at), (crate::ToolId::Rectangle, None), "placed by the pointer: the crosshair goes, the tool stays");
// Another page in front: the crosshair shown on the first isn't there, Escape has nothing to
// take, and the next arrow starts over in the middle of this page's view.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 5})).unwrap();
s.execute("page.add", &json!({})).unwrap();
s.execute("page.select", &json!({"index": 1})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
assert_eq!(s.crosshair(), None);
assert!(s.tool_info()["crosshair"].is_null());
s.escape().unwrap();
assert_eq!(s.tool.tool, crate::ToolId::Move, "Escape went on to the tool");
s.execute("tool.rectangle", &json!({})).unwrap();
let r = s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
let v = &s.ds().unwrap().view;
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(v.cx), Some(v.cy)), "shown afresh in the middle of this page's view");
}
#[test]
fn the_view_pans_to_keep_the_crosshair_in_sight() {
// "Use the arrow keys to move the crosshair around the canvas or board": nudged past an edge
// of the view, the view follows it by the overshoot; inside it, the view stays put.
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 10.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
for _ in 0..6 {
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((s.tool.place_at.map(|p| p.x), v.cx, v.cy), (Some(360.0), 300.0, 200.0), "at the edge of the 120 px wide view: no pan yet");
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 200.0), "ten past the edge: the view moved ten");
for _ in 0..5 {
s.execute("tool.crosshair", &json!({"dy": -1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 190.0), "the view is 80 px tall: 40 up is its edge, 50 moves it ten");
}
#[test]
fn the_board_shapes_tool_places_on_the_crosshair_too() {
// "...move the crosshair around the canvas or board, then press Return/Enter to place the
// object": the board Shapes tool takes the crosshair like the shape tools, its shape centred
// on it (the tool centres its own 160 px shape on a press).
let mut s = crate::board_tests::board();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.set", &json!({"tool": "shape"})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 2})).unwrap();
let placed = s.execute("tool.place", &json!({})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.center().x, b.center().y, b.width(), b.height()), (302.0, 200.0, 160.0, 160.0));
assert_eq!(s.tool.place_at, None);
}
@@ -252,3 +252,29 @@ fn publishing_sends_a_checked_folder() {
assert_eq!(r["id"], ID);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_bundle_asking_for_more_than_the_directory_showed_is_not_installed() {
let (mut s, d) = session();
// Shown as read-only with one command; the bundle's manifest asks to write, any command.
let text =
json!({"id": ID, "name": "Tiny", "version": "1.0.0", "author": "Ada", "main": "tiny.wasm", "permissions": {"access": "write"}}).to_string();
let b = Bundle { manifest: text, wasm: wasm(), assets: vec![] }.encode();
let mut e = entry(ID, "1.0.0", &b);
e.commands = vec!["node.get".into()];
d.lock().unwrap().plugins.push((e, b));
s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap();
let err = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string();
assert!(err.contains("write"), "{err}");
assert!(s.prefs.plugins.is_empty(), "nothing was installed");
// Read-only as shown, but more commands than shown.
let text = json!({"id": ID, "name": "Tiny", "version": "1.0.1", "author": "Ada", "main": "tiny.wasm", "permissions": {"access": "read", "commands": ["node.get", "node.set"]}}).to_string();
let b = Bundle { manifest: text, wasm: wasm(), assets: vec![] }.encode();
let mut e = entry(ID, "1.0.1", &b);
e.commands = vec!["node.get".into()];
d.lock().unwrap().plugins.clear();
d.lock().unwrap().plugins.push((e, b));
s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap();
let err = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string();
assert!(err.contains("commands"), "{err}");
}
+12
View File
@@ -173,6 +173,18 @@ pub struct UiRequest {
pub data: Value,
}
/// What Edit ▸ Set default properties keeps: the properties new layers drawn with the tools take,
/// as Copy properties copies them (fills, strokes and their settings, effects, opacity, blend,
/// corners; for text also the font, size, line height and letter spacing). One set for shapes
/// (rectangles, ellipses, polygons and stars), set from any layer but text, and one for text.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct DefaultProps {
/// For new rectangles, ellipses, polygons and stars.
pub shape: Option<Value>,
/// For new text.
pub text: Option<Value>,
}
/// Clipboard contents (internal; the UI mirrors to the system clipboard).
#[derive(Clone, Debug, Default)]
pub struct Clipboard {
+28
View File
@@ -97,3 +97,31 @@ fn explicit_variable_modes_go_back_to_auto() {
assert!(s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": "Dusk"})).is_err());
assert!(s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens"})).is_err(), "a mode, or null for Auto");
}
/// #2027: an unknown device is refused with the devices to pick from, as an unknown colour is, and
/// nothing changes; a known one is taken whatever its case and stored as the picker names it; the
/// settings read back from `proto.settings` without parameters.
#[test]
fn unknown_devices_are_refused_and_settings_read_back() {
let mut s = Session::new();
let before = settings(&s);
for junk in [json!("Pixel Nonexistent"), json!(""), json!(42), json!(["iPhone 16"])] {
let err = s.execute("proto.settings", &json!({"device": junk, "responsive": true})).unwrap_err().to_string();
assert!(err.contains("unknown device") && err.contains("iPhone 17|") && err.contains("Custom size"), "{junk}: {err}");
assert_eq!(settings(&s), before, "{junk}: nothing changes");
}
let set = s.execute("proto.settings", &json!({"device": " iphone 16 ", "color": "black"})).unwrap();
assert_eq!(settings(&s).device, "iPhone 16", "stored as the picker names it");
assert_eq!((&set["device"], &set["color"]), (&json!("iPhone 16"), &json!("Black")));
let read = s.execute("proto.settings", &json!({})).unwrap();
assert_eq!(read, set, "a call without parameters reads the settings");
assert_eq!((&read["ok"], &read["rotation"], &read["responsive"]), (&json!(true), &json!("portrait"), &json!(false)));
assert_eq!(read["screen"], json!({"width": 393.0, "height": 852.0}));
assert!(read["background"].as_str().is_some_and(|c| c.starts_with('#')), "{read}");
// "None" and "Custom size" in any case; no device has no screen or colour.
s.execute("proto.settings", &json!({"device": "custom SIZE", "width": 800, "height": 600})).unwrap();
assert_eq!(settings(&s).device, PrototypeSettings::CUSTOM_DEVICE);
let none = s.execute("proto.settings", &json!({"device": "none"})).unwrap();
assert_eq!((&none["device"], &none["screen"], &none["color"]), (&json!("None"), &json!(null), &json!(null)));
assert_eq!(none["customSize"], json!({"width": 800.0, "height": 600.0}));
}
+1
View File
@@ -14,6 +14,7 @@ impl Session {
docs: Vec::new(),
active: 0,
clipboard: Clipboard::default(),
default_props: Default::default(),
prefs: Prefs::default(),
prefs_unsaved: false,
tool: tools::ToolState::default(),
+50 -1
View File
@@ -233,7 +233,14 @@ fn corner_smoothing_follows_the_documented_rules() {
(NodeKind::Frame, true, "frames"),
(NodeKind::Polygon { count: 5 }, true, "polygons"),
(NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, "stars"),
(NodeKind::Vector { network: Default::default() }, false, "documented for closed vectors; their points round without it here"),
(
NodeKind::Vector {
network: figcraft_doc::VectorNetwork::from_points(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true),
},
true,
"vector networks that are closed shapes",
),
(NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"),
(NodeKind::Line, false, "lines take no radius, so no smoothing"),
];
for (kind, smoothing, why) in doc_rows {
@@ -241,6 +248,48 @@ fn corner_smoothing_follows_the_documented_rules() {
}
}
#[test]
fn closed_vectors_take_corner_smoothing() {
// "Vector networks that are closed shapes" take corner smoothing, which applies to the entire
// shape; a single open stroke does not.
let mut s = Session::new();
let closed = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}));
let open = create(&mut s, "vector.create", json!({"points": [[200, 0], [300, 0], [300, 100]], "closed": false}));
ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerRadius": 20}}));
let outline = |s: &Session, id: NodeId| figcraft_doc::geometry::shape_path(&node(s, id)).unwrap();
let svg = |s: &Session, id: NodeId| figcraft_svg::export_svg(s.doc().unwrap(), &[id], &figcraft_svg::SvgOptions::default()).unwrap();
let render = |s: &Session, id: NodeId| figcraft_render::node_outline(s.doc().unwrap(), &node(s, id)).unwrap().0;
let (round, open_round, svg_round, drawn_round) = (outline(&s, closed), outline(&s, open), svg(&s, closed), render(&s, closed));
ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerSmoothing": 0.6}}));
let smooth = outline(&s, closed);
// The smoothed corner reaches 1.6 radii along each edge, as a smoothed rectangle's does.
let want = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6).area().abs();
assert!((smooth.area().abs() - want).abs() < 0.1, "the closed vector smooths: {} vs {want}", smooth.area().abs());
assert!((smooth.area().abs() - round.area().abs()).abs() > 1.0, "smoothing changes the closed vector's outline");
assert_ne!(svg(&s, closed), svg_round, "SVG export draws the smoothed corners");
assert_ne!(render(&s, closed), drawn_round, "the canvas draws the smoothed corners");
assert_eq!(outline(&s, open), open_round, "an open vector keeps its plain rounded points");
let stroke = figcraft_doc::geometry::stroke_geometry(&node(&s, closed)).unwrap();
assert!((stroke.area().abs() - want).abs() < 0.1, "the stroke follows the smoothed outline");
// Smoothing needs a radius, and undo restores the plain corners in one step.
s.undo().unwrap();
assert_eq!(outline(&s, closed), round, "one undo step");
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 0}}));
let sharp = outline(&s, closed);
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0.6}}));
assert_eq!(outline(&s, closed), sharp, "smoothing needs a radius");
// A vector whose region has paints of its own draws the smoothed corners too, on the canvas
// and in SVG.
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 20}}));
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(closed).unwrap().kind {
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
let (region_svg, region_path) = (svg(&s, closed), render(&s, closed));
assert!((region_path.area().abs() - want).abs() < 0.5, "region paints follow the smoothed outline: {}", region_path.area().abs());
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0}}));
assert_ne!(svg(&s, closed), region_svg, "SVG export draws the region's smoothed corners");
}
#[test]
fn rounded_vector_points_are_drawn() {
// A converted 100 × 100 square: a point's radius rounds that corner only (it wasn't drawn).
@@ -73,8 +73,15 @@ fn bundle() -> Vec<u8> {
fn directory(req: PluginReq) -> PluginReply {
let bytes = bundle();
let entry =
PluginEntry { id: ID.into(), name: "Tiny tool".into(), version: "1.0.0".into(), sha256: sha256_hex(&bytes), ..PluginEntry::default() };
let entry = PluginEntry {
id: ID.into(),
name: "Tiny tool".into(),
version: "1.0.0".into(),
access: "write".into(),
commands: vec!["shape.rectangle".into()],
sha256: sha256_hex(&bytes),
..PluginEntry::default()
};
match req {
PluginReq::List { .. } => PluginReply::List(vec![entry]),
PluginReq::Fetch { .. } => PluginReply::Bundle { entry, bundle: bytes },
+8 -5
View File
@@ -117,14 +117,17 @@ pub(crate) fn plain_server(server: &str) -> String {
/// Two addresses name the same server (case, a closing slash and the scheme's own port aside).
pub fn same_server(a: &str, b: &str) -> bool {
let norm = |s: &str| {
let s = plain_server(s).to_ascii_lowercase();
let s = plain_server(s);
let s = s.trim_end_matches('/');
let port = if s.starts_with("wss://") { ":443" } else { ":80" };
let start = s.find("://").map_or(0, |i| i + 3);
let end = s[start..].find('/').map_or(s.len(), |i| start + i);
match s[start..end].strip_suffix(port) {
Some(host) => format!("{}{}{}", &s[..start], host, &s[end..]),
None => s.to_string(),
// The scheme and host compare without case; the path routes (a proxy may send
// `/Team/` and `/team/` to different servers), so it keeps its case.
let head = s[..end].to_ascii_lowercase();
let port = if head.starts_with("wss://") { ":443" } else { ":80" };
match head[start..].strip_suffix(port) {
Some(host) => format!("{}{}{}", &head[..start], host, &s[end..]),
None => format!("{head}{}", &s[end..]),
}
};
norm(a) == norm(b)
+1 -1
View File
@@ -520,7 +520,7 @@ fn sketch_shared_text_styles_match_their_layers() {
.styles
.iter()
.find_map(|st| match &st.kind {
figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()),
figcraft_doc::StyleKind::Text { style, .. } if st.name == "Body" => Some(style.clone()),
_ => None,
})
.unwrap();
+82 -1
View File
@@ -5,10 +5,11 @@
//! line; "docs silent" marks behaviour the documentation doesn't describe (pinned as it is).
use serde_json::{Value, json};
use std::collections::BTreeMap;
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{Node, NodeId};
use figcraft_doc::{ListStyle, Node, NodeId, ParagraphStyle, StyleKind, TextData};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
@@ -355,6 +356,68 @@ fn text_styles_go_on_text_layers_and_chars() {
assert_eq!(tx.styles.text, None, "mixed: the layer isn't linked as a whole");
}
#[test]
fn text_styles_carry_paragraph_spacing_indent_lists_and_features() {
// "Supported in text styles": "Paragraph spacing and indentation", "Lists" and "OpenType
// features", beside the character properties (Create and apply text styles). A style made
// from a text takes them, applying it sets them, editing one on a styled text unlinks it.
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);
let props = json!({"characters": "One\nTwo", "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted", "features": {"tnum": true, "liga": false}});
run(&mut s, "node.set", json!({"id": t, "props": props}));
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t})));
let listed = run(&mut s, "style.list", json!({"type": "text"}));
let audit = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap();
assert_eq!(
audit["paragraph"],
json!({"paragraph_spacing": 12.0, "paragraph_indent": 8.0, "list": "bulleted", "features": {"liga": false, "tnum": true}})
);
// Applying it sets them on a plain text, every paragraph in the list; the layer links.
let t2 = text(&mut s, 300.0);
run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB\nC"}}));
run(&mut s, "style.apply", json!({"ids": [t2], "style": st}));
let d = td(&s, t2);
assert_eq!((d.paragraph_spacing, d.paragraph_indent), (12.0, 8.0));
assert!((0..3).all(|p| d.para_list(p).list == ListStyle::Bulleted), "{:?}", d.paragraphs);
assert_eq!(d.features, BTreeMap::from([("liga".to_string(), false), ("tnum".to_string(), true)]));
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()));
// Redefining the style moves its layers' paragraph values with the rest.
let value = json!({"fontSize": 22, "paragraphSpacing": 20, "listStyle": "numbered", "openTypeFeatures": {"tnum": true}});
run(&mut s, "style.update", json!({"style": st, "value": value}));
let d = td(&s, t2);
assert_eq!((d.paragraph_spacing, d.paragraph_indent, d.para_list(1).list), (20.0, 0.0, ListStyle::Numbered));
assert_eq!(d.features, BTreeMap::from([("tnum".to_string(), true)]));
assert_eq!(font_size(&s, t2), 22.0);
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update");
// Editing a paragraph value the style sets unlinks the text, as editing its font does.
run(&mut s, "node.set", json!({"id": t2, "props": {"paragraphSpacing": 5}}));
assert_eq!(node(&s, t2).styles.text, None, "paragraph spacing edited: unlinked");
assert!(td(&s, t2).spans().iter().all(|(_, _, x)| x.style_id.is_none()));
// On a range, the list goes to the paragraphs touched; the spacing is the layer's. Taking
// the paragraph out of the style's list unlinks its chars.
let t3 = text(&mut s, 600.0);
run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB\nC"}}));
run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1}));
let d = td(&s, t3);
assert_eq!((d.para_list(0).list, d.para_list(1).list, d.paragraph_spacing), (ListStyle::Numbered, ListStyle::None, 20.0));
assert_eq!(d.style_at(0).style_id.as_deref(), Some(st.as_str()));
run(&mut s, "node.set", json!({"id": t3, "props": {"start": 0, "end": 1, "listStyle": "none"}}));
assert_eq!(td(&s, t3).style_at(0).style_id, None, "its paragraph left the style's list");
// A definition given as a value takes the node.set spellings; junk is refused.
let value = json!({"fontSize": 11, "paragraphSpacing": 4, "listStyle": "bulleted", "features": {"liga": false}});
let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": value})));
let want = ParagraphStyle {
paragraph_spacing: 4.0,
list: ListStyle::Bulleted,
features: BTreeMap::from([("liga".to_string(), false)]),
..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": {"listStyle": "dotted"}})).is_err());
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).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
@@ -410,3 +473,21 @@ fn descriptions_and_library_styles() {
run(&mut s, "style.update", json!({"style": st, "description": "Body text"}));
assert_eq!(s.doc().unwrap().style(&st).unwrap().description, "Body text");
}
#[test]
fn a_style_without_paragraph_values_keeps_a_bulleted_text_linked() {
// Styles from older files set no paragraph values, so a list or spacing on their text isn't
// an edit of the style: it stays linked (docs silent, pinned to how such files worked).
let mut s = Session::new();
let t = text(&mut s, 0.0);
run(
&mut s,
"node.set",
json!({"id": t, "props": {"characters": "A
B"}}),
);
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Plain", "value": {"fontSize": 11}})));
run(&mut s, "style.apply", json!({"ids": [t], "style": st}));
run(&mut s, "node.set", json!({"id": t, "props": {"listStyle": "bulleted", "paragraphSpacing": 6}}));
assert_eq!(node(&s, t).styles.text.as_deref(), Some(st.as_str()));
}
+58
View File
@@ -371,6 +371,64 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
}
#[test]
fn text_direction_is_forced_per_paragraph() {
// Add right-to-left text: "By default, Figma automatically handles text direction based on
// language detection. However, controls are available to override text direction per
// paragraph" ("Go to Text > Text direction. Select Left to right or Right to left.").
let font = test_mono_font();
let mut s = Session::new();
let t = text(&mut s, "abc def\nghi\njkl", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
let rtl = |s: &Session| -> Vec<bool> {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
l.lines.iter().map(|l| l.rtl).collect()
};
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
assert_eq!(rtl(&s), [false, false, false], "Latin letters read left to right");
assert!(inspect(&s).get("textDirection").is_none() && inspect(&s).get("lineDirections").is_none());
// The layer's paragraphs at once, by the command or `node.set`; inspect reports the one
// direction they share, and Dev Mode's CSS has it.
s.execute("text.direction", &json!({"ids": [t], "direction": "rtl"})).unwrap();
assert_eq!(rtl(&s), [true, true, true], "forced right to left whatever the letters");
assert_eq!(inspect(&s)["textDirection"], json!("rtl"));
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
assert!(css(&mut s).contains("direction: rtl;"), "{}", css(&mut s));
assert!(css(&mut s).contains("text-align: left;"), "Left stays the left edge, which `start` would not be: {}", css(&mut s));
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
assert_eq!(rtl(&s), [false, false, false]);
assert_eq!(inspect(&s)["textDirection"], json!("ltr"));
assert!(css(&mut s).contains("direction: ltr;"));
s.execute("text.directionAuto", &json!({"ids": [t]})).unwrap();
assert!(inspect(&s).get("textDirection").is_none(), "auto throughout says nothing");
assert!(!css(&mut s).contains("direction:"));
// While editing, the caret's paragraph alone (the menu's Right to left); inspect lists each
// paragraph's direction when they differ, and the CSS stays quiet.
s.execute("text.edit.begin", &json!({"id": t, "caret": 9})).unwrap();
s.execute("text.directionRtl", &json!({})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(rtl(&s), [false, true, false], "the second paragraph only");
assert_eq!(inspect(&s)["lineDirections"], json!(["auto", "rtl", "auto"]));
assert!(!css(&mut s).contains("direction:"));
// Enter inside a forced paragraph keeps the direction for the new one, as list settings carry.
s.execute("text.edit.begin", &json!({"id": t, "caret": 10})).unwrap();
s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(rtl(&s), [false, true, true, false]);
// An empty last paragraph (after a final newline) keeps the direction forced on it.
s.execute("node.set", &json!({"id": t, "props": {"characters": "abc\n", "textDirection": "auto"}})).unwrap();
s.execute("text.edit.begin", &json!({"id": t, "caret": 4})).unwrap();
s.execute("text.directionRtl", &json!({})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(rtl(&s), [false, true], "the empty last line runs right to left");
// A direction the document has words for; junk is refused.
s.execute("node.set", &json!({"id": t, "props": {"characters": "\u{05E9}\u{05DC}\u{05D5}\u{05DD} abc", "textDirection": "auto"}})).unwrap();
assert_eq!(rtl(&s), [true], "Hebrew first: right to left by detection");
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
assert_eq!(rtl(&s), [false], "forced left to right");
assert!(s.execute("node.set", &json!({"id": t, "props": {"textDirection": "sideways"}})).is_err());
assert!(s.execute("text.direction", &json!({"ids": [t], "direction": "up"})).is_err());
}
#[test]
fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
// Explore text properties: "Auto: Figma fits as many words as possible on each line before
+85 -1
View File
@@ -1,10 +1,94 @@
//! Range fills on text: set on a range, cleared back to the layer's fill.
//! Range fills on text: set on a range, cleared back to the layer's fill; a layer-level fill edit
//! reaches every character.
use serde_json::json;
use crate::Session;
use crate::cmd::lists::character_fills;
use crate::params::id_of;
/// The fill each character of `t` shows, as hex.
fn fills_shown(s: &Session, t: figcraft_doc::NodeId) -> Vec<String> {
let n = s.doc().unwrap().node(t).unwrap().clone();
let tx = n.kind.text().unwrap().clone();
(0..tx.char_count())
.map(|i| {
tx.style_at(i)
.fills
.clone()
.unwrap_or_else(|| n.fills.clone())
.iter()
.map(|p| p.solid_color().unwrap().to_hex())
.collect::<Vec<_>>()
.join("+")
})
.collect()
}
/// A text styled on a range, or on all of its characters, keeps those fills until the layer's
/// fill is edited: then every character takes the layer's fills, in one undo step (#1856).
#[test]
fn a_layer_fill_edit_reaches_every_character() {
let mut s = Session::new();
let t = id_of(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
s.execute("fill.set", &json!({"ids": [t], "color": "#FF0000"})).unwrap();
let node = |s: &Session| s.doc().unwrap().node(t).unwrap().clone();
assert_eq!(character_fills(&node(&s)).map(|f| f.len()), Some(1), "one shared list");
// "ello" yellow: the characters differ, the layer's fill is mixed, the canvas keeps both.
s.execute("text.style", &json!({"id": t, "start": 1, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
assert_eq!(fills_shown(&s, t), ["FF0000", "FFFF00", "FFFF00", "FFFF00", "FFFF00"]);
assert_eq!(character_fills(&node(&s)), None, "mixed");
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("select.set", &json!({"ids": [t]})).unwrap();
assert_eq!(fills_shown(&s, t), ["FF0000", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "reselecting changes nothing");
// The layer's fill set: every character takes it, their own fills are gone.
let steps = s.ds().unwrap().history.undo.len();
s.execute("fill.set", &json!({"ids": [t], "color": "#0000FF"})).unwrap();
assert_eq!(fills_shown(&s, t), ["0000FF"; 5]);
assert!(node(&s).kind.text().unwrap().runs.is_empty(), "no character keeps fills of its own");
assert_eq!(s.ds().unwrap().history.undo.len(), steps + 1, "one undo step");
s.undo().unwrap();
assert_eq!(fills_shown(&s, t), ["FF0000", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "undo puts the characters' fills back");
s.redo().unwrap();
// All of the characters styled while editing: they share a list the layer doesn't have yet.
// A fill added at the layer goes on top of that list, not the layer's old one.
s.execute("text.style", &json!({"id": t, "start": 0, "end": 5, "textFills": ["#00FF00"]})).unwrap();
assert_eq!(fills_shown(&s, t), ["00FF00"; 5]);
assert_eq!(character_fills(&node(&s)).map(|f| f.len()), Some(1), "one shared list, the characters'");
assert_eq!(node(&s).fills[0].solid_color().unwrap().to_hex(), "0000FF", "the layer's own list is older");
s.execute("fill.add", &json!({"ids": [t], "color": "#FF00FF"})).unwrap();
assert_eq!(fills_shown(&s, t), ["00FF00+FF00FF"; 5]);
assert!(node(&s).kind.text().unwrap().runs.is_empty());
assert_eq!(node(&s).fills.len(), 2, "the layer's list is the characters' now");
// `node.set` with a fill, and a mixed text again: both reach every character.
s.execute("text.style", &json!({"id": t, "start": 1, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
s.execute("node.set", &json!({"id": t, "props": {"fill": "#123456"}})).unwrap();
assert_eq!(fills_shown(&s, t), ["123456"; 5]);
// A removal that names no paint leaves the characters' own fills alone; a copy takes the fill
// the characters show, the list the Design panel's rows are.
s.execute("text.style", &json!({"id": t, "start": 1, "end": 5, "textFills": ["#FFFF00"]})).unwrap();
s.execute("fill.remove", &json!({"ids": [t], "index": 7})).unwrap();
assert_eq!(fills_shown(&s, t), ["123456", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "nothing removed, nothing taken");
// Characters coloured differently show no fill rows at all: a removal with or without an
// index leaves the layer's hidden fill and the characters' own alone, and there is no row to
// crop (the command is unavailable, as the menu has it).
s.execute("fill.remove", &json!({"ids": [t], "index": 0})).unwrap();
s.execute("fill.remove", &json!({"ids": [t]})).unwrap();
assert_eq!(fills_shown(&s, t), ["123456", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "mixed: nothing to remove");
assert_eq!(node(&s).fills.len(), 1, "the layer's own fill stays");
assert!(matches!(s.execute("image.crop", &json!({"ids": [t]})), Err(crate::EngineError::Disabled(..))), "mixed: nothing to crop");
assert_eq!(fills_shown(&s, t), ["123456", "FFFF00", "FFFF00", "FFFF00", "FFFF00"], "and nothing taken");
assert_eq!(crate::cmd::image::top_image_fill(&node(&s)), None, "the menu finds no image to crop either");
s.execute("text.style", &json!({"id": t, "start": 0, "end": 5, "textFills": ["#00FF00"]})).unwrap();
s.execute("fill.copy", &json!({"id": t})).unwrap();
let copied = s.clipboard.paint.clone().and_then(|(_, p)| p.solid_color()).map(|c| c.to_hex());
assert_eq!(copied.as_deref(), Some("00FF00"), "the characters' fill is copied, not the layer's older one");
// A rectangle is left alone by all of this.
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap();
s.execute("fill.set", &json!({"ids": [r], "color": "#ABCDEF"})).unwrap();
assert_eq!(character_fills(s.doc().unwrap().node(r).unwrap()).map(|f| f.len()), Some(1));
}
#[test]
fn range_fill_sets_and_clears() {
let mut s = Session::new();
+2 -2
View File
@@ -86,7 +86,7 @@ fn editing_text_applies_to_the_selected_chars() {
s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "fontSize": 20})).unwrap();
let c = s.execute("style.create", &json!({"type": "text", "name": "Type/Caption"})).unwrap()["id"].as_str().unwrap().to_string();
let style = s.doc().unwrap().style(&c).cloned().unwrap();
let StyleKind::Text { style: def } = style.kind else { panic!("not a text style") };
let StyleKind::Text { style: def, .. } = style.kind else { panic!("not a text style") };
assert_eq!(def.font_size, 20.0);
assert_eq!(def.style_id, None, "definitions link to nothing");
assert_eq!(links(&s, t)[8].as_deref(), Some(c.as_str()));
@@ -117,7 +117,7 @@ fn edits_unlink_and_style_edits_follow() {
assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str()));
// Redefining the style moves linked chars only.
let mut def = match &s.doc().unwrap().style(&h).unwrap().kind {
StyleKind::Text { style } => style.clone(),
StyleKind::Text { style, .. } => style.clone(),
_ => panic!("not a text style"),
};
def.font_size = 40.0;
+9 -4
View File
@@ -64,7 +64,7 @@ fn a_list_with_a_start_number_toggles_off() {
let mut s = Session::new();
let t = text(&mut s, "two\nthree\nfour");
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
let n = |start_offset| ParaList { list: N, indent: 0, start_offset, ..ParaList::default() };
td.paragraphs = vec![n(1), n(0), n(0)];
}
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
@@ -157,7 +157,8 @@ fn list_keys_at_list_items() {
for (chars, before, caret, k, shift, after, want, why) in cases {
let mut s = Session::new();
let t = text(&mut s, chars);
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
let ps: Vec<ParaList> =
before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0, ..ParaList::default() }).collect();
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
td.paragraphs = ps;
}
@@ -181,7 +182,11 @@ fn paragraph_lists_follow_edits() {
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
// the one they split from; joined paragraphs keep the first one's.
let mut t = TextData::new("ab\ncd\nef");
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
t.paragraphs = vec![
ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() },
ParaList::default(),
ParaList { list: N, indent: 0, start_offset: 0, ..ParaList::default() },
];
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
t.replace_range(1, 1, "x\ny");
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
@@ -196,7 +201,7 @@ fn paragraph_lists_follow_edits() {
(seed >> 33) as usize % n.max(1)
};
let mut t = TextData::new("a\nb\nc\nd");
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0, ..ParaList::default() }; 4];
for _ in 0..500 {
let n = t.char_count();
let (a, b) = (rnd(n + 2), rnd(n + 2));
+190
View File
@@ -0,0 +1,190 @@
//! Increase and decrease font weight step through the styles the family has, by weight with the
//! same slant and width (#1831), not by 100 to a canonical name no face provides.
use figcraft_doc::{NodeId, TextStyle};
use serde_json::json;
use crate::Session;
use crate::cmd::text::{name_weight, next_style};
/// A family's styles listed by the weights their names say, as for faces not read yet.
fn styles(names: &[&str]) -> Vec<(String, u16)> {
names.iter().map(|s| ((*s).to_string(), name_weight(s))).collect()
}
/// A family's styles with the faces' own weights.
fn weighed(faces: &[(&str, u16)]) -> Vec<(String, u16)> {
faces.iter().map(|(s, w)| ((*s).to_string(), *w)).collect()
}
fn at(style: &str) -> TextStyle {
let mut st = TextStyle::default();
st.set_font_style(style);
st
}
/// Walk `from` heavier (`up`) or lighter until the family has nothing further.
fn walk(family: &[(String, u16)], from: &str, up: bool) -> Vec<String> {
let mut st = at(from);
let mut out = Vec::new();
while let Some(next) = next_style(family, &st, up) {
st.set_font_style(next.clone());
out.push(next);
}
out
}
#[test]
fn the_steps_follow_the_family_with_the_same_slant_and_width() {
let arial = styles(&["Narrow", "Regular", "Italic", "Narrow Italic", "Bold", "Narrow Bold", "Bold Italic", "Narrow Bold Italic", "Black"]);
assert_eq!(walk(&arial, "Regular", true), ["Bold", "Black"]);
assert_eq!(walk(&arial, "Black", false), ["Bold", "Regular"]);
assert_eq!(walk(&arial, "Italic", true), ["Bold Italic"]);
assert_eq!(walk(&arial, "Narrow", true), ["Narrow Bold"]);
assert_eq!(walk(&arial, "Narrow Bold Italic", false), ["Narrow Italic"]);
let segoe = styles(&["Light", "Semilight", "Regular", "Italic", "Semibold", "Bold", "Black", "Bold Italic"]);
assert_eq!(walk(&segoe, "Regular", true), ["Semibold", "Bold", "Black"]);
assert_eq!(walk(&segoe, "Regular", false), ["Semilight", "Light"]);
// A style the family doesn't have (as the old steps stored) moves to the next real one.
assert_eq!(next_style(&segoe, &at("Medium"), true).as_deref(), Some("Semibold"));
}
#[test]
fn extra_and_semi_before_a_width_keep_the_width() {
let family = styles(&["Extra Condensed Regular", "Condensed Regular", "Condensed Bold", "Extra Condensed Bold", "Semi Condensed Extra Bold"]);
assert_eq!(walk(&family, "Extra Condensed Regular", true), ["Extra Condensed Bold"]);
assert_eq!(walk(&family, "Condensed Bold", false), ["Condensed Regular"]);
assert_eq!(walk(&family, "Semi Condensed Extra Bold", false), Vec::<String>::new());
}
#[test]
fn oblique_names_step_to_oblique_faces() {
let dejavu = styles(&["Book", "Oblique", "Bold", "Bold Oblique"]);
assert_eq!(walk(&dejavu, "Oblique", true), ["Bold Oblique"]);
assert_eq!(walk(&dejavu, "Bold Oblique", false), ["Oblique"]);
assert_eq!(walk(&dejavu, "Book", true), ["Bold"]);
}
#[test]
fn hairline_and_w_numbers_are_weights() {
let family = styles(&["Hairline", "Thin", "Regular", "Bold"]);
assert_eq!(walk(&family, "Regular", false), ["Thin", "Hairline"]);
// Off the list (a stored style the family lacks): by weight, the nearer of two equal ones.
assert_eq!(next_style(&family, &at("Extra Light"), false).as_deref(), Some("Thin"));
assert_eq!(next_style(&family, &at("Extra Light"), true).as_deref(), Some("Regular"));
let hiragino = styles(&["W3", "W6"]);
assert_eq!(walk(&hiragino, "W3", true), ["W6"]);
assert_eq!(next_style(&hiragino, &at("Bold"), false).as_deref(), Some("W6"));
}
#[test]
fn number_weights_count_after_a_width_but_other_numbers_dont() {
// An off-list "Condensed 600" is 600, as "600" alone is: between Condensed 500 and 700.
let condensed = weighed(&[("Condensed 300", 300), ("Condensed 500", 500), ("Condensed 700", 700)]);
assert_eq!(next_style(&condensed, &at("Condensed 600"), true).as_deref(), Some("Condensed 700"));
assert_eq!(next_style(&condensed, &at("Condensed 600"), false).as_deref(), Some("Condensed 500"));
assert_eq!((name_weight("Condensed 600"), name_weight("600 Italic"), name_weight("Condensed 1000")), (600, 600, 1000));
// A number that isn't a weight (a width grade, an optical size) is part of the width.
assert_eq!((name_weight("Condensed 2"), name_weight("Caption 12 Bold"), name_weight("Condensed +600")), (400, 700, 400));
let optical = styles(&["Caption 12 Regular", "Caption 24 Regular", "Caption 12 Bold", "Caption 24 Bold"]);
assert_eq!(walk(&optical, "Caption 12 Regular", true), ["Caption 12 Bold"]);
assert_eq!(walk(&optical, "Caption 24 Bold", false), ["Caption 24 Regular"]);
}
#[test]
fn univers_numbers_step_by_weight_within_their_width() {
// Helvetica Neue LT, Univers, Frutiger: the first digit grades the weight, the second the width.
let hn = weighed(&[
("45 Light", 300),
("46 Light Italic", 300),
("55 Roman", 400),
("56 Italic", 400),
("57 Condensed", 400),
("65 Medium", 500),
("67 Medium Condensed", 500),
("75 Bold", 700),
("76 Bold Italic", 700),
("77 Bold Condensed", 700),
]);
assert_eq!(walk(&hn, "55 Roman", true), ["65 Medium", "75 Bold"]);
assert_eq!(walk(&hn, "65 Medium", false), ["55 Roman", "45 Light"]);
assert_eq!(walk(&hn, "57 Condensed", true), ["67 Medium Condensed", "77 Bold Condensed"]);
assert_eq!(walk(&hn, "67 Medium Condensed", false), ["57 Condensed"]);
assert_eq!(walk(&hn, "46 Light Italic", true), ["56 Italic", "76 Bold Italic"]);
// Faces not read yet: the words after the number give the weight.
let univers = styles(&["45 Light", "55 Roman", "65 Bold", "75 Black", "57 Condensed", "67 Bold Condensed"]);
assert_eq!(walk(&univers, "45 Light", true), ["55 Roman", "65 Bold", "75 Black"]);
assert_eq!(walk(&univers, "67 Bold Condensed", false), ["57 Condensed"]);
assert_eq!((name_weight("45 Light"), name_weight("75 Black"), name_weight("57 Condensed")), (300, 900, 400));
// Other leading numbers stay part of the width: before another word, or not a width digit.
let other = styles(&["12 Regular", "12 Bold", "55 Rounded", "55 Rounded Bold", "Regular", "Bold"]);
assert_eq!(walk(&other, "12 Regular", true), ["12 Bold"]);
assert_eq!(walk(&other, "55 Rounded", true), ["55 Rounded Bold"]);
assert_eq!(walk(&other, "Regular", true), ["Bold"]);
}
#[test]
fn a_custom_weight_axis_value_steps_from_where_it_is() {
let family = styles(&["Light", "Regular", "Medium", "Bold"]);
let mut st = at("Regular");
st.variations.insert("wght".into(), 550.0);
assert_eq!(next_style(&family, &st, true).as_deref(), Some("Bold"));
assert_eq!(next_style(&family, &st, false).as_deref(), Some("Medium"));
}
#[test]
fn off_the_list_the_faces_own_weights_decide() {
// From 500 between 400 and 700 faces: the neighbours, under their own names (the axis value goes).
let family = weighed(&[("Light", 300), ("Regular", 400), ("Bold", 700)]);
let mut st = at("Regular");
st.variations.insert("wght".into(), 500.0);
assert_eq!(next_style(&family, &st, true).as_deref(), Some("Bold"));
assert_eq!(next_style(&family, &st, false).as_deref(), Some("Regular"));
st.set_font_style("Bold");
assert_eq!((st.weight(), st.variations.get("wght")), (700, None));
// Segoe UI Variable's Semilight is 350, not the 300 its name says: from a `wght` of 325 it is
// the next heavier style, and from 375 the next lighter.
let segoe = weighed(&[("Light", 300), ("Semilight", 350), ("Regular", 400), ("Semibold", 600), ("Bold", 700)]);
st.variations.insert("wght".into(), 325.0);
assert_eq!(next_style(&segoe, &st, true).as_deref(), Some("Semilight"));
assert_eq!(next_style(&segoe, &st, false).as_deref(), Some("Light"));
st.variations.insert("wght".into(), 375.0);
assert_eq!(next_style(&segoe, &st, false).as_deref(), Some("Semilight"));
assert_eq!(next_style(&segoe, &st, true).as_deref(), Some("Regular"));
// A W-numbered name with a slant after it is still that weight ("W7 Italic" is 700).
let hiragino = weighed(&[("W3 Italic", 300), ("W6 Italic", 600), ("W8 Italic", 800)]);
assert_eq!(next_style(&hiragino, &at("W7 Italic"), false).as_deref(), Some("W6 Italic"));
assert_eq!(next_style(&hiragino, &at("W7 Italic"), true).as_deref(), Some("W8 Italic"));
// And after a width ("Condensed W7 Italic" is 700 too).
let condensed = weighed(&[("Condensed W3 Italic", 300), ("Condensed W6 Italic", 600), ("Condensed W8 Italic", 800)]);
assert_eq!(next_style(&condensed, &at("Condensed W7 Italic"), true).as_deref(), Some("Condensed W8 Italic"));
assert_eq!(next_style(&condensed, &at("Condensed W7 Italic"), false).as_deref(), Some("Condensed W6 Italic"));
}
fn font_style(s: &Session, t: NodeId) -> String {
s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style.clone()
}
#[test]
fn the_commands_land_on_styles_the_family_has() {
figcraft_text::scan_system_fonts();
// Any installed family with a Regular face and a style the step takes it to (CI machines differ).
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
let st = figcraft_text::family_style_weights(&f);
let up = next_style(&st, &at("Regular"), true).filter(|_| st.iter().any(|(x, _)| x == "Regular"))?;
Some((f, st, up))
});
let Some((family, styles, heavier)) = pick else { return };
let mut s = Session::new();
let r = s.execute("node.create", &json!({"type": "text", "text": "Hi", "props": {"fontFamily": family, "fontStyle": "Regular"}})).unwrap();
let t = crate::params::id_of(&r["id"]).unwrap();
s.execute("text.weightUp", &json!({"ids": [t]})).unwrap();
let up = font_style(&s, t);
assert_eq!(up, heavier, "{family}: {styles:?}");
s.execute("text.weightDown", &json!({"ids": [t]})).unwrap();
assert_eq!(font_style(&s, t), "Regular", "{family}: {styles:?}");
// A family that isn't installed still steps by 100 under the canonical names.
s.execute("node.set", &json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Regular"}})).unwrap();
s.execute("text.weightUp", &json!({"ids": [t]})).unwrap();
assert_eq!(font_style(&s, t), "Medium");
}
@@ -278,6 +278,45 @@ fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() {
assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole");
}
#[test]
fn simplify_and_offset_keep_a_closed_vectors_smoothed_corners() {
// A closed 100 × 100 vector with radius 20 on every point and corner smoothing 0.6 draws a
// smoothed rectangle's outline; simplify and offset rebuild it from that outline.
let drawn = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6);
let plain = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.0).area().abs();
let (area, d) = (drawn.area().abs(), 2.0);
// Offsetting a convex outline by d adds its perimeter times d and a circle of radius d.
let grown = area + drawn.perimeter(1e-6) * d + std::f64::consts::PI * d * d;
assert!((area - plain).abs() > 10.0, "smoothing changes the outline: {area} vs {plain}");
// (command, params, region paints of its own, expected area, tolerance: the offset ring is
// flattened before the union)
let rows = [
("vector.simplify", json!({"tolerance": 0.01}), false, area, 1.0),
("vector.simplify", json!({"tolerance": 0.01}), true, area, 1.0),
("vector.offset", json!({"offset": d}), false, grown, 4.0),
("vector.offset", json!({"offset": d}), true, grown, 4.0),
];
let mut wrong = Vec::new();
for (cmd, mut params, painted, expected, tolerance) in rows {
let mut s = Session::new();
let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": 20}}));
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0.6}}));
if painted {
let doc = s.doc_mut().unwrap();
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
params["ids"] = json!([id]);
ex(&mut s, cmd, params);
let got = net(&s, id).fill_path().area().abs();
if (got - expected).abs() >= tolerance {
wrong.push(format!("{cmd} (region paints: {painted}): {got} vs {expected}"));
}
}
assert!(wrong.is_empty(), "simplify and offset keep the smoothed corners: {wrong:?}");
}
/// Give vector `id` a painted region, converting a plain shape to a vector first.
fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
let n = doc.get(id).unwrap();
+2 -2
View File
@@ -225,7 +225,7 @@ fn same_after_round_trip(doc: &Document, what: &str) -> Vec<String> {
normalize(&mut n, &|i| i);
*paints = n.fills;
}
StyleKind::Text { style } => {
StyleKind::Text { style, .. } => {
if let NodeKind::Text(td) = &mut n.kind {
td.style = style.clone();
}
@@ -444,7 +444,7 @@ fn styled() -> Document {
bound: Default::default(),
};
doc.styles = vec![
style("s-text", "Heading", StyleKind::Text { style: heading.clone() }),
style("s-text", "Heading", StyleKind::Text { style: heading.clone(), paragraph: Default::default() }),
style("s-brand", "Brand/Primary", StyleKind::Paint { paints: vec![solid(0.25, 0.5, 1.0)] }),
style("s-unused", "Brand/Unused", StyleKind::Paint { paints: vec![solid(1.0, 0.0, 0.0), solid(0.0, 1.0, 0.0)] }),
style("s-shadow", "Elevation/1", StyleKind::Effect { effects: vec![shadow.clone()] }),
+2 -2
View File
@@ -27,8 +27,8 @@ use figcraft_doc::proto::{
use figcraft_doc::{
AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode,
LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap,
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType,
VariableValue, VectorNetwork, WindingRule,
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, VConstraint,
VariableType, VariableValue, VectorNetwork, WindingRule,
};
use serde_json::Value;
use sha1::Digest;
+4 -3
View File
@@ -159,7 +159,7 @@ impl<'d> Exporter<'d> {
Bound::default()
} else {
let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0);
if let StyleKind::Text { style } = &st.kind {
if let StyleKind::Text { style, .. } = &st.kind {
sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") });
}
sample.bound.clone_from(&st.bound);
@@ -179,8 +179,9 @@ impl<'d> Exporter<'d> {
let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect();
f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]);
}
StyleKind::Text { style } => {
let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") };
StyleKind::Text { style, paragraph } => {
let mut td = TextData { style: style.as_text_style(), ..TextData::new("Ag") };
td.take_paragraph_style(paragraph);
let fills = [Paint::solid(Color::BLACK)];
f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none)));
self.text(&td, &fills, &mut f);
+2 -2
View File
@@ -34,10 +34,10 @@ pub(super) fn outlined(n: &Node) -> Option<(Node, String)> {
}
let path = figcraft_doc::geometry::stroke_geometry(n)?;
let net = match &n.kind {
NodeKind::Vector { network } => Some(network),
NodeKind::Vector { network } => Some(figcraft_doc::geometry::drawn_network(network, n.corners.smoothing)),
_ => None,
};
let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net);
let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net.as_deref());
if area.elements().is_empty() || area.elements().len() > MAX_NETWORK / 4 {
return None;
}
+10 -5
View File
@@ -1,8 +1,8 @@
use super::common::{style_fields, unsupported_decoration_look};
use super::{
Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,
StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,
join_name, network, o, path_blob, s, u, xy,
StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextDirection, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite,
fl, int, join_name, network, o, path_blob, s, u, xy,
};
impl<'d> Exporter<'d> {
@@ -136,11 +136,16 @@ impl<'d> Exporter<'d> {
let listed = *l != ListStyle::None;
let begins = first(i, l);
let start = starts.get(i).copied().unwrap_or(0);
let direction = match td.para_list(i).direction {
TextDirection::Auto => "AUTO",
TextDirection::Ltr => "LTR",
TextDirection::Rtl => "RTL",
};
o(vec![
("lineType", e(ty)),
("styleId", int(0)),
("indentationLevel", int(i64::from(listed))),
("sourceDirectionality", e("AUTO")),
("sourceDirectionality", e(direction)),
("listStartOffset", int(i64::from(start))),
("isFirstLineOfList", b(begins)),
])
@@ -265,7 +270,7 @@ impl<'d> Exporter<'d> {
if net.regions.iter().any(|r| r.winding != winding) {
self.warn("vectors whose filled areas use different winding rules use the first one's in their outline");
}
self.geometry("fillGeometry", &net.fill_path(), winding, f);
self.geometry("fillGeometry", &figcraft_doc::geometry::drawn_network(net, n.corners.smoothing).fill_path(), winding, f);
}
}
@@ -317,7 +322,7 @@ impl<'d> Exporter<'d> {
self.geometry("strokeGeometry", &ring, WindingRule::EvenOdd, f);
} else if sp.align == StrokeAlign::Center {
let path = match &n.kind {
NodeKind::Vector { network } => Some(network.stroke_path()),
NodeKind::Vector { .. } => figcraft_doc::geometry::stroke_geometry(n),
NodeKind::Text(_) => None,
_ => figcraft_doc::geometry::shape_path(n),
};
+43
View File
@@ -912,6 +912,49 @@ fn written_files_decode_and_list_parents_first() {
}
}
#[test]
fn closed_vector_geometry_is_written_with_its_corner_smoothing() {
// A closed vector with rounded points: its written fill and stroke outlines take the layer's
// corner smoothing, and the smoothing and each point's radius come back on import.
let mut doc = Document::new("smoothing");
let page = doc.first_page().unwrap();
let mut network = VectorNetwork::from_points(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)), true);
for (v, r) in network.vertices.iter_mut().zip([20.0, 0.0, 10.0, 0.0]) {
v.radius = r;
}
let id = doc.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
doc.node_mut(id).unwrap().strokes = vec![Paint::solid(Color::BLACK)];
// The vector's written fill and stroke outlines.
let outlines = |doc: &Document| -> Vec<Vec<u8>> {
let (bytes, _) = export(doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let blobs = msg.get("blobs").unwrap().list();
let changes = msg.get("nodeChanges").unwrap().list();
let nc = changes.iter().find(|nc| nc.get("type").and_then(Kv::str) == Some("VECTOR")).unwrap();
["fillGeometry", "strokeGeometry"]
.iter()
.map(|key| {
let paths = nc.get(key).map(Kv::list).unwrap_or_default();
let blob = paths.first().unwrap_or_else(|| panic!("{key} is written")).get("commandsBlob").unwrap().f64().unwrap() as usize;
blobs[blob].get("bytes").and_then(Kv::bytes).unwrap().to_vec()
})
.collect()
};
let plain = outlines(&doc);
doc.node_mut(id).unwrap().corners.smoothing = 0.6;
let smoothed = outlines(&doc);
assert_ne!(smoothed[0], plain[0], "the fill outline takes the smoothing");
assert_ne!(smoothed[1], plain[1], "the stroke outline takes the smoothing");
let (back, _) = round_trip(&doc, "smoothed vector");
let v = back.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. })).unwrap();
assert!((v.corners.smoothing - 0.6).abs() < 1e-6, "smoothing comes back: {}", v.corners.smoothing);
let NodeKind::Vector { network } = &v.kind else { panic!("vector") };
let radii: Vec<f64> = network.vertices.iter().map(|x| x.radius).collect();
assert_eq!(radii, vec![20.0, 0.0, 10.0, 0.0], "each point keeps its own radius");
}
#[test]
fn the_written_schema_parses_and_round_trips() {
let schema = Schema::parse(export::SCHEMA).unwrap();
+2 -2
View File
@@ -403,10 +403,10 @@ fn the_schema_numbers_what_the_format_numbers() {
#[test]
fn schema_text_takes_numbers_and_refuses_bad_ones() {
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=2").unwrap();
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=5").unwrap();
let values = |i: usize| s.defs[i].fields.iter().map(|f| (f.name.as_str(), f.value)).collect::<Vec<_>>();
assert_eq!(values(0), [("A", 3), ("B", 1), ("C", 9)]);
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 2)]);
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 5)]);
for bad in ["enum E A=", "enum E A=x", "enum E A=-1", "message M uint a=1.5", "message M uint a=99999999999", "enum E =", "message M uint ="] {
assert!(Schema::parse(bad).is_err() || bad.ends_with('='), "{bad}");
}
+6
View File
@@ -276,6 +276,9 @@ impl Schema {
let ty = r.var_int()?;
let array = r.byte()? & 1 != 0;
let value = r.var_uint()?;
if kind == Kind::Message && fields.iter().any(|f: &Field| f.value == value) {
return err(format!("schema: duplicate field id {value} in {name}"));
}
raw_types.push(ty);
fields.push(Field { name: fname, ty: None, array, value });
}
@@ -645,6 +648,9 @@ impl Schema {
}
.ok_or_else(|| KiwiError(format!("schema text: unknown type {base}")))?;
let (name, value) = numbered(n.trim(), id)?;
if *kind == "message" && fields.iter().any(|f: &Field| f.value == value) {
return err(format!("schema text: duplicate field id {value} in {name}"));
}
fields.push(Field { name, ty: Some(ty), array: t.ends_with("[]"), value });
}
(if *kind == "struct" { Kind::Struct } else { Kind::Message }, fields)
+50 -4
View File
@@ -2,7 +2,7 @@ use super::layer::{cap_name, own_text, svg_path, valid_size};
use super::paint::{paint, stroke};
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows};
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
use figcraft_doc::{ConnectorPath, MAX_RUNS, RouteEnd, Routing, Side, attach_points, attached_sides, route_along};
/// How a connector imports: from its saved snapshot (`terminal`: as one vector, no room for its
/// label), or drawn from its ends, as a frame with its line and `label` when it has one.
@@ -48,7 +48,8 @@ impl<'a> Mapper<'a> {
/// A connector's end caps, and when it has no saved outline or network its line between its
/// ends (free points, or a sibling layer): straight to the point given on the layer (its
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs.
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs;
/// an elbow saved with its path (FigJam's control points) runs along it (#1910).
/// Returns the middle of that line by length, in the connector's own coordinates.
pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) -> Option<Point> {
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
@@ -94,11 +95,13 @@ impl<'a> Mapper<'a> {
};
ok.then_some(end)
};
let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else {
let ends = end("connectorStart").zip(end("connectorEnd"));
let path = ends.filter(|_| routing == Routing::Elbow).and_then(|(a, b)| saved_runs(v, a, b));
let Some(pts) = ends.and_then(|(a, b)| route_along(a, b, routing, path.as_ref())) else {
self.warn("connectors whose ends can't be placed are empty");
return None;
};
if routing != Routing::Straight {
if routing != Routing::Straight && path.is_none() {
self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing");
}
// A curved route is one curve: its ends and the two control points between them.
@@ -407,6 +410,49 @@ pub(super) fn connector_has_saved_label(v: &View<'_>) -> bool {
.is_some_and(|s| !s.trim().is_empty())
}
/// The path a FigJam elbow connector was saved with: its `connectorControlPoints`, one per run
/// between the first and the last, each a point on its run (in the connector's parent's
/// coordinates, like its ends) with the run's axis; the first run leaves the start's side and the
/// last arrives at the end's, so neither is saved. `None` without any, or when they don't make
/// such a path: a point off both axes, two runs in a row the same way, a first or last run that
/// would leave or arrive sideways to its side, or more runs than a hand could make here.
fn saved_runs(v: &View<'_>, from: RouteEnd, to: RouteEnd) -> Option<ConnectorPath> {
let saved = v.list("connectorControlPoints");
if saved.is_empty() || saved.len() + 2 > MAX_RUNS {
return None;
}
let (p0, p1) = attach_points(from, to);
let mid = p0.midpoint(p1);
let mut at = Vec::with_capacity(saved.len());
let (mut first, mut last) = (None, None);
for p in saved {
let pos = p.get("position").and_then(|q| Some(Point::new(num(q, "x")?, num(q, "y")?)))?;
let (ax, ay) = p.get("axis").and_then(|q| Some((num(q, "x")?, num(q, "y")?)))?;
let sideways = match (ax != 0.0, ay != 0.0) {
(true, false) => true,
(false, true) => false,
_ => return None,
};
if last == Some(sideways) {
return None;
}
first.get_or_insert(!sideways);
last = Some(sideways);
at.push(if sideways { pos.y - mid.y } else { pos.x - mid.x });
}
// The run through each end goes the way its side faces, out of the object: the first leaves
// the start's side for the first saved run, the last arrives at the end's from the last saved
// run. One along the side, or back into the object, doesn't.
let (across, last) = (first?, last?);
let (&a0, &a1) = (at.first()?, at.last()?);
// Where the run from `p` (`sideways`: going across) meets the saved run at `v`.
let onto = |sideways: bool, p: Point, v: f64| if sideways { Point::new(mid.x + v, p.y) } else { Point::new(p.x, mid.y + v) };
let out_of = |s: Option<Side>, p: Point, q: Point| s.is_none_or(|s| (q - p).dot(s.normal()) > 0.0);
let (s0, s1) = attached_sides(from, to);
let fits = out_of(s0, p0, onto(across, p0, a0)) && out_of(s1, p1, onto(!last, p1, a1));
fits.then_some(ConnectorPath::Runs { across, at })
}
/// The side of its layer a connector end leaves from: the `magnet` the file names, else the edge
/// its point on the layer (`relativePosition`, 0–1) lies on, else the side facing the other end.
fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff. Show more