mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 03:51:42 +00:00
Merge upstream/main into fix/padding-all-sides
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
a0cb09c017
190 files changed
+4789
-585
No files matched your search
+9
-2
@@ -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 (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 each layer's bindings, sizing, text and instance checks, on every Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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 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 (Line 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) |
|
||||
@@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **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.
|
||||
@@ -359,6 +361,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| 2026-11-06 | patch | M8: glass takes one backdrop sample instead of its 18-sample dispersion window where the colours split under 1/256 px (the flat end of the curve, where the move is short), within one level of the window over any backdrop: a large pane renders about 2–5% faster, with the same pixels in the renders compared. Tests: `glass::tests::a_split_too_narrow_to_show_takes_one_sample`. Numbers unchanged |
|
||||
| 2026-11-02 | patch | M8, #1885: a glass whose shape is too big to keep whole at the zoom (past `MAX_SHAPE_PX` with its core's margin) bends along its edge normals instead of working out a core, so its fields never cover more pixels than a kept shape's, and the core's feature transform holds 16 bytes a pixel. The edge normals are box-blurred twice again and the bend directions once, as documented. Test: `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`. Numbers unchanged |
|
||||
| 2026-11-02 | minor | M3, #1885: glass bends the backdrop towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly instead of meeting along the diagonal, and a part thinner than twice that (a capsule, a letter's stroke) bends across itself towards its own middle (pixels about as deep as any within 2 px). Rectangles, frames, components, instances and sections whose corners aren't smoothed work their glass out in closed form, with nothing to rasterise, transform or keep: a zoom frame draws in 40 ms instead of 54 and a resize frame in 46 instead of 67 (`glass_render_timings`, release, 2×). Other shapes keep their fields, with the bend directions for the last two depths asked for beside them, so dragging the Depth slider works out only those again. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::neighbouring_strokes_bend_towards_their_own_middles`, `glass::tests::a_rounded_rectangle_in_closed_form_matches_its_distance_transform`, `glass_cut_tests::what_the_shape_depends_on_works_it_out_again_and_nothing_else_does`. Numbers unchanged |
|
||||
|
||||
@@ -112,3 +112,6 @@ display_name = "Denis Gomes"
|
||||
[people.Hamza0101]
|
||||
real_name = "Hamza Naveed"
|
||||
display_name = "Hamza0101"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
@@ -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,
|
||||
|
||||
@@ -235,6 +235,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+204
-13
@@ -9,16 +9,20 @@
|
||||
//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by
|
||||
//! item: both sides changing different items of the list (or one adding items at its end) is no
|
||||
//! conflict, only the same item changed both ways or the list shortened on a side while the other
|
||||
//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for
|
||||
//! one side, and the other side's changes to it are left out. Everything else from both sides is
|
||||
//! kept.
|
||||
//! changed it. Auto layout settings (a frame's and a child's) merge field by field: each field
|
||||
//! changed both ways is a conflict of its own, and the other fields merge. Values a file works out
|
||||
//! for itself (a size or place auto layout gives a layer, text sizing to its characters, a value a
|
||||
//! layer in an instance takes from its main component) never conflict: the merged file works them
|
||||
//! out again from what they follow. Nothing that conflicts is merged until it is resolved: each
|
||||
//! conflict is resolved for one side, and the other side's changes to it are left out. Everything
|
||||
//! else from both sides is kept.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, Sizing, TextAutoResize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Json, Prop};
|
||||
use crate::replica::{Mode, Positions, Replica, create_change};
|
||||
@@ -33,6 +37,7 @@ pub enum Side {
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// The side that isn't `self`.
|
||||
pub fn other(self) -> Side {
|
||||
match self {
|
||||
Side::Branch => Side::Main,
|
||||
@@ -48,6 +53,7 @@ impl Side {
|
||||
}
|
||||
}
|
||||
|
||||
/// The side `"branch"` or `"main"` names (`None` for anything else).
|
||||
pub fn parse(s: &str) -> Option<Side> {
|
||||
match s {
|
||||
"branch" => Some(Side::Branch),
|
||||
@@ -62,6 +68,9 @@ impl Side {
|
||||
pub enum Key {
|
||||
/// A layer property. A layer's parent and position are one property (where it is).
|
||||
Node(NodeId, Prop),
|
||||
/// One field of a layer property that merges field by field ([`PER_FIELD`]): `padding` of
|
||||
/// `layout`, `sizing_h` of `childLayout`…
|
||||
Field(NodeId, Prop, String),
|
||||
/// A layer deleted on one side and edited (it or what's inside it) on the other.
|
||||
Layer(NodeId),
|
||||
/// A document-level property.
|
||||
@@ -78,11 +87,13 @@ fn camel<T: Serialize>(v: &T) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `12/fills`, `12/layer`, `doc/styleOrder`, `style/S:3`, `comment/7`… (the keys resolutions use).
|
||||
/// `12/fills`, `12/layout.padding`, `12/layer`, `doc/styleOrder`, `style/S:3`, `comment/7`… (the
|
||||
/// keys resolutions use).
|
||||
impl fmt::Display for Key {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Key::Node(id, prop) => write!(f, "{id}/{}", camel(prop)),
|
||||
Key::Field(id, prop, field) => write!(f, "{id}/{}.{field}", camel(prop)),
|
||||
Key::Layer(id) => write!(f, "{id}/layer"),
|
||||
Key::Doc(prop) => write!(f, "doc/{}", camel(prop)),
|
||||
Key::Entry(e) => match e {
|
||||
@@ -129,10 +140,25 @@ pub struct Plan {
|
||||
/// The main file's changes since the base.
|
||||
pub main: Vec<Change>,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
/// Layer properties changed both ways to values both files work out for themselves
|
||||
/// ([`computed`]): no conflict, and a merge leaves the file merged into its own value.
|
||||
derived: BTreeSet<Key>,
|
||||
/// Layer properties ([`Key::Node`]) merged field by field with fields in conflict.
|
||||
fields: BTreeMap<Key, Fields>,
|
||||
branch_positions: Positions,
|
||||
main_positions: Positions,
|
||||
}
|
||||
|
||||
/// A layer property merged field by field ([`merge_fields`]): the value with every field changed
|
||||
/// one way (or the same way on both sides) merged, and each side's values of the fields changed
|
||||
/// both ways (`Null` for a field a side left out).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct Fields {
|
||||
merged: Map<String, Value>,
|
||||
branch: Map<String, Value>,
|
||||
main: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Document-level properties that belong to each copy, not to the work: the file name (a branch
|
||||
/// is named after itself) and the page it opens on.
|
||||
fn merges(c: &Change) -> bool {
|
||||
@@ -152,6 +178,7 @@ fn key_of(c: &Change) -> Option<Key> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The changes (indexes) in `changes` grouped by what they write ([`key_of`]).
|
||||
fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
|
||||
let mut out: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
|
||||
for (i, c) in changes.iter().enumerate() {
|
||||
@@ -193,6 +220,108 @@ fn merged_list(base: &Document, key: &Key, b: Option<&Change>, m: Option<&Change
|
||||
merge_items(o.0.as_array()?, bv.0.as_array()?, mv.0.as_array()?).map(|v| Json(Value::Array(v)))
|
||||
}
|
||||
|
||||
/// Layer properties whose fields merge one by one: auto layout settings, a frame's (`layout`) and
|
||||
/// a child's (`childLayout`). Changing the padding on one side and the gap on the other is no
|
||||
/// conflict, and each field changed both ways is a conflict of its own ([`Key::Field`]).
|
||||
const PER_FIELD: &[Prop] = &[Prop::Layout, Prop::ChildLayout];
|
||||
|
||||
/// Three-way merge of object `o` changed to `b` on one side and `m` on the other, field by field:
|
||||
/// a field changed on one side takes that side's value, and one changed both ways (differently)
|
||||
/// is in conflict. `None` when any of them isn't an object (auto layout added or removed).
|
||||
fn merge_fields(o: &Value, b: &Value, m: &Value) -> Option<Fields> {
|
||||
let (o, b, m) = (o.as_object()?, b.as_object()?, m.as_object()?);
|
||||
let names: BTreeSet<&String> = o.keys().chain(b.keys()).chain(m.keys()).collect();
|
||||
let mut out = Fields::default();
|
||||
for name in names {
|
||||
let (base, x, y) = (o.get(name), b.get(name), m.get(name));
|
||||
let kept = if x == base {
|
||||
y
|
||||
} else if y == base || x == y {
|
||||
x
|
||||
} else {
|
||||
out.branch.insert(name.clone(), x.cloned().unwrap_or(Value::Null));
|
||||
out.main.insert(name.clone(), y.cloned().unwrap_or(Value::Null));
|
||||
base
|
||||
};
|
||||
if let Some(v) = kept {
|
||||
out.merged.insert(name.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Both sides' writes `b` and `m` of property `key` (one of [`PER_FIELD`]) merged field by field
|
||||
/// against the base; `None` when `key` isn't such a property or a value isn't an object.
|
||||
fn merged_fields(base: &Document, key: &Key, b: Option<&Change>, m: Option<&Change>) -> Option<Fields> {
|
||||
let Key::Node(id, prop) = key else { return None };
|
||||
let (Some(Change::Set { value: bv, .. }), Some(Change::Set { value: mv, .. })) = (b, m) else { return None };
|
||||
if !PER_FIELD.contains(prop) {
|
||||
return None;
|
||||
}
|
||||
let o = prop.get(base.node(*id)?).ok()?;
|
||||
merge_fields(&o.0, &bv.0, &mv.0)
|
||||
}
|
||||
|
||||
/// Whether layer `n` of `doc` is placed by its parent's auto layout (not absolute).
|
||||
fn in_flow(doc: &Document, n: &Node) -> bool {
|
||||
!n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())
|
||||
}
|
||||
|
||||
/// Whether auto layout or text works out layer `n`'s width (`horizontal`) or height: an auto layout
|
||||
/// frame hugging its contents, a child filling its auto layout parent, or text sizing to its
|
||||
/// characters.
|
||||
fn sized_for_itself(doc: &Document, n: &Node, horizontal: bool) -> bool {
|
||||
let sizing = if horizontal { n.child_layout.sizing_h } else { n.child_layout.sizing_v };
|
||||
let by_layout = match sizing {
|
||||
Sizing::Hug => n.layout.is_some(),
|
||||
Sizing::Fill => in_flow(doc, n),
|
||||
Sizing::Fixed => false,
|
||||
};
|
||||
let by_text = match &n.kind {
|
||||
NodeKind::Text(t) => t.auto_resize == TextAutoResize::WidthAndHeight || (!horizontal && t.auto_resize == TextAutoResize::Height),
|
||||
_ => false,
|
||||
};
|
||||
by_layout || by_text
|
||||
}
|
||||
|
||||
/// Whether layer `n` of `doc` (an instance, or a layer inside one) has property `prop` as its main
|
||||
/// component's layer has it: it follows the main rather than overriding it.
|
||||
fn follows_main(doc: &Document, n: &Node, prop: Prop) -> bool {
|
||||
let main = match &n.kind {
|
||||
NodeKind::Instance { component, .. } => Some(*component),
|
||||
_ => n.source,
|
||||
};
|
||||
Prop::FIELDS.contains(&prop) && main.and_then(|m| doc.node(m)).is_some_and(|m| !prop.differs(n, m))
|
||||
}
|
||||
|
||||
/// Whether layer property `key` holds a value `doc` works out for itself rather than one set on
|
||||
/// the layer: a size auto layout or text gives it ([`sized_for_itself`]), its place in an auto
|
||||
/// layout parent, or a value it takes from its main component ([`follows_main`]).
|
||||
fn computed(doc: &Document, key: &Key) -> bool {
|
||||
let Key::Node(id, prop) = key else { return false };
|
||||
let Some(n) = doc.node(*id) else { return false };
|
||||
let by_layout = match prop {
|
||||
Prop::Width => sized_for_itself(doc, n, true),
|
||||
Prop::Height => sized_for_itself(doc, n, false),
|
||||
Prop::Transform => in_flow(doc, n),
|
||||
_ => false,
|
||||
};
|
||||
by_layout || follows_main(doc, n, *prop)
|
||||
}
|
||||
|
||||
/// Whether property `key`, changed both ways, is one both files work out for themselves
|
||||
/// ([`computed`]), so neither side's value is a choice to make. A place given by auto layout is
|
||||
/// only that when both sides also turn and scale the layer the same way (a rotation is set on the
|
||||
/// layer, not worked out).
|
||||
fn derived_on_both(branch: &Document, main: &Document, key: &Key) -> bool {
|
||||
if !(computed(branch, key) && computed(main, key)) {
|
||||
return false;
|
||||
}
|
||||
let Key::Node(id, Prop::Transform) = key else { return true };
|
||||
let linear = |doc: &Document| doc.node(*id).map(|n| n.transform.as_coeffs()).map(|c| [c[0], c[1], c[2], c[3]]);
|
||||
linear(branch) == linear(main)
|
||||
}
|
||||
|
||||
/// The change at the only index in `c`.
|
||||
fn only<'a>(c: &[usize], all: &'a [Change]) -> Option<&'a Change> {
|
||||
match c {
|
||||
@@ -265,6 +394,8 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
||||
let mut mc: Vec<Change> = m.changes.into_iter().filter(merges).collect();
|
||||
let mut conflicts = Vec::new();
|
||||
let mut lists = Vec::new();
|
||||
let mut derived = BTreeSet::new();
|
||||
let mut fields = BTreeMap::new();
|
||||
let km = by_key(&mc);
|
||||
for (k, bi) in by_key(&bc) {
|
||||
// Deletions conflict with edits (below); images are content-addressed, so never differ.
|
||||
@@ -279,13 +410,28 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
match merged_list(base, &k, only(&bi, &bc), only(mi, &mc)) {
|
||||
Some(value) => lists.push((bi, mi.clone(), value)),
|
||||
None => conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() }),
|
||||
if derived_on_both(branch, main, &k) {
|
||||
derived.insert(k);
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = merged_list(base, &k, only(&bi, &bc), only(mi, &mc)) {
|
||||
lists.push((bi, mi.clone(), value));
|
||||
continue;
|
||||
}
|
||||
match (&k, merged_fields(base, &k, only(&bi, &bc), only(mi, &mc))) {
|
||||
(_, Some(f)) if f.branch.is_empty() => lists.push((bi, mi.clone(), Json(Value::Object(f.merged)))),
|
||||
(Key::Node(id, prop), Some(f)) => {
|
||||
for name in f.branch.keys() {
|
||||
let key = Key::Field(*id, *prop, name.clone());
|
||||
conflicts.push(Conflict { key, deleted_by: None, branch: bi.clone(), main: mi.clone() });
|
||||
}
|
||||
fields.insert(k, f);
|
||||
}
|
||||
_ => conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() }),
|
||||
}
|
||||
}
|
||||
// Both sides' writes of a list merged item by item become the merged list, so merging either
|
||||
// way writes it.
|
||||
// Both sides' writes of a list merged item by item (or of auto layout settings merged field by
|
||||
// field) become the merged value, so merging either way writes it.
|
||||
for (bi, mi, value) in lists {
|
||||
for (changes, at) in [(&mut bc, bi), (&mut mc, mi)] {
|
||||
for i in at {
|
||||
@@ -313,6 +459,8 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
||||
branch: bc,
|
||||
main: mc,
|
||||
conflicts,
|
||||
derived,
|
||||
fields,
|
||||
branch_positions: b.replica.positions().clone(),
|
||||
main_positions: m.replica.positions().clone(),
|
||||
})
|
||||
@@ -332,6 +480,42 @@ impl Plan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer properties both sides changed to values both files work out for themselves (a size or
|
||||
/// place auto layout gives, a value an instance takes from its main): not conflicts, and
|
||||
/// merging leaves the file merged into its own value for the merged file to work out again.
|
||||
pub fn derived(&self) -> &BTreeSet<Key> {
|
||||
&self.derived
|
||||
}
|
||||
|
||||
/// The branch's and main's values of field conflict `key` ([`Key::Field`]); `None` for other
|
||||
/// keys.
|
||||
pub fn field_values(&self, key: &Key) -> Option<(&Value, &Value)> {
|
||||
let Key::Field(id, prop, name) = key else { return None };
|
||||
let f = self.fields.get(&Key::Node(*id, *prop))?;
|
||||
Some((f.branch.get(name)?, f.main.get(name)?))
|
||||
}
|
||||
|
||||
/// The side `pick` chose for each field in conflict of every property merged field by field,
|
||||
/// written into the value merged from the other fields: the writes that replace both sides'
|
||||
/// own writes of those properties.
|
||||
fn field_writes(&self, pick: &dyn Fn(&Key) -> Option<Side>) -> Vec<Change> {
|
||||
let mut out = Vec::new();
|
||||
for (key, f) in &self.fields {
|
||||
let Key::Node(id, prop) = key else { continue };
|
||||
let mut value = f.merged.clone();
|
||||
for name in f.branch.keys() {
|
||||
let won = if pick(&Key::Field(*id, *prop, name.clone())) == Some(Side::Branch) { &f.branch } else { &f.main };
|
||||
match won.get(name) {
|
||||
Some(Value::Null) | None => value.remove(name),
|
||||
Some(v) => value.insert(name.clone(), v.clone()),
|
||||
};
|
||||
}
|
||||
out.push(Change::Set { id: *id, prop: *prop, value: Json(Value::Object(value)) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `side`'s positions (fractional keys) of the layers, for applying its changes elsewhere.
|
||||
fn positions(&self, side: Side) -> &Positions {
|
||||
match side {
|
||||
Side::Branch => &self.branch_positions,
|
||||
@@ -355,7 +539,10 @@ impl Plan {
|
||||
let mut skip: BTreeSet<usize> = BTreeSet::new();
|
||||
let mut batch = Vec::new();
|
||||
for c in &self.conflicts {
|
||||
if pick(&c.key) != Some(from) {
|
||||
if matches!(c.key, Key::Field(..)) {
|
||||
// Written whole below, each field from the side picked for it.
|
||||
skip.extend(c.changes(from));
|
||||
} else if pick(&c.key) != Some(from) {
|
||||
skip.extend(c.changes(from));
|
||||
} else if let (Key::Layer(id), Some(by)) = (&c.key, c.deleted_by)
|
||||
&& by != from
|
||||
@@ -372,7 +559,11 @@ impl Plan {
|
||||
// shortcut: images a side stopped using are kept (unused ones cost file size only); drop
|
||||
// them if files grow.
|
||||
let kept = |c: &Change| !matches!(c, Change::Remove { entry: Entry::Image(_) });
|
||||
batch.extend(self.changes(from).iter().enumerate().filter(|(i, c)| !skip.contains(i) && kept(c)).map(|(_, c)| c.clone()));
|
||||
// Values both files work out for themselves stay as `into` has them; normalising the merged
|
||||
// file works them out again from what changed.
|
||||
let own = |c: &Change| key_of(c).is_some_and(|k| self.derived.contains(&k));
|
||||
batch.extend(self.changes(from).iter().enumerate().filter(|(i, c)| !skip.contains(i) && kept(c) && !own(c)).map(|(_, c)| c.clone()));
|
||||
batch.extend(self.field_writes(pick));
|
||||
let mut r = Replica::from_parts(into.clone(), self.positions(from.other()).clone());
|
||||
let applied = r.apply(&batch, Mode::Strict)?;
|
||||
Ok((r.document().clone(), applied.touched))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,15 @@ impl RouteEnd {
|
||||
/// How far an elbow connector going around the objects it joins keeps from them.
|
||||
const ROOM: f64 = 24.0;
|
||||
|
||||
/// The points a connector runs through from `from` to `to`: from the middle of the side of each
|
||||
/// box facing the other end (or the side it names), straight, in right angles, or (curved) its two
|
||||
/// ends with the curve's two control points between them. `None` when a point isn't finite.
|
||||
/// How far outside the border a connector attached to a side starts and ends (#1916): the stroke's
|
||||
/// round cap never lies on the fill, and elbows leaving one side share their first run along the
|
||||
/// side's normal (curves leave tangent to it), as FigJam's connectors do.
|
||||
pub const END_GAP: f64 = 4.0;
|
||||
|
||||
/// The points a connector runs through from `from` to `to`: from [`END_GAP`] outside the middle of
|
||||
/// the side of each box facing the other end (or the side it names), straight, in right angles, or
|
||||
/// (curved) its two ends with the curve's two control points between them. `None` when a point isn't
|
||||
/// finite.
|
||||
pub fn route_points(from: RouteEnd, to: RouteEnd, routing: Routing) -> Option<Vec<Point>> {
|
||||
let (p0, s0) = attach(from, to.centre());
|
||||
let (p1, s1) = attach(to, from.centre());
|
||||
@@ -169,8 +175,9 @@ fn attach(e: RouteEnd, other: Point) -> (Point, Option<Side>) {
|
||||
RouteEnd::Point(p) => (p, None),
|
||||
RouteEnd::Box(b, side) => {
|
||||
let side = named_or_facing(b, side, other);
|
||||
(side.point(b), Some(side))
|
||||
(side.point(b) + side.normal() * END_GAP, Some(side))
|
||||
}
|
||||
// Placed by hand: the point is the user's, gap or not.
|
||||
RouteEnd::On(b, side, p) => (p, Some(named_or_facing(b, side, other))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Connector routes (`board_route`): where an end attached to a side starts (#1916).
|
||||
|
||||
use figcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::board::{Routing, Side};
|
||||
use crate::board_route::{END_GAP, RouteEnd, route_points};
|
||||
|
||||
fn shape(x: f64, y: f64) -> Rect {
|
||||
Rect::new(x, y, x + 100.0, y + 60.0)
|
||||
}
|
||||
|
||||
/// An end attached to a side starts `END_GAP` outside the middle of that side, not on the border,
|
||||
/// so the stroke never lies on the fill (#1916: FigJam starts the line a few pixels outside).
|
||||
#[test]
|
||||
fn an_attached_end_starts_outside_the_border() {
|
||||
let (a, b) = (shape(0.0, 0.0), shape(300.0, 0.0));
|
||||
for routing in [Routing::Straight, Routing::Elbow, Routing::Curved] {
|
||||
let pts = route_points(RouteEnd::Box(a, Side::Auto), RouteEnd::Box(b, Side::Auto), routing).unwrap();
|
||||
assert_eq!(pts.first().copied(), Some(Point::new(100.0 + END_GAP, 30.0)), "{routing:?}: leaves the right side");
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(300.0 - END_GAP, 30.0)), "{routing:?}: arrives at the left side");
|
||||
}
|
||||
// A named side, and the gap along its own normal.
|
||||
let pts = route_points(RouteEnd::Box(a, Side::Bottom), RouteEnd::Point(Point::new(50.0, 200.0)), Routing::Straight).unwrap();
|
||||
assert_eq!(pts.first().copied(), Some(Point::new(50.0, 60.0 + END_GAP)));
|
||||
}
|
||||
|
||||
/// Elbows leaving one side share their first run along its normal, and curves leave tangent to it:
|
||||
/// the second point of each lies straight out of the side.
|
||||
#[test]
|
||||
fn connectors_from_one_side_leave_together() {
|
||||
let a = shape(0.0, 0.0);
|
||||
let (up, down) = (RouteEnd::Box(shape(300.0, -200.0), Side::Auto), RouteEnd::Box(shape(300.0, 200.0), Side::Auto));
|
||||
for routing in [Routing::Elbow, Routing::Curved] {
|
||||
let p = route_points(RouteEnd::Box(a, Side::Right), up, routing).unwrap();
|
||||
let q = route_points(RouteEnd::Box(a, Side::Right), down, routing).unwrap();
|
||||
assert_eq!(p[0], q[0], "{routing:?}: the same start");
|
||||
assert_eq!(p[1].y, p[0].y, "{routing:?}: out along the side's normal first");
|
||||
assert_eq!(q[1].y, q[0].y, "{routing:?}: out along the side's normal first");
|
||||
}
|
||||
}
|
||||
|
||||
/// A point placed by hand on the object (`RouteEnd::On`) is the user's: no gap is added.
|
||||
#[test]
|
||||
fn a_hand_placed_end_keeps_its_point() {
|
||||
let a = shape(0.0, 0.0);
|
||||
let on = Point::new(80.0, 60.0);
|
||||
let pts = route_points(RouteEnd::On(a, Side::Bottom, on), RouteEnd::Point(Point::new(80.0, 200.0)), Routing::Straight).unwrap();
|
||||
assert_eq!(pts.first().copied(), Some(on));
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -75,10 +75,10 @@ fn glass_fields_take_bound_numbers_as_the_panel_shows_them() {
|
||||
}
|
||||
assert_eq!((g.light_angle, g.light_intensity, g.refraction, g.depth, g.dispersion, g.frost, g.splay), (30.0, 0.25, 0.4, 12.0, 0.1, 8.0, 0.5));
|
||||
// Out of range: clamped to the field's range.
|
||||
for (field, x) in [("lightAngle", 270.0), ("lightIntensity", 250.0), ("depth", 0.0), ("radius", 150.0), ("splay", -5.0)] {
|
||||
for (field, x) in [("lightIntensity", 250.0), ("depth", 0.0), ("radius", 150.0), ("splay", -5.0)] {
|
||||
assert!(g.set_bound(field, x), "{field}");
|
||||
}
|
||||
assert_eq!((g.light_angle, g.light_intensity, g.depth, g.frost, g.splay), (180.0, 1.0, 1.0, Glass::MAX_FROST, 0.0));
|
||||
assert_eq!((g.light_intensity, g.depth, g.frost, g.splay), (1.0, 1.0, Glass::MAX_FROST, 0.0));
|
||||
// A field glass doesn't have, or a value that isn't a number, changes nothing.
|
||||
let before = g.clone();
|
||||
assert!(!g.set_bound("spread", 3.0));
|
||||
@@ -90,6 +90,24 @@ fn glass_fields_take_bound_numbers_as_the_panel_shows_them() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_light_angle_turns_round_instead_of_stopping() {
|
||||
// An angle past ±180 is the same direction a turn away (270 is -90), not the end of the range.
|
||||
for (x, want) in [(270.0, -90.0), (-270.0, 90.0), (540.0, 180.0), (-180.0, 180.0), (180.0, 180.0), (720.5, 0.5), (-360.0, 0.0), (-45.0, -45.0)] {
|
||||
let mut g = Glass::default();
|
||||
assert!(g.set_bound("lightAngle", x), "{x}");
|
||||
assert_eq!(g.light_angle, want, "{x}");
|
||||
}
|
||||
// A whole turn back is 0, not -0, so a saved file says 0.
|
||||
let mut g = Glass::default();
|
||||
assert!(g.set_bound("lightAngle", -360.0));
|
||||
assert!(g.light_angle == 0.0 && g.light_angle.is_sign_positive());
|
||||
// A huge angle still lands in range.
|
||||
let mut g = Glass::default();
|
||||
assert!(g.set_bound("lightAngle", 1e300));
|
||||
assert!((-180.0..=180.0).contains(&g.light_angle) && g.light_angle != -180.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_colours() {
|
||||
let mut e = Effect::noise();
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
+11
-2
@@ -27,7 +27,7 @@ pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use board::*;
|
||||
pub use board_route::{RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use board_route::{END_GAP, RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
pub use facilitation::*;
|
||||
@@ -598,7 +598,14 @@ impl Document {
|
||||
let Some(n) = self.node(id) else { return Affine::IDENTITY };
|
||||
let mut t = n.transform;
|
||||
let mut parent = n.parent;
|
||||
// More hops than nodes means the parent chain loops (a restored snapshot can leave one):
|
||||
// stop rather than spin.
|
||||
let mut hops = 0;
|
||||
while let Some(p) = parent.and_then(|p| self.node(p)).filter(|p| p.kind.bounds_follow_children()) {
|
||||
hops += 1;
|
||||
if hops > self.nodes.len() {
|
||||
break;
|
||||
}
|
||||
t = p.transform * t;
|
||||
parent = p.parent;
|
||||
}
|
||||
@@ -1223,7 +1230,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();
|
||||
@@ -1303,6 +1310,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
|
||||
#[cfg(test)]
|
||||
mod blur_tests;
|
||||
#[cfg(test)]
|
||||
mod board_route_tests;
|
||||
#[cfg(test)]
|
||||
mod board_shape_tests;
|
||||
#[cfg(test)]
|
||||
mod boolean_hit_tests;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+27
-3
@@ -675,6 +675,13 @@ impl Default for Glass {
|
||||
}
|
||||
}
|
||||
|
||||
/// `deg` (finite) as the same direction within -180 (exclusive) to 180 degrees.
|
||||
fn wrap_degrees(deg: f64) -> f64 {
|
||||
// `+ 0.0`: -0 reads as 0, so a saved file says 0.
|
||||
let a = deg.rem_euclid(360.0) + 0.0;
|
||||
if a > 180.0 { a - 360.0 } else { a }
|
||||
}
|
||||
|
||||
impl Glass {
|
||||
/// The most frost a glass takes, as the reference tool's Frost field (0–100).
|
||||
pub const MAX_FROST: f64 = 100.0;
|
||||
@@ -684,15 +691,16 @@ impl Glass {
|
||||
|
||||
/// Sets `field` (one of [`Self::BOUND_FIELDS`]) from a bound number variable, read as the
|
||||
/// Design panel's field shows it: intensity, refraction, dispersion and splay in percent, the
|
||||
/// frost as `radius`, each clamped to the field's range. False for a field glass doesn't have
|
||||
/// or a value that isn't finite (nothing changes).
|
||||
/// frost as `radius`, each clamped to the field's range, and the light angle turned into
|
||||
/// -180–180 (270 is -90, the same way). False for a field glass doesn't have or a value that
|
||||
/// isn't finite (nothing changes).
|
||||
pub fn set_bound(&mut self, field: &str, x: f64) -> bool {
|
||||
if !x.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let pct = (x / 100.0).clamp(0.0, 1.0);
|
||||
match field {
|
||||
"lightAngle" => self.light_angle = x.clamp(-180.0, 180.0),
|
||||
"lightAngle" => self.light_angle = wrap_degrees(x),
|
||||
"lightIntensity" => self.light_intensity = pct,
|
||||
"refraction" => self.refraction = pct,
|
||||
"depth" => self.depth = x.clamp(1.0, 1000.0),
|
||||
@@ -1520,6 +1528,9 @@ pub struct ExportSetting {
|
||||
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
|
||||
#[serde(skip_serializing_if = "VdCompat::is_default")]
|
||||
pub vd_compat: VdCompat,
|
||||
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
|
||||
#[serde(skip_serializing_if = "is_default_quality")]
|
||||
pub quality: u8,
|
||||
}
|
||||
|
||||
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
|
||||
@@ -1570,6 +1581,7 @@ impl Default for ExportSetting {
|
||||
svg_id_attribute: false,
|
||||
svg_simplify_stroke: true,
|
||||
vd_compat: VdCompat::BestEffort,
|
||||
quality: ExportSetting::DEFAULT_QUALITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1582,7 +1594,19 @@ fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
fn is_default_quality(q: &u8) -> bool {
|
||||
*q == ExportSetting::DEFAULT_QUALITY
|
||||
}
|
||||
|
||||
impl ExportSetting {
|
||||
/// The JPG quality a setting has unless it sets one.
|
||||
pub const DEFAULT_QUALITY: u8 = 90;
|
||||
|
||||
/// The JPG quality made safe: within `1..=100`.
|
||||
pub fn quality(&self) -> u8 {
|
||||
self.quality.clamp(1, 100)
|
||||
}
|
||||
|
||||
/// The row adding a setting after `last` creates: 1x first, then one scale step up with a
|
||||
/// matching "@2x"-style suffix.
|
||||
pub fn next_after(last: Option<&ExportSetting>) -> ExportSetting {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -679,6 +679,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(®ion, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(®ion, *p));
|
||||
}
|
||||
assert!(
|
||||
(length(&fill) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: fill {} and stroke {} trace the same outline",
|
||||
length(&fill),
|
||||
length(&stroke)
|
||||
);
|
||||
assert!(
|
||||
(length(®ion) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: region {} and stroke {} trace the same outline",
|
||||
length(®ion),
|
||||
length(&stroke)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -861,6 +1065,19 @@ fn the_colour_walker_visits_calculation_literals() {
|
||||
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_relative_ends_on_a_looping_parent_chain() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Group, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
// F's parent is its own child G (as a restored snapshot can leave things).
|
||||
v["nodes"][f.to_string()]["parent"] = serde_json::json!(g);
|
||||
let bad: Document = serde_json::from_value(v).unwrap();
|
||||
let _ = bad.frame_relative(g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_children_removes_every_subtree_and_keeps_the_parent() {
|
||||
let mut doc = Document::new("T");
|
||||
|
||||
+119
-6
@@ -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") {
|
||||
@@ -835,6 +869,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 +902,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 +1085,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 +1133,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
@@ -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),
|
||||
})
|
||||
|
||||
@@ -29,13 +29,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
|
||||
};
|
||||
ok.then_some(Color)
|
||||
}
|
||||
(StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => match (&effects.get(i)?.kind, field) {
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color),
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number),
|
||||
(EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number),
|
||||
(EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number),
|
||||
_ => None,
|
||||
},
|
||||
(StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => effect_field_type(&effects.get(i)?.kind, field),
|
||||
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number),
|
||||
(StyleKind::Text { .. }, None) => match key {
|
||||
"fontSize" | "lineHeight" | "letterSpacing" => Some(Number),
|
||||
@@ -46,6 +40,19 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of variable an effect of `kind` takes on `field` (the last part of an
|
||||
/// `effects/<i>/<field>` key), on a layer or in an effect style; None when it has no such field.
|
||||
pub fn effect_field_type(kind: &EffectKind, field: &str) -> Option<VariableType> {
|
||||
use VariableType::{Color, Number};
|
||||
match (kind, field) {
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color),
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number),
|
||||
(EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number),
|
||||
(EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable scopes a picker offers for `key` of a style of `kind`: a variable scoped to any one
|
||||
/// of them fits. A paint style's colors take fill and stroke variables, an effect style's fields
|
||||
/// take effect variables, and a text style's fields take the variables of their font property.
|
||||
@@ -114,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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -207,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -129,6 +129,40 @@ fn glass_fields_take_number_variables() {
|
||||
assert!(s.execute("style.bind", &json!({"style": fx, "field": "effects/0/spread", "variable": "large"})).is_err(), "glass has no spread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_binds_only_fields_its_effects_have() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "effect.add", json!({"ids": [r], "type": "dropShadow"}));
|
||||
ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"}));
|
||||
// As an effect style does: a drop shadow has no refraction, glass no spread, there is no
|
||||
// effect 5, and a key that names no effect's field names nothing. Nothing is bound.
|
||||
for (field, var, says) in [
|
||||
("effects/0/refraction", "large", "no field"),
|
||||
("effects/1/spread", "large", "no field"),
|
||||
("effects/5/radius", "large", "no field"),
|
||||
("effects/x/radius", "large", "no field"),
|
||||
("effects/0", "large", "no field"),
|
||||
("effects", "large", "no field"),
|
||||
("effects/1/lightAngle", "brand", "doesn't take a color variable"),
|
||||
("effects/0/color", "large", "doesn't take a number variable"),
|
||||
] {
|
||||
let e = s.execute("variable.bind", &json!({"ids": [r], "field": field, "variable": var})).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{field} ← {var}: {e}");
|
||||
}
|
||||
assert!(s.doc().unwrap().node(r).unwrap().bound.is_empty());
|
||||
// The fields each effect has still bind.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/spread", "variable": "small"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/1/refraction", "variable": "large"}));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().bound.len(), 2);
|
||||
// A binding an older file already has on a field its effect lacks does nothing, and unbinds.
|
||||
let large = s.doc().unwrap().variables.get("large").unwrap().id.clone();
|
||||
s.doc_mut().unwrap().get_mut(r).unwrap().bound.insert("effects/0/refraction".into(), large);
|
||||
ex(&mut s, "variable.set", json!({"variable": "large", "value": 40}));
|
||||
assert!(matches!(&s.doc().unwrap().node(r).unwrap().effects[0].kind, EffectKind::DropShadow(_)));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/refraction"}));
|
||||
assert!(!s.doc().unwrap().node(r).unwrap().bound.contains_key("effects/0/refraction"));
|
||||
}
|
||||
|
||||
fn ex_value(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! command and a drag of the selected connector's end), curved connectors, and elbows that go
|
||||
//! around the objects they join.
|
||||
|
||||
use figcraft_doc::{BoardItem, End, NodeKind, Routing, Side};
|
||||
use figcraft_doc::{BoardItem, END_GAP, End, NodeKind, Routing, Side};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -27,7 +27,7 @@ fn reattach_moves_one_end_and_undoes() {
|
||||
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap();
|
||||
assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top, at: None });
|
||||
assert_eq!(connector(&s, k).from.node(), Some(a));
|
||||
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k));
|
||||
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, -END_GAP)), "arrives END_GAP above c's top: {:?}", drawn(&s, k));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(connector(&s, k).to.node(), Some(b), "one undo step");
|
||||
// Off into the canvas: the end stays there, free. The selection is the default connector.
|
||||
@@ -107,9 +107,9 @@ fn curved_connectors_leave_and_arrive_along_the_sides() {
|
||||
let NodeKind::Vector { network } = &n.kind else { panic!("a vector") };
|
||||
assert_eq!((network.vertices.len(), network.segments.len()), (2, 1));
|
||||
assert!(!network.segments[0].is_straight(), "one curve");
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(296.0, 50.0)]);
|
||||
// Side to side in a line: the curve's control points lie on that line, so its box is flat.
|
||||
assert!(n.height.abs() < 1e-9 && (n.width - 200.0).abs() < 1e-9, "{} × {}", n.width, n.height);
|
||||
assert!(n.height.abs() < 1e-9 && (n.width - 192.0).abs() < 1e-9, "{} × {}", n.width, n.height);
|
||||
// From a's bottom to c's top: out downwards and in from above; the box holds the whole curve.
|
||||
let k2 = id(
|
||||
&s.execute("board.connector", &json!({"from": a, "to": c, "fromSide": "bottom", "toSide": "top", "style": "curved", "label": "x"})).unwrap(),
|
||||
@@ -146,18 +146,18 @@ fn elbows_go_around_the_objects_they_join() {
|
||||
let pts = drawn(&s, k2);
|
||||
let (ra, rb) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(300.0, 0.0, 400.0, 100.0));
|
||||
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
|
||||
assert_eq!(pts.first().copied(), Some(Point::new(0.0, 50.0)));
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(400.0, 50.0)));
|
||||
assert_eq!(pts.first().copied(), Some(Point::new(-END_GAP, 50.0)));
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(400.0 + END_GAP, 50.0)));
|
||||
assert_eq!(pts.len(), 6, "out, over, down and in: {pts:?}");
|
||||
assert!(pts.iter().all(|p| p.y <= 50.0), "over the top, the nearer way round: {pts:?}");
|
||||
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles only");
|
||||
// Facing each other, the short way stays.
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(296.0, 50.0)]);
|
||||
// An end at the bottom of b leaving towards a above it: around b's side and up.
|
||||
s.execute("board.reattach", &json!({"id": k2, "end": "to", "target": b, "side": "bottom"})).unwrap();
|
||||
let pts = drawn(&s, k2);
|
||||
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0)));
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0 + END_GAP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -202,8 +202,12 @@ fn the_connector_being_drawn_is_the_one_made() {
|
||||
// Over b: its four attach points, the one the end is at lit, and b outlined.
|
||||
assert_eq!(ov.drop_target, Some(b));
|
||||
let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect();
|
||||
// The lit attach point is the side's middle, on the border; the route's end sits
|
||||
// END_GAP outside it along the side's normal (#1916).
|
||||
let end = *drawn(&s, drawing.id).last().unwrap();
|
||||
assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}");
|
||||
assert_eq!(ov.attach.len(), 4, "{style}");
|
||||
let [lit] = lit.as_slice() else { panic!("{style}: one lit attach point, got {lit:?}") };
|
||||
assert!(((lit[0] - end.x).abs() + (lit[1] - end.y).abs() - END_GAP).abs() < 1e-9, "{style}: {lit:?} is END_GAP inside {end:?}");
|
||||
} else {
|
||||
assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end");
|
||||
}
|
||||
@@ -259,7 +263,7 @@ fn an_end_dropped_along_a_side_attaches_to_that_side() {
|
||||
s.pointer(&ev(PointerKind::Up, drop)).unwrap();
|
||||
assert_eq!(connector(&s, k).from, End::Node { node: a, side, at: None }, "{drop:?}");
|
||||
if side != Side::Auto {
|
||||
assert_eq!(drawn(&s, k)[0], side.point(bounds), "the route leaves from that side");
|
||||
assert_eq!(drawn(&s, k)[0], side.point(bounds) + side.normal() * END_GAP, "the route leaves from that side, END_GAP out");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,5 +311,5 @@ fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() {
|
||||
(connector(&s, k).from, connector(&s, k).to),
|
||||
(End::Node { node: a, side: Side::Right, at: None }, End::Node { node: b, side: Side::Top, at: None })
|
||||
);
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]);
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(350.0, 196.0)]);
|
||||
}
|
||||
@@ -11,8 +11,8 @@ use crate::board::{route, selected_connector_handles};
|
||||
use crate::board_tests::{board, connector, drawn, id, near};
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
|
||||
const A: Point = Point::new(100.0, 50.0);
|
||||
const B: Point = Point::new(300.0, 250.0);
|
||||
const A: Point = Point::new(104.0, 50.0);
|
||||
const B: Point = Point::new(296.0, 250.0);
|
||||
|
||||
/// Shapes a (at the origin) and b (down and to the right), 100 × 100, joined side to side.
|
||||
fn joined(style: &str) -> (Session, NodeId, NodeId, NodeId) {
|
||||
@@ -80,11 +80,11 @@ fn a_reshaped_connector_keeps_the_sides_it_left_by() {
|
||||
fn the_first_and_last_runs_gain_a_run_and_stay_on_their_ends() {
|
||||
let (mut s, _, _, k) = joined("elbow");
|
||||
reshape(&mut s, k, 0.0, 150.0, 120.0).unwrap();
|
||||
assert_eq!(drawn(&s, k), [A, pt(124.0, 50.0), pt(124.0, 120.0), pt(200.0, 120.0), pt(200.0, 250.0), B]);
|
||||
assert_eq!(drawn(&s, k), [A, pt(128.0, 50.0), pt(128.0, 120.0), pt(200.0, 120.0), pt(200.0, 250.0), B]);
|
||||
let last = drawn(&s, k).len() - 2;
|
||||
reshape(&mut s, k, last as f64, 250.0, 200.0).unwrap();
|
||||
let pts = drawn(&s, k);
|
||||
assert_eq!(pts, [A, pt(124.0, 50.0), pt(124.0, 120.0), pt(200.0, 120.0), pt(200.0, 200.0), pt(276.0, 200.0), pt(276.0, 250.0), B]);
|
||||
assert_eq!(pts, [A, pt(128.0, 50.0), pt(128.0, 120.0), pt(200.0, 120.0), pt(200.0, 200.0), pt(272.0, 200.0), pt(272.0, 250.0), B]);
|
||||
assert!(right_angled(&pts));
|
||||
}
|
||||
|
||||
@@ -97,8 +97,8 @@ fn a_lone_run_gains_a_run_at_each_end() {
|
||||
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap(), "id");
|
||||
assert_eq!(drawn(&s, k).len(), 2);
|
||||
reshape(&mut s, k, 0.0, 200.0, -40.0).unwrap();
|
||||
let (l, r) = (pt(100.0, 50.0), pt(300.0, 50.0));
|
||||
assert_eq!(drawn(&s, k), [l, pt(124.0, 50.0), pt(124.0, -40.0), pt(276.0, -40.0), pt(276.0, 50.0), r]);
|
||||
let (l, r) = (pt(104.0, 50.0), pt(296.0, 50.0));
|
||||
assert_eq!(drawn(&s, k), [l, pt(128.0, 50.0), pt(128.0, -40.0), pt(272.0, -40.0), pt(272.0, 50.0), r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -168,7 +168,7 @@ fn connectors_follow_what_they_join_and_undo_with_it() {
|
||||
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
|
||||
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id");
|
||||
let pts = drawn(&s, c);
|
||||
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(pts[1], Point::new(400.0, 100.0)), "{pts:?}");
|
||||
assert!(near(pts[0], Point::new(204.0, 100.0)) && near(pts[1], Point::new(396.0, 100.0)), "{pts:?}");
|
||||
assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow);
|
||||
// Its label sits at the middle.
|
||||
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
|
||||
@@ -178,19 +178,19 @@ fn connectors_follow_what_they_join_and_undo_with_it() {
|
||||
s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap();
|
||||
s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap();
|
||||
let pts = drawn(&s, c);
|
||||
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(*pts.last().unwrap(), Point::new(400.0, 400.0)), "{pts:?}");
|
||||
assert!(near(pts[0], Point::new(204.0, 100.0)) && near(*pts.last().unwrap(), Point::new(396.0, 400.0)), "{pts:?}");
|
||||
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}");
|
||||
// Undo takes the move back and the route with it.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
|
||||
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0)));
|
||||
// A deleted end stays where it was, as a point.
|
||||
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
||||
assert!(matches!(connector(&s, c).to, End::Point { .. }));
|
||||
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
|
||||
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0)));
|
||||
// A connector moved on its own takes its free end along; the attached one stays.
|
||||
s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap();
|
||||
assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 400.0 && y == 150.0), "{:?}", connector(&s, c));
|
||||
assert!(near(drawn(&s, c)[0], Point::new(200.0, 100.0)));
|
||||
assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 396.0 && y == 150.0), "{:?}", connector(&s, c));
|
||||
assert!(near(drawn(&s, c)[0], Point::new(204.0, 100.0)));
|
||||
// Deleting the connector takes its label.
|
||||
s.execute("edit.delete", &json!({"ids": [c]})).unwrap();
|
||||
assert!(!s.doc().unwrap().contains(label));
|
||||
@@ -210,8 +210,8 @@ fn routes_are_the_same_for_the_same_document() {
|
||||
let doc = s.doc().unwrap();
|
||||
let r = route(doc, &c).unwrap();
|
||||
assert_eq!(r, route(&doc.clone(), &c).unwrap());
|
||||
// Down out of the bottom, across halfway, down into the point.
|
||||
assert_eq!(r, vec![Point::new(80.0, 160.0), Point::new(80.0, 330.0), Point::new(300.0, 330.0), Point::new(300.0, 500.0)]);
|
||||
// Down out of the bottom (END_GAP below it), across halfway, down into the point.
|
||||
assert_eq!(r, vec![Point::new(80.0, 164.0), Point::new(80.0, 332.0), Point::new(300.0, 332.0), Point::new(300.0, 500.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -338,7 +338,7 @@ fn boards_sync_in_live_sessions() {
|
||||
let doc = room.server.document();
|
||||
let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") };
|
||||
let end = doc.world_transform(c) * network.vertices.last().unwrap().point();
|
||||
assert_eq!(end, Point::new(400.0, 350.0), "to the moved sticky's facing side");
|
||||
assert_eq!(end, Point::new(396.0, 350.0), "to END_GAP before the moved sticky's facing side");
|
||||
assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet"));
|
||||
}
|
||||
|
||||
@@ -410,7 +410,9 @@ fn a_press_on_a_stickys_background_picks_and_drags_it() {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap();
|
||||
assert_eq!(s.selection(), vec![a]);
|
||||
assert_eq!(node(&s, a).transform.translation().y, 300.0);
|
||||
assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c));
|
||||
// Its own connector's start, END_GAP outside its edge, is no snap target for it (#1916).
|
||||
assert_eq!(node(&s, a).transform.translation().x, 0.0, "not nudged sideways by its own connector");
|
||||
assert!(near(drawn(&s, c)[0], Point::new(204.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -100,18 +100,18 @@ fn routes_take_named_sides_and_free_points() {
|
||||
let side = |n, side| End::Node { node: n, side, at: None };
|
||||
let p = |x, y| End::Point { x, y };
|
||||
let pt = Point::new;
|
||||
// Side to side: out, across halfway, in.
|
||||
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(100.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(300.0, 350.0)]);
|
||||
// Side to side: out (from END_GAP outside each side), across halfway, in.
|
||||
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(104.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(296.0, 350.0)]);
|
||||
// A side and a top or bottom: one turn.
|
||||
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(100.0, 50.0), pt(350.0, 50.0), pt(350.0, 300.0)]);
|
||||
assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 100.0), pt(50.0, 350.0), pt(300.0, 350.0)]);
|
||||
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(104.0, 50.0), pt(350.0, 50.0), pt(350.0, 296.0)]);
|
||||
assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 104.0), pt(50.0, 350.0), pt(296.0, 350.0)]);
|
||||
// Two free points: the longer way first; in a line, no turns.
|
||||
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]);
|
||||
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]);
|
||||
// An automatic side faces the other end.
|
||||
let straight =
|
||||
route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight, path: None });
|
||||
assert_eq!(straight.unwrap(), vec![pt(50.0, 100.0), pt(50.0, 500.0)]);
|
||||
assert_eq!(straight.unwrap(), vec![pt(50.0, 104.0), pt(50.0, 500.0)]);
|
||||
assert_eq!(Side::from_id("TOP"), Some(Side::Top));
|
||||
assert_eq!(Side::from_id("inside"), None);
|
||||
// The same place twice is refused, as are a side that doesn't exist and a point without numbers.
|
||||
@@ -128,10 +128,10 @@ fn a_connector_follows_a_layer_whose_section_moves_and_ignores_unrelated_edits()
|
||||
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
|
||||
let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id");
|
||||
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id");
|
||||
assert!(near(drawn(&s, c)[0], Point::new(250.0, 150.0)));
|
||||
assert!(near(drawn(&s, c)[0], Point::new(254.0, 150.0)));
|
||||
// Only the section is edited; the sticky inside it moves with it, and so does the connector.
|
||||
s.execute("transform.move", &json!({"ids": [sec], "dx": 0, "dy": 100})).unwrap();
|
||||
assert!(near(drawn(&s, c)[0], Point::new(250.0, 250.0)), "{:?}", drawn(&s, c));
|
||||
assert!(near(drawn(&s, c)[0], Point::new(254.0, 250.0)), "{:?}", drawn(&s, c));
|
||||
// An edit that touches neither end leaves the connector as it was.
|
||||
let before = s.doc().unwrap().node_arc(c).cloned().unwrap();
|
||||
s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap();
|
||||
@@ -276,3 +276,29 @@ fn a_board_tool_left_on_presses_as_move_in_a_design_file() {
|
||||
assert_eq!(s.tool.tool, ToolId::Move);
|
||||
assert_eq!(s.selection(), vec![r], "the press selected as Move does");
|
||||
}
|
||||
|
||||
/// #1916: a connector's start, END_GAP outside the layer it leaves, is a snap line for other layers
|
||||
/// but not for that layer, nor for a section holding it (both carry the connector along), and nor
|
||||
/// is the connector's label.
|
||||
#[test]
|
||||
fn a_connector_joined_to_a_moving_layer_or_its_section_is_no_snap_target() {
|
||||
let mut s = board();
|
||||
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 300})).unwrap(), "id");
|
||||
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
|
||||
let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id");
|
||||
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "to"})).unwrap(), "id");
|
||||
// A layer the connector has nothing to do with.
|
||||
let d = id(&s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(), "id");
|
||||
let page = s.page().unwrap();
|
||||
let start = drawn(&s, c)[0].x;
|
||||
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
|
||||
let lx = s.doc().unwrap().world_bounds(label).unwrap().x0;
|
||||
// A box whose left edge is 1 past a line snaps back by 1 when the line is a target.
|
||||
let pull = |s: &Session, moving: &[NodeId], x: f64| {
|
||||
let t = crate::snap::Targets::gather(s.doc().unwrap(), page, moving);
|
||||
t.snap_rect(figcraft_geom::Rect::new(x + 1.0, 600.0, x + 9.0, 610.0), 4.0).0
|
||||
};
|
||||
assert_eq!((pull(&s, &[d], start), pull(&s, &[d], lx)), (-1.0, -1.0), "for an unrelated layer, the connector and its label snap");
|
||||
assert_eq!(pull(&s, &[b], start), 0.0, "not for the layer it arrives at");
|
||||
assert_eq!((pull(&s, &[sec], start), pull(&s, &[sec], lx)), (0.0, 0.0), "not for the section the connector leaves from");
|
||||
}
|
||||
@@ -133,13 +133,19 @@ impl Session {
|
||||
(to.node().is_none() || to.node() != from.node()).then_some(to)
|
||||
}
|
||||
|
||||
/// The object `end` is on (if any) outlined, with its attach points; the one the end is at
|
||||
/// (`at`, else its named side's) lit.
|
||||
/// The object `end` is on (if any) outlined, with its attach points (the side middles, on the
|
||||
/// border); the one the end is at lit: the nearest to `at` (a route's end sits `END_GAP` outside
|
||||
/// its side's middle, #1916), else its named side's.
|
||||
fn show_attach(&mut self, end: Option<End>, at: Option<Point>) {
|
||||
let attach = match end {
|
||||
Some(End::Node { node, side, .. }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
|
||||
let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b)));
|
||||
SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec()
|
||||
let pts = SIDES.map(|s| s.point(b));
|
||||
let lit = on.and_then(|o| {
|
||||
let near = pts.iter().enumerate().map(|(i, p)| (i, (o - *p).hypot())).filter(|(_, d)| *d <= figcraft_doc::END_GAP + 1e-6);
|
||||
near.min_by(|x, y| x.1.total_cmp(&y.1)).map(|(i, _)| i)
|
||||
});
|
||||
pts.iter().enumerate().map(|(i, p)| ([p.x, p.y], lit == Some(i))).collect()
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -193,6 +193,109 @@ fn branch_commands_refuse_what_makes_no_sense() {
|
||||
assert!(s.execute("branch.merge", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Node id `v` of a command's answer (`{"id": …}` or a bare id).
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
NodeId(v.get("id").unwrap_or(v).as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// The issue #2026 file, with a branch of it made and shown: a component `Card` (horizontal auto
|
||||
/// layout hugging its contents, padding 12, one 40×20 rectangle) and an instance of it in a
|
||||
/// vertical auto layout frame hugging its contents. Returns (session, branch id, card, instance,
|
||||
/// frame, rectangle).
|
||||
fn card_file() -> (Session, String, NodeId, NodeId, NodeId, NodeId) {
|
||||
let mut s = Session::with_document(Document::new("Cards"));
|
||||
let props = json!({"layout": "horizontal", "padding": 12, "sizingH": "hug", "sizingV": "hug"});
|
||||
let frame = idv(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "name": "Card", "props": props})));
|
||||
let rect = idv(&ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": frame})));
|
||||
let card = idv(&ok(&mut s, "component.create", json!({"ids": [frame]})));
|
||||
let props = json!({"layout": "vertical", "padding": 0, "sizingH": "hug", "sizingV": "hug"});
|
||||
let stack = idv(&ok(&mut s, "frame.create", json!({"x": 0, "y": 200, "width": 100, "height": 100, "name": "Stack", "props": props})));
|
||||
let inst = idv(&ok(&mut s, "instance.create", json!({"component": card, "parent": stack})));
|
||||
let b = ok(&mut s, "branch.create", json!({"name": "padding 8"}));
|
||||
(s, b["id"].as_str().unwrap().to_string(), card, inst, stack, rect)
|
||||
}
|
||||
|
||||
/// Size and place of every layer of `doc` (what auto layout works out).
|
||||
fn geometry(doc: &Document) -> Vec<(NodeId, [f64; 8])> {
|
||||
doc.nodes()
|
||||
.map(|n| {
|
||||
let c = n.transform.as_coeffs();
|
||||
(n.id, [n.width, n.height, c[0], c[1], c[2], c[3], c[4], c[5]])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Issue #2026: one auto layout edit on each side is one conflict (the field changed both ways),
|
||||
/// not one for every size and place auto layout works out from it; the merged file lays out
|
||||
/// from the padding picked.
|
||||
#[test]
|
||||
fn one_conflicting_auto_layout_edit_is_one_conflict() {
|
||||
for (pick, padding) in [("branch", 8.0), ("main", 16.0)] {
|
||||
let (mut s, id, card, inst, _, _) = card_file();
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 8}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 16}}));
|
||||
// What main looks like with the picked padding set directly.
|
||||
let mut direct = Session::with_document(s.doc().unwrap().clone());
|
||||
ok(&mut direct, "node.set", json!({"id": card, "props": {"padding": padding}}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
let conflicts = review["conflicts"].as_array().unwrap();
|
||||
assert_eq!(conflicts.len(), 1, "{review}");
|
||||
let key = format!("{card}/layout.padding");
|
||||
assert_eq!(conflicts[0]["key"], key.as_str());
|
||||
assert_eq!(
|
||||
(&conflicts[0]["property"], &conflicts[0]["branch"], &conflicts[0]["main"]),
|
||||
(&json!("auto layout padding"), &json!("8, 8, 8, 8"), &json!("16, 16, 16, 16"))
|
||||
);
|
||||
let err = s.execute("branch.merge", &json!({"id": id})).unwrap_err().to_string();
|
||||
assert!(err.contains(&key) && !err.contains("width"), "{err}");
|
||||
ok(&mut s, "branch.merge", json!({"id": id, "resolutions": {&key: pick}}));
|
||||
let doc = s.doc().unwrap();
|
||||
for n in [card, inst] {
|
||||
assert_eq!(doc.node(n).unwrap().layout.as_ref().unwrap().padding, [padding; 4], "{pick} wins");
|
||||
}
|
||||
assert_eq!(geometry(doc), geometry(direct.doc().unwrap()), "{pick} wins: laid out from the padding picked");
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout merges field by field: a gap changed only on the branch is kept whichever side's
|
||||
/// padding wins, both when merging and when updating the branch from main.
|
||||
#[test]
|
||||
fn auto_layout_fields_changed_one_way_survive_a_conflict() {
|
||||
for (cmd, pick, padding) in [("branch.merge", "main", 16.0), ("branch.merge", "branch", 8.0), ("branch.updateFromMain", "main", 16.0)] {
|
||||
let (mut s, id, card, _, _, _) = card_file();
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 8, "gap": 30}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 16, "mainAlign": "center"}}));
|
||||
let key = format!("{card}/layout.padding");
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
assert_eq!(review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].clone()).collect::<Vec<_>>(), vec![json!(key)], "{review}");
|
||||
ok(&mut s, cmd, json!({"id": id, "resolutions": {&key: pick}}));
|
||||
if cmd == "branch.updateFromMain" {
|
||||
ok(&mut s, "doc.switch", json!({"index": 1}));
|
||||
}
|
||||
let layout = s.doc().unwrap().node(card).unwrap().layout.clone().unwrap();
|
||||
assert_eq!((layout.padding, layout.gap, layout.main_align), ([padding; 4], 30.0, figcraft_doc::MainAlign::Center), "{cmd}, {pick} wins");
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout on one side and a hugging frame's size on both: the sizes auto layout works out
|
||||
/// on each side are no conflict, and merging leaves no stale size behind.
|
||||
#[test]
|
||||
fn sizes_auto_layout_works_out_never_conflict() {
|
||||
let (mut s, id, card, _, stack, rect) = card_file();
|
||||
ok(&mut s, "node.set", json!({"id": rect, "props": {"width": 60}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 4}}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
assert_eq!(review["conflicts"], json!([]), "{review}");
|
||||
let mut direct = Session::with_document(s.doc().unwrap().clone());
|
||||
ok(&mut direct, "node.set", json!({"id": rect, "props": {"width": 60}}));
|
||||
ok(&mut s, "branch.merge", json!({"id": id}));
|
||||
assert_eq!(s.doc().unwrap().node(stack).unwrap().width, 60.0 + 8.0);
|
||||
assert_eq!(geometry(s.doc().unwrap()), geometry(direct.doc().unwrap()));
|
||||
}
|
||||
|
||||
/// Every change a side made (that the other side didn't overwrite) shows in `doc`.
|
||||
fn shows(changes: &[Change], doc: &Document) -> Result<(), String> {
|
||||
let r = Replica::new(doc.clone());
|
||||
@@ -282,16 +385,25 @@ fn random_edits_on_both_sides_merge() {
|
||||
let none = |_: &figcraft_collab::merge::Key| None;
|
||||
let (into_main, _) = p.merge(&m, Side::Branch, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
|
||||
let (into_branch, _) = p.merge(&b, Side::Main, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
|
||||
// Values both sides work out for themselves (sizes and places auto layout gives) stay
|
||||
// as the file merged into has them.
|
||||
let chosen = |changes: &[Change]| -> Vec<Change> {
|
||||
changes
|
||||
.iter()
|
||||
.filter(|c| !matches!(c, Change::Set { id, prop, .. } if p.derived().contains(&Key::Node(*id, *prop))))
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
let mut both = Replica::new(start.clone());
|
||||
both.apply(&p.main, Mode::Strict).unwrap();
|
||||
both.apply(&p.branch, Mode::Strict).unwrap();
|
||||
both.apply(&chosen(&p.branch), Mode::Strict).unwrap();
|
||||
let mut want = both.document().clone();
|
||||
(want.name, want.current_page) = (m.name.clone(), m.current_page);
|
||||
assert_eq!(into_main, want, "seed {seed}");
|
||||
for doc in [&into_main, &into_branch] {
|
||||
assert!(doc.validate().is_empty(), "seed {seed}: {:?}", doc.validate());
|
||||
for side in [&p.branch, &p.main] {
|
||||
shows(side, doc).unwrap_or_else(|c| panic!("seed {seed}: lost {c}"));
|
||||
shows(&chosen(side), doc).unwrap_or_else(|c| panic!("seed {seed}: lost {c}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! guide lines (with spacing labels) for the canvas to draw in red. Vector points also snap to
|
||||
//! other points (see `vectoredit::snap`); landing on one shows a marker (a zero-length guide).
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_doc::{BoardItem, Document, NodeId};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -94,10 +94,23 @@ impl Targets {
|
||||
t.ys.push((y, b.x0, b.x1));
|
||||
}
|
||||
}
|
||||
// Siblings and, at the top level, all top-level layers.
|
||||
// Siblings and, at the top level, all top-level layers. A board connector joined to a
|
||||
// moving layer, or to one inside it (a section carries its stickies), re-routes with it, as
|
||||
// does its label: no snapping to where its own route starts (#1916).
|
||||
let carried = |e: NodeId| moving.contains(&e) || doc.ancestors(e).iter().any(|a| moving.contains(a));
|
||||
let follows = |id: NodeId| match doc.node(id).and_then(|n| n.board.as_ref()) {
|
||||
Some(BoardItem::Connector(k)) => [k.from.node(), k.to.node()].into_iter().flatten().any(carried),
|
||||
_ => false,
|
||||
};
|
||||
if let Some(p) = doc.node(parent) {
|
||||
for c in &p.children {
|
||||
if moving.contains(c) || !doc.node(*c).is_some_and(|n| n.visible) {
|
||||
let Some(n) = doc.node(*c).filter(|n| n.visible) else { continue };
|
||||
let joined = match n.board.as_ref() {
|
||||
Some(BoardItem::Connector(_)) => follows(*c),
|
||||
Some(BoardItem::Label { connector }) => follows(*connector),
|
||||
_ => false,
|
||||
};
|
||||
if moving.contains(c) || joined {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = bbox(*c) {
|
||||
|
||||
@@ -201,6 +201,7 @@ impl ToolId {
|
||||
| ToolId::Star
|
||||
| ToolId::Line
|
||||
| ToolId::Arrow
|
||||
| ToolId::BoardShape
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -544,6 +545,8 @@ pub struct PenPath {
|
||||
pub closed: bool,
|
||||
/// The path ends connected to this anchor of its own (a vector network).
|
||||
pub join: Option<usize>,
|
||||
/// Anchors taken back by undo, the last one first to come back on redo.
|
||||
pub undone: Vec<(Point, Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -589,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 {
|
||||
|
||||
@@ -212,6 +212,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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -118,10 +118,47 @@ impl Session {
|
||||
p = at_45_degrees(*last, p);
|
||||
}
|
||||
pen.points.push((p, Vec2::ZERO, Vec2::ZERO));
|
||||
pen.undone.clear();
|
||||
self.tool.gesture = Gesture::PenDrag { anchor: p };
|
||||
self.pen_update()
|
||||
}
|
||||
|
||||
/// Undo while the pen draws (#1745): its last anchor goes (before two anchors, with the layer
|
||||
/// they made; with the only one, the whole path). Returns whether there was a path.
|
||||
pub(crate) fn pen_undo(&mut self) -> Result<bool> {
|
||||
let Some(pen) = self.tool.pen.as_mut() else { return Ok(false) };
|
||||
let Some(last) = pen.points.pop() else {
|
||||
self.tool.pen = None;
|
||||
return Ok(false);
|
||||
};
|
||||
pen.undone.push(last);
|
||||
let drop_layer = pen.points.len() < 2 && pen.id.take().is_some();
|
||||
let empty = pen.points.is_empty();
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.rubber_handle = None;
|
||||
if drop_layer {
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
if empty {
|
||||
self.pen_finish()?;
|
||||
return Ok(true);
|
||||
}
|
||||
self.pen_update()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Redo while the pen draws: the last anchor undo took back returns. Returns whether there was
|
||||
/// one.
|
||||
pub(crate) fn pen_redo(&mut self) -> Result<bool> {
|
||||
let Some(pen) = self.tool.pen.as_mut() else { return Ok(false) };
|
||||
let Some(point) = pen.undone.pop() else { return Ok(false) };
|
||||
pen.points.push(point);
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.pen_update()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Rebuild the pen's vector node from its anchors.
|
||||
pub(super) fn pen_update(&mut self) -> Result<()> {
|
||||
let Some(pen) = self.tool.pen.clone() else { return Ok(()) };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>) {
|
||||
|
||||
@@ -418,7 +418,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"export.add",
|
||||
"Add export setting",
|
||||
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
|
||||
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.add";
|
||||
@@ -436,7 +436,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"export.update",
|
||||
"Edit export setting",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.update";
|
||||
|
||||
@@ -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;
|
||||
@@ -119,7 +120,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), quality? (JPG: 1-100, default 90)}",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -409,7 +410,8 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
|
||||
_ => {
|
||||
let pix = figcraft_render::render_area(doc, &drawn, area, scale)
|
||||
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
|
||||
Ok((figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)?, Vec::new()))
|
||||
let bytes = figcraft_render::encode_export_with(&pix, setting.format, doc.color_profile, setting.quality());
|
||||
Ok((bytes.map_err(EngineError::Other)?, Vec::new()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::load::{ext, write};
|
||||
use super::save::thumbnail;
|
||||
use super::*;
|
||||
use crate::params::f64_of;
|
||||
|
||||
/// Encode `ids` (or the page) in `format` at `scale`.
|
||||
pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result<Vec<u8>> {
|
||||
@@ -159,7 +160,8 @@ pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) ->
|
||||
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
|
||||
}
|
||||
|
||||
/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets.
|
||||
/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix`, `format` and the
|
||||
/// format options (`quality`, `vdCompat`, PDF print options, the SVG flags) that `p` sets.
|
||||
pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result<ExportSetting> {
|
||||
if let Some(size) = size_param(cmd, p)? {
|
||||
e.size = size;
|
||||
@@ -173,6 +175,9 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value)
|
||||
if let Some(c) = vd_compat_param(cmd, p)? {
|
||||
e.vd_compat = c;
|
||||
}
|
||||
if let Some(q) = quality_param(cmd, p)? {
|
||||
e.quality = q;
|
||||
}
|
||||
e.print = print_param(cmd, p, e.print)?;
|
||||
for (key, slot) in [
|
||||
("contentsOnly", &mut e.contents_only),
|
||||
@@ -189,6 +194,16 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value)
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
/// The JPG `quality` `p` passes: a whole number from 1 to 100 (anything else is an error).
|
||||
fn quality_param(cmd: &str, p: &Value) -> Result<Option<u8>> {
|
||||
let Some(v) = get(p, "quality") else { return Ok(None) };
|
||||
let q = if v.is_boolean() { None } else { f64_of(v) };
|
||||
match q.filter(|q| q.fract() == 0.0 && (1.0..=100.0).contains(q)) {
|
||||
Some(q) => Ok(Some(q as u8)),
|
||||
None => Err(bad(cmd, "quality: a whole number from 1 to 100 (JPG)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p` sets any PDF print option.
|
||||
fn has_print_param(p: &Value) -> bool {
|
||||
["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null()))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -220,6 +220,9 @@ fn set_operand_op(doc: &mut Document, id: NodeId, v: &Value, cmd: &str) -> Resul
|
||||
|
||||
pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)] | [t, left, right] (one-sided: each side's scale, looking along the path), ...] or [{t, scale} | {t, left, right}], {preset} | {points}, or null (uniform)";
|
||||
|
||||
/// The stroke caps `strokeCap`, `startCap` and `endCap` take (every [`StrokeCap`]).
|
||||
pub(crate) const STROKE_CAP_HELP: &str = "none|round|square|lineArrow|triangleArrow|reversedTriangle|circleFilled|diamondFilled";
|
||||
|
||||
pub(crate) const BRUSH_HELP: &str = "brush: a preset (taper|ink|ribbon|bristle: stretched; dots|stars|confetti|leaves: scattered), {preset, spacing? (× weight), sizeJitter? (0-1), rotationJitter? (0-1), scatter? (0-4 × weight), seed?}, or null (a plain stroke)";
|
||||
|
||||
/// A brush from a preset id, `{preset, spacing?, sizeJitter?, rotationJitter?, scatter?, seed?}`
|
||||
@@ -635,13 +638,13 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
|
||||
st.sides = Some(sides);
|
||||
}
|
||||
"strokeCap" => {
|
||||
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?;
|
||||
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, format!("strokeCap: {STROKE_CAP_HELP}")))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
n.stroke.cap_start = c;
|
||||
n.stroke.cap_end = c;
|
||||
}
|
||||
"startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
|
||||
"endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
|
||||
"startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, format!("startCap: {STROKE_CAP_HELP}")))?,
|
||||
"endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, format!("endCap: {STROKE_CAP_HELP}")))?,
|
||||
"strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?,
|
||||
"miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0),
|
||||
"miterAngle" => {
|
||||
@@ -980,6 +983,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")))?;
|
||||
@@ -1234,7 +1243,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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
@@ -788,6 +859,9 @@ pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field
|
||||
for i in ids {
|
||||
match field.strip_prefix(crate::components::PROP_BINDING) {
|
||||
Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?,
|
||||
None if !super::variables::has_field(doc, *i, field) => {
|
||||
return Err(bad(cmd, format!("the layer has no field `{field}` to bind")));
|
||||
}
|
||||
None if !super::variables::field_takes(doc, *i, field, v.kind) => {
|
||||
return Err(bad(cmd, format!("`{field}` doesn't take a {} variable", v.kind.id())));
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -319,6 +326,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)
|
||||
|
||||
@@ -654,10 +654,29 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static
|
||||
})
|
||||
}
|
||||
|
||||
/// The type of variable effect `i` of `node` takes on `field`, as an effect style's does: None when
|
||||
/// the layer has no effect `i` or that effect has no such field.
|
||||
fn effect_field(doc: &Document, node: NodeId, i: usize, field: &str) -> Option<VariableType> {
|
||||
crate::style_vars::effect_field_type(&doc.node(node)?.effects.get(i)?.kind, field)
|
||||
}
|
||||
|
||||
/// Whether `node` has the field a binding key names. Only effect fields are checked: a layer has
|
||||
/// `effects/<i>/<field>` when its effect `i` has that field (a drop shadow has no `refraction`),
|
||||
/// and an `effects…` key naming no effect's field (`effects/x/radius`, `effects/0`) has none.
|
||||
pub fn has_field(doc: &Document, node: NodeId, field: &str) -> bool {
|
||||
match super::lists::list_binding(field) {
|
||||
Some((BoundList::Effects, i, f)) => effect_field(doc, node, i, f).is_some(),
|
||||
_ => field.split('/').next() != Some(BoundList::Effects.prefix()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether property `field` of `node` takes a variable of type `kind`: the type its scope belongs
|
||||
/// to (number variables also fill text content), booleans for visibility, numbers for layout grid
|
||||
/// fields; fields without a known type take any.
|
||||
/// fields, an effect's field the type its effect takes there; fields without a known type take any.
|
||||
pub fn field_takes(doc: &Document, node: NodeId, field: &str, kind: VariableType) -> bool {
|
||||
if let Some((BoundList::Effects, i, f)) = super::lists::list_binding(field) {
|
||||
return effect_field(doc, node, i, f) == Some(kind);
|
||||
}
|
||||
match (field, field_scope(doc, node, field)) {
|
||||
("visible", _) => kind == VariableType::Boolean,
|
||||
(_, Some(scope)) => scope_known(kind, scope),
|
||||
|
||||
@@ -529,6 +529,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",
|
||||
|
||||
@@ -46,7 +46,7 @@ fn without_cmd_an_end_keeps_snapping_to_a_side() {
|
||||
let end = *drawn(&s, k).last().unwrap();
|
||||
drag_end(&mut s, end, Point::new(650.0, 25.0), false);
|
||||
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Right, at: None });
|
||||
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0, 50.0)));
|
||||
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0 + figcraft_doc::END_GAP, 50.0)), "END_GAP outside the side");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! JPG exports: the quality an export or export setting asks for, and the colour profile every
|
||||
//! JPG embeds (#2004).
|
||||
|
||||
use image::ImageDecoder;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_color::ColorProfile;
|
||||
use figcraft_doc::{ExportSetting, NodeId};
|
||||
|
||||
/// A rectangle with a gradient, so the quality shows in the file's size.
|
||||
fn gradient_rect(s: &mut Session) -> NodeId {
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"]).unwrap();
|
||||
let fill = json!({"type": "linearGradient", "stops": ["#FF0000", "#00FF88", "#2040FF"]});
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"fills": [fill]}})).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
fn bytes(v: &Value) -> Vec<u8> {
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, v["data"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn jpg(s: &mut Session, r: NodeId, extra: Value) -> Vec<u8> {
|
||||
let mut p = json!({"ids": [r], "format": "jpg"});
|
||||
if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) {
|
||||
m.extend(e);
|
||||
}
|
||||
bytes(&s.execute("file.export", &p).unwrap())
|
||||
}
|
||||
|
||||
fn icc(jpg: &[u8]) -> Option<Vec<u8>> {
|
||||
image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(jpg)).unwrap().icc_profile().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpg_quality_is_taken_and_defaults_to_90() {
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
let default = jpg(&mut s, r, json!({}));
|
||||
assert_eq!(default, jpg(&mut s, r, json!({"quality": 90})), "90 is the default");
|
||||
let low = jpg(&mut s, r, json!({"quality": 20}));
|
||||
let high = jpg(&mut s, r, json!({"quality": 100}));
|
||||
assert!(low.len() < default.len() && default.len() < high.len(), "{} {} {}", low.len(), default.len(), high.len());
|
||||
// Agents send numbers as strings too.
|
||||
assert_eq!(jpg(&mut s, r, json!({"quality": "20"})), low);
|
||||
image::load_from_memory(&low).unwrap();
|
||||
// The other formats ignore it.
|
||||
let png = |s: &mut Session, extra: Value| {
|
||||
let mut p = json!({"ids": [r], "format": "png"});
|
||||
if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) {
|
||||
m.extend(e);
|
||||
}
|
||||
bytes(&s.execute("file.export", &p).unwrap())
|
||||
};
|
||||
assert_eq!(png(&mut s, json!({"quality": 20})), png(&mut s, json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_qualities_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg"}})).unwrap();
|
||||
for q in [json!(0), json!(101), json!(-5), json!(50.5), json!("high"), json!(true), json!([80]), json!(1e300)] {
|
||||
let e = s.execute("file.export", &json!({"ids": [r], "format": "jpg", "quality": q})).unwrap_err();
|
||||
assert!(e.to_string().contains("quality: a whole number from 1 to 100"), "{q}: {e}");
|
||||
assert!(s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg", "quality": q}})).is_err(), "{q}");
|
||||
assert!(s.execute("export.update", &json!({"ids": [r], "index": 0, "quality": q})).is_err(), "{q}");
|
||||
}
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!(n.exports.len(), 1);
|
||||
assert_eq!(n.exports[0].quality, ExportSetting::DEFAULT_QUALITY);
|
||||
// null leaves the setting as it is.
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 0, "quality": 40})).unwrap();
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 0, "quality": null})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].quality, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_quality_is_refused_whatever_the_format() {
|
||||
// As `vdCompat`, `bleed` and the SVG flags are: a value is checked for every format, so a
|
||||
// setting that becomes a JPG later never holds one the encoder can't take.
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
assert!(s.execute("file.export", &json!({"ids": [r], "format": "png", "vdCompat": "junk"})).is_err());
|
||||
let png = std::env::temp_dir().join(format!("figcraft-bad-quality-{}.png", std::process::id()));
|
||||
for mut p in [json!({"format": "png"}), json!({"format": "webp"}), json!({"format": "svg"}), json!({"path": png.to_string_lossy()})] {
|
||||
p["ids"] = json!([r]);
|
||||
p["quality"] = json!(0);
|
||||
let e = s.execute("file.export", &p).unwrap_err();
|
||||
assert!(e.to_string().contains("quality: a whole number from 1 to 100"), "{p}: {e}");
|
||||
}
|
||||
assert!(!png.exists(), "the refused export wrote nothing");
|
||||
// A valid quality on a PNG setting is kept, and used once the setting is a JPG.
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"format": "png", "quality": 40}})).unwrap();
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 0, "format": "jpg"})).unwrap();
|
||||
let files = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap()["files"].as_array().unwrap().clone();
|
||||
assert_eq!(bytes(&files[0]), jpg(&mut s, r, json!({"quality": 40})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_settings_keep_their_quality() {
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg", "quality": 30}})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg", "suffix": "-hq"}})).unwrap();
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 1, "quality": 95})).unwrap();
|
||||
let exports = s.doc().unwrap().node(r).unwrap().exports.clone();
|
||||
assert_eq!((exports[0].quality, exports[1].quality), (30, 95));
|
||||
// `file.exportLayers` encodes each setting at its own quality.
|
||||
let files = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap()["files"].as_array().unwrap().clone();
|
||||
assert_eq!(bytes(&files[0]), jpg(&mut s, r, json!({"quality": 30})));
|
||||
assert_eq!(bytes(&files[1]), jpg(&mut s, r, json!({"quality": 95})));
|
||||
// Saved only when it isn't the default, so older files and settings read as 90.
|
||||
assert_eq!(serde_json::to_value(&exports[0]).unwrap()["quality"], 30);
|
||||
assert!(serde_json::to_value(ExportSetting::default()).unwrap().get("quality").is_none());
|
||||
let old: ExportSetting = serde_json::from_value(json!({"format": "jpg"})).unwrap();
|
||||
assert_eq!(old.quality, ExportSetting::DEFAULT_QUALITY);
|
||||
// A value out of range in a file is clamped when it encodes.
|
||||
let odd: ExportSetting = serde_json::from_value(json!({"format": "jpg", "quality": 0})).unwrap();
|
||||
assert_eq!(odd.quality(), 1);
|
||||
// Undo takes the setting's quality back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[1].quality, ExportSetting::DEFAULT_QUALITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_jpg_embeds_the_file_colour_profile() {
|
||||
for profile in ColorProfile::ALL {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.colorProfile", &json!({"profile": profile.label(), "convert": false})).unwrap();
|
||||
let r = gradient_rect(&mut s);
|
||||
for quality in [json!({}), json!({"quality": 10})] {
|
||||
assert_eq!(icc(&jpg(&mut s, r, quality)), Some(profile.icc()), "{profile:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -367,6 +367,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;
|
||||
@@ -383,6 +385,8 @@ mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod jam_open_tests;
|
||||
#[cfg(test)]
|
||||
mod jpg_export_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
#[cfg(test)]
|
||||
mod library_beacon_tests;
|
||||
@@ -457,12 +461,16 @@ mod pen_end_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_overlay_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_undo_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_vector_edit_tests;
|
||||
#[cfg(test)]
|
||||
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;
|
||||
@@ -545,6 +553,8 @@ mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod stored_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod stroke_cap_error_tests;
|
||||
#[cfg(test)]
|
||||
mod stroke_style_tests;
|
||||
#[cfg(test)]
|
||||
mod style_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(©_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();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Undo while the pen draws (#1745): each undo takes back the path's last anchor, not the whole
|
||||
//! path (nor the step before it); redo puts the anchors back.
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
use figcraft_doc::NodeKind;
|
||||
use serde_json::json;
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
|
||||
}
|
||||
|
||||
fn pen_points(s: &Session) -> Option<usize> {
|
||||
s.tool.pen.as_ref().map(|p| p.points.len())
|
||||
}
|
||||
|
||||
/// (vertices, segments) of the pen's vector, if it has one.
|
||||
fn vector(s: &Session) -> Option<(usize, usize)> {
|
||||
let id = s.tool.pen.as_ref().and_then(|p| p.id)?;
|
||||
match &s.doc().unwrap().node(id)?.kind {
|
||||
NodeKind::Vector { network } => Some((network.vertices.len(), network.segments.len())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session) -> bool {
|
||||
s.execute("edit.undo", &json!({})).unwrap()["undone"] == true
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session) {
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_while_drawing_takes_back_one_anchor() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 50, "height": 50})).unwrap();
|
||||
assert_eq!(layers(&s), 1);
|
||||
s.set_tool(ToolId::Pen);
|
||||
click(&mut s, 100.0, 100.0);
|
||||
click(&mut s, 200.0, 100.0);
|
||||
click(&mut s, 200.0, 200.0);
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(3), Some((3, 2)), 2));
|
||||
|
||||
assert!(undo(&mut s));
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(2), Some((2, 1)), 2), "only the last anchor goes");
|
||||
assert!(undo(&mut s));
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(1), None, 1), "one anchor, no layer yet");
|
||||
|
||||
// Redo puts the anchors back in order.
|
||||
redo(&mut s);
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(2), Some((2, 1)), 2));
|
||||
redo(&mut s);
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(3), Some((3, 2)), 2));
|
||||
let pts: Vec<_> = s.tool.pen.as_ref().unwrap().points.iter().map(|(p, _, _)| (p.x, p.y)).collect();
|
||||
assert_eq!(pts, vec![(100.0, 100.0), (200.0, 100.0), (200.0, 200.0)]);
|
||||
|
||||
for _ in 0..3 {
|
||||
assert!(undo(&mut s));
|
||||
}
|
||||
assert_eq!((pen_points(&s), layers(&s)), (None, 1), "the path is gone, the rectangle stays");
|
||||
assert!(undo(&mut s));
|
||||
assert_eq!(layers(&s), 0, "the next undo takes the rectangle");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -114,6 +114,10 @@ impl Session {
|
||||
|
||||
pub fn undo(&mut self) -> Result<bool> {
|
||||
textpreview::end(self);
|
||||
// While the pen draws, undo works on the anchors of its path (#1745).
|
||||
if self.ds().is_ok_and(|d| d.preview.is_none()) && self.pen_undo()? {
|
||||
return Ok(true);
|
||||
}
|
||||
let live = self.ds().is_ok_and(|d| self.live_doc(d.uid));
|
||||
let ds = self.ds_mut()?;
|
||||
if ds.preview.is_some() {
|
||||
@@ -152,6 +156,10 @@ impl Session {
|
||||
|
||||
pub fn redo(&mut self) -> Result<bool> {
|
||||
textpreview::end(self);
|
||||
// While the pen draws, redo works on the anchors of its path (#1745).
|
||||
if self.ds().is_ok_and(|d| d.preview.is_none()) && self.pen_redo()? {
|
||||
return Ok(true);
|
||||
}
|
||||
let live = self.ds().is_ok_and(|d| self.live_doc(d.uid));
|
||||
let ds = self.ds_mut()?;
|
||||
if ds.preview.is_some() {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -611,6 +611,21 @@ fn doc_change(plan: &Plan, c: &Change) -> Option<DocChange> {
|
||||
Some(DocChange { what, change: entry_change(plan, c) })
|
||||
}
|
||||
|
||||
/// One side's value of an auto layout field in conflict, in words: `16`, `8, 8, 8, 8`, `min`,
|
||||
/// `on`, `none`.
|
||||
fn field_text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Null => "none".to_string(),
|
||||
Value::Bool(on) => if *on { "on" } else { "off" }.to_string(),
|
||||
Value::Number(n) => n.as_f64().map_or_else(|| n.to_string(), |x| x.to_string()),
|
||||
Value::String(s) => words(s),
|
||||
Value::Array(items) => items.iter().map(field_text).collect::<Vec<_>>().join(", "),
|
||||
Value::Object(_) => v.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The review's row for conflict `c`: the layer (or file entry) and property, and what each side
|
||||
/// changed it to.
|
||||
fn conflict_row(plan: &Plan, c: &Conflict, base: &Document, head: &Document, main: &Document) -> ConflictRow {
|
||||
let layer = |id: NodeId| main.node(id).or(head.node(id)).or(base.node(id)).map_or_else(|| id.to_string(), |n| n.name.clone());
|
||||
let text = |side: Side| match c.changes(side).first().and_then(|i| plan.changes(side).get(*i)) {
|
||||
@@ -627,6 +642,11 @@ fn conflict_row(plan: &Plan, c: &Conflict, base: &Document, head: &Document, mai
|
||||
let property = if *prop == Prop::Parent { "place in layers".to_string() } else { words(prop) };
|
||||
(Some(*id), layer(*id), property, b, m)
|
||||
}
|
||||
Key::Field(id, prop, field) => {
|
||||
let (b, m) = plan.field_values(&c.key).map_or_else(|| ("changed".into(), "changed".into()), |(b, m)| (field_text(b), field_text(m)));
|
||||
let owner = if *prop == Prop::Layout { "auto layout".to_string() } else { words(prop) };
|
||||
(Some(*id), layer(*id), format!("{owner} {}", field.replace('_', " ")), b, m)
|
||||
}
|
||||
Key::Layer(id) => {
|
||||
let (b, m) = if c.deleted_by == Some(Side::Branch) { ("deleted", "edited") } else { ("edited", "deleted") };
|
||||
(Some(*id), layer(*id), "layer".to_string(), b.to_string(), m.to_string())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! An unknown stroke cap is refused with the caps there are, as `strokeAlign` and `strokeJoin`
|
||||
//! list theirs (#2005).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::props::STROKE_CAP_HELP;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::StrokeCap;
|
||||
|
||||
#[test]
|
||||
fn an_unknown_cap_lists_every_cap() {
|
||||
let names: Vec<String> = StrokeCap::ALL.iter().map(|c| serde_json::to_value(c).unwrap().as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(STROKE_CAP_HELP.split('|').collect::<Vec<_>>(), names, "the list is every cap, in order");
|
||||
let mut s = Session::new();
|
||||
let v = id_of(&s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
|
||||
for key in ["strokeCap", "startCap", "endCap"] {
|
||||
for junk in [json!("butt"), json!(3), json!("")] {
|
||||
let e = s.execute("node.set", &json!({"id": v, "props": {key: junk}})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("{key}: {STROKE_CAP_HELP}")), "{key} {junk}: {e}");
|
||||
}
|
||||
// Every name the error lists is taken.
|
||||
for name in &names {
|
||||
s.execute("node.set", &json!({"id": v, "props": {key: name}})).unwrap_or_else(|e| panic!("{key} {name}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -172,7 +172,8 @@ fn effects_follow_their_variables() {
|
||||
bind(&mut s, "effects/0/offsetY", "y");
|
||||
bind(&mut s, "effects/0/color", "shadow");
|
||||
bind(&mut s, "effects/1/radius", "blur");
|
||||
bind(&mut s, "effects/9/radius", "blur");
|
||||
// There is no effect 9 to bind (see `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`).
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "effects/9/radius", "variable": "blur"})).is_err());
|
||||
let fx = |s: &Session| s.doc().unwrap().node(r).unwrap().effects.clone();
|
||||
match &fx(&s)[0].kind {
|
||||
figcraft_doc::EffectKind::DropShadow(sh) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()] }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
@@ -56,16 +56,24 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
let spans = td.spans();
|
||||
let mut table: Vec<&TextStyle> = Vec::new();
|
||||
// Each distinct style once, found by its serialized form rather than by comparing it
|
||||
// with every earlier one (quadratic in the number of distinct styles).
|
||||
let mut index: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
let mut span_ids = Vec::with_capacity(spans.len());
|
||||
for (_, _, st) in &spans {
|
||||
span_ids.push(if **st == td.style {
|
||||
0
|
||||
} else {
|
||||
let i = table.iter().position(|t| *t == *st).unwrap_or_else(|| {
|
||||
table.push(st);
|
||||
table.len() - 1
|
||||
});
|
||||
i as u32 + 1
|
||||
match serde_json::to_string(st) {
|
||||
Ok(key) => *index.entry(key).or_insert_with(|| {
|
||||
table.push(st);
|
||||
table.len() as u32
|
||||
}),
|
||||
Err(_) => {
|
||||
table.push(st);
|
||||
table.len() as u32
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut chars = String::new();
|
||||
@@ -128,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)),
|
||||
])
|
||||
@@ -257,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,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),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
@@ -1631,6 +1674,22 @@ fn a_whole_ellipse_starting_anywhere_comes_back_whole() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_thousands_of_distinct_styles_exports_in_linear_time() {
|
||||
// The style table used to be searched end to end for every span (quadratic in distinct styles).
|
||||
let mut doc = Document::new("styles");
|
||||
let p = doc.first_page().unwrap();
|
||||
let mut td = TextData::new("x".repeat(20_000));
|
||||
td.runs = (0..20_000)
|
||||
.map(|i| StyleRun { start: i, end: i + 1, style: TextStyle { font_size: 8.0 + i as f64 / 100.0, ..TextStyle::default() } })
|
||||
.collect();
|
||||
doc.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
}
|
||||
|
||||
/// An ellipse whose start has gone round many times (a start typed in degrees past a few hundred
|
||||
/// turns) comes back drawn the same: the same sweep, and a start the same way round.
|
||||
#[test]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -130,6 +130,18 @@ impl<'a> Mapper<'a> {
|
||||
if lines.iter().any(|l| *l != "NONE") {
|
||||
o.insert("lineTypes".into(), json!(lines));
|
||||
}
|
||||
// A forced base direction per paragraph (`sourceDirectionality`; not a REST field).
|
||||
let dirs: Vec<&str> = raw
|
||||
.iter()
|
||||
.map(|l| match l.get("sourceDirectionality").and_then(Kv::str) {
|
||||
Some("LTR") => "LTR",
|
||||
Some("RTL") => "RTL",
|
||||
_ => "AUTO",
|
||||
})
|
||||
.collect();
|
||||
if dirs.iter().any(|d| *d != "AUTO") {
|
||||
o.insert("lineDirections".into(), json!(dirs));
|
||||
}
|
||||
// Where a numbered list starts counting, less one (not a REST field).
|
||||
let starts: Vec<u32> =
|
||||
raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect();
|
||||
|
||||
@@ -619,7 +619,7 @@ fn other_layer_kinds_import_as_the_closest_layers() {
|
||||
let world = doc.world_transform(link.id);
|
||||
let ends: Vec<(f64, f64)> =
|
||||
network_of(&doc, "Link").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
|
||||
assert_eq!(ends, [(0.0, 300.0), (200.0, 300.0), (200.0, 50.0), (400.0, 50.0)]);
|
||||
assert_eq!(ends, [(0.0, 300.0), (198.0, 300.0), (198.0, 50.0), (396.0, 50.0)]);
|
||||
assert_eq!(link.stroke.cap_end, StrokeCap::TriangleArrow);
|
||||
// A table: a frame.
|
||||
assert!(matches!(named(&doc, "Grid").kind, NodeKind::Frame));
|
||||
@@ -656,9 +656,9 @@ fn connectors_without_a_saved_path_follow_their_line_style() {
|
||||
// Curved: one curve from A's right side to B's left, leaving and arriving level.
|
||||
let curve = network_of(&doc, "Curve");
|
||||
let ends: Vec<_> = curve.vertices.iter().map(|v| world("Curve") * figcraft_doc::geom::Point::new(v.x, v.y)).collect();
|
||||
assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::<Vec<_>>(), [(100.0, 650.0), (300.0, 650.0)]);
|
||||
assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::<Vec<_>>(), [(104.0, 650.0), (296.0, 650.0)], "END_GAP outside the sides");
|
||||
assert_eq!(curve.segments.len(), 1);
|
||||
assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((100.0, 0.0), (-100.0, 0.0)), "the curve leaves and arrives along the sides");
|
||||
assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((96.0, 0.0), (-96.0, 0.0)), "the curve leaves and arrives along the sides");
|
||||
// Straight: unchanged, to the points the file gives on the layers.
|
||||
let line: Vec<_> =
|
||||
network_of(&doc, "Line").vertices.iter().map(|v| world("Line") * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
|
||||
|
||||
@@ -562,6 +562,11 @@ fn oversized_grid_track_lists_warn_and_use_default_tracks() {
|
||||
assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_explicit_field_ids_are_ambiguous() {
|
||||
assert!(Schema::parse("message M uint a=1, uint b=1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kiwi_round_trip() {
|
||||
let sc = schema();
|
||||
@@ -1529,6 +1534,9 @@ mod image_filters;
|
||||
#[path = "text_list_tests.rs"]
|
||||
mod text_list;
|
||||
|
||||
#[path = "text_direction_tests.rs"]
|
||||
mod text_direction;
|
||||
|
||||
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
|
||||
pub(super) fn samples() -> Vec<std::path::PathBuf> {
|
||||
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! A paragraph's forced base direction (#1830): each text line's `sourceDirectionality` (AUTO,
|
||||
//! LTR or RTL) comes in as the paragraph's direction and goes back out. Fixtures built in code with
|
||||
//! the parent module's helpers.
|
||||
|
||||
use figcraft_doc::{NodeKind, TextDirection};
|
||||
|
||||
use super::*;
|
||||
|
||||
const DIRECTION_SCHEMA: &str = "
|
||||
enum NodeType NONE DOCUMENT CANVAS TEXT
|
||||
enum LineType PLAIN ORDERED_LIST UNORDERED_LIST
|
||||
enum SourceDirectionality AUTO LTR RTL
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
message ParentIndex GUID guid, string position
|
||||
message TextLineData LineType lineType, int indentationLevel, SourceDirectionality sourceDirectionality, int listStartOffset, bool isFirstLineOfList
|
||||
message TextData string characters, TextLineData[] lines
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData
|
||||
message Message NodeChange[] nodeChanges
|
||||
";
|
||||
|
||||
/// A plain line with a base direction.
|
||||
fn line(direction: &'static str) -> Kv<'static> {
|
||||
o(vec![
|
||||
("lineType", e("PLAIN")),
|
||||
("indentationLevel", Kv::Int(0)),
|
||||
("sourceDirectionality", e(direction)),
|
||||
("listStartOffset", Kv::Int(0)),
|
||||
("isFirstLineOfList", Kv::Bool(false)),
|
||||
])
|
||||
}
|
||||
|
||||
fn text_node(id: u64, name: &str, chars: &str, lines: Vec<Kv<'static>>) -> Kv<'static> {
|
||||
let pos = ["!", "\"", "#", "$"][id as usize % 4];
|
||||
node(
|
||||
id,
|
||||
Some((1, pos)),
|
||||
"TEXT",
|
||||
name,
|
||||
vec![
|
||||
("size", xy(200.0, 60.0)),
|
||||
("transform", at(0.0, 0.0)),
|
||||
("fontSize", f(16.0)),
|
||||
("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn directions(doc: &figcraft_doc::Document, name: &str) -> Vec<TextDirection> {
|
||||
let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") };
|
||||
(0..t.paragraph_count()).map(|p| t.para_list(p).direction).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lines_source_directionality_is_its_paragraphs_direction_both_ways() {
|
||||
use TextDirection::{Auto as A, Ltr as L, Rtl as R};
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
|
||||
text_node(2, "Detected", "abc\ndef", vec![line("AUTO"), line("AUTO")]),
|
||||
text_node(3, "Forced", "abc\ndef\nghi", vec![line("RTL"), line("AUTO"), line("LTR")]),
|
||||
];
|
||||
let sc = Schema::parse(DIRECTION_SCHEMA).unwrap();
|
||||
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(directions(&doc, "Detected"), [A, A]);
|
||||
let NodeKind::Text(t) = &named(&doc, "Detected").kind else { panic!() };
|
||||
assert!(t.paragraphs.is_empty(), "AUTO throughout keeps the paragraphs folded");
|
||||
assert_eq!(directions(&doc, "Forced"), [R, A, L]);
|
||||
// Out again: every line says its direction, and a reader gets the same paragraphs back.
|
||||
let (bytes, _) = super::super::export(&doc, None).unwrap();
|
||||
let a = Archive::open(&bytes).unwrap();
|
||||
let (schema, message) = decode_canvas(&a.canvas).unwrap();
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Forced")).unwrap();
|
||||
let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap();
|
||||
let out: Vec<&str> = lines.iter().map(|l| l.get("sourceDirectionality").and_then(Kv::str).unwrap()).collect();
|
||||
assert_eq!(out, ["RTL", "AUTO", "LTR"]);
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
assert_eq!(directions(&back, "Forced"), [R, A, L]);
|
||||
assert_eq!(directions(&back, "Detected"), [A, A]);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ fn the_export_writes_where_a_list_starts() {
|
||||
let page = doc.first_page().unwrap();
|
||||
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
||||
let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot");
|
||||
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset };
|
||||
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset, ..ParaList::default() };
|
||||
td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)];
|
||||
doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap();
|
||||
let (bytes, _) = super::super::export(&doc, None).unwrap();
|
||||
|
||||
@@ -113,7 +113,7 @@ fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() {
|
||||
let node = doc.nodes().find(|n| n.name == "Label").unwrap();
|
||||
assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected);
|
||||
let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap();
|
||||
let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") };
|
||||
let StyleKind::Text { style, .. } = &linked.kind else { panic!("linked definition must remain text") };
|
||||
assert!(style.look().skip_ink, "local value must not alter definition");
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only
|
||||
id: "body".into(),
|
||||
name: "Body".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Text { style: shared },
|
||||
kind: StyleKind::Text { style: shared, paragraph: Default::default() },
|
||||
bound: Default::default(),
|
||||
});
|
||||
let id = linked_doc
|
||||
@@ -237,7 +237,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only
|
||||
let node = back.nodes().find(|n| n.name == "Label").unwrap();
|
||||
assert!(!node.kind.text().unwrap().style.look().skip_ink);
|
||||
let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap();
|
||||
let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") };
|
||||
let StyleKind::Text { style, .. } = &linked.kind else { panic!("shared style link must remain text") };
|
||||
assert!(style.look().skip_ink, "local false must not alter the shared definition");
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ fn seen(doc: &Document) -> Seen {
|
||||
assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0));
|
||||
let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(linked.name, "Shared");
|
||||
let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") };
|
||||
let StyleKind::Text { style, .. } = &linked.kind else { panic!("the linked definition must stay a text style") };
|
||||
Seen {
|
||||
case: text.style.case,
|
||||
decoration: text.style.decoration,
|
||||
|
||||
@@ -1531,7 +1531,7 @@ fn import_typography_styles(
|
||||
id: format!("penpot:text:{base}:{id}"),
|
||||
name: name.chars().take(256).collect(),
|
||||
description: "Imported from Penpot typography library".into(),
|
||||
kind: StyleKind::Text { style },
|
||||
kind: StyleKind::Text { style, paragraph: Default::default() },
|
||||
bound: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
@@ -1596,7 +1596,7 @@ fn composite_token_style(token: &Value) -> Option<StyleKind> {
|
||||
}
|
||||
}
|
||||
let parsed = text_style(&Value::Object(fields), &style);
|
||||
Some(StyleKind::Text { style: parsed })
|
||||
Some(StyleKind::Text { style: parsed, paragraph: Default::default() })
|
||||
}
|
||||
"boxShadow" | "shadow" => {
|
||||
let parts: Vec<&Value> = if let Some(items) = value.as_array() { items.iter().take(32).collect() } else { vec![value] };
|
||||
@@ -2155,7 +2155,10 @@ fn import_themed_tokens(
|
||||
if set_names.is_empty() {
|
||||
return false;
|
||||
}
|
||||
set_names.sort_by_key(|name| order.iter().position(|o| o == name).unwrap_or(usize::MAX));
|
||||
// Each set's place in the order once: a position search per comparison was quadratic in
|
||||
// the order's length for every one of the sort's comparisons.
|
||||
let place: BTreeMap<&str, usize> = order.iter().enumerate().rev().map(|(i, o)| (*o, i)).collect();
|
||||
set_names.sort_by_key(|name| place.get(name).copied().unwrap_or(usize::MAX));
|
||||
let set_names: Vec<&str> = set_names.into_iter().take(512).collect();
|
||||
let mut tokens: BTreeMap<&str, BTreeMap<String, &Value>> = BTreeMap::new();
|
||||
for name in &set_names {
|
||||
@@ -5951,7 +5954,7 @@ mod tests {
|
||||
let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap();
|
||||
assert_eq!(doc.styles.len(), 2);
|
||||
assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/title"
|
||||
&& matches!(&s.kind, StyleKind::Text { style }
|
||||
&& matches!(&s.kind, StyleKind::Text { style, .. }
|
||||
if style.font_family == "Spicy Rice" && style.font_size == 24.0 && style.line_height == LineHeight::Pixels(32.0))));
|
||||
assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/elevation"
|
||||
&& matches!(&s.kind, StyleKind::Effect { effects }
|
||||
@@ -6007,10 +6010,8 @@ mod tests {
|
||||
let (doc, warnings) = import(&archive_with_design_system(serde_json::json!({"sets":{}}), Some(typ), Some(color), None)).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(doc.styles.len(), 2);
|
||||
assert!(
|
||||
doc.styles.iter().any(|s| s.name == "Brand/Heading"
|
||||
&& matches!(&s.kind, StyleKind::Text { style } if style.font_family == "Inter" && style.font_size == 24.0))
|
||||
);
|
||||
assert!(doc.styles.iter().any(|s| s.name == "Brand/Heading"
|
||||
&& matches!(&s.kind, StyleKind::Text { style, .. } if style.font_family == "Inter" && style.font_size == 24.0)));
|
||||
assert!(doc.styles.iter().any(|s| s.name == "Brand/Primary" && matches!(s.kind, StyleKind::Paint { .. })));
|
||||
assert_eq!(doc.document_colors.len(), 1);
|
||||
}
|
||||
|
||||
@@ -713,7 +713,9 @@ impl<'a> Exporter<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Vector { network } if node.fills.is_empty() || !node.strokes.is_empty() => self.doc.world_transform(id) * network.stroke_path(),
|
||||
NodeKind::Vector { network } if node.fills.is_empty() || !node.strokes.is_empty() => {
|
||||
self.doc.world_transform(id) * figcraft_doc::geometry::drawn_network(network, node.corners.smoothing).stroke_path()
|
||||
}
|
||||
_ => match figcraft_doc::geometry::shape_path(node) {
|
||||
Some(p) => self.doc.world_transform(id) * p,
|
||||
None => BezPath::new(),
|
||||
|
||||
@@ -174,6 +174,32 @@ fn rectangles_ellipses_and_paths_keep_their_geometry() {
|
||||
assert!(matches!(back.node(named(&back, "Circle")).unwrap().kind, NodeKind::Ellipse { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroked_closed_vectors_are_written_with_their_corner_smoothing() {
|
||||
// A stroked closed 100 × 100 vector with radius 20 on every point and corner smoothing 0.6 is
|
||||
// written as the smoothed outline it draws.
|
||||
let mut doc = Document::new("Smoothed vector");
|
||||
let p = page(&doc);
|
||||
let corners = [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 network = VectorNetwork::from_points(&corners, true);
|
||||
for v in &mut network.vertices {
|
||||
v.radius = 20.0;
|
||||
}
|
||||
let id = add(&mut doc, p, NodeKind::Vector { network }, "Smoothed", 0.0, 0.0, 100.0, 100.0);
|
||||
{
|
||||
let n = doc.node_mut(id).unwrap();
|
||||
n.fills = vec![Paint::solid(Color::BLACK)];
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
n.corners.smoothing = 0.6;
|
||||
}
|
||||
let want = figcraft_doc::geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6).area().abs();
|
||||
let plain = figcraft_doc::geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.0).area().abs();
|
||||
assert!((want - plain).abs() > 10.0, "smoothing changes the outline: {want} vs {plain}");
|
||||
let (back, _, _) = roundtrip(&doc);
|
||||
let written = figcraft_doc::geometry::shape_path(back.node(named(&back, "Smoothed")).unwrap()).unwrap().area().abs();
|
||||
assert!((written - want).abs() < 0.5, "the written path keeps the smoothed corners: {written} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fills_and_strokes_keep_their_order_and_styles() {
|
||||
let mut doc = Document::new("Paints");
|
||||
|
||||
@@ -427,6 +427,9 @@ pub(crate) struct Ctx<'a> {
|
||||
component_meta: BTreeMap<&'a str, &'a Value>,
|
||||
set_meta: BTreeMap<&'a str, &'a Value>,
|
||||
style_meta: BTreeMap<&'a str, &'a Value>,
|
||||
/// The ids of the styles imported so far (`doc.styles` is a Vec: a lookup per reference
|
||||
/// would be quadratic in a document with many distinct references).
|
||||
style_ids: BTreeSet<String>,
|
||||
/// Instances (imported as frames until their main is resolved) with their JSON.
|
||||
instances: Vec<(NodeId, &'a Value)>,
|
||||
/// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON.
|
||||
@@ -468,6 +471,7 @@ impl<'a> Ctx<'a> {
|
||||
component_meta: BTreeMap::new(),
|
||||
set_meta: BTreeMap::new(),
|
||||
style_meta: BTreeMap::new(),
|
||||
style_ids: BTreeSet::new(),
|
||||
instances: Vec::new(),
|
||||
slots: Vec::new(),
|
||||
slot_defaults: BTreeMap::new(),
|
||||
@@ -918,14 +922,18 @@ impl<'a> Ctx<'a> {
|
||||
fn style_definitions(&mut self, v: &'a Value) {
|
||||
for d in list(v, "styleDefinitions").iter().take(MAX_STYLES) {
|
||||
let (Some(id), Some(node)) = (text(d, "id").filter(|i| !i.is_empty()).map(clip_str), d.get("node")) else { continue };
|
||||
if self.doc.styles.iter().any(|s| s.id == id) {
|
||||
if self.style_ids.contains(id) {
|
||||
continue;
|
||||
}
|
||||
let kind = match text(d, "styleType") {
|
||||
Some("FILL") => StyleKind::Paint { paints: props::paints(self, node, "fills").unwrap_or_default() },
|
||||
Some("EFFECT") => StyleKind::Effect { effects: props::effects(self, node) },
|
||||
Some("GRID") => StyleKind::Grid { grids: props::layout_grids(node) },
|
||||
Some("TEXT") => StyleKind::Text { style: text::text_data(self, node).style.as_text_style() },
|
||||
Some("TEXT") => {
|
||||
// The sample layer carries the paragraph-level values too (#1829).
|
||||
let td = text::text_data(self, node);
|
||||
StyleKind::Text { style: td.style.as_text_style(), paragraph: Box::new(td.paragraph_style()) }
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let name = text(d, "name").filter(|s| !s.is_empty()).map_or_else(|| format!("Style {id}"), |s| clip_str(s).to_string());
|
||||
@@ -953,6 +961,7 @@ impl<'a> Ctx<'a> {
|
||||
};
|
||||
self.bindings(&mut sample, node);
|
||||
let bound = sample.bound.iter().filter_map(|(k, v)| Some((style_binding(slot, k)?, v.clone()))).collect();
|
||||
self.style_ids.insert(id.to_string());
|
||||
self.doc.styles.push(Style { id: id.to_string(), name, description, kind, bound });
|
||||
}
|
||||
}
|
||||
@@ -967,19 +976,22 @@ impl<'a> Ctx<'a> {
|
||||
"effect" | "effects" => (StyleKind::Effect { effects: n.effects.clone() }, &mut n.styles.effect),
|
||||
"grid" | "grids" => (StyleKind::Grid { grids: n.grids.clone() }, &mut n.styles.grid),
|
||||
"text" => match &n.kind {
|
||||
NodeKind::Text(t) => (StyleKind::Text { style: t.style.as_text_style() }, &mut n.styles.text),
|
||||
NodeKind::Text(t) => {
|
||||
(StyleKind::Text { style: t.style.as_text_style(), paragraph: Box::new(t.paragraph_style()) }, &mut n.styles.text)
|
||||
}
|
||||
_ => continue,
|
||||
},
|
||||
_ => continue,
|
||||
};
|
||||
*target = Some(sid.to_string());
|
||||
if self.doc.styles.iter().any(|s| s.id == sid) {
|
||||
if self.style_ids.contains(sid) {
|
||||
continue;
|
||||
}
|
||||
let meta = self.style_meta.get(sid);
|
||||
let name =
|
||||
meta.and_then(|m| text(m, "name")).filter(|s| !s.is_empty()).map_or_else(|| format!("Style {sid}"), |s| clip_str(s).to_string());
|
||||
let description = meta.and_then(|m| text(m, "description")).map(|s| clip_str(s).to_string()).unwrap_or_default();
|
||||
self.style_ids.insert(sid.to_string());
|
||||
self.doc.styles.push(Style { id: sid.to_string(), name, description, kind, bound: BTreeMap::new() });
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user