mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 02:34:12 +00:00
Merge remote-tracking branch 'upstream/main' into fix/pencil-dark-background
This commit is contained in:
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
Generated
+22
-22
@@ -1763,7 +1763,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-menu",
|
||||
@@ -1795,7 +1795,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -1813,7 +1813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1831,7 +1831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1854,7 +1854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1863,7 +1863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1876,7 +1876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1910,7 +1910,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-layout",
|
||||
@@ -1927,7 +1927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1935,7 +1935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1943,7 +1943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1959,7 +1959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1968,7 +1968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1998,7 +1998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -2017,7 +2017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
@@ -2042,7 +2042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -2058,7 +2058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"craft-font-catalog",
|
||||
@@ -2076,7 +2076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -2104,7 +2104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-vectordrawable"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -2118,7 +2118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"eframe",
|
||||
@@ -6739,7 +6739,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -314,14 +314,22 @@ stand on its own.
|
||||
|
||||
| | App | What it's for | Code | Learn more |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | **Interface design, components and prototypes · you are here** | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[path = "../src/args.rs"]
|
||||
#[expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser.")]
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser."))]
|
||||
mod args;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -98,3 +98,6 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.aleksei-suholita]
|
||||
real_name = "Oleksii Sukholita"
|
||||
|
||||
@@ -333,6 +333,17 @@ impl Change {
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The synced list this entry belongs to (`None` for entries kept in maps).
|
||||
pub(crate) fn family(&self) -> Option<Family> {
|
||||
match self {
|
||||
Entry::Style(_) => Some(Family::Styles),
|
||||
Entry::Comment(_) => Some(Family::Comments),
|
||||
Entry::Collection(_) => Some(Family::Collections),
|
||||
Entry::Variable(_) => Some(Family::Variables),
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
|
||||
/// session carry their creator's client id, ids from before and ids of other forms are in
|
||||
/// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by
|
||||
|
||||
+123
-29
@@ -54,18 +54,71 @@ struct List<T: Keyed> {
|
||||
entry: fn(T::Key) -> Entry,
|
||||
get: fn(&Document) -> &Vec<T>,
|
||||
get_mut: fn(&mut Document) -> &mut Vec<T>,
|
||||
/// The list's slot in an [`Index`].
|
||||
index: fn(&mut Index) -> &mut Option<KeyIndex<T::Key>>,
|
||||
}
|
||||
|
||||
const STYLES: List<Style> =
|
||||
List { whole: DocProp::Styles, order: DocProp::StyleOrder, entry: Entry::Style, get: |d| &d.styles, get_mut: |d| &mut d.styles };
|
||||
const COMMENTS: List<Comment> =
|
||||
List { whole: DocProp::Comments, order: DocProp::CommentOrder, entry: Entry::Comment, get: |d| &d.comments, get_mut: |d| &mut d.comments };
|
||||
/// Where the entries of each keyed list sit, kept while one batch is applied: a batch can put
|
||||
/// hundreds of thousands of new entries, and finding each one's place by a scan of the growing
|
||||
/// list would cost the square of that on the thread every session shares.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Index {
|
||||
styles: Option<KeyIndex<String>>,
|
||||
comments: Option<KeyIndex<u64>>,
|
||||
collections: Option<KeyIndex<String>>,
|
||||
variables: Option<KeyIndex<String>>,
|
||||
}
|
||||
|
||||
/// The position of the first entry with each key in one list, and the list's length when it was
|
||||
/// last brought up to date: a list whose length changed since is indexed again.
|
||||
struct KeyIndex<K> {
|
||||
at: BTreeMap<K, usize>,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<K: Ord> KeyIndex<K> {
|
||||
fn of<T: Keyed<Key = K>>(list: &[T]) -> Self {
|
||||
let mut at = BTreeMap::new();
|
||||
for (i, e) in list.iter().enumerate() {
|
||||
at.entry(e.key()).or_insert(i);
|
||||
}
|
||||
KeyIndex { at, len: list.len() }
|
||||
}
|
||||
|
||||
/// The index in `slot`, built again from `list` unless its length and its place for `key`
|
||||
/// still match the list, so a list changed some other way is never trusted.
|
||||
fn current<'a, T: Keyed<Key = K>>(slot: &'a mut Option<Self>, list: &[T], key: &K) -> &'a mut Self {
|
||||
let stale = |ix: &Self| ix.len != list.len() || ix.at.get(key).is_some_and(|&i| list.get(i).is_none_or(|e| e.key() != *key));
|
||||
if slot.as_ref().is_none_or(stale) {
|
||||
*slot = Some(KeyIndex::of(list));
|
||||
}
|
||||
slot.get_or_insert_with(|| KeyIndex::of(list))
|
||||
}
|
||||
}
|
||||
|
||||
const STYLES: List<Style> = List {
|
||||
whole: DocProp::Styles,
|
||||
order: DocProp::StyleOrder,
|
||||
entry: Entry::Style,
|
||||
get: |d| &d.styles,
|
||||
get_mut: |d| &mut d.styles,
|
||||
index: |i| &mut i.styles,
|
||||
};
|
||||
const COMMENTS: List<Comment> = List {
|
||||
whole: DocProp::Comments,
|
||||
order: DocProp::CommentOrder,
|
||||
entry: Entry::Comment,
|
||||
get: |d| &d.comments,
|
||||
get_mut: |d| &mut d.comments,
|
||||
index: |i| &mut i.comments,
|
||||
};
|
||||
const COLLECTIONS: List<Collection> = List {
|
||||
whole: DocProp::Collections,
|
||||
order: DocProp::CollectionOrder,
|
||||
entry: Entry::Collection,
|
||||
get: |d| &d.variables.collections,
|
||||
get_mut: |d| &mut d.variables.collections,
|
||||
index: |i| &mut i.collections,
|
||||
};
|
||||
const VARIABLES: List<Variable> = List {
|
||||
whole: DocProp::Variables,
|
||||
@@ -73,6 +126,7 @@ const VARIABLES: List<Variable> = List {
|
||||
entry: Entry::Variable,
|
||||
get: |d| &d.variables.variables,
|
||||
get_mut: |d| &mut d.variables.variables,
|
||||
index: |i| &mut i.variables,
|
||||
};
|
||||
|
||||
impl<T: Keyed> List<T> {
|
||||
@@ -111,25 +165,53 @@ impl<T: Keyed> List<T> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put(&self, doc: &mut Document, key: T::Key, value: &Json) -> Result<(), Error> {
|
||||
fn put(&self, doc: &mut Document, key: T::Key, value: &Json, index: &mut Index) -> Result<(), Error> {
|
||||
let t: T = value.read("entry")?;
|
||||
if t.key() != key {
|
||||
return Err(Error::Invalid("an entry's id must match the entry it is put at".into()));
|
||||
}
|
||||
let list = (self.get_mut)(doc);
|
||||
match list.iter_mut().find(|e| e.key() == key) {
|
||||
let ix = KeyIndex::current((self.index)(index), list, &key);
|
||||
match ix.at.get(&key).copied().and_then(|i| list.get_mut(i)) {
|
||||
Some(e) => *e = t,
|
||||
None => list.push(t),
|
||||
None => {
|
||||
list.push(t);
|
||||
ix.at.insert(key, list.len() - 1);
|
||||
ix.len = list.len();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove(&self, doc: &mut Document, key: &T::Key) {
|
||||
(self.get_mut)(doc).retain(|e| e.key() != *key);
|
||||
fn remove(&self, doc: &mut Document, key: &T::Key, index: &mut Index) {
|
||||
let list = (self.get_mut)(doc);
|
||||
let slot = (self.index)(index);
|
||||
let ix = KeyIndex::current(slot, list, key);
|
||||
// An absent entry leaves the list as it is, without a scan of it.
|
||||
let Some(at) = ix.at.get(key).copied() else {
|
||||
return;
|
||||
};
|
||||
let before = list.len();
|
||||
list.retain(|e| e.key() != *key);
|
||||
// One entry gone at `at`: the index is kept current by shifting the entries after it down,
|
||||
// so the next removal in the batch need not index the list again. Duplicates of the key
|
||||
// (more than one entry gone) leave it to be rebuilt.
|
||||
if list.len() + 1 == before {
|
||||
ix.at.remove(key);
|
||||
for i in ix.at.values_mut() {
|
||||
if *i > at {
|
||||
*i -= 1;
|
||||
}
|
||||
}
|
||||
ix.len = list.len();
|
||||
} else {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries in `keys` order first, the rest after in their current order.
|
||||
fn reorder(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
|
||||
fn reorder(&self, doc: &mut Document, value: &Json, index: &mut Index) -> Result<(), Error> {
|
||||
*(self.index)(index) = None;
|
||||
let keys: Vec<T::Key> = value.read("order")?;
|
||||
let list = (self.get_mut)(doc);
|
||||
let mut slots: Vec<Option<T>> = std::mem::take(list).into_iter().map(Some).collect();
|
||||
@@ -150,7 +232,8 @@ impl<T: Keyed> List<T> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_whole(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
|
||||
fn set_whole(&self, doc: &mut Document, value: &Json, index: &mut Index) -> Result<(), Error> {
|
||||
*(self.index)(index) = None;
|
||||
*(self.get_mut)(doc) = value.read("list")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -313,31 +396,38 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<
|
||||
diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out)
|
||||
}
|
||||
|
||||
/// Apply a document-level change. Returns whether it can change which pages are listed (the
|
||||
/// library store page is never listed).
|
||||
/// [`apply_indexed`] for one change on its own.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error> {
|
||||
apply_indexed(doc, change, &mut Index::default())
|
||||
}
|
||||
|
||||
/// Apply a document-level change, with the keyed lists' positions kept in `index` between the
|
||||
/// changes of one batch. Returns whether it can change which pages are listed (the library store
|
||||
/// page is never listed).
|
||||
pub(crate) fn apply_indexed(doc: &mut Document, change: &Change, index: &mut Index) -> Result<bool, Error> {
|
||||
match change {
|
||||
Change::SetDoc { prop, value } => {
|
||||
if set_scalar(doc, *prop, value)? {
|
||||
return Ok(*prop == DocProp::Libraries);
|
||||
}
|
||||
match prop {
|
||||
DocProp::Styles => STYLES.set_whole(doc, value),
|
||||
DocProp::StyleOrder => STYLES.reorder(doc, value),
|
||||
DocProp::Comments => COMMENTS.set_whole(doc, value),
|
||||
DocProp::CommentOrder => COMMENTS.reorder(doc, value),
|
||||
DocProp::Collections => COLLECTIONS.set_whole(doc, value),
|
||||
DocProp::CollectionOrder => COLLECTIONS.reorder(doc, value),
|
||||
DocProp::Variables => VARIABLES.set_whole(doc, value),
|
||||
DocProp::VariableOrder => VARIABLES.reorder(doc, value),
|
||||
DocProp::Styles => STYLES.set_whole(doc, value, index),
|
||||
DocProp::StyleOrder => STYLES.reorder(doc, value, index),
|
||||
DocProp::Comments => COMMENTS.set_whole(doc, value, index),
|
||||
DocProp::CommentOrder => COMMENTS.reorder(doc, value, index),
|
||||
DocProp::Collections => COLLECTIONS.set_whole(doc, value, index),
|
||||
DocProp::CollectionOrder => COLLECTIONS.reorder(doc, value, index),
|
||||
DocProp::Variables => VARIABLES.set_whole(doc, value, index),
|
||||
DocProp::VariableOrder => VARIABLES.reorder(doc, value, index),
|
||||
_ => Err(Error::Invalid(format!("{prop:?} is not a list"))),
|
||||
}?;
|
||||
}
|
||||
Change::Put { entry, value } => match entry {
|
||||
Entry::Style(k) => STYLES.put(doc, k.clone(), value)?,
|
||||
Entry::Comment(k) => COMMENTS.put(doc, *k, value)?,
|
||||
Entry::Collection(k) => COLLECTIONS.put(doc, k.clone(), value)?,
|
||||
Entry::Variable(k) => VARIABLES.put(doc, k.clone(), value)?,
|
||||
Entry::Style(k) => STYLES.put(doc, k.clone(), value, index)?,
|
||||
Entry::Comment(k) => COMMENTS.put(doc, *k, value, index)?,
|
||||
Entry::Collection(k) => COLLECTIONS.put(doc, k.clone(), value, index)?,
|
||||
Entry::Variable(k) => VARIABLES.put(doc, k.clone(), value, index)?,
|
||||
Entry::DevSnapshot(k) => {
|
||||
let snap: DevSnapshot = value.read("dev snapshot")?;
|
||||
doc.dev_snapshots.insert(*k, Arc::new(snap));
|
||||
@@ -351,14 +441,18 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
|
||||
}
|
||||
},
|
||||
Change::Remove { entry } => match entry {
|
||||
Entry::Style(k) => STYLES.remove(doc, k),
|
||||
Entry::Comment(k) => COMMENTS.remove(doc, k),
|
||||
Entry::Collection(k) => COLLECTIONS.remove(doc, k),
|
||||
Entry::Variable(k) => VARIABLES.remove(doc, k),
|
||||
Entry::Style(k) => STYLES.remove(doc, k, index),
|
||||
Entry::Comment(k) => COMMENTS.remove(doc, k, index),
|
||||
Entry::Collection(k) => COLLECTIONS.remove(doc, k, index),
|
||||
Entry::Variable(k) => VARIABLES.remove(doc, k, index),
|
||||
Entry::DevSnapshot(k) => {
|
||||
doc.dev_snapshots.remove(k);
|
||||
}
|
||||
Entry::Image(k) => {
|
||||
// Nothing to remove: no need to look through the document for what shows it.
|
||||
if !doc.images.contains_key(k) {
|
||||
return Ok(false);
|
||||
}
|
||||
// An image a layer or style shows stays: removing it would let other bytes go
|
||||
// under its key, swapping the picture that layer shows ([`image_for`]).
|
||||
if figcraft_doc::used_images(doc).contains(k) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Keyed document lists (comments, styles, variables, collections) applied in large batches: a
|
||||
//! batch of new entries is placed through an index, not a scan of the growing list per entry, and
|
||||
//! puts, removes and reorders in one batch still leave the list a scan would.
|
||||
|
||||
use figcraft_doc::{Comment, Document, Message};
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Json};
|
||||
use crate::{Mode, Replica};
|
||||
|
||||
fn comment(id: u64, text: &str, doc: &Document) -> Comment {
|
||||
let page = doc.first_page().unwrap();
|
||||
Comment { id, page, x: 1.0, y: 2.0, anchor: None, first: Message::new("A", text, 0), resolved: false, replies: Vec::new(), read_by: None }
|
||||
}
|
||||
|
||||
fn put(c: &Comment) -> Change {
|
||||
Change::Put { entry: Entry::Comment(c.id), value: Json::of(c).unwrap() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_batch_of_new_comments_lands_whole_and_in_order() {
|
||||
let doc = Document::new("t");
|
||||
let changes: Vec<Change> = (1..=100_000).map(|i| put(&comment(i, "hi", &doc))).collect();
|
||||
let mut r = Replica::new(doc);
|
||||
// A scan per put took minutes here; indexed it takes well under a second, even unoptimised.
|
||||
r.apply(&changes, Mode::Strict).unwrap();
|
||||
assert_eq!(r.document().comments.len(), 100_000);
|
||||
assert!(r.document().comments.iter().zip(1..).all(|(c, i)| c.id == i));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puts_removes_and_reorders_in_one_batch_match_one_by_one() {
|
||||
let doc = Document::new("t");
|
||||
let c = |i, t| comment(i, t, &doc);
|
||||
let batch = vec![
|
||||
put(&c(1, "a")),
|
||||
put(&c(2, "b")),
|
||||
put(&c(3, "c")),
|
||||
// Replaced in place.
|
||||
put(&c(2, "B")),
|
||||
Change::Remove { entry: Entry::Comment(1) },
|
||||
// Absent: nothing changes.
|
||||
Change::Remove { entry: Entry::Comment(9) },
|
||||
put(&c(4, "d")),
|
||||
put(&c(6, "f")),
|
||||
// Two removals in a row: the second goes through the index the first kept current.
|
||||
Change::Remove { entry: Entry::Comment(2) },
|
||||
Change::Remove { entry: Entry::Comment(6) },
|
||||
put(&c(2, "B")),
|
||||
put(&c(3, "C")),
|
||||
Change::SetDoc { prop: DocProp::CommentOrder, value: Json::of(&[4u64, 3, 2]).unwrap() },
|
||||
put(&c(2, "BB")),
|
||||
put(&c(1, "again")),
|
||||
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&vec![c(7, "x"), c(8, "y")]).unwrap() },
|
||||
put(&c(8, "Y")),
|
||||
put(&c(5, "e")),
|
||||
];
|
||||
let mut together = Replica::new(doc.clone());
|
||||
together.apply(&batch, Mode::Strict).unwrap();
|
||||
let mut one_by_one = Replica::new(doc.clone());
|
||||
for ch in &batch {
|
||||
one_by_one.apply(std::slice::from_ref(ch), Mode::Strict).unwrap();
|
||||
}
|
||||
assert_eq!(together.document().comments, one_by_one.document().comments);
|
||||
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
|
||||
assert_eq!(got, vec![(7, "x"), (8, "Y"), (5, "e")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_absent_image_does_not_fail() {
|
||||
let mut doc = Document::new("t");
|
||||
let remove = Change::Remove { entry: Entry::Image("missing".into()) };
|
||||
assert!(!crate::docprops::apply(&mut doc, &remove).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removals_in_one_batch_keep_the_index_current() {
|
||||
let doc = Document::new("t");
|
||||
let c = |i, t| comment(i, t, &doc);
|
||||
let batch = vec![
|
||||
put(&c(1, "a")),
|
||||
put(&c(2, "b")),
|
||||
put(&c(3, "c")),
|
||||
put(&c(4, "d")),
|
||||
put(&c(5, "e")),
|
||||
Change::Remove { entry: Entry::Comment(2) },
|
||||
Change::Remove { entry: Entry::Comment(9) },
|
||||
Change::Remove { entry: Entry::Comment(4) },
|
||||
// Replaced where the shifted index says they now are.
|
||||
put(&c(5, "E")),
|
||||
put(&c(3, "C")),
|
||||
put(&c(1, "A")),
|
||||
Change::Remove { entry: Entry::Comment(1) },
|
||||
put(&c(2, "B")),
|
||||
put(&c(5, "EE")),
|
||||
];
|
||||
let mut together = Replica::new(doc.clone());
|
||||
together.apply(&batch, Mode::Strict).unwrap();
|
||||
let mut one_by_one = Replica::new(doc.clone());
|
||||
for ch in &batch {
|
||||
one_by_one.apply(std::slice::from_ref(ch), Mode::Strict).unwrap();
|
||||
}
|
||||
assert_eq!(together.document().comments, one_by_one.document().comments);
|
||||
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
|
||||
assert_eq!(got, vec![(3, "C"), (5, "EE"), (2, "B")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keys_from_a_whole_list_match_one_by_one() {
|
||||
let doc = Document::new("t");
|
||||
let c = |i, t| comment(i, t, &doc);
|
||||
let batch = vec![
|
||||
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&vec![c(1, "a"), c(2, "b"), c(2, "b2"), c(3, "c"), c(4, "d")]).unwrap() },
|
||||
// The first entry with the key is the one replaced, as a scan finds it.
|
||||
put(&c(2, "B")),
|
||||
put(&c(4, "D")),
|
||||
// Both entries with the key go: the index is built again for what follows.
|
||||
Change::Remove { entry: Entry::Comment(2) },
|
||||
put(&c(3, "C")),
|
||||
Change::Remove { entry: Entry::Comment(1) },
|
||||
put(&c(2, "again")),
|
||||
put(&c(4, "DD")),
|
||||
];
|
||||
let mut together = Replica::new(doc.clone());
|
||||
together.apply(&batch, Mode::Lenient).unwrap();
|
||||
let mut one_by_one = Replica::new(doc.clone());
|
||||
for ch in &batch {
|
||||
one_by_one.apply(std::slice::from_ref(ch), Mode::Lenient).unwrap();
|
||||
}
|
||||
assert_eq!(together.document().comments, one_by_one.document().comments);
|
||||
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
|
||||
assert_eq!(got, vec![(3, "C"), (4, "DD"), (2, "again")]);
|
||||
}
|
||||
@@ -148,6 +148,8 @@ mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod journal_tests;
|
||||
#[cfg(test)]
|
||||
mod keyed_list_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_list_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_tests;
|
||||
|
||||
@@ -192,8 +192,9 @@ impl Replica {
|
||||
self.doc.take_dirty();
|
||||
let mut out = Applied::default();
|
||||
let (mut relist, mut relist_all) = (BTreeSet::new(), false);
|
||||
let mut index = docprops::Index::default();
|
||||
for c in changes {
|
||||
match self.apply_one(c, &mut out) {
|
||||
match self.apply_one(c, &mut out, &mut index) {
|
||||
Ok(Relist::No) => {}
|
||||
Ok(Relist::Page(p)) => {
|
||||
relist.insert(p);
|
||||
@@ -214,7 +215,7 @@ impl Replica {
|
||||
}
|
||||
|
||||
/// Apply one change. Returns what of the page list needs rebuilding.
|
||||
fn apply_one(&mut self, c: &Change, out: &mut Applied) -> Result<Relist, Error> {
|
||||
fn apply_one(&mut self, c: &Change, out: &mut Applied, index: &mut docprops::Index) -> Result<Relist, Error> {
|
||||
match c {
|
||||
Change::Create { id, kind, parent, position, props } => {
|
||||
frac::validate(position).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
@@ -297,7 +298,7 @@ impl Replica {
|
||||
Ok(if page { Relist::Page(*id) } else { Relist::No })
|
||||
}
|
||||
Change::SetDoc { .. } | Change::Put { .. } | Change::Remove { .. } => {
|
||||
Ok(if docprops::apply(&mut self.doc, c)? { Relist::All } else { Relist::No })
|
||||
Ok(if docprops::apply_indexed(&mut self.doc, c, index)? { Relist::All } else { Relist::No })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::change::{Change, Entry, Prop, Target};
|
||||
use crate::change::{Change, Entry, Family, Prop, Target};
|
||||
use crate::message::{
|
||||
Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals,
|
||||
};
|
||||
@@ -557,6 +557,7 @@ impl Server {
|
||||
fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result<Vec<(Entry, bool)>, Error> {
|
||||
let before = self.state.document();
|
||||
let mut touched: BTreeSet<Entry> = BTreeSet::new();
|
||||
let mut listed: BTreeMap<Family, (BTreeSet<Entry>, BTreeSet<Entry>)> = BTreeMap::new();
|
||||
for c in changes {
|
||||
match c {
|
||||
Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => {
|
||||
@@ -565,8 +566,10 @@ impl Server {
|
||||
// A whole list replaced (the fallback for lists whose ids aren't unique).
|
||||
Change::SetDoc { prop, .. } => {
|
||||
if let Some(Ok(family)) = prop.list() {
|
||||
touched.extend(docprops::entries(before, family));
|
||||
touched.extend(docprops::entries(after, family));
|
||||
let (b, a) = (docprops::entries(before, family), docprops::entries(after, family));
|
||||
touched.extend(b.iter().chain(&a).cloned());
|
||||
// Looked up in sets below: a linear search per entry would be quadratic.
|
||||
listed.entry(family).or_insert_with(|| (b.into_iter().collect(), a.into_iter().collect()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -574,7 +577,10 @@ impl Server {
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in touched {
|
||||
let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry));
|
||||
let (was, now) = match entry.family().and_then(|f| listed.get(&f)) {
|
||||
Some((b, a)) => (b.contains(&entry), a.contains(&entry)),
|
||||
None => (docprops::has(before, &entry), docprops::has(after, &entry)),
|
||||
};
|
||||
if was == now {
|
||||
continue;
|
||||
}
|
||||
|
||||
+24
-18
@@ -376,26 +376,32 @@ impl Gradient {
|
||||
s
|
||||
}
|
||||
|
||||
/// The colour at `t` (0..1) along the gradient.
|
||||
/// The colour at `t` (0..1) along the gradient. Sorts the stops on every call: to sample one
|
||||
/// gradient many times, sort once with [`Self::sorted_stops`] and use [`sample_sorted`].
|
||||
pub fn sample(&self, t: f32) -> Color {
|
||||
let stops = self.sorted_stops();
|
||||
let t = clamp01(t);
|
||||
let mut prev = match stops.first() {
|
||||
Some(s) => *s,
|
||||
None => return Color::BLACK,
|
||||
};
|
||||
if t <= prev.position {
|
||||
return prev.color;
|
||||
sample_sorted(&self.sorted_stops(), t)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour at `t` (0..1) along stops already sorted by [`Gradient::sorted_stops`]: found by a
|
||||
/// binary search, so a renderer sampling a file's gradient per pixel doesn't scan or sort its
|
||||
/// stops each time.
|
||||
pub fn sample_sorted(stops: &[ColorStop], t: f32) -> Color {
|
||||
let t = clamp01(t);
|
||||
let Some(first) = stops.first() else { return Color::BLACK };
|
||||
if t <= first.position {
|
||||
return first.color;
|
||||
}
|
||||
// The first stop after the first one at or past `t`, and the stop before it.
|
||||
let rest = stops.get(1..).unwrap_or_default();
|
||||
let i = rest.partition_point(|s| s.position < t);
|
||||
match (rest.get(i), stops.get(i)) {
|
||||
(Some(s), Some(prev)) => {
|
||||
let span = s.position - prev.position;
|
||||
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
|
||||
prev.color.lerp(s.color, f)
|
||||
}
|
||||
for s in stops.iter().skip(1) {
|
||||
if t <= s.position {
|
||||
let span = s.position - prev.position;
|
||||
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
|
||||
return prev.color.lerp(s.color, f);
|
||||
}
|
||||
prev = *s;
|
||||
}
|
||||
prev.color
|
||||
_ => stops.last().map_or(Color::BLACK, |s| s.color),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,44 @@ fn gradient_sampling() {
|
||||
assert_eq!(g.sample(0.5).to_hex(), "808080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_sorted_stops_matches_a_scan() {
|
||||
// The scan `sample` used to make: the first stop at or past `t`, mixed with the one before.
|
||||
fn scan(stops: &[ColorStop], t: f32) -> Color {
|
||||
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
|
||||
let mut prev = stops[0];
|
||||
if t <= prev.position {
|
||||
return prev.color;
|
||||
}
|
||||
for s in &stops[1..] {
|
||||
if t <= s.position {
|
||||
let span = s.position - prev.position;
|
||||
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
|
||||
return prev.color.lerp(s.color, f);
|
||||
}
|
||||
prev = *s;
|
||||
}
|
||||
prev.color
|
||||
}
|
||||
let grey = |v: f32| Color { r: v, g: v, b: v, a: 1.0 };
|
||||
let positions = [0.3, 0.0, 0.3, 1.0, 0.7, f32::NAN, 0.5, 0.5, 0.9];
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Diamond,
|
||||
stops: positions.iter().enumerate().map(|(i, p)| ColorStop { position: *p, color: grey(i as f32 / 10.0) }).collect(),
|
||||
from: (0.5, 0.5),
|
||||
to: (1.0, 0.5),
|
||||
width: (0.5, 1.0),
|
||||
};
|
||||
let sorted = g.sorted_stops();
|
||||
for i in -10..=110 {
|
||||
let t = i as f32 / 100.0;
|
||||
assert_eq!(sample_sorted(&sorted, t), scan(&sorted, t), "t = {t}");
|
||||
assert_eq!(g.sample(t), scan(&sorted, t), "t = {t}");
|
||||
}
|
||||
assert_eq!(sample_sorted(&sorted, f32::NAN), scan(&sorted, f32::NAN));
|
||||
assert_eq!(sample_sorted(&[], 0.5), Color::BLACK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_serde_shape() {
|
||||
let p = Paint::solid(Color::from_hex("FF0000").unwrap());
|
||||
|
||||
@@ -574,6 +574,19 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove every child of `id` with its subtree, in one pass over `id`'s child list (removing
|
||||
/// them one by one would search that list once per child).
|
||||
pub fn remove_children(&mut self, id: NodeId) -> DocResult<()> {
|
||||
let kids = std::mem::take(&mut self.get_mut(id)?.children);
|
||||
for c in kids {
|
||||
for d in self.subtree(c) {
|
||||
self.nodes.remove(&d);
|
||||
self.dirty.touch(d);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether sections may go in `parent`: a page or another section (never a frame or a group).
|
||||
pub fn takes_sections(&self, parent: NodeId) -> bool {
|
||||
self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
|
||||
+11
-6
@@ -942,9 +942,9 @@ impl Node {
|
||||
matches!(&self.kind, NodeKind::Vector { network } if network.vertices.iter().any(|v| v.cap.is_some_and(StrokeCap::is_arrow)));
|
||||
let caps =
|
||||
if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() || point_arrows { w.max(self.stroke.weight) * 3.0 } else { w };
|
||||
// Right-angled or smooth outlines (boxes, ellipses) end their mitres on the grown box.
|
||||
let boxy = matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like();
|
||||
let miter = if boxy { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
|
||||
// Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown
|
||||
// box; an arc's corners don't.
|
||||
let miter = if self.grows_by_exactly_its_spread() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
|
||||
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
|
||||
} else {
|
||||
0.0
|
||||
@@ -960,14 +960,19 @@ impl Node {
|
||||
!(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke()))
|
||||
}
|
||||
|
||||
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners).
|
||||
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners). An arc
|
||||
/// (an ellipse that doesn't go all the way round) has corners where its ends meet its sides.
|
||||
fn grows_by_exactly_its_spread(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like()
|
||||
match self.kind {
|
||||
NodeKind::Rectangle | NodeKind::Section { .. } => true,
|
||||
NodeKind::Ellipse { sweep, .. } => figcraft_geom::full_sweep(sweep),
|
||||
_ => self.kind.is_frame_like(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
|
||||
/// spread moves the edges out in parallel and they meet in a mitre (see
|
||||
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread.
|
||||
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread.
|
||||
fn spread_miter_reach(&self) -> f64 {
|
||||
if self.grows_by_exactly_its_spread() {
|
||||
return 0.0;
|
||||
|
||||
@@ -846,3 +846,17 @@ fn the_colour_walker_visits_calculation_literals() {
|
||||
let ColorOperand::Literal { literal } = from else { panic!("literal operand") };
|
||||
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_children_removes_every_subtree_and_keeps_the_parent() {
|
||||
let mut doc = Document::new("T");
|
||||
let page = doc.first_page().unwrap();
|
||||
let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
doc.remove_children(frame).unwrap();
|
||||
assert!(doc.contains(frame) && doc.contains(other));
|
||||
assert!(!doc.contains(inner) && !doc.contains(leaf));
|
||||
assert!(doc.get(frame).unwrap().children.is_empty());
|
||||
}
|
||||
|
||||
+41
-5
@@ -234,6 +234,22 @@ pub enum LeadingTrim {
|
||||
CapHeight,
|
||||
}
|
||||
|
||||
/// Where lines break when text wraps to more than one line ("Wrap style controls where lines
|
||||
/// break when text wraps to more than one line", Explore text properties).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapStyle {
|
||||
/// "Figma fits as many words as possible on each line before wrapping to the next."
|
||||
#[default]
|
||||
Auto,
|
||||
/// "Figma distributes text as evenly as possible across every line, so each line is close to
|
||||
/// the same length."
|
||||
Balance,
|
||||
/// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single
|
||||
/// word stranded alone on the last line."
|
||||
Pretty,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListStyle {
|
||||
@@ -251,7 +267,10 @@ pub const MAX_LIST_INDENT: u8 = 8;
|
||||
#[serde(default)]
|
||||
pub struct ParaList {
|
||||
pub list: ListStyle,
|
||||
/// Nesting level (0 = outermost; plain paragraphs are always 0).
|
||||
/// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by
|
||||
/// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level
|
||||
/// it had and starts where that level's item text does ("delete the counter, but keep the
|
||||
/// same level of indentation").
|
||||
pub indent: u8,
|
||||
/// A numbered list that starts counting at this paragraph counts from `start_offset + 1`
|
||||
/// (items that go on counting ignore it; other paragraphs are always 0).
|
||||
@@ -463,6 +482,10 @@ pub struct TextData {
|
||||
/// as fit).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub max_lines: u32,
|
||||
/// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow
|
||||
/// (Balance), or full with no one word alone on the last line (Pretty).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub wrap_style: WrapStyle,
|
||||
/// OpenType feature toggles (e.g. "liga": false).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub features: std::collections::BTreeMap<String, bool>,
|
||||
@@ -606,12 +629,14 @@ impl TextData {
|
||||
}
|
||||
|
||||
/// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height
|
||||
/// with a line limit (the box hugs up to `max_lines` lines).
|
||||
/// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is
|
||||
/// available only if truncate text is enabled, and: Text resizing is set to auto height or
|
||||
/// auto width").
|
||||
pub fn truncates(&self) -> bool {
|
||||
match self.auto_resize {
|
||||
TextAutoResize::Truncate => true,
|
||||
TextAutoResize::Height => self.max_lines > 0,
|
||||
TextAutoResize::WidthAndHeight | TextAutoResize::None => false,
|
||||
TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0,
|
||||
TextAutoResize::None => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,11 +823,22 @@ impl TextData {
|
||||
/// paragraphs stay at level 0, only numbered ones keep a start, levels stop at
|
||||
/// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`.
|
||||
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
|
||||
self.set_para_lists_with(start, end, false, f);
|
||||
}
|
||||
|
||||
/// Take the list items among the paragraphs chars `start..end` touch out of their list at the
|
||||
/// level they had, as Backspace or Delete at an item's start does: "delete the counter, but
|
||||
/// keep the same level of indentation" (Create bulleted and numbered lists).
|
||||
pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) {
|
||||
self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None);
|
||||
}
|
||||
|
||||
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.fill_paragraphs();
|
||||
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
|
||||
f(pl);
|
||||
if pl.list == ListStyle::None {
|
||||
if pl.list == ListStyle::None && !keep_levels {
|
||||
pl.indent = 0;
|
||||
}
|
||||
if pl.list != ListStyle::Numbered {
|
||||
|
||||
@@ -72,6 +72,52 @@ fn fill_on_a_child_turns_the_hugging_parent_fixed() {
|
||||
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_resized_to_the_full_available_space_becomes_fill() {
|
||||
// Documented: "Child objects of an auto layout frame will also be set to Fill container if
|
||||
// they are manually resized to the full available space of the parent frame."
|
||||
let mut s = Session::new();
|
||||
let f = al_frame(&mut s, json!({"padding": 10, "gap": 10})); // 200 × 100, fixed
|
||||
let a = rect(&mut s, f, 50.0, 20.0, "#FF0000");
|
||||
// Along the flow, alone in the frame: its width less the padding (200 - 20).
|
||||
s.execute("transform.resize", &json!({"ids": [a], "width": 180})).unwrap();
|
||||
assert_eq!(sizing(&s, a), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, a), (180.0, 20.0));
|
||||
s.undo().unwrap();
|
||||
assert_eq!(sizing(&s, a), (Sizing::Fixed, Sizing::Fixed), "undone as part of the edit");
|
||||
// Across the flow, beside a sibling too: the frame's height less its padding.
|
||||
let b = rect(&mut s, f, 50.0, 20.0, "#0000FF");
|
||||
set(&mut s, b, json!({"height": 80}));
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fill));
|
||||
assert_eq!(size(&s, b), (50.0, 80.0));
|
||||
// Short of it: fixed, as before.
|
||||
set(&mut s, b, json!({"height": 70}));
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
|
||||
// Along the flow beside a sibling, what is left is not "the full available space": a width
|
||||
// that happens to match it (180 - 10 - 50) stays fixed.
|
||||
s.execute("transform.resize", &json!({"ids": [b], "width": 120})).unwrap();
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
|
||||
// A hugging frame has no full space to offer: the child stays fixed.
|
||||
let g = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug", "padding": 10}));
|
||||
let c = rect(&mut s, g, 50.0, 20.0, "#00FF00");
|
||||
set(&mut s, c, json!({"width": 180}));
|
||||
assert_eq!(sizing(&s, c), (Sizing::Fixed, Sizing::Fixed));
|
||||
// An inside stroke the layout includes takes room like the padding: the full space is what
|
||||
// layout leaves the child (200 - 2 × 10 - 2 × 4), not the frame less its padding alone.
|
||||
let h = al_frame(&mut s, json!({"padding": 10, "strokesIncluded": true, "stroke": "#000000", "strokeWeight": 4, "strokeAlign": "inside"}));
|
||||
let d = rect(&mut s, h, 50.0, 20.0, "#FF0000");
|
||||
set(&mut s, d, json!({"width": 180}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed), "wider than the room the stroke leaves");
|
||||
assert_eq!(size(&s, d), (180.0, 20.0), "and kept as typed");
|
||||
set(&mut s, d, json!({"width": 172}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, d), (172.0, 20.0));
|
||||
// Excluded from the layout, the stroke takes no room: the frame less its padding again.
|
||||
set(&mut s, h, json!({"strokesIncluded": false}));
|
||||
set(&mut s, d, json!({"width": 172}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hug_and_fill_only_where_they_apply() {
|
||||
// Documented: Hug applies to auto layout frames (and text); Fill to children of auto layout,
|
||||
@@ -232,6 +278,20 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
|
||||
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
|
||||
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
// The axis is judged by the size the path is given, not the one before it: the frame's full
|
||||
// height (100) given with a path fills; a smaller height given with the next path is fixed,
|
||||
// though the layer filled the frame until then.
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fill);
|
||||
assert!((size(&s, v).1 - 100.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
set(&mut s, v, json!({"height": 40, "path": "M0 0 L20 5"}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
|
||||
assert!((size(&s, v).1 - 40.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
// Rotated, the layer's local size isn't the room layout gives it: a height matching the
|
||||
// frame's stays fixed rather than have Fill squash the path.
|
||||
set(&mut s, v, json!({"rotation": 90}));
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
|
||||
}
|
||||
|
||||
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
|
||||
|
||||
@@ -318,3 +318,30 @@ fn fill_text_in_a_hugging_card_sets_the_height_in_one_step() {
|
||||
// Whatever the fonts, the card hugs the wrapped text exactly.
|
||||
assert!((cb.height() - (tb.height() + 20.0)).abs() < 1e-6, "card {cb:?} text {tb:?}");
|
||||
}
|
||||
|
||||
/// "To remove auto layout from a frame and all of its nested objects at once, right-click on the
|
||||
/// frame and go to More layout options > Remove all auto layout." (Toggle on auto layout in designs)
|
||||
#[test]
|
||||
fn remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it() {
|
||||
let mut s = Session::new();
|
||||
let outer = frame(&mut s, None, 0.0, 0.0, 300.0, 100.0);
|
||||
let inner = frame(&mut s, Some(outer), 0.0, 0.0, 100.0, 50.0);
|
||||
let deep = frame(&mut s, Some(inner), 0.0, 0.0, 40.0, 20.0);
|
||||
rect(&mut s, Some(deep), 0.0, 0.0, 10.0, 10.0);
|
||||
for (f, dir) in [(outer, "horizontal"), (inner, "vertical"), (deep, "horizontal")] {
|
||||
s.execute("node.set", &json!({"id": f, "props": {"layout": dir}})).unwrap();
|
||||
}
|
||||
let laid_out = |s: &Session| [outer, inner, deep].map(|n| s.doc().unwrap().node(n).unwrap().layout.is_some());
|
||||
// Remove auto layout takes it off the frame alone.
|
||||
s.execute("layout.remove", &json!({"ids": [outer]})).unwrap();
|
||||
assert_eq!(laid_out(&s), [false, true, true]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(laid_out(&s), [true, true, true]);
|
||||
let steps = s.ds().unwrap().history.undo.len();
|
||||
let r = s.execute("layout.removeAll", &json!({"ids": [outer]})).unwrap();
|
||||
assert_eq!(laid_out(&s), [false, false, false], "every nested frame too");
|
||||
assert_eq!(r["removed"].as_array().map(Vec::len), Some(3));
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(laid_out(&s), [true, true, true]);
|
||||
}
|
||||
|
||||
@@ -147,6 +147,10 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
|
||||
/// main layer's, for files from before `modes` was an override group. Copies follow their main
|
||||
/// layer's modes, so a layer on other modes chose them itself.
|
||||
pub fn mark_layer_modes(doc: &mut Document) {
|
||||
// Without an instance nothing can be inside one: skip the per-layer ancestry lookups.
|
||||
if !doc.nodes().any(|n| matches!(n.kind, NodeKind::Instance { .. })) {
|
||||
return;
|
||||
}
|
||||
let chose: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
|
||||
|
||||
@@ -132,6 +132,7 @@ fn text_style_differs(x: &TextData, y: &TextData) -> bool {
|
||||
|| x.hanging_list != y.hanging_list
|
||||
|| x.max_lines != y.max_lines
|
||||
|| x.features != y.features
|
||||
|| x.wrap_style != y.wrap_style
|
||||
}
|
||||
|
||||
/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was
|
||||
|
||||
@@ -160,6 +160,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
b.hanging_list = a.hanging_list;
|
||||
b.max_lines = a.max_lines;
|
||||
b.features = a.features.clone();
|
||||
b.wrap_style = a.wrap_style;
|
||||
}
|
||||
to.styles.text = from.styles.text.clone();
|
||||
}
|
||||
|
||||
@@ -144,7 +144,6 @@ fn instances_refuse_structure_edits() {
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let refused = [
|
||||
("edit.delete", json!({"ids": [ibg]})),
|
||||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": i})),
|
||||
("node.reorder", json!({"id": idot, "index": 0})),
|
||||
("object.group", json!({"ids": [ibg, idot]})),
|
||||
@@ -158,6 +157,11 @@ fn instances_refuse_structure_edits() {
|
||||
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
|
||||
}
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
// Delete on a layer inside hides it instead (an override), as the reference tool does.
|
||||
ex(&mut s, "edit.delete", json!({"ids": [ibg]}));
|
||||
assert!(s.doc().unwrap().node(ibg).is_some_and(|n| !n.visible), "hidden, not removed");
|
||||
assert_eq!(s.doc().unwrap().node(i).unwrap().children, before.node(i).unwrap().children, "the structure is intact");
|
||||
healthy(&s);
|
||||
// Deleting the whole instance is fine.
|
||||
ex(&mut s, "edit.delete", json!({"ids": [i]}));
|
||||
healthy(&s);
|
||||
|
||||
@@ -79,10 +79,13 @@ pub fn usage(doc: &Document, library: &str) -> Usage {
|
||||
let records: std::collections::BTreeMap<NodeId, NodeId> =
|
||||
doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect();
|
||||
if !records.is_empty() {
|
||||
// One component's asset root is looked up once, however many instances use it.
|
||||
let mut roots = std::collections::BTreeMap::<NodeId, Option<NodeId>>::new();
|
||||
for n in doc.nodes() {
|
||||
if let NodeKind::Instance { component, .. } = &n.kind
|
||||
&& store.is_none_or(|st| doc.page_of(n.id) != Some(st))
|
||||
&& let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r))
|
||||
&& let Some(key) =
|
||||
roots.entry(*component).or_insert_with(|| crate::library::asset_root(doc, *component)).and_then(|r| records.get(&r))
|
||||
{
|
||||
let c = counts.entry(*key).or_default();
|
||||
*c = c.saturating_add(1);
|
||||
|
||||
@@ -103,6 +103,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"visible",
|
||||
"width",
|
||||
"widthProfile",
|
||||
"wrapStyle",
|
||||
"x",
|
||||
"y",
|
||||
];
|
||||
@@ -442,6 +443,9 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
|
||||
}
|
||||
if t.wrap_style != figcraft_doc::WrapStyle::Auto {
|
||||
insert_prop(o, options, "wrapStyle", || json!(t.wrap_style));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
|
||||
@@ -277,14 +277,15 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
|
||||
let text = normalized;
|
||||
edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
// "- ", "* " or "1. " typed at the start of a plain paragraph starts a list.
|
||||
// "- ", "* ", "1. " or "1) " typed at the start of a plain paragraph starts a list (the
|
||||
// creation characters of Create bulleted and numbered lists).
|
||||
if text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let start = line_start(&chars, a);
|
||||
let typed: String = chars.get(start..a).unwrap_or(&[]).iter().collect();
|
||||
let list = match typed.as_str() {
|
||||
"-" | "*" => ListStyle::Bulleted,
|
||||
"1." => ListStyle::Numbered,
|
||||
"1." | "1)" => ListStyle::Numbered,
|
||||
_ => ListStyle::None,
|
||||
};
|
||||
if list != ListStyle::None {
|
||||
@@ -302,9 +303,21 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's
|
||||
/// start and Enter on an empty item move it a level out (at the top: out of the list). Returns
|
||||
/// whether the key was taken.
|
||||
/// What a list key does to the selected items.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ListMove {
|
||||
/// A level deeper (Tab).
|
||||
Deeper,
|
||||
/// A level out; at the top, out of the list (Shift+Tab, Enter on an empty item).
|
||||
Out,
|
||||
/// Out of the list at the level it had: "Use Backspace or Delete at the beginning of a list
|
||||
/// item to delete the counter, but keep the same level of indentation."
|
||||
Unlist,
|
||||
}
|
||||
|
||||
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab and Enter on an empty
|
||||
/// item move it a level out (at the top: out of the list); Backspace and Delete at an item's start
|
||||
/// take its marker and keep its level. Returns whether the key was taken.
|
||||
fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
|
||||
let t = text_of(s, te.id)?;
|
||||
@@ -314,19 +327,24 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
|
||||
let item = t.para_list(t.para_of(a)).list != ListStyle::None;
|
||||
let (pa, pb) = t.para_span(a, b);
|
||||
let any_item = (pa..=pb).any(|p| t.para_list(p).list != ListStyle::None);
|
||||
let deeper = match key {
|
||||
"Tab" if any_item => !shift,
|
||||
"Backspace" if item && at_start => false,
|
||||
"Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => false,
|
||||
let mv = match key {
|
||||
"Tab" if any_item && !shift => ListMove::Deeper,
|
||||
"Tab" if any_item => ListMove::Out,
|
||||
"Backspace" | "Delete" if item && at_start => ListMove::Unlist,
|
||||
"Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => ListMove::Out,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
edit(s, |t, _| {
|
||||
t.set_para_lists(a, b, |pl| match pl.list {
|
||||
ListStyle::None => {}
|
||||
_ if deeper => pl.indent = pl.indent.saturating_add(1),
|
||||
_ if pl.indent > 0 => pl.indent -= 1,
|
||||
_ => pl.list = ListStyle::None,
|
||||
})
|
||||
if mv == ListMove::Unlist {
|
||||
t.unlist_keeping_levels(a, b);
|
||||
} else {
|
||||
t.set_para_lists(a, b, |pl| match (pl.list, mv) {
|
||||
(ListStyle::None, _) | (_, ListMove::Unlist) => {}
|
||||
(_, ListMove::Deeper) => pl.indent = pl.indent.saturating_add(1),
|
||||
(_, ListMove::Out) if pl.indent > 0 => pl.indent -= 1,
|
||||
(_, ListMove::Out) => pl.list = ListStyle::None,
|
||||
});
|
||||
}
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -410,7 +428,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
|
||||
let t = text_of(s, te.id)?;
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let n = chars.len();
|
||||
if matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? {
|
||||
if matches!(key, "Tab" | "Backspace" | "Delete" | "Enter") && !word && !line && list_key(s, key, shift)? {
|
||||
return Ok(());
|
||||
}
|
||||
match key {
|
||||
|
||||
@@ -242,7 +242,7 @@ fn paragraph_and_feature_commands_on_nodes() {
|
||||
s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap();
|
||||
let t = data(&s, id);
|
||||
assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2));
|
||||
assert_eq!(t.auto_resize, TextAutoResize::Truncate);
|
||||
assert_eq!(t.auto_resize, TextAutoResize::WidthAndHeight, "max lines keep an auto-width text auto width");
|
||||
assert_eq!(t.style.decoration, TextDecoration::Strikethrough);
|
||||
assert_eq!(t.style.case, TextCase::SmallCaps);
|
||||
assert_eq!(t.list, ListStyle::Bulleted);
|
||||
|
||||
@@ -22,6 +22,29 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-click on an edge handle of the one selected layer auto layout sizes: Hug contents
|
||||
/// on that axis for an auto layout frame, Fill container with Alt for a child of one (a
|
||||
/// vertical edge sizes the width, a horizontal one the height). Whether it applied: on any
|
||||
/// other layer, or off the edges, the double-click goes on as before.
|
||||
fn edge_double_click(&mut self, doc: &Document, sel: &[NodeId], ev: &PointerEvent) -> Result<bool> {
|
||||
let [id] = sel[..] else { return Ok(false) };
|
||||
let Some(super::Handle::Edge(edge)) = super::handles::handle_at(doc, sel, ev.p(), ev.zoom) else { return Ok(false) };
|
||||
let Some(n) = doc.node(id) else { return Ok(false) };
|
||||
let applies = if ev.alt {
|
||||
!n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())
|
||||
} else {
|
||||
n.layout.is_some()
|
||||
};
|
||||
if !applies {
|
||||
return Ok(false);
|
||||
}
|
||||
// Edges run top, right, bottom, left: the odd ones are vertical and size the width.
|
||||
let mut props = serde_json::Map::new();
|
||||
props.insert(if edge % 2 == 1 { "sizingH" } else { "sizingV" }.to_string(), json!(if ev.alt { "fill" } else { "hug" }));
|
||||
self.execute("node.set", &json!({"id": id, "props": props}))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn double_click(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let ds = self.ds()?;
|
||||
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
|
||||
@@ -61,6 +84,14 @@ impl Session {
|
||||
self.tool.overlay.marquee = None;
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
// An edge of an auto layout object's bounding box: Hug contents on that axis, with Alt
|
||||
// Fill container ("Tip: Double-click the vertical or horizontal edge of an auto layout
|
||||
// object's bounding box to set it to Hug contents." and "Hold ⌥ Option or Alt and
|
||||
// double-click the vertical or horizontal edge of an auto layout object's bounding box to
|
||||
// set it to Fill container.", Guide to auto layout).
|
||||
if edits && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd) && self.edge_double_click(&doc, &sel, ev)? {
|
||||
return Ok(());
|
||||
}
|
||||
let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0);
|
||||
// A double-click anywhere on the one selected shape edits its points, like Enter: also
|
||||
// inside a hollow (unfilled) closed shape, where the hit test finds nothing under the
|
||||
|
||||
@@ -403,7 +403,9 @@ fn visible_handles(net: &VectorNetwork, ve: &VectorEdit) -> Vec<(usize, bool)> {
|
||||
}
|
||||
|
||||
/// Hit test in page coordinates with a tolerance in page units. Visible handles win, then
|
||||
/// vertices, then segments.
|
||||
/// vertices, then segments; between a handle and a point both within reach, the nearer one on
|
||||
/// the page, the handle on a tie (the handles of a short segment end a few pixels from their
|
||||
/// point, and a click on the point is on the point, #1875).
|
||||
pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option<VHit> {
|
||||
let net = network(doc, ve.id)?;
|
||||
let w = doc.world_transform(ve.id);
|
||||
@@ -414,17 +416,31 @@ pub fn hit(doc: &Document, ve: &VectorEdit, p: Point, tol: f64) -> Option<VHit>
|
||||
if !(lp.x.is_finite() && lp.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
for (si, start) in visible_handles(net, ve) {
|
||||
let (Some(seg), Some(t)) = (net.segments.get(si), net.handle(si, start)) else { continue };
|
||||
let Some(v) = net.vertices.get(if start { seg.a } else { seg.b }) else { continue };
|
||||
if (Point::new(v.x + t.0, v.y + t.1) - lp).hypot() <= tl {
|
||||
return Some(VHit::Handle { seg: si, start });
|
||||
}
|
||||
}
|
||||
let nearest_vertex =
|
||||
net.vertices.iter().enumerate().map(|(i, v)| (i, (v.point() - lp).hypot())).filter(|(_, d)| *d <= tl).min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
if let Some((index, _)) = nearest_vertex {
|
||||
return Some(VHit::Vertex { index });
|
||||
// Candidates are within the tolerance as before; the nearest is judged on the page, where a
|
||||
// non-uniform scale doesn't distort it.
|
||||
let on_page = |q: Point| (w * q - p).hypot();
|
||||
let nearest_handle = visible_handles(net, ve)
|
||||
.into_iter()
|
||||
.filter_map(|(si, start)| {
|
||||
let (seg, t) = (net.segments.get(si)?, net.handle(si, start)?);
|
||||
let v = net.vertices.get(if start { seg.a } else { seg.b })?;
|
||||
let end = Point::new(v.x + t.0, v.y + t.1);
|
||||
((end - lp).hypot() <= tl).then(|| ((si, start), on_page(end)))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let nearest_vertex = net
|
||||
.vertices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, v)| (v.point() - lp).hypot() <= tl)
|
||||
.map(|(i, v)| (i, on_page(v.point())))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
match (nearest_handle, nearest_vertex) {
|
||||
// A tie (a handle's end on another point) goes to the handle, as handles always won before.
|
||||
(Some(((seg, start), dh)), Some((_, dv))) if dh <= dv => return Some(VHit::Handle { seg, start }),
|
||||
(Some(((seg, start), _)), None) => return Some(VHit::Handle { seg, start }),
|
||||
(_, Some((index, _))) => return Some(VHit::Vertex { index }),
|
||||
(None, None) => {}
|
||||
}
|
||||
use figcraft_geom::kurbo::ParamCurveNearest;
|
||||
let nearest_seg = (0..net.segments.len())
|
||||
|
||||
@@ -1072,12 +1072,18 @@ impl Session {
|
||||
}
|
||||
}
|
||||
VGesture::BendVertex { v, moved, smooth, .. } => {
|
||||
let id = ve.id;
|
||||
if moved {
|
||||
self.commit_interaction()?;
|
||||
} else if smooth {
|
||||
let id = ve.id;
|
||||
self.preview(|doc, _| edit(doc, id, |net| ops::make_sharp(net, v)))?;
|
||||
self.commit_interaction()?;
|
||||
} else if network(self.doc()?, id).is_some_and(|n| n.incident(v).len() == 2) {
|
||||
// "Click on a point or path where you want to add a curve" (Edit vector
|
||||
// layers): a sharp point between two segments gets handles along the line
|
||||
// between its neighbours (#1875); an endpoint has nothing to curve.
|
||||
self.preview(|doc, _| edit(doc, id, |net| ops::auto_smooth(net, v)))?;
|
||||
self.commit_interaction()?;
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
|
||||
@@ -119,6 +119,16 @@ fn bend_tool_smooths_and_sharpens_points() {
|
||||
let n = net(&s, id);
|
||||
let v = vertex_at(&n, 100.0, 0.0);
|
||||
assert!(n.incident(v).iter().all(|(s, st)| n.handle(*s, *st) == Some((0.0, 0.0))), "a click sharpens it");
|
||||
// Clicked again, the sharp corner curves: handles along the line between its neighbours
|
||||
// (0,0) and (100,100), a third of the way to each (the box refits around the bulge, so the
|
||||
// point is found by its index).
|
||||
click(&mut s, 100.0, 0.0);
|
||||
let n = net(&s, id);
|
||||
assert_eq!(n.mirroring(v), HandleMirroring::Angle);
|
||||
for (seg, st) in n.incident(v) {
|
||||
let h = n.handle(seg, st).unwrap();
|
||||
assert!((h.0.abs() - 100.0 / 3.0 / 2f64.sqrt()).abs() < 1e-6 && (h.0 - h.1).abs() < 1e-6, "along the neighbours' line: {h:?}");
|
||||
}
|
||||
// ⌘-drag on a segment with the Move tool bends it.
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
|
||||
let mut down = ev(PointerKind::Down, 0.0, 50.0);
|
||||
@@ -130,6 +140,123 @@ fn bend_tool_smooths_and_sharpens_points() {
|
||||
assert!(b.x0 < -25.0, "bent outwards: {b:?}");
|
||||
}
|
||||
|
||||
/// #1875: a line whose middle point was moved off the line, clicked with the Bend tool, curves
|
||||
/// there ("Click on a point or path where you want to add a curve", Edit vector layers): the
|
||||
/// handles run along the line between the neighbours, a third of the way to each, in one undo
|
||||
/// step; an endpoint is left alone; a click on the curved point makes it sharp again.
|
||||
#[test]
|
||||
fn a_bend_click_curves_a_sharp_point_between_its_neighbours() {
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 30], [100, 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": "bend"}));
|
||||
let (v, end) = (vertex_at(&net(&s, id), 50.0, 30.0), vertex_at(&net(&s, id), 0.0, 0.0));
|
||||
let before = s.undo_depth().0;
|
||||
click(&mut s, 50.0, 30.0);
|
||||
let n = net(&s, id);
|
||||
let reach = (50f64.powi(2) + 30f64.powi(2)).sqrt() / 3.0;
|
||||
for (seg, st) in n.incident(v) {
|
||||
let h = n.handle(seg, st).unwrap();
|
||||
assert!((h.0.abs() - reach).abs() < 1e-6 && h.1.abs() < 1e-6, "along the neighbours' line, a third of the way: {h:?}");
|
||||
}
|
||||
assert_eq!(n.mirroring(v), HandleMirroring::Angle);
|
||||
assert_eq!(s.undo_depth().0, before + 1, "one undo step");
|
||||
// An endpoint has nothing to curve: the click changes nothing and leaves no undo step.
|
||||
click(&mut s, 0.0, 0.0);
|
||||
let n = net(&s, id);
|
||||
assert!(n.incident(end).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))));
|
||||
assert_eq!(s.undo_depth().0, before + 1);
|
||||
// The curved point clicked again is sharp again.
|
||||
click(&mut s, 50.0, 30.0);
|
||||
let n = net(&s, id);
|
||||
assert!(n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))));
|
||||
}
|
||||
|
||||
/// Between a handle's end and a point both within reach, the nearer on the page wins, also under a
|
||||
/// non-uniform scale (10× wide here), where local distances would pick the point.
|
||||
#[test]
|
||||
fn a_handle_and_a_point_are_compared_on_the_page() {
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [5, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
{
|
||||
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
|
||||
let mut n = VectorNetwork::default();
|
||||
n.vertices.push(figcraft_doc::Vertex::new(0.0, 0.0));
|
||||
n.vertices.push(figcraft_doc::Vertex::new(5.0, 0.0));
|
||||
let mut seg = figcraft_doc::Segment::line(0, 1);
|
||||
seg.ta = (0.5, -0.9);
|
||||
n.segments.push(seg);
|
||||
node.kind = figcraft_doc::NodeKind::Vector { network: n };
|
||||
node.transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0);
|
||||
}
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
// On the page the point is at (0, 0) and its handle's end at (5, -0.9): from (4, 0) the
|
||||
// handle's end is 1.3 away and the point 4, though locally the point is the nearer.
|
||||
assert_eq!(hit(&doc, &ve(&s), pt(4.0, 0.0), 6.0), Some(VHit::Handle { seg: 0, start: true }));
|
||||
assert_eq!(hit(&doc, &ve(&s), pt(0.0, 0.0), 6.0), Some(VHit::Vertex { index: 0 }));
|
||||
}
|
||||
|
||||
/// A handle whose end lies exactly on another point (snapped there) can still be taken there: on a
|
||||
/// tie the handle wins, as handles always did.
|
||||
#[test]
|
||||
fn a_handle_on_another_point_is_still_the_handles() {
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [10, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
{
|
||||
let node = s.doc_mut().unwrap().get_mut(id).unwrap();
|
||||
let mut n = VectorNetwork::default();
|
||||
n.vertices.push(figcraft_doc::Vertex::new(0.0, 0.0));
|
||||
n.vertices.push(figcraft_doc::Vertex::new(10.0, 0.0));
|
||||
let mut seg = figcraft_doc::Segment::line(0, 1);
|
||||
seg.ta = (10.0, 0.0);
|
||||
n.segments.push(seg);
|
||||
node.kind = figcraft_doc::NodeKind::Vector { network: n };
|
||||
node.transform = figcraft_geom::Affine::IDENTITY;
|
||||
}
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [0]}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
// Where the points are on the page now (entering edit mode may refit the layer's box).
|
||||
let n = net(&s, id);
|
||||
let w = doc.world_transform(id);
|
||||
let (p0, p1) = (w * n.vertices[0].point(), w * n.vertices[1].point());
|
||||
// Point 0's handle ends on point 1: there the handle; on point 0, the point.
|
||||
assert_eq!(hit(&doc, &ve(&s), p1, 6.0), Some(VHit::Handle { seg: 0, start: true }));
|
||||
assert_eq!(hit(&doc, &ve(&s), p0, 6.0), Some(VHit::Vertex { index: 0 }));
|
||||
}
|
||||
|
||||
/// A short path's handles end a few pixels from their point: a click on the point is still on the
|
||||
/// point (the nearer of the two wins), so the Bend tool can sharpen it again, while a click on the
|
||||
/// handle's end still takes the handle.
|
||||
#[test]
|
||||
fn short_handles_leave_their_point_clickable() {
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [6, 10], [12, 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": "bend"}));
|
||||
let v = vertex_at(&net(&s, id), 6.0, 10.0);
|
||||
click(&mut s, 6.0, 10.0);
|
||||
let n = net(&s, id);
|
||||
let curved = |n: &VectorNetwork| n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st).is_some_and(|h| h != (0.0, 0.0)));
|
||||
assert!(curved(&n));
|
||||
let reach = (6f64.powi(2) + 10f64.powi(2)).sqrt() / 3.0;
|
||||
assert!(reach < 6.0, "the handles end within the 6 px hit tolerance: {reach}");
|
||||
// A click on the handle's end takes the handle (nothing changes); one on the point sharpens it.
|
||||
let (sg, st) = n.incident(v)[0];
|
||||
let h = n.handle(sg, st).unwrap();
|
||||
click(&mut s, 6.0 + h.0, 10.0 + h.1);
|
||||
assert!(curved(&net(&s, id)), "the handle's end is the handle's");
|
||||
click(&mut s, 6.0, 10.0);
|
||||
let n = net(&s, id);
|
||||
assert!(n.incident(v).iter().all(|(sg, st)| n.handle(*sg, *st) == Some((0.0, 0.0))), "the point is the point's: sharp again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_bucket_fills_and_unfills_areas() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -569,12 +569,6 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = doc.get_mut(i)?;
|
||||
let after = n.transform * Point::new(w * ax, h * ay);
|
||||
n.transform = Affine::translate(before - after) * n.transform;
|
||||
if num(p, "width").is_some() {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
}
|
||||
if num(p, "height").is_some() {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
if num(p, "height").is_some() {
|
||||
t.fix_size(num(p, "width").is_some(), true);
|
||||
@@ -582,6 +576,13 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.auto_resize = TextAutoResize::Height;
|
||||
}
|
||||
}
|
||||
// A side given is fixed, or fills its auto layout parent when it is the full space there.
|
||||
if num(p, "width").is_some() {
|
||||
super::props::fix_axis(doc, i, true)?;
|
||||
}
|
||||
if num(p, "height").is_some() {
|
||||
super::props::fix_axis(doc, i, false)?;
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
|
||||
@@ -544,6 +544,15 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.delete", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
// A layer inside an instance can't be removed: Delete hides it instead, an override ("Figma
|
||||
// will only toggle the layer's visibility instead of removing it", Guide to auto layout).
|
||||
let (hidden, ids): (Vec<_>, Vec<_>) = ids.into_iter().partition(|i| crate::assets::components::inside_instance(doc, *i));
|
||||
for i in &hidden {
|
||||
doc.get_mut(*i)?.visible = false;
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"deleted": [], "hidden": hidden}));
|
||||
}
|
||||
let mut parents = Vec::new();
|
||||
for i in &ids {
|
||||
if doc.node(*i).is_some_and(Node::is_page) {
|
||||
@@ -556,7 +565,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Select the parent frame like Figma? Figma clears the selection.
|
||||
s.set_selection(vec![]);
|
||||
Ok(json!({"deleted": ids}))
|
||||
Ok(json!({"deleted": ids, "hidden": hidden}))
|
||||
}
|
||||
|
||||
/// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the
|
||||
|
||||
@@ -42,8 +42,8 @@ fn set_track(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(CMD, format!("node {id} is not a grid auto layout frame")));
|
||||
};
|
||||
let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize).min(MAX_TRACKS);
|
||||
if !(0.0..count as f64).contains(&index) {
|
||||
return Err(bad(CMD, format!("`index` must be 0..{count}")));
|
||||
if index.fract() != 0.0 || !(0.0..count as f64).contains(&index) {
|
||||
return Err(bad(CMD, format!("`index` must be an integer in 0..{count}")));
|
||||
}
|
||||
let i = index as usize;
|
||||
let track = match kind {
|
||||
@@ -256,6 +256,7 @@ mod tests {
|
||||
json!({"ids": [f], "axis": "diagonal", "index": 0, "sizing": "fixed"}),
|
||||
json!({"ids": [f], "axis": "columns", "index": 9, "sizing": "fixed"}),
|
||||
json!({"ids": [f], "axis": "columns", "index": -1, "sizing": "hug"}),
|
||||
json!({"ids": [f], "axis": "columns", "index": 1.5, "sizing": "fixed", "value": 50}),
|
||||
json!({"ids": [f], "axis": "columns", "index": 0, "sizing": "stretchy"}),
|
||||
json!({"ids": [kids[1]], "axis": "columns", "index": 0, "sizing": "hug"}),
|
||||
] {
|
||||
|
||||
@@ -437,17 +437,55 @@ fn layout_keys_beside_none(cmd: &str, props: &Map<String, Value>) -> Result<()>
|
||||
}
|
||||
|
||||
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
|
||||
/// into W or H.
|
||||
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
|
||||
/// into W or H; sized to the full space its auto layout parent has for it on that axis, the layer
|
||||
/// fills instead: "Child objects of an auto layout frame will also be set to Fill container if
|
||||
/// they are manually resized to the full available space of the parent frame" (Guide to auto
|
||||
/// layout).
|
||||
pub(crate) fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
|
||||
let sizing = if fills_available_space(doc, id, is_w) { Sizing::Fill } else { Sizing::Fixed };
|
||||
let n = doc.get_mut(id)?;
|
||||
if is_w {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
n.child_layout.sizing_h = sizing;
|
||||
} else {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
n.child_layout.sizing_v = sizing;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `id`'s width (`is_w`) or height is the full space its auto layout parent has for it on
|
||||
/// that axis: the frame's size less its padding and an inside stroke included in the layout (the
|
||||
/// insets layout itself takes). Across the flow that is every child's room;
|
||||
/// along the flow only a lone child has it (beside siblings what is left depends on the gaps, the
|
||||
/// alignment and overflow, so a size that happens to match it is not "the full available
|
||||
/// space"). Never in a hugging or wrapping frame or a grid, or for a layer that ignores the layout.
|
||||
fn fills_available_space(doc: &Document, id: NodeId, is_w: bool) -> bool {
|
||||
let Some(n) = doc.node(id) else { return false };
|
||||
let Some(parent) = n.parent.and_then(|p| doc.node(p)) else { return false };
|
||||
let Some(l) = &parent.layout else { return false };
|
||||
if n.child_layout.absolute || l.wrap || l.mode == LayoutMode::Grid {
|
||||
return false;
|
||||
}
|
||||
// A rotated layer's local size isn't the size layout gives it room for (its rotated bounds
|
||||
// are): it stays Fixed rather than have Fill squash its geometry.
|
||||
let [_, b, c, ..] = n.transform.as_coeffs();
|
||||
if b.abs() > 1e-9 || c.abs() > 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let hugs = if is_w { parent.child_layout.sizing_h } else { parent.child_layout.sizing_v } == Sizing::Hug;
|
||||
if hugs {
|
||||
return false;
|
||||
}
|
||||
if (l.mode == LayoutMode::Horizontal) == is_w
|
||||
&& parent.children.iter().filter(|c| **c != id).filter_map(|c| doc.node(*c)).any(|c| c.visible && !c.child_layout.absolute)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (pt, pr, pb, pl) = figcraft_layout::insets(parent, l);
|
||||
let available = if is_w { parent.width - pl - pr } else { parent.height - pt - pb };
|
||||
let size = if is_w { n.width } else { n.height };
|
||||
available > 0.0 && (size - available).abs() < 0.01
|
||||
}
|
||||
|
||||
/// The `geometry` properties of `apply`; `false` when `k` is not one of them.
|
||||
fn apply_geometry(
|
||||
cmd: &str,
|
||||
@@ -459,10 +497,17 @@ fn apply_geometry(
|
||||
path_offset: &mut (f64, f64),
|
||||
) -> Result<bool> {
|
||||
match k {
|
||||
"path" => *path_offset = set_path(cmd, doc, id, v, props)?,
|
||||
// A new path takes the size asked for as its scale (`set_path`); the size still fixes
|
||||
// its auto layout axis.
|
||||
"width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
|
||||
"path" => {
|
||||
*path_offset = set_path(cmd, doc, id, v, props)?;
|
||||
// A size given beside the path is its scale (`set_path`), and still fixes or fills
|
||||
// its auto layout axis, judged once the path has given the layer that size.
|
||||
for (long, short, is_w) in [("width", "w", true), ("height", "h", false)] {
|
||||
if props.contains_key(long) || props.contains_key(short) {
|
||||
fix_axis(doc, id, is_w)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
"width" | "height" | "w" | "h" if props.contains_key("path") => {}
|
||||
"x" | "y" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
|
||||
@@ -578,7 +623,15 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
|
||||
doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg);
|
||||
}
|
||||
"dashes" => {
|
||||
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
|
||||
let d: Vec<f64> = if v.is_null() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, "`dashes` must be an array of numbers"))?;
|
||||
a.iter()
|
||||
.map(|x| if x.is_boolean() { None } else { f64_of(x).map(|n| n.max(0.0)) })
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| bad(cmd, "`dashes` must be an array of numbers"))?
|
||||
};
|
||||
doc.get_mut(id)?.stroke.dashes = d;
|
||||
}
|
||||
"dashCap" => {
|
||||
@@ -759,6 +812,14 @@ fn apply_sizing(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -
|
||||
other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)),
|
||||
};
|
||||
set_limit(&mut doc.get_mut(id)?.child_layout, k, val);
|
||||
// A text has a max height or a number of max lines, not both: a max height sets the
|
||||
// lines back to Auto (Guide to auto layout).
|
||||
if k == "maxHeight"
|
||||
&& val.is_some()
|
||||
&& let Some(t) = doc.get_mut(id)?.kind.text_mut()
|
||||
{
|
||||
t.max_lines = 0;
|
||||
}
|
||||
}
|
||||
"columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?;
|
||||
@@ -900,7 +961,19 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
|
||||
*if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on;
|
||||
}
|
||||
"leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?,
|
||||
"maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32,
|
||||
"wrapStyle" | "textWrap" => {
|
||||
text_mut(cmd, doc.get_mut(id)?)?.wrap_style = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?
|
||||
}
|
||||
"maxLines" => {
|
||||
let lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32;
|
||||
let n = doc.get_mut(id)?;
|
||||
text_mut(cmd, n)?.max_lines = lines;
|
||||
// A text has a max height or a number of max lines, not both: a number of lines takes
|
||||
// the max height away (Guide to auto layout).
|
||||
if lines > 0 {
|
||||
n.child_layout.max_h = None;
|
||||
}
|
||||
}
|
||||
"features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => {
|
||||
let f = features_of(cmd, v)?;
|
||||
let t = text_mut(cmd, doc.get_mut(id)?)?;
|
||||
@@ -1135,7 +1208,7 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"name" => "name",
|
||||
"characters" | "text" => "characters",
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
|
||||
| "hangingPunctuation" | "hangingList" => "textStyle",
|
||||
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" => "size",
|
||||
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
|
||||
|
||||
@@ -31,8 +31,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("proto.interaction.remove", "Remove interaction", "{id?, index}", true, |s, p| {
|
||||
let cmd = "proto.interaction.remove";
|
||||
let k = req_num(cmd, p, "index")?;
|
||||
if k < 0.0 {
|
||||
return Err(bad(cmd, "`index` must be ≥ 0"));
|
||||
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
|
||||
return Err(bad(cmd, "`index` must be a whole number in 0..1e9"));
|
||||
}
|
||||
let k = k as usize;
|
||||
let mut hit = false;
|
||||
@@ -137,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"proto.presentOptions",
|
||||
"Presentation options",
|
||||
"{scale? fit|fitWidth|fill|actual, deviceFrame? bool, hotspotHints? bool, inspect? bool} how the presenter sizes the prototype (Fit scales down to fit, Fit width fills the width, Fill covers the window, Actual shows it at 100%), whether it draws the device, whether a click where nothing reacts flashes the hotspots (on by default) and whether the Inspect panel shows the properties of the layer under the pointer; kept for later presentations",
|
||||
"{scale? fit|fitWidth|fill|actual, deviceFrame? bool, hotspotHints? bool, inspect? bool, hideUi? bool} how the presenter sizes the prototype (Fit scales down to fit, Fit width fills the width, Fill covers the window, Actual shows it at 100%), whether it draws the device, whether a click where nothing reacts flashes the hotspots (on by default), whether the Inspect panel shows the properties of the layer under the pointer, and whether the bars are hidden (Hide UI: the prototype alone; Cmd/Ctrl+\\ shows them again); kept for later presentations",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "proto.presentOptions";
|
||||
@@ -146,13 +146,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let scale = v.as_str().filter(|x| PRESENT_SCALES.contains(x)).ok_or_else(|| bad(cmd, "scale: fit|fitWidth|fill|actual"))?;
|
||||
data.insert("scale".into(), json!(scale));
|
||||
}
|
||||
for key in ["deviceFrame", "hotspotHints", "inspect"] {
|
||||
for key in ["deviceFrame", "hotspotHints", "inspect", "hideUi"] {
|
||||
if let Some(v) = p.get(key).filter(|v| !v.is_null()) {
|
||||
data.insert(key.into(), json!(v.as_bool().ok_or_else(|| bad(cmd, format!("{key}: true|false")))?));
|
||||
}
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Err(bad(cmd, "nothing to set: scale, deviceFrame, hotspotHints or inspect"));
|
||||
return Err(bad(cmd, "nothing to set: scale, deviceFrame, hotspotHints, inspect or hideUi"));
|
||||
}
|
||||
s.request_ui("present.options", Value::Object(data));
|
||||
ok()
|
||||
@@ -406,8 +406,8 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "proto.interaction.set";
|
||||
let p = &super::proto_actions::with_expressions(cmd, &s.doc()?.variables, p)?;
|
||||
let k = req_num(cmd, p, "index")?;
|
||||
if k < 0.0 {
|
||||
return Err(bad(cmd, "`index` must be ≥ 0"));
|
||||
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
|
||||
return Err(bad(cmd, "`index` must be a whole number in 0..1e9"));
|
||||
}
|
||||
let k = k as usize;
|
||||
let whole: Option<Interaction> = parse(cmd, p, "interaction")?;
|
||||
@@ -536,6 +536,20 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_index_is_rejected() {
|
||||
let mut s = Session::new();
|
||||
let a = frame(&mut s, 0.0);
|
||||
let b = frame(&mut s, 200.0);
|
||||
s.execute("proto.interaction.add", &json!({"id": a, "dest": b})).unwrap();
|
||||
s.execute("proto.interaction.add", &json!({"id": a, "dest": b})).unwrap();
|
||||
assert!(s.execute("proto.interaction.remove", &json!({"id": a, "index": 0.5})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2);
|
||||
let before = s.doc().unwrap().node(a).unwrap().interactions[0].clone();
|
||||
assert!(s.execute("proto.interaction.set", &json!({"id": a, "index": 0.5, "easing": "linear"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().interactions[0], before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flows_add_rename_remove() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -83,6 +83,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"layout.removeAll",
|
||||
"Remove all auto layout",
|
||||
"{ids?} takes the auto layout off the frames and every frame nested in them at once (More layout options ▸ Remove all auto layout); instances and the layers inside them keep theirs",
|
||||
true,
|
||||
|s, p| {
|
||||
let ids = req_targets("layout.removeAll", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut removed = Vec::new();
|
||||
for root in ids {
|
||||
for i in doc.subtree(root) {
|
||||
let strip = doc.node(i).is_some_and(|n| n.layout.is_some() && !matches!(n.kind, figcraft_doc::NodeKind::Instance { .. }))
|
||||
&& !crate::assets::components::inside_instance(doc, i);
|
||||
if !strip {
|
||||
continue;
|
||||
}
|
||||
let n = doc.get_mut(i)?;
|
||||
n.layout = None;
|
||||
super::props::drop_layout_bindings(n);
|
||||
figcraft_layout::coherent_sizing(doc, i);
|
||||
removed.push(i);
|
||||
}
|
||||
}
|
||||
Ok(json!({"removed": removed}))
|
||||
}
|
||||
),
|
||||
spec!("layout.suggest", "Suggest auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add),
|
||||
spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page),
|
||||
spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| {
|
||||
|
||||
@@ -101,7 +101,9 @@ fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
targets(s, p).iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if t.spans().iter().all(|(_, _, st)| pred(st))))
|
||||
// The texts `style` restyles: the targets and the texts inside them.
|
||||
let texts: Vec<&TextData> = targets(s, p).iter().flat_map(|i| doc.subtree(*i)).filter_map(|d| doc.node(d).and_then(|n| n.kind.text())).collect();
|
||||
!texts.is_empty() && texts.iter().all(|t| t.spans().iter().all(|(_, _, st)| pred(st)))
|
||||
}
|
||||
|
||||
fn toggle_bold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -217,10 +219,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let on = all_have(s, p, |st| st.decoration == TextDecoration::Strikethrough);
|
||||
style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Strikethrough })
|
||||
}),
|
||||
spec!("text.link", "Create link", "{url, ids?}", true, |s, p| {
|
||||
let url = req_string("text.link", p, "url")?;
|
||||
style(s, p, |st| st.link = Some(url.clone()))
|
||||
}),
|
||||
spec!(
|
||||
"text.link",
|
||||
"Create link",
|
||||
"{url, ids?} linked text is underlined unless it has a decoration of its own (Underline takes it off again)",
|
||||
true,
|
||||
|s, p| {
|
||||
let url = req_string("text.link", p, "url")?;
|
||||
style(s, p, |st| {
|
||||
// "Links are styled with an underline by default" (Add links to text); Underline
|
||||
// (Cmd/Ctrl+U) removes it, as documented there, and changing the URL of a link
|
||||
// leaves its decoration as it is.
|
||||
if st.link.is_none() && st.decoration == TextDecoration::None {
|
||||
st.decoration = TextDecoration::Underline;
|
||||
}
|
||||
st.link = Some(url.clone());
|
||||
})
|
||||
}
|
||||
),
|
||||
spec!("text.bulletList", "Bulleted list", "{ids?} while editing: the selected paragraphs", true, |s, p| set_list(
|
||||
s,
|
||||
p,
|
||||
@@ -292,6 +308,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
set_list(s, p, l, false)
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.wrapStyle",
|
||||
"Wrap style",
|
||||
"{style: auto|balance|pretty, ids?} where lines break when the text wraps: Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
|
||||
true,
|
||||
|s, p| {
|
||||
let w: figcraft_doc::WrapStyle =
|
||||
get(p, "style").and_then(super::props::enum_of).ok_or_else(|| bad("text.wrapStyle", "`style`: auto|balance|pretty"))?;
|
||||
para(s, p, |t| t.wrap_style = w)
|
||||
}
|
||||
),
|
||||
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)
|
||||
@@ -303,16 +330,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"text.maxLines",
|
||||
"Max lines",
|
||||
"{lines (0 = no limit; truncates: auto-height text hugs up to that many lines, other text becomes a truncating box), ids?}",
|
||||
"{lines (0 = no limit; truncates: auto-height and auto-width text hug up to that many lines, fixed-size text becomes a truncating box), ids?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
|
||||
para(s, p, |t| {
|
||||
let r = para(s, p, |t| {
|
||||
t.max_lines = v;
|
||||
if v > 0 && t.auto_resize != TextAutoResize::Height {
|
||||
if v > 0 && !matches!(t.auto_resize, TextAutoResize::Height | TextAutoResize::WidthAndHeight) {
|
||||
t.auto_resize = TextAutoResize::Truncate;
|
||||
}
|
||||
})
|
||||
})?;
|
||||
// A text has a max height or a number of max lines, not both (Guide to auto layout).
|
||||
if v > 0 {
|
||||
let ids = match textedit::selected_range(s) {
|
||||
Some((id, _, _)) => vec![id],
|
||||
None => targets(s, p),
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
for d in doc.subtree(i) {
|
||||
if let Some(n) = doc.node_mut(d)
|
||||
&& n.kind.text().is_some()
|
||||
{
|
||||
n.child_layout.max_h = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
@@ -487,6 +532,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
textedit::end(s)?;
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"text.openLink",
|
||||
"Open link",
|
||||
"{} while editing: the link at the caret (the selection's start) goes to the UI to open, as Alt+Enter does (\"press Option Return / Alt Enter to activate a focused link\"); only a web or mail link opens → {url} ({url: null}: no link there)",
|
||||
false,
|
||||
|s, _| {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Err(bad("text.openLink", "no text is being edited")) };
|
||||
let doc = s.doc()?;
|
||||
let t = doc.node(te.id).and_then(|n| n.kind.text()).ok_or_else(|| bad("text.openLink", format!("{} is not text", te.id)))?;
|
||||
let n = t.char_count();
|
||||
let at = te.caret.min(te.anchor);
|
||||
// The caret sits between two chars: the link of the one after it, else before it.
|
||||
let link = (at < n)
|
||||
.then(|| t.style_at(at).link.clone())
|
||||
.flatten()
|
||||
.or_else(|| (at > 0 && at <= n).then(|| t.style_at(at - 1).link.clone()).flatten());
|
||||
match link {
|
||||
Some(url) if crate::doc_urls::openable(&url) => {
|
||||
s.request_ui("open.url", json!({"url": url}));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
Some(url) => Err(bad("text.openLink", format!("`{url}` is not a web or mail link"))),
|
||||
None => Ok(json!({"url": Value::Null})),
|
||||
}
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.fonts",
|
||||
"List fonts",
|
||||
|
||||
@@ -194,6 +194,22 @@ fn region(s: &mut Session, step: i64) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Step the interface size through the Preferences sizes: `dir` 1 up, -1 down, 0 back to the
|
||||
/// system's size (1). At an end it stays. Kept at once, as a size picked in Preferences is.
|
||||
fn ui_scale_step(s: &mut Session, dir: i32) -> Result<Value> {
|
||||
let now = s.prefs.ui_scale;
|
||||
let next = match dir {
|
||||
0 => 1.0,
|
||||
1 => crate::UI_SCALE_SIZES.iter().copied().find(|v| *v > now + 1e-9).unwrap_or(now),
|
||||
_ => crate::UI_SCALE_SIZES.iter().rev().copied().find(|v| *v < now - 1e-9).unwrap_or(now),
|
||||
};
|
||||
if (next - now).abs() > 1e-9 {
|
||||
s.prefs.ui_scale = next;
|
||||
s.prefs_unsaved = true;
|
||||
}
|
||||
Ok(json!({"scale": s.prefs.ui_scale}))
|
||||
}
|
||||
|
||||
fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Result<Value> {
|
||||
let slot = f(&mut s.prefs);
|
||||
*slot = boolean(p, "on").unwrap_or(!*slot);
|
||||
@@ -305,6 +321,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")),
|
||||
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")),
|
||||
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
|
||||
spec!(
|
||||
"view.minimize",
|
||||
"Minimize the interface",
|
||||
"{on?} collapses the navigation bar and both sidebars and keeps the toolbar (Shift+Cmd/Ctrl+\\, the Minimize button by the file name); the right sidebar shows while something is selected",
|
||||
false,
|
||||
|s, p| flag(s, p, |v| &mut v.ui_minimized)
|
||||
),
|
||||
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
|
||||
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
|
||||
ok()
|
||||
@@ -395,6 +418,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"scale": s.prefs.ui_scale}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"pref.uiScaleUp",
|
||||
"Larger interface",
|
||||
"{} the next of the Preferences sizes above the current one (the interface size keys of Accessibility at Figma); at the largest it stays → {scale}",
|
||||
false,
|
||||
|s, _| ui_scale_step(s, 1)
|
||||
),
|
||||
spec!(
|
||||
"pref.uiScaleDown",
|
||||
"Smaller interface",
|
||||
"{} the next of the Preferences sizes below the current one; at the smallest it stays → {scale}",
|
||||
false,
|
||||
|s, _| ui_scale_step(s, -1)
|
||||
),
|
||||
spec!("pref.uiScaleReset", "Default interface size", "{} back to the system's own size (1) → {scale}", false, |s, _| ui_scale_step(s, 0)),
|
||||
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
|
||||
spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)),
|
||||
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
|
||||
|
||||
@@ -338,6 +338,15 @@ fn overridable_properties_stay_through_main_edits_and_reset() {
|
||||
|n| text(n).paragraph_spacing.to_string(),
|
||||
"a text property like the others",
|
||||
),
|
||||
(
|
||||
"wrap style",
|
||||
Layer::Title,
|
||||
"textStyle",
|
||||
json!({"wrapStyle": "balance"}),
|
||||
Some(json!({"wrapStyle": "pretty"})),
|
||||
|n| format!("{:?}", text(n).wrap_style),
|
||||
"a text property like the others (#1827)",
|
||||
),
|
||||
(
|
||||
"padding",
|
||||
Layer::Row,
|
||||
@@ -512,7 +521,6 @@ fn what_instances_cannot_override_is_refused() {
|
||||
),
|
||||
("remove auto layout", "layout.remove", json!({"ids": ["$"]}), Layer::Row, "structure of the instance"),
|
||||
("reorder layers", "node.reorder", json!({"id": "$", "index": 3}), Layer::Bg, "\"the order, or z-index, of any layers within the instance\""),
|
||||
("delete a layer", "edit.delete", json!({"ids": ["$"]}), Layer::Bg, "layers can only be hidden"),
|
||||
(
|
||||
"add a layer",
|
||||
"shape.rectangle",
|
||||
@@ -969,3 +977,26 @@ fn a_new_component_set_is_named_after_its_first_variant() {
|
||||
assert_eq!(node(&s, c).name, "State=Default");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
/// "You can't delete a layer or object from an instance. If you try, Figma will only toggle the
|
||||
/// layer's visibility instead of removing it." (Guide to auto layout; the flows article says the
|
||||
/// same). Delete hides the layer, an override; the main component is untouched.
|
||||
#[test]
|
||||
fn deleting_a_layer_inside_an_instance_hides_it_instead() {
|
||||
let mut s = Session::new();
|
||||
let k = kit(&mut s);
|
||||
let target = k.copy(&s, Layer::Bg);
|
||||
let steps = undo_len(&s);
|
||||
let r = s.execute("edit.delete", &json!({"ids": [target]})).unwrap();
|
||||
assert_eq!((r["hidden"].clone(), r["deleted"].clone()), (json!([target]), json!([])));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(target).is_some_and(|n| !n.visible), "hidden, not removed");
|
||||
assert!(doc.node(k.bg).is_some_and(|n| n.visible), "the main is untouched");
|
||||
assert_eq!(doc.node(k.inst).unwrap().children.len(), doc.node(k.card).unwrap().children.len(), "the structure is intact");
|
||||
assert_eq!(undo_len(&s), steps + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().node(target).is_some_and(|n| n.visible));
|
||||
// The instance itself still goes away.
|
||||
s.execute("edit.delete", &json!({"ids": [k.inst]})).unwrap();
|
||||
assert!(s.doc().unwrap().node(k.inst).is_none());
|
||||
}
|
||||
|
||||
@@ -69,3 +69,18 @@ fn inspect_shows_it_and_copied_properties_carry_it() {
|
||||
s.execute("edit.pasteProperties", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(cap(&s, b), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_rejects_bad_dashes_and_keeps_the_stroke() {
|
||||
let mut s = Session::new();
|
||||
let l = line(&mut s);
|
||||
let dashes = |s: &Session| s.doc().unwrap().node(l).unwrap().stroke.dashes.clone();
|
||||
for bad in [json!([5, "x", 20]), json!([5, true, 20]), json!([5, false, 20]), json!("abc"), json!(5)] {
|
||||
assert!(s.execute("node.set", &json!({"id": l, "props": {"dashes": bad}})).is_err());
|
||||
assert_eq!(dashes(&s), vec![20.0, 20.0], "a rejected value changes nothing");
|
||||
}
|
||||
s.execute("node.set", &json!({"id": l, "props": {"dashes": [5, 20]}})).unwrap();
|
||||
assert_eq!(dashes(&s), vec![5.0, 20.0]);
|
||||
s.execute("node.set", &json!({"id": l, "props": {"dashes": null}})).unwrap();
|
||||
assert!(dashes(&s).is_empty());
|
||||
}
|
||||
|
||||
@@ -302,6 +302,11 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
|
||||
TextAlignH::Right => put("text-align", "right".into()),
|
||||
TextAlignH::Justified => put("text-align", "justify".into()),
|
||||
}
|
||||
match tx.wrap_style {
|
||||
figcraft_doc::WrapStyle::Auto => {}
|
||||
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
|
||||
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Original => {}
|
||||
TextCase::Upper => put("text-transform", "uppercase".into()),
|
||||
|
||||
@@ -310,6 +310,8 @@ fn decl(key: &str, value: &str) -> Vec<(String, String)> {
|
||||
("text-transform", "uppercase") => kw("uppercase"),
|
||||
("text-transform", "lowercase") => kw("lowercase"),
|
||||
("text-transform", "capitalize") => kw("capitalize"),
|
||||
("text-wrap", "balance") => kw("text-balance"),
|
||||
("text-wrap", "pretty") => kw("text-pretty"),
|
||||
("text-decoration", "underline") => kw("underline"),
|
||||
("text-decoration", "line-through") => kw("line-through"),
|
||||
_ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))),
|
||||
|
||||
@@ -128,3 +128,77 @@ fn a_press_drags_only_past_the_threshold_in_screen_pixels() {
|
||||
assert_eq!(origin(&s, r).2 != 200.0, resizes, "resize by {by}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-click with Alt held throughout, as the canvas sends it.
|
||||
fn alt_double_click(s: &mut Session, x: f64, y: f64, zoom: f64) {
|
||||
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
|
||||
let mut ev = PointerEvent::at(kind, x, y, zoom);
|
||||
ev.alt = true;
|
||||
s.pointer(&ev).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// "Tip: Double-click the vertical or horizontal edge of an auto layout object's bounding box to
|
||||
/// set it to Hug contents." and "Hold ⌥ Option or Alt and double-click the vertical or horizontal
|
||||
/// edge of an auto layout object's bounding box to set it to Fill container." (Guide to auto
|
||||
/// layout): a vertical edge sizes the width, a horizontal one the height; a layer auto layout
|
||||
/// doesn't size keeps the double-click's old meaning.
|
||||
#[test]
|
||||
fn a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills() {
|
||||
use figcraft_doc::Sizing;
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0}}));
|
||||
let c =
|
||||
id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"fill": "#FF0000"}}))["id"])
|
||||
.unwrap();
|
||||
let sizing = |s: &Session, id: NodeId| {
|
||||
let cl = &s.doc().unwrap().node(id).unwrap().child_layout;
|
||||
(cl.sizing_h, cl.sizing_v)
|
||||
};
|
||||
let size = |s: &Session, id: NodeId| {
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
(n.width, n.height)
|
||||
};
|
||||
ex(&mut s, "select.set", json!({"ids": [f]}));
|
||||
assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)));
|
||||
// The frame's right edge: Hug contents horizontally, and the frame shrinks onto its child.
|
||||
double_click(&mut s, 400.0, 50.0, 0.0, 1.0);
|
||||
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Fixed));
|
||||
assert_eq!(size(&s, f), (100.0, 100.0));
|
||||
assert_eq!(s.selection(), [f], "still selected, not entered");
|
||||
s.undo().unwrap();
|
||||
assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)), "one undo step");
|
||||
// Its bottom edge: Hug contents vertically.
|
||||
double_click(&mut s, 200.0, 100.0, 0.0, 1.0);
|
||||
assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Hug));
|
||||
assert_eq!(size(&s, f), (400.0, 40.0));
|
||||
// Alt on the child's right edge: Fill container horizontally, and the child takes the width.
|
||||
ex(&mut s, "select.set", json!({"ids": [c]}));
|
||||
alt_double_click(&mut s, 100.0, 20.0, 1.0);
|
||||
assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, c), (400.0, 40.0));
|
||||
// Without Alt the child, no auto layout frame itself, keeps the old meaning (a frame's
|
||||
// double-click: nothing to enter here), and a plain rectangle's edge still edits its points.
|
||||
double_click(&mut s, 400.0, 20.0, 0.0, 1.0);
|
||||
assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed));
|
||||
// A text child of the frame: Alt+double-click on its edge sets Fill like any child's, and a
|
||||
// plain double-click on its edge still enters the text.
|
||||
let x = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": x, "props": {"textAutoResize": "none", "width": 60, "height": 20}}));
|
||||
ex(&mut s, "select.set", json!({"ids": [x]}));
|
||||
let b = s.doc().unwrap().world_bounds(x).unwrap();
|
||||
alt_double_click(&mut s, b.x1, (b.y0 + b.y1) / 2.0, 1.0);
|
||||
assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed), "a text child fills too");
|
||||
let b = s.doc().unwrap().world_bounds(x).unwrap();
|
||||
// On the edge handle, a pixel in (the hit test's reach): the text, not the frame edge it shares.
|
||||
double_click(&mut s, b.x1 - 1.0, (b.y0 + b.y1) / 2.0, 0.0, 1.0);
|
||||
assert!(s.ds().unwrap().text_edit.as_ref().is_some_and(|te| te.id == x), "a text's edge still enters it without Alt");
|
||||
assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed));
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 100, "height": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
double_click(&mut s, 99.0, 350.0, 0.0, 1.0);
|
||||
assert_eq!(sizing(&s, r), (Sizing::Fixed, Sizing::Fixed));
|
||||
assert!(s.ds().unwrap().vector_edit.is_some(), "a shape's double-click still edits its points");
|
||||
}
|
||||
|
||||
@@ -243,9 +243,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
|
||||
if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) {
|
||||
return Err(bad(cmd, "the layer is no longer this plugin's widget"));
|
||||
}
|
||||
for c in n.children.clone() {
|
||||
doc.remove(c)?;
|
||||
}
|
||||
doc.remove_children(w)?;
|
||||
crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?;
|
||||
let n = doc.get_mut(w)?;
|
||||
n.locked = false;
|
||||
|
||||
@@ -212,14 +212,20 @@ pub static KEYS: &[Binding] = &[
|
||||
k(View, "view.comments", &["Shift+C"]),
|
||||
k(View, "view.multiplayerCursors", &["Alt+Cmd+\\"]),
|
||||
k(View, "view.snapPixel", &["Shift+Cmd+'"]),
|
||||
k(View, "view.layers", &["Alt+1"]).label("Layers panel"),
|
||||
// Windows: one help-center article writes Ctrl+1, another Alt+1; both open it there.
|
||||
k(View, "view.layers", &["Alt+1"]).win(&["Alt+1", "Ctrl+1"]).label("Layers panel"),
|
||||
// The interface size (Accessibility at Figma): Cmd/Ctrl+Shift+Alt with =, - and 0, while
|
||||
// editing text too.
|
||||
k(View, "pref.uiScaleUp", &["Alt+Shift+Cmd+="]).both(),
|
||||
k(View, "pref.uiScaleDown", &["Alt+Shift+Cmd+-"]).both(),
|
||||
k(View, "pref.uiScaleReset", &["Alt+Shift+Cmd+0"]).both(),
|
||||
k(View, "view.assets", &["Alt+2"]).label("Assets panel"),
|
||||
k(View, "libraries.open", &["Alt+3"]),
|
||||
k(View, "view.design", &["Alt+8"]).label("Design panel"),
|
||||
k(View, "view.prototype", &["Alt+9"]).label("Prototype panel"),
|
||||
k(View, "view.designPrototype", &["Shift+E"]),
|
||||
k(View, "view.devMode", &["Shift+D"]).label("Dev Mode on/off"),
|
||||
k(View, "view.leftSidebar", &["Shift+Cmd+\\"]),
|
||||
k(View, "view.minimize", &["Shift+Cmd+\\"]),
|
||||
k(View, "view.nextRegion", &["F6"]).win(&["F6", "Ctrl+F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(),
|
||||
k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(),
|
||||
k(View, "view.contextMenu", &["Shift+F10", "Menu"]).label("Context menu of the selection (the focused layer row or the canvas)"),
|
||||
@@ -241,6 +247,7 @@ pub static KEYS: &[Binding] = &[
|
||||
// Text (on selected text layers, and on the selected characters while editing)
|
||||
k(Text, "text.bold", &["Cmd+B"]).both(),
|
||||
k(Text, "text.italic", &["Cmd+I"]).both(),
|
||||
k(Text, "text.openLink", &["Alt+Enter"]).both(),
|
||||
k(Text, "text.underline", &["Cmd+U"]).both(),
|
||||
k(Text, "text.strikethrough", &["Shift+Cmd+X"]).both(),
|
||||
k(Text, "text.link", &["Shift+Cmd+U", "Cmd+K"]).text(),
|
||||
@@ -345,6 +352,7 @@ pub static KEYS: &[Binding] = &[
|
||||
pub static GESTURES: &[(Group, &str, &str, &str)] = &[
|
||||
(Tools, "Pan the canvas", "Space drag", "Space+drag"),
|
||||
(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"),
|
||||
(Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"),
|
||||
(Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"),
|
||||
@@ -357,6 +365,8 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
|
||||
(Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"),
|
||||
(Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"),
|
||||
(Transform, "Suspend snapping while dragging", "Hold ⌃ or ⌘", "Hold Ctrl"),
|
||||
(Arrange, "Set hug contents", "Double-click an edge of an auto layout frame", "Double-click an edge of an auto layout frame"),
|
||||
(Arrange, "Set fill container", "⌥ double-click an edge of an auto layout child", "Alt+double-click an edge of an auto layout child"),
|
||||
(Prototype, "Presenting: next frame", "→, Space or N", "→, Space or N"),
|
||||
(Prototype, "Presenting: previous frame", "←", "←"),
|
||||
(Prototype, "Presenting: restart", "R", "R"),
|
||||
|
||||
@@ -305,3 +305,56 @@ fn ctrl_tab_cycles_documents() {
|
||||
assert_eq!(lookup(Platform::Other, Context::Canvas, "Tab", mods(false, false, true, true)).map(|b| b.id), Some("doc.nextTab"));
|
||||
assert_eq!(lookup(Platform::Mac, Context::Canvas, "Tab", mods(true, false, false, true)).map(|b| b.id), Some("doc.previousTab"));
|
||||
}
|
||||
|
||||
/// "Adjust the scale of the Figma UI [...] Windows: Reset to Default: Control ⇧Shift Alt 0; Make
|
||||
/// Larger: Control ⇧Shift Alt =; Make Smaller: Control ⇧Shift Alt -" (Accessibility at Figma; ⌘ on
|
||||
/// a Mac). The steps go through the sizes Preferences offers, stop at the ends, and are kept at once.
|
||||
#[test]
|
||||
fn the_interface_size_keys_step_through_the_preferences_sizes() {
|
||||
let m = mods(true, true, true, false);
|
||||
for p in PLATFORMS {
|
||||
for c in [Context::Canvas, Context::Text] {
|
||||
assert_eq!(id_for(p, c, "=", m), Some("pref.uiScaleUp"), "{p:?} {c:?}");
|
||||
assert_eq!(id_for(p, c, "-", m), Some("pref.uiScaleDown"), "{p:?} {c:?}");
|
||||
assert_eq!(id_for(p, c, "0", m), Some("pref.uiScaleReset"), "{p:?} {c:?}");
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
let scale = |s: &Session| s.prefs.ui_scale;
|
||||
ex(&mut s, "pref.uiScale", json!({"scale": 1}));
|
||||
s.prefs_unsaved = false;
|
||||
ex(&mut s, "pref.uiScaleUp", json!({}));
|
||||
assert_eq!(scale(&s), 1.1);
|
||||
assert!(s.prefs_unsaved, "kept at once");
|
||||
ex(&mut s, "pref.uiScaleUp", json!({}));
|
||||
assert_eq!(scale(&s), 1.25);
|
||||
ex(&mut s, "pref.uiScaleDown", json!({}));
|
||||
assert_eq!(scale(&s), 1.1);
|
||||
assert_eq!(ex(&mut s, "pref.uiScaleReset", json!({}))["scale"], json!(1.0));
|
||||
assert_eq!(scale(&s), 1.0);
|
||||
// Between two sizes a step lands on the next one; at an end it stays.
|
||||
ex(&mut s, "pref.uiScale", json!({"scale": 1.3}));
|
||||
ex(&mut s, "pref.uiScaleUp", json!({}));
|
||||
assert_eq!(scale(&s), 1.5);
|
||||
ex(&mut s, "pref.uiScale", json!({"scale": 1.3}));
|
||||
ex(&mut s, "pref.uiScaleDown", json!({}));
|
||||
assert_eq!(scale(&s), 1.25);
|
||||
ex(&mut s, "pref.uiScale", json!({"scale": 2}));
|
||||
ex(&mut s, "pref.uiScaleUp", json!({}));
|
||||
assert_eq!(scale(&s), 2.0);
|
||||
ex(&mut s, "pref.uiScale", json!({"scale": 0.75}));
|
||||
ex(&mut s, "pref.uiScaleDown", json!({}));
|
||||
assert_eq!(scale(&s), 0.75);
|
||||
}
|
||||
|
||||
/// The Layers panel's Windows key is Ctrl+1 in one help-center article ("Mac: Option 1 Windows:
|
||||
/// Ctrl 1", Select layers and objects) and Alt+1 in another (Explore the navigation bar and left
|
||||
/// sidebar): both open it there. macOS keeps Option+1 alone; Cmd+1 is not documented.
|
||||
#[test]
|
||||
fn the_layers_panel_answers_both_of_its_documented_windows_keys() {
|
||||
let (alt, ctrl, cmd) = (mods(false, true, false, false), mods(false, false, false, true), mods(false, false, true, false));
|
||||
assert_eq!(id_for(Platform::Other, Context::Canvas, "1", alt), Some("view.layers"));
|
||||
assert_eq!(id_for(Platform::Other, Context::Canvas, "1", ctrl), Some("view.layers"));
|
||||
assert_eq!(id_for(Platform::Mac, Context::Canvas, "1", alt), Some("view.layers"));
|
||||
assert_eq!(id_for(Platform::Mac, Context::Canvas, "1", cmd), None);
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ mod view_state;
|
||||
pub use derived::{changed_nodes, normalize};
|
||||
pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview};
|
||||
pub use error::{EngineError, Result, bad};
|
||||
pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UiRequest, clamp_ui_scale};
|
||||
pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_SIZES, UiRequest, clamp_ui_scale};
|
||||
pub use session::Forward;
|
||||
pub use view_state::ViewState;
|
||||
|
||||
@@ -565,6 +565,8 @@ mod text_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod text_fill_tests;
|
||||
#[cfg(test)]
|
||||
mod text_link_tests;
|
||||
#[cfg(test)]
|
||||
mod text_metrics_local_tests;
|
||||
#[cfg(test)]
|
||||
mod text_motion_tests;
|
||||
|
||||
@@ -7,6 +7,10 @@ pub const UI_SCALE_MIN: f64 = 0.75;
|
||||
pub const UI_SCALE_MAX: f64 = 3.0;
|
||||
|
||||
/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`.
|
||||
/// The interface sizes Preferences offers, relative to the system's scale (the keys step through
|
||||
/// them; `pref.uiScale` takes any size up to [`UI_SCALE_MAX`]).
|
||||
pub const UI_SCALE_SIZES: [f64; 8] = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0];
|
||||
|
||||
pub fn clamp_ui_scale(scale: f64) -> Option<f64> {
|
||||
scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX))
|
||||
}
|
||||
|
||||
@@ -145,8 +145,11 @@ static ROWS: &[Row] = &[
|
||||
doc(View, "Outline mode", &["Shift+Cmd+O"], &[], Bound("view.outlines"), ""),
|
||||
doc(View, "Pixel preview", &["Ctrl+P"], &["Ctrl+Alt+P"], Bound("view.pixelPreview"), ""),
|
||||
doc(View, "Others' cursors in a live session", &["Alt+Cmd+\\"], &["Ctrl+Alt+\\"], Bound("view.multiplayerCursors"), "new command"),
|
||||
doc(View, "Hide the side panels (minimize the interface)", &["Shift+Cmd+\\"], &[], Bound("view.leftSidebar"), ""),
|
||||
doc(View, "Layers panel", &["Alt+1"], &[], Bound("view.layers"), "one article writes Ctrl+1 for Windows, another Alt+1; Alt+1 bound"),
|
||||
doc(View, "Minimize the interface: the navigation bar and both sidebars", &["Shift+Cmd+\\"], &[], Bound("view.minimize"), "the toolbar stays; the right sidebar shows while something is selected"),
|
||||
doc(View, "Layers panel", &["Alt+1"], &["Alt+1", "Ctrl+1"], Bound("view.layers"), "one article writes Ctrl+1 for Windows, another Alt+1; both bound there"),
|
||||
doc(View, "Interface scale: make larger", &["Alt+Shift+Cmd+="], &[], Bound("pref.uiScaleUp"), "Accessibility at Figma, Adjust the scale of the Figma UI"),
|
||||
doc(View, "Interface scale: make smaller", &["Alt+Shift+Cmd+-"], &[], Bound("pref.uiScaleDown"), "Accessibility at Figma"),
|
||||
doc(View, "Interface scale: reset to default", &["Alt+Shift+Cmd+0"], &[], Bound("pref.uiScaleReset"), "Accessibility at Figma"),
|
||||
doc(View, "Assets panel", &["Alt+2"], &[], Bound("view.assets"), ""),
|
||||
doc(View, "Libraries", &["Alt+3"], &[], Bound("libraries.open"), ""),
|
||||
doc(View, "Dev Mode on or off", &["Shift+D"], &[], Bound("view.devMode"), ""),
|
||||
@@ -178,6 +181,7 @@ static ROWS: &[Row] = &[
|
||||
pin(Zoom, "view.nextFrame", &["N", "End"], &[]),
|
||||
// Text
|
||||
doc(Text, "Bold", &["Cmd+B"], &[], Bound("text.bold"), ""),
|
||||
doc(Text, "Activate a focused link", &["Alt+Enter"], &[], Bound("text.openLink"), "while editing: the link at the caret; web and mail links only"),
|
||||
doc(Text, "Italic", &["Cmd+I"], &[], Bound("text.italic"), ""),
|
||||
doc(Text, "Underline", &["Cmd+U"], &[], Bound("text.underline"), ""),
|
||||
doc(Text, "Strikethrough", &["Shift+Cmd+X"], &[], Bound("text.strikethrough"), ""),
|
||||
@@ -255,6 +259,8 @@ static ROWS: &[Row] = &[
|
||||
doc(Transform, "Use as mask (again: release)", &["Ctrl+Cmd+M"], &["Ctrl+Alt+M"], Bound("object.mask"), ""),
|
||||
doc(Transform, "Resize a frame to fit its contents", &["Alt+Shift+Cmd+R"], &["Ctrl+Alt+Shift+R"], Bound("object.resizeToFit"), ""),
|
||||
doc(Transform, "Nudge (Shift: big nudge)", &["ArrowRight"], &[], Gesture("Nudge (Shift: big nudge)"), ""),
|
||||
doc(Transform, "Rotate a degree (Shift: 15 degrees)", &["Alt+Cmd+ArrowDown"], &[], Gesture("Rotate a degree (Shift: 15)"),
|
||||
"Accessibility at Figma; down and right turn clockwise, up and left back (silent on the direction)"),
|
||||
doc(Transform, "Suspend snapping while dragging", &["Ctrl"], &[], Gesture("Suspend snapping while dragging"),
|
||||
"Control on macOS (Cmd keeps working); Ctrl elsewhere"),
|
||||
doc(Transform, "Show the rotation origin to move it", &["Alt+R"], &[], Bound("transform.rotationOrigin"),
|
||||
@@ -407,7 +413,7 @@ fn counts_match_the_docs() {
|
||||
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
|
||||
let pinned = ROWS.len() - documented.len();
|
||||
// docs/ui-parity.md quotes these.
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 57));
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (134, 133, 1, 0, 2, 57));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -365,12 +365,146 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 1}})).unwrap();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"height": 30}})).unwrap();
|
||||
assert_eq!((resize_mode(&s, t), size(&s, t).1), (TextAutoResize::Truncate, 30.0));
|
||||
// Other text still becomes a truncating box with max lines.
|
||||
// Fixed-size text still becomes a truncating box with max lines.
|
||||
let fixed = text(&mut s, "Lorem", json!({"textAutoResize": "none", "width": 100, "height": 100}));
|
||||
s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap();
|
||||
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
|
||||
}
|
||||
|
||||
#[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
|
||||
// wrapping to the next." "Balance: Figma distributes text as evenly as possible across every
|
||||
// line, so each line is close to the same length." "Pretty: Figma keeps each line full, but
|
||||
// adjusts the last four lines to avoid an orphan -- a single word stranded alone on the last
|
||||
// line." The synthetic font: 8 px per character at 16 px.
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "aaa bbb ccc ddd eee", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 136}})).unwrap();
|
||||
let lines = |s: &Session| -> Vec<(usize, usize)> {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
l.lines.iter().map(|l| (l.char_start, l.char_end)).collect()
|
||||
};
|
||||
// 136 px holds 17 characters: Auto fills the first line with four words and strands the fifth.
|
||||
assert_eq!(lines(&s), [(0, 16), (16, 19)]);
|
||||
// Balance: as even as the words allow, three and two.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 12), (12, 19)]);
|
||||
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["wrapStyle"], json!("balance"));
|
||||
// Pretty: the stranded word gets company from the line before.
|
||||
s.execute("text.wrapStyle", &json!({"ids": [t], "style": "pretty"})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 12), (12, 19)]);
|
||||
// Three lines of two-letter words in a 72 px box (nine characters): Auto strands "gg"; Pretty
|
||||
// narrows the line before it alone ("aa bb cc" / "dd ee" / "ff gg"); Balance's narrowest
|
||||
// width that still gives three lines (64 px) fills the first two lines as Auto does.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "aa bb cc dd ee ff gg", "width": 72, "wrapStyle": "auto"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 18), (18, 20)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 15), (15, 20)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 18), (18, 20)]);
|
||||
// A last line of two words is no orphan: Pretty leaves Auto's lines; a text on one line is
|
||||
// left alone whatever the style.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "aa bb cc dd ee ff gg hh", "wrapStyle": "pretty"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 18), (18, 23)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"width": 400, "wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 23)]);
|
||||
// Dev Mode's CSS says `text-wrap` for Balance and Pretty and nothing for Auto; junk is refused.
|
||||
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
|
||||
assert!(css(&mut s).contains("text-wrap: balance;"), "{}", css(&mut s));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
|
||||
assert!(css(&mut s).contains("text-wrap: pretty;"));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "auto"}})).unwrap();
|
||||
assert!(!css(&mut s).contains("text-wrap"));
|
||||
assert!(s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "zigzag"}})).is_err());
|
||||
assert!(s.execute("text.wrapStyle", &json!({"ids": [t], "style": "even"})).is_err());
|
||||
// A word as wide as the box is never broken in two by Balance or Pretty: 80 px holds the ten
|
||||
// letters, and every narrower width Balance could try would have to split them.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "abcdefghij klm", "width": 80, "wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 11), (11, 14)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 11), (11, 14)]);
|
||||
// Hanging punctuation: a line starting with an opening mark has that much more room, and
|
||||
// Pretty narrows it by as much, so the line before the orphan still gives a word away.
|
||||
s.execute(
|
||||
"node.set",
|
||||
&json!({"id": t, "props": {"characters": "aa bb cc (dd ee ff gg", "width": 72, "hangingPunctuation": true, "wrapStyle": "auto"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 19), (19, 21)]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty"}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 16), (16, 21)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
|
||||
// "Max lines is available only if truncate text is enabled, and: Text resizing is set to
|
||||
// auto height or auto width" (Explore text properties): an auto-width text with max lines
|
||||
// stays auto width, growing with its longest line and hugging up to that many lines.
|
||||
use figcraft_doc::TextAutoResize;
|
||||
// The synthetic font: every character 8 px wide at 16 px, so the widths are the same
|
||||
// everywhere and never depend on what the system has installed.
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "one\ntwo\nthree", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
|
||||
assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight);
|
||||
assert_eq!(size(&s, t), (40.0, 72.0), "three lines tall, as wide as 'three'");
|
||||
s.execute("text.maxLines", &json!({"ids": [t], "lines": 2})).unwrap();
|
||||
assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight, "max lines keep auto width");
|
||||
assert_eq!(size(&s, t), (32.0, 48.0), "two lines tall, as wide as the widest line it shows with its ellipsis (two…), not the hidden third");
|
||||
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["maxLines"], json!(2));
|
||||
// Still auto width: a longer line widens the box.
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 3})).unwrap();
|
||||
s.execute("text.edit.insert", &json!({"text": " and a longer line"})).unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
assert_eq!(size(&s, t), (168.0, 48.0), "the box grew with its longest line (21 characters)");
|
||||
assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight);
|
||||
// No limit: all of its lines again.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap();
|
||||
assert_eq!(size(&s, t).1, 72.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_and_subscript_are_synthesised_without_glyphs_for_them() {
|
||||
// "If it isn't provided, Figma draws a synthesized version by using the font's standard
|
||||
// version of the character, shrinking it, and positioning it accordingly" (Explore text
|
||||
// properties). The synthetic font has no sups or subs feature and no OS/2 metrics, so the
|
||||
// usual 0.65 em at 0.35 em up (superscript) or 0.14 em down (subscript) apply.
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "x2", json!({"fontFamily": font, "fontSize": 40}));
|
||||
let glyphs = |s: &Session| -> Vec<(f64, f64, f64)> {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
l.glyphs.iter().map(|g| (g.size, g.y, g.advance)).collect()
|
||||
};
|
||||
let plain = glyphs(&s);
|
||||
assert_eq!(plain.len(), 2);
|
||||
assert_eq!((plain[0].0, plain[0].2), (40.0, 20.0));
|
||||
assert_eq!(size(&s, t).0, 40.0);
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": true}})).unwrap();
|
||||
let sup = glyphs(&s);
|
||||
for (g, p) in sup.iter().zip(&plain) {
|
||||
assert!((g.0 - 26.0).abs() < 1e-6 && (g.2 - 13.0).abs() < 1e-6, "shrunk to 0.65 em, the advance with it: {g:?}");
|
||||
assert!((p.1 - g.1 - 14.0).abs() < 1e-6, "raised 0.35 em: {g:?} against {p:?}");
|
||||
}
|
||||
assert!((size(&s, t).0 - 26.0).abs() < 1e-6, "the auto-width box hugs the smaller glyphs: {:?}", size(&s, t));
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": true}})).unwrap();
|
||||
let sub = glyphs(&s);
|
||||
for (g, p) in sub.iter().zip(&plain) {
|
||||
assert!((g.0 - 26.0).abs() < 1e-6 && (g.1 - p.1 - 5.6).abs() < 1e-6, "shrunk, and lowered 0.14 em: {g:?} against {p:?}");
|
||||
}
|
||||
// Capitalised, the same: the synthesis follows the characters shaping saw.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textCase": "upper"}})).unwrap();
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"subs": null, "sups": true}})).unwrap();
|
||||
for (g, p) in glyphs(&s).iter().zip(&plain) {
|
||||
assert!((g.0 - 26.0).abs() < 1e-6 && (p.1 - g.1 - 14.0).abs() < 1e-6, "upper case, superscript: {g:?} against {p:?}");
|
||||
}
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textCase": "original"}})).unwrap();
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": false}})).unwrap();
|
||||
assert_eq!(glyphs(&s), plain, "off again");
|
||||
}
|
||||
|
||||
/// A synthetic monospaced TrueType font (outline-less glyphs, every printable ASCII character 500 of
|
||||
/// 1000 units wide), loaded into the font database so text measures the same on every machine.
|
||||
fn test_mono_font() -> &'static str {
|
||||
@@ -550,3 +684,67 @@ fn css_font_family_escapes_backslash_and_newline() {
|
||||
let css = crate::codegen::css_decls(s.doc().unwrap(), s.doc().unwrap().node(t).unwrap(), &mut Default::default(), (true, true));
|
||||
assert!(css.contains(r#"font-family: "Foo\"\3C /style>";"#), "{css}");
|
||||
}
|
||||
|
||||
/// "Text layers cannot have both a max height and a set number of max lines. Adding a max height
|
||||
/// will set max lines to Auto. Setting max lines to a number will remove the layer's max height
|
||||
/// setting." (Guide to auto layout)
|
||||
#[test]
|
||||
fn a_text_has_a_max_height_or_max_lines_not_both() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 100})).unwrap());
|
||||
s.execute("node.set", &json!({"id": f, "props": {"layout": "vertical"}})).unwrap();
|
||||
let t = id(s.execute("text.create", &json!({"text": "Hello there world", "x": 0, "y": 0, "parent": f, "width": 60})).unwrap());
|
||||
let state = |s: &Session| {
|
||||
let n = s.doc().unwrap().node(t).unwrap().clone();
|
||||
(n.kind.text().unwrap().max_lines, n.child_layout.max_h)
|
||||
};
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 2}})).unwrap();
|
||||
assert_eq!(state(&s), (2, None));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxHeight": 40}})).unwrap();
|
||||
assert_eq!(state(&s), (0, Some(40.0)), "a max height sets max lines to Auto");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 3}})).unwrap();
|
||||
assert_eq!(state(&s), (3, None), "max lines take the max height away");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxHeight": 50}})).unwrap();
|
||||
s.execute("text.maxLines", &json!({"ids": [t], "lines": 1})).unwrap();
|
||||
assert_eq!(state(&s), (1, None), "the Max lines command too");
|
||||
// Auto (0) leaves a max height alone, and a frame's max height has no lines to clear.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxHeight": 50}})).unwrap();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap();
|
||||
assert_eq!(state(&s), (0, Some(50.0)));
|
||||
s.execute("node.set", &json!({"id": f, "props": {"maxHeight": 80}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().child_layout.max_h, Some(80.0));
|
||||
}
|
||||
|
||||
/// "Links are styled with an underline by default. Use the keyboard shortcut to remove the
|
||||
/// underline: Mac: Command U, Windows: Control U" (Add links to text).
|
||||
#[test]
|
||||
fn a_link_is_underlined_by_default_and_underline_takes_it_off() {
|
||||
use figcraft_doc::TextDecoration;
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "docs", json!({}));
|
||||
let style = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().style.clone();
|
||||
s.execute("text.link", &json!({"ids": [t], "url": "https://example.com"})).unwrap();
|
||||
assert_eq!((style(&s).link.as_deref(), style(&s).decoration), (Some("https://example.com"), TextDecoration::Underline));
|
||||
s.execute("text.underline", &json!({"ids": [t]})).unwrap();
|
||||
assert_eq!(
|
||||
(style(&s).link.as_deref(), style(&s).decoration),
|
||||
(Some("https://example.com"), TextDecoration::None),
|
||||
"the link stays, the underline goes"
|
||||
);
|
||||
// Changing the URL of a link whose underline was taken off keeps it off.
|
||||
s.execute("text.link", &json!({"ids": [t], "url": "https://example.org"})).unwrap();
|
||||
assert_eq!((style(&s).link.as_deref(), style(&s).decoration), (Some("https://example.org"), TextDecoration::None));
|
||||
// A decoration of the text's own is kept.
|
||||
let u = text(&mut s, "struck", json!({"textDecoration": "strikethrough"}));
|
||||
s.execute("text.link", &json!({"ids": [u], "url": "https://example.com"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(u).unwrap().kind.text().unwrap().style.decoration, TextDecoration::Strikethrough);
|
||||
// On a frame, the link reaches the text inside, and Underline reads that text: it takes the
|
||||
// underline off rather than putting it on again.
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 200, "height": 100})).unwrap());
|
||||
let inner = id(s.execute("text.create", &json!({"text": "inside", "x": 0, "y": 100, "parent": f})).unwrap());
|
||||
let inner_style = |s: &Session| s.doc().unwrap().node(inner).unwrap().kind.text().unwrap().style.clone();
|
||||
s.execute("text.link", &json!({"ids": [f], "url": "https://example.com"})).unwrap();
|
||||
assert_eq!((inner_style(&s).link.as_deref(), inner_style(&s).decoration), (Some("https://example.com"), TextDecoration::Underline));
|
||||
s.execute("text.underline", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!(inner_style(&s).decoration, TextDecoration::None, "Underline on the frame reads the text inside it");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Alt+Enter while editing text activates the link at the caret ("When editing text, press
|
||||
//! ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma): `text.openLink`
|
||||
//! hands a web or mail link at the caret to the UI, refuses other schemes, and does nothing where
|
||||
//! there is no link.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
#[test]
|
||||
fn the_link_at_the_caret_goes_to_the_ui_and_only_a_web_or_mail_link() {
|
||||
let mut s = Session::new();
|
||||
let t = id_of(&s.execute("text.create", &json!({"text": "See the docs now", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "https://example.com/docs"})).unwrap();
|
||||
assert!(s.execute("text.openLink", &json!({})).is_err(), "nothing to activate while no text is edited");
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 6})).unwrap();
|
||||
let r = s.execute("text.openLink", &json!({})).unwrap();
|
||||
assert_eq!(r["url"], "https://example.com/docs");
|
||||
let req = s.ui_requests.iter().find(|r| r.kind == "open.url").expect("a UI request to open it");
|
||||
assert_eq!(req.data["url"], "https://example.com/docs");
|
||||
assert!(s.ds().unwrap().text_edit.is_some(), "still editing");
|
||||
// Right after the link's last char the caret still counts as on it; before its start it doesn't.
|
||||
s.execute("text.edit.caret", &json!({"index": 12})).unwrap();
|
||||
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs");
|
||||
s.execute("text.edit.caret", &json!({"index": 2})).unwrap();
|
||||
assert!(s.execute("text.openLink", &json!({})).unwrap()["url"].is_null(), "no link there");
|
||||
// A selection: its start decides.
|
||||
s.execute("text.edit.caret", &json!({"index": 14})).unwrap();
|
||||
s.execute("text.edit.caret", &json!({"index": 8, "extend": true})).unwrap();
|
||||
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs");
|
||||
// Mail links open; anything that isn't a web or mail link is refused and nothing is requested.
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "mailto:hello@example.com"})).unwrap();
|
||||
s.execute("text.edit.caret", &json!({"index": 6})).unwrap();
|
||||
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "mailto:hello@example.com");
|
||||
let before = s.ui_requests.len();
|
||||
for bad in ["javascript:alert(1)", "file:///etc/passwd", "example.com/docs"] {
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": bad})).unwrap();
|
||||
assert!(s.execute("text.openLink", &json!({})).is_err(), "{bad} is refused");
|
||||
}
|
||||
assert_eq!(s.ui_requests.len(), before, "nothing requested for a refused link");
|
||||
}
|
||||
@@ -129,6 +129,28 @@ fn list_keys_at_list_items() {
|
||||
"silent: Backspace at an item's start removes its bullet",
|
||||
),
|
||||
("a\nbc", vec![(B, 0), (B, 0)], 4, "Backspace", false, "a\nb", vec![(B, 0), (B, 0)], "Backspace inside an item deletes text"),
|
||||
(
|
||||
"a\nb",
|
||||
vec![(B, 0), (B, 1)],
|
||||
2,
|
||||
"Backspace",
|
||||
false,
|
||||
"a\nb",
|
||||
vec![(B, 0), (P, 1)],
|
||||
"Backspace at a nested item's start removes its bullet and keeps its level",
|
||||
),
|
||||
("a\nb", vec![(N, 0), (N, 2)], 2, "Delete", false, "a\nb", vec![(N, 0), (P, 2)], "Delete at an item's start does the same"),
|
||||
("a\nbc", vec![(B, 0), (B, 1)], 3, "Delete", false, "a\nb", vec![(B, 0), (B, 1)], "Delete inside an item deletes text"),
|
||||
(
|
||||
"a\nb",
|
||||
vec![(B, 0), (P, 1)],
|
||||
2,
|
||||
"Tab",
|
||||
false,
|
||||
"a\n\tb",
|
||||
vec![(B, 0), (P, 1)],
|
||||
"a plain paragraph at a level isn't an item: Tab types a tab",
|
||||
),
|
||||
("a\nb", vec![(P, 0), (P, 0)], 3, "Tab", false, "a\nb\t", vec![(P, 0), (P, 0)], "outside a list Tab types a tab"),
|
||||
("a", vec![(B, 8)], 1, "Tab", false, "a", vec![(B, 8)], "silent: nesting stops at 8 levels"),
|
||||
];
|
||||
@@ -318,3 +340,30 @@ fn rest_import_lists_and_decorations() {
|
||||
(DecorationStyle::Wavy, DecorationLength::Pixels(2.0), DecorationLength::Auto, true, Some(Color::new(0.0, 0.0, 1.0, 1.0)))
|
||||
);
|
||||
}
|
||||
|
||||
/// "From within the text layer, there are a couple of ways to start a numbered list: Enter one of
|
||||
/// the creation characters below followed by a Space: 1. 1)" (Create bulleted and numbered lists);
|
||||
/// "-" and "*" start a bulleted one the same way.
|
||||
#[test]
|
||||
fn typing_a_creation_character_and_a_space_starts_a_list() {
|
||||
for (start, want) in [("1.", N), ("1)", N), ("-", B), ("*", B)] {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap();
|
||||
begin(&mut s, t, Some(0), false).unwrap();
|
||||
for c in start.chars() {
|
||||
insert(&mut s, &c.to_string()).unwrap();
|
||||
}
|
||||
insert(&mut s, " ").unwrap();
|
||||
assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), ("", vec![(want, 0)]), "{start}");
|
||||
}
|
||||
// "2)" is not a creation character: it stays text.
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "x");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap();
|
||||
begin(&mut s, t, Some(0), false).unwrap();
|
||||
for c in ["2", ")", " "] {
|
||||
insert(&mut s, c).unwrap();
|
||||
}
|
||||
assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), ("2) ", vec![(P, 0)]));
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct ViewState {
|
||||
pub snap_objects: bool,
|
||||
pub snap_pixel: bool,
|
||||
pub ui_hidden: bool,
|
||||
/// Minimized interface (Shift+Cmd/Ctrl+\): the navigation bar and both sidebars are collapsed
|
||||
/// and the toolbar stays; the right sidebar shows while something is selected.
|
||||
pub ui_minimized: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
@@ -67,6 +70,7 @@ impl Default for ViewState {
|
||||
snap_objects: true,
|
||||
snap_pixel: true,
|
||||
ui_hidden: false,
|
||||
ui_minimized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,9 @@ use kiwi::{Kv, Schema};
|
||||
/// Largest `canvas.fig` member read, and largest decompressed chunk.
|
||||
const MAX_CANVAS: u64 = 256 * 1024 * 1024;
|
||||
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
|
||||
/// Largest decompressed schema chunk: Figma's schema unpacks to well under 1 MB, so a schema that
|
||||
/// inflates past this is refused before its definitions are decoded.
|
||||
const MAX_SCHEMA: u64 = 16 * 1024 * 1024;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
|
||||
@@ -324,7 +327,7 @@ fn decode_canvas(canvas: &[u8]) -> Result<(Schema, Vec<u8>), FormatError> {
|
||||
let (Some(schema), Some(message)) = (it.next(), it.next()) else {
|
||||
return Err(corrupt("canvas.fig needs a schema and a message chunk"));
|
||||
};
|
||||
let schema = Schema::decode(&inflate(schema)?).map_err(|e| corrupt(e.to_string()))?;
|
||||
let schema = Schema::decode(&inflate_within(schema, MAX_SCHEMA)?).map_err(|e| corrupt(e.to_string()))?;
|
||||
Ok((schema, inflate(message)?))
|
||||
}
|
||||
|
||||
@@ -347,18 +350,23 @@ fn chunks(canvas: &[u8]) -> Result<Vec<&[u8]>, FormatError> {
|
||||
|
||||
/// Decompress a chunk: zstd (by its magic number), else raw deflate, else zlib.
|
||||
fn inflate(data: &[u8]) -> Result<Vec<u8>, FormatError> {
|
||||
fn capped(r: impl Read) -> Option<Vec<u8>> {
|
||||
inflate_within(data, MAX_CHUNK)
|
||||
}
|
||||
|
||||
/// [`inflate`] refusing a chunk that unpacks to more than `max` bytes.
|
||||
fn inflate_within(data: &[u8], max: u64) -> Result<Vec<u8>, FormatError> {
|
||||
let capped = |r: &mut dyn Read| -> Option<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
r.take(MAX_CHUNK + 1).read_to_end(&mut out).ok()?;
|
||||
(out.len() as u64 <= MAX_CHUNK).then_some(out)
|
||||
}
|
||||
r.take(max.saturating_add(1)).read_to_end(&mut out).ok()?;
|
||||
(out.len() as u64 <= max).then_some(out)
|
||||
};
|
||||
if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) {
|
||||
let mut src = data;
|
||||
let dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?;
|
||||
return capped(dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk"));
|
||||
let mut dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?;
|
||||
return capped(&mut dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk"));
|
||||
}
|
||||
capped(flate2::read::DeflateDecoder::new(data))
|
||||
.or_else(|| capped(flate2::read::ZlibDecoder::new(data)))
|
||||
capped(&mut flate2::read::DeflateDecoder::new(data))
|
||||
.or_else(|| capped(&mut flate2::read::ZlibDecoder::new(data)))
|
||||
.ok_or_else(|| corrupt("damaged or oversized deflate chunk"))
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,64 @@ fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a m
|
||||
})
|
||||
}
|
||||
|
||||
fn set_connector_field(message: &mut Kv<'_>, key: &'static str, value: Kv<'static>) {
|
||||
let connector = connector_mut(message, 123).expect("synthetic connector");
|
||||
let Kv::Object(fields) = connector else { panic!("connector object") };
|
||||
if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) {
|
||||
*current = value;
|
||||
} else {
|
||||
fields.push((key, value));
|
||||
}
|
||||
}
|
||||
|
||||
/// A connector without a saved snapshot (a curved or elbowed one is routed here) still draws its
|
||||
/// line as the generated record says: the paint, weight, cap and dashes live there, not on the
|
||||
/// connector (#1807).
|
||||
#[test]
|
||||
fn a_routed_connector_takes_its_line_style_from_the_generated_record() {
|
||||
let mut message = synthetic_connector(1.0, false, 0.0);
|
||||
set_connector_field(&mut message, "connectorLineStyle", Kv::Enum("CURVED"));
|
||||
set_connector_field(&mut message, "connectorStart", obj(vec![("position", point(10.0, 10.0))]));
|
||||
set_connector_field(&mut message, "connectorEnd", obj(vec![("position", point(110.0, 60.0))]));
|
||||
set_override_field(&mut message, "nodeGenerationData", 0, "strokeWeight", scalar(4.0));
|
||||
set_override_field(&mut message, "nodeGenerationData", 0, "strokeCap", Kv::Enum("ROUND"));
|
||||
set_override_field(&mut message, "nodeGenerationData", 0, "dashPattern", Kv::Array(vec![scalar(16.0), scalar(10.0)]));
|
||||
let (rest, warnings, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["type"], "VECTOR");
|
||||
assert!(connector.get("vectorNetwork").is_some(), "routed here: {connector}");
|
||||
assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1), "{connector}");
|
||||
assert!((connector["strokes"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6);
|
||||
assert_eq!(connector["strokeWeight"], 4.0);
|
||||
assert_eq!(connector["strokeDashes"], serde_json::json!([16.0, 10.0]));
|
||||
assert_eq!(connector["strokeCapEnd"], "ARROW");
|
||||
assert!(warnings.iter().any(|warning| warning.contains("follow FigCraft's own routing")), "{warnings:?}");
|
||||
|
||||
let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap();
|
||||
let node = find_node(&native, "synthetic connector");
|
||||
assert_eq!((node.strokes.len(), node.stroke.weight, node.stroke.dashes.clone()), (1, 4.0, vec![16.0, 10.0]));
|
||||
}
|
||||
|
||||
/// A routed connector that writes its own empty paints and dash pattern is meant to have no stroke
|
||||
/// and a solid line: the generated record's paint and dashes don't replace them.
|
||||
#[test]
|
||||
fn a_routed_connector_keeps_its_own_empty_paints_and_dashes() {
|
||||
let mut message = synthetic_connector(1.0, false, 0.0);
|
||||
set_connector_field(&mut message, "connectorLineStyle", Kv::Enum("CURVED"));
|
||||
set_connector_field(&mut message, "connectorStart", obj(vec![("position", point(10.0, 10.0))]));
|
||||
set_connector_field(&mut message, "connectorEnd", obj(vec![("position", point(110.0, 60.0))]));
|
||||
set_connector_field(&mut message, "strokePaints", Kv::Array(Vec::new()));
|
||||
set_connector_field(&mut message, "dashPattern", Kv::Array(Vec::new()));
|
||||
set_override_field(&mut message, "nodeGenerationData", 0, "strokeWeight", scalar(4.0));
|
||||
set_override_field(&mut message, "nodeGenerationData", 0, "dashPattern", Kv::Array(vec![scalar(16.0), scalar(10.0)]));
|
||||
let (rest, _, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert!(connector.get("vectorNetwork").is_some(), "routed here: {connector}");
|
||||
assert!(connector["strokes"].as_array().is_none_or(Vec::is_empty), "{connector}");
|
||||
assert!(connector.get("strokeDashes").is_none(), "{connector}");
|
||||
assert_eq!(connector["strokeWeight"], 4.0);
|
||||
}
|
||||
|
||||
fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) {
|
||||
let connector = connector_mut(message, 123).expect("synthetic connector");
|
||||
let section = field_mut(connector, field).expect("connector override section");
|
||||
|
||||
@@ -91,7 +91,10 @@ impl<'d> Exporter<'d> {
|
||||
NodeKind::Group => f.push(("resizeToFit", b(true))),
|
||||
NodeKind::Section { contents_hidden } => f.push(("sectionContentsHidden", b(*contents_hidden))),
|
||||
NodeKind::Ellipse { start, sweep, inner } => {
|
||||
f.push(("arcData", o(vec![("startingAngle", fl(*start)), ("endingAngle", fl(start + sweep)), ("innerRadius", fl(*inner))])))
|
||||
// Within a turn, where a 32-bit angle still holds the start and the sweep exactly
|
||||
// enough; a start past a turn draws the same.
|
||||
let start = crate::rest::start_within_a_turn(*start);
|
||||
f.push(("arcData", o(vec![("startingAngle", fl(start)), ("endingAngle", fl(start + sweep)), ("innerRadius", fl(*inner))])))
|
||||
}
|
||||
NodeKind::Polygon { count } => f.push(("count", u(*count))),
|
||||
NodeKind::Star { count, inner_ratio } => f.extend([("count", u(*count)), ("starInnerScale", fl(*inner_ratio))]),
|
||||
|
||||
@@ -282,7 +282,7 @@ impl<'d> Exporter<'d> {
|
||||
if !(plain && weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9);
|
||||
let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && figcraft_doc::geom::full_sweep(sweep));
|
||||
if boxy || whole {
|
||||
let (out, inset) = match sp.align {
|
||||
StrokeAlign::Inside => (0.0, weight),
|
||||
|
||||
@@ -1592,3 +1592,46 @@ fn a_name_used_with_two_types_in_a_set_writes_both_definitions() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole ellipse that starts anywhere round comes back whole: the file holds its start and end
|
||||
/// angles as 32-bit floats, and their difference falls a hair short of a turn as often as not.
|
||||
#[test]
|
||||
fn a_whole_ellipse_starting_anywhere_comes_back_whole() {
|
||||
let tau = std::f64::consts::TAU;
|
||||
for start in [0.3, 2.0, -std::f64::consts::FRAC_PI_2, std::f64::consts::PI, 100.25] {
|
||||
for sweep in [tau, -tau] {
|
||||
let mut doc = Document::new("whole");
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.create(page, NodeKind::Ellipse { start, sweep, inner: 0.0 }, "Ellipse", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
let kinds: Vec<NodeKind> =
|
||||
back.ids().filter_map(|id| back.node(id)).map(|n| n.kind.clone()).filter(|k| matches!(k, NodeKind::Ellipse { .. })).collect();
|
||||
assert!(matches!(kinds.as_slice(), [NodeKind::Ellipse { sweep: s, .. }] if *s == sweep), "start {start}, sweep {sweep}: {kinds:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn an_ellipse_starting_many_turns_round_comes_back_the_same() {
|
||||
let tau = std::f64::consts::TAU;
|
||||
// Not all of them hold exactly as 32-bit floats, the way a start typed in degrees doesn't.
|
||||
for start in [999.0, 1500.0, -5000.0, 1e6, 1e6 + 0.1, 1234.567] {
|
||||
for sweep in [tau, -tau, 1.0, -2.5, 1.3, tau - 0.1] {
|
||||
let mut doc = Document::new("turns");
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.create(page, NodeKind::Ellipse { start, sweep, inner: 0.0 }, "Ellipse", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
let kinds: Vec<NodeKind> =
|
||||
back.ids().filter_map(|id| back.node(id)).map(|n| n.kind.clone()).filter(|k| matches!(k, NodeKind::Ellipse { .. })).collect();
|
||||
let Some(NodeKind::Ellipse { start: s, sweep: w, .. }) = kinds.first() else { panic!("start {start}: no ellipse") };
|
||||
// Where the arc starts, the same way round (as close as a 32-bit angle holds it).
|
||||
let off = (s - start).rem_euclid(tau);
|
||||
assert!(off.min(tau - off) < 1e-5, "start {start}, sweep {sweep}: came back starting at {s}");
|
||||
assert!((w - sweep).abs() < 1e-5, "start {start}, sweep {sweep}: came back sweeping {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,3 +449,34 @@ fn local_samples_agree_with_the_written_schema() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An ellipse drawn the other way round (a sweep of −2π) is whole: its inside stroke is written
|
||||
/// as the same ring a forward one's is, path and winding rule alike.
|
||||
#[test]
|
||||
fn a_whole_ellipse_drawn_backwards_carries_its_stroke_ring() {
|
||||
let ring = |sweep: f64| {
|
||||
let mut doc = sample();
|
||||
let ids: Vec<NodeId> = doc.ids().collect();
|
||||
for id in ids {
|
||||
if let Some(n) = doc.node_mut(id)
|
||||
&& let NodeKind::Ellipse { sweep: s, .. } = &mut n.kind
|
||||
{
|
||||
*s = sweep;
|
||||
}
|
||||
}
|
||||
let out = std::cell::RefCell::new(None);
|
||||
with_changes(&doc, |of, msg| {
|
||||
let blobs = msg.get("blobs").unwrap().list();
|
||||
let geometry = of("ELLIPSE").get("strokeGeometry").map(|g| {
|
||||
let p = g.list().first().unwrap().clone();
|
||||
let bytes = blobs.get(num(&p, "commandsBlob") as usize).and_then(|b| b.get("bytes")).and_then(Kv::bytes).unwrap();
|
||||
(name(&p, "windingRule").to_string(), bytes.to_vec())
|
||||
});
|
||||
*out.borrow_mut() = geometry;
|
||||
});
|
||||
out.into_inner()
|
||||
};
|
||||
let forwards = ring(std::f64::consts::TAU).expect("a forward ellipse writes its stroke ring");
|
||||
assert_eq!(forwards.0, "ODD");
|
||||
assert_eq!(ring(-std::f64::consts::TAU), Some(forwards));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! Everything here reads untrusted bytes: reads are bounds-checked, nesting is capped, array
|
||||
//! counts can't exceed the bytes left, and the total number of decoded values is capped.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
/// Deepest struct/message nesting decoded.
|
||||
@@ -32,6 +33,9 @@ const MAX_VALUES: usize = 32 << 20;
|
||||
/// Most definitions in a schema, and fields in one definition.
|
||||
const MAX_DEFS: usize = 10_000;
|
||||
const MAX_FIELDS: usize = 10_000;
|
||||
/// Most fields in a schema, every definition together: Figma's own schema has a few thousand, and
|
||||
/// without a total a schema of [`MAX_DEFS`] wide definitions could hold 100 million field records.
|
||||
const MAX_SCHEMA_FIELDS: usize = 200_000;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct KiwiError(pub String);
|
||||
@@ -252,6 +256,7 @@ impl Schema {
|
||||
}
|
||||
let mut defs = Vec::with_capacity(count);
|
||||
let mut raw_types = Vec::new();
|
||||
let mut total_fields = 0usize;
|
||||
for _ in 0..count {
|
||||
let name = r.string()?;
|
||||
let kind = match r.byte()? {
|
||||
@@ -261,7 +266,8 @@ impl Schema {
|
||||
k => return err(format!("schema: unknown definition kind {k}")),
|
||||
};
|
||||
let n = r.var_uint()? as usize;
|
||||
if n > MAX_FIELDS || n > r.left() {
|
||||
total_fields = total_fields.saturating_add(n);
|
||||
if n > MAX_FIELDS || n > r.left() || total_fields > MAX_SCHEMA_FIELDS {
|
||||
return err("schema: too many fields");
|
||||
}
|
||||
let mut fields = Vec::with_capacity(n);
|
||||
@@ -308,7 +314,7 @@ impl Schema {
|
||||
|
||||
/// [`Self::decode_message`] within `budget` values (see [`value_budget`]).
|
||||
pub fn decode_message_within<'s>(&'s self, root: usize, data: &[u8], budget: usize) -> Result<Kv<'s>, KiwiError> {
|
||||
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget };
|
||||
let mut d = Decoder { schema: self, r: Reader { data, pos: 0 }, budget, ids: vec![None; self.defs.len()] };
|
||||
d.def(root, 0)
|
||||
}
|
||||
}
|
||||
@@ -322,6 +328,10 @@ struct Decoder<'s, 'b> {
|
||||
schema: &'s Schema,
|
||||
r: Reader<'b>,
|
||||
budget: usize,
|
||||
/// Per definition, the index of the first field with each id (message field ids, enum
|
||||
/// values), built the first time the definition is decoded: a message can carry many tags
|
||||
/// against a wide definition, and a scan per tag would multiply the two.
|
||||
ids: Vec<Option<HashMap<u32, usize>>>,
|
||||
}
|
||||
|
||||
impl<'s> Decoder<'s, '_> {
|
||||
@@ -330,6 +340,19 @@ impl<'s> Decoder<'s, '_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The first field of definition `i` with id (or enum value) `id`.
|
||||
fn field_by_id(&mut self, i: usize, id: u32) -> Option<&'s Field> {
|
||||
let fields = &self.schema.defs.get(i)?.fields;
|
||||
let ids = self.ids.get_mut(i)?.get_or_insert_with(|| {
|
||||
let mut m = HashMap::with_capacity(fields.len());
|
||||
for (j, f) in fields.iter().enumerate() {
|
||||
m.entry(f.value).or_insert(j);
|
||||
}
|
||||
m
|
||||
});
|
||||
fields.get(*ids.get(&id)?)
|
||||
}
|
||||
|
||||
fn def(&mut self, i: usize, depth: usize) -> Result<Kv<'s>, KiwiError> {
|
||||
if depth > MAX_DEPTH {
|
||||
return err("message: nested too deeply");
|
||||
@@ -340,7 +363,7 @@ impl<'s> Decoder<'s, '_> {
|
||||
match d.kind {
|
||||
Kind::Enum => {
|
||||
let v = self.r.var_uint()?;
|
||||
Ok(d.fields.iter().find(|f| f.value == v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str())))
|
||||
Ok(self.field_by_id(i, v).map_or(Kv::Uint(u64::from(v)), |f| Kv::Enum(f.name.as_str())))
|
||||
}
|
||||
Kind::Struct => {
|
||||
let mut out = Vec::with_capacity(d.fields.len());
|
||||
@@ -358,7 +381,7 @@ impl<'s> Decoder<'s, '_> {
|
||||
out.shrink_to_fit();
|
||||
return Ok(Kv::Object(out));
|
||||
}
|
||||
let f = d.fields.iter().find(|f| f.value == id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
|
||||
let f = self.field_by_id(i, id).ok_or_else(|| KiwiError(format!("message: {} has no field {id}", d.name)))?;
|
||||
out.push((f.name.as_str(), self.field(f, depth)?));
|
||||
}
|
||||
}
|
||||
@@ -379,8 +402,9 @@ impl<'s> Decoder<'s, '_> {
|
||||
if n > self.r.left() {
|
||||
return err("message: array longer than the data");
|
||||
}
|
||||
// The field itself counts, so empty arrays can't pile up for free.
|
||||
self.spend(1)?;
|
||||
if ty == Type::Byte {
|
||||
self.spend(1)?;
|
||||
return Ok(Kv::Bytes(self.r.take(n)?.to_vec()));
|
||||
}
|
||||
self.spend(n)?;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::layer::{cap_name, own_text, svg_path, valid_size};
|
||||
use super::paint::{paint, stroke};
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows};
|
||||
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
|
||||
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
|
||||
@@ -29,6 +30,7 @@ impl<'a> Mapper<'a> {
|
||||
return Vec::new();
|
||||
}
|
||||
let mid = self.connector(o, v, space);
|
||||
self.connector_line_style(o, v);
|
||||
if shape.label {
|
||||
return self.connector_label(o, v, rest_id, mid);
|
||||
}
|
||||
@@ -122,6 +124,30 @@ impl<'a> Mapper<'a> {
|
||||
middle(&path).map(|m| m - b.origin().to_vec2())
|
||||
}
|
||||
|
||||
/// The line's paint, weight, cap and dashes, which a connector keeps in its generated line
|
||||
/// record rather than on itself: without a saved snapshot the routed line took none of them and
|
||||
/// drew nothing (#1807). The connector's own values, when it has any, win.
|
||||
fn connector_line_style(&mut self, o: &mut Obj, v: &View<'a>) {
|
||||
let Some(line_style) = generated_record(v, "nodeGenerationData", 0) else { return };
|
||||
let style = View::of(line_style);
|
||||
// A connector that writes its own (even empty) paints or dash pattern keeps them: an empty
|
||||
// list is no stroke, or a solid line, not a missing value.
|
||||
if v.get("strokePaints").is_none() && o.get("strokes").and_then(Value::as_array).is_none_or(Vec::is_empty) {
|
||||
let strokes: Vec<Value> = style.list("strokePaints").iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect();
|
||||
if !strokes.is_empty() {
|
||||
o.insert("strokes".into(), Value::Array(strokes));
|
||||
}
|
||||
}
|
||||
let mut line = Obj::new();
|
||||
stroke(&mut line, &style);
|
||||
if v.get("dashPattern").is_some() {
|
||||
line.remove("strokeDashes");
|
||||
}
|
||||
for (key, value) in line {
|
||||
o.entry(key).or_insert(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// A connector with a label but no saved snapshot of it, as the frame a saved one imports as:
|
||||
/// `o` keeps the connector's place and size and gives its line to a vector child, and the
|
||||
/// label is a text child centred on `mid` (the line's middle; the frame's centre when the line
|
||||
|
||||
@@ -1179,6 +1179,60 @@ fn scalar_fields_count_against_the_value_budget() {
|
||||
assert!(sc.decode_message_within(0, &data, 1001).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_arrays_count_against_the_value_budget() {
|
||||
// 1000 empty `uint[]` fields: each field is a value even with no elements, so a small budget
|
||||
// can't be outgrown by piling up empty containers.
|
||||
let fields = vec![Field { name: "a".into(), ty: Some(Type::Uint), array: true, value: 1 }];
|
||||
let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] };
|
||||
let mut data = [1u8, 0].repeat(1000);
|
||||
data.push(0);
|
||||
let e = sc.decode_message_within(0, &data, 10).unwrap_err();
|
||||
assert_eq!(e.0, "message: too many values");
|
||||
assert!(sc.decode_message_within(0, &data, 1001).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_tags_find_their_field_by_id_in_a_wide_definition() {
|
||||
// A wide definition: every tag is looked up by id, and the first field with an id wins, as
|
||||
// before the lookup was indexed.
|
||||
let mut fields: Vec<Field> = (1..=5000).map(|i| Field { name: format!("f{i}"), ty: Some(Type::Bool), array: false, value: i }).collect();
|
||||
fields.push(Field { name: "shadow".into(), ty: Some(Type::Bool), array: false, value: 7 });
|
||||
let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] };
|
||||
let mut w = encode::Writer::default();
|
||||
for _ in 0..20_000 {
|
||||
w.var_uint(5000);
|
||||
w.byte(1);
|
||||
}
|
||||
w.var_uint(7);
|
||||
w.byte(0);
|
||||
w.var_uint(0);
|
||||
let Kv::Object(out) = sc.decode_message(0, &w.0).unwrap() else { panic!() };
|
||||
assert_eq!(out.len(), 20_001);
|
||||
assert!(out.iter().take(20_000).all(|(n, v)| *n == "f5000" && *v == Kv::Bool(true)));
|
||||
assert_eq!(out.last().map(|(n, _)| *n), Some("f7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schema_is_refused_past_its_total_field_count() {
|
||||
// 25 definitions of 10 000 fields each: every one is within the per-definition cap, but
|
||||
// together they pass the schema's total.
|
||||
let mut w = encode::Writer::default();
|
||||
w.var_uint(25);
|
||||
for d in 0..25 {
|
||||
w.string(&format!("D{d}"));
|
||||
w.byte(0);
|
||||
w.var_uint(10_000);
|
||||
for i in 0..10_000u32 {
|
||||
w.string("");
|
||||
w.var_int(0);
|
||||
w.byte(0);
|
||||
w.var_uint(i);
|
||||
}
|
||||
}
|
||||
assert_eq!(Schema::decode(&w.0).unwrap_err().0, "schema: too many fields");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_refused() {
|
||||
// A message that contains itself, nested past the depth cap.
|
||||
|
||||
+78
-14
@@ -2030,27 +2030,51 @@ fn token_theme_groups<'a>(value: &'a Value, tokens: &BTreeMap<&str, BTreeMap<Str
|
||||
}
|
||||
|
||||
/// Drops the resolved tokens that alias a variable which isn't being made, until none does; how
|
||||
/// many were dropped.
|
||||
/// many were dropped. Each token is looked at once to start with, and again only when a variable
|
||||
/// it aliases goes: a chain of aliases dropped one link per round would otherwise rescan every
|
||||
/// token per link.
|
||||
fn drop_dangling_aliases(
|
||||
resolved: &mut BTreeMap<&str, (usize, VariableType, BTreeMap<String, VariableValue>)>,
|
||||
var_id: &dyn Fn(&str) -> String,
|
||||
) -> usize {
|
||||
let mut dropped = 0;
|
||||
loop {
|
||||
let ids: BTreeSet<String> = resolved.keys().map(|p| var_id(p)).collect();
|
||||
let dangling: Vec<&str> = resolved
|
||||
.iter()
|
||||
.filter(|(_, (_, _, values))| values.values().any(|v| matches!(v, VariableValue::Alias(target) if !ids.contains(target))))
|
||||
.map(|(path, _)| *path)
|
||||
.collect();
|
||||
if dangling.is_empty() {
|
||||
return dropped;
|
||||
// How many tokens make each variable id, and the tokens aliasing each id.
|
||||
let mut makers: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for path in resolved.keys() {
|
||||
*makers.entry(var_id(path)).or_default() += 1;
|
||||
}
|
||||
let mut aliased_by: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
let mut gone: Vec<&str> = Vec::new();
|
||||
for (path, (_, _, values)) in resolved.iter() {
|
||||
let mut dangling = false;
|
||||
for v in values.values() {
|
||||
if let VariableValue::Alias(target) = v {
|
||||
dangling |= !makers.contains_key(target);
|
||||
aliased_by.entry(target.as_str()).or_default().push(*path);
|
||||
}
|
||||
}
|
||||
dropped += dangling.len();
|
||||
for path in dangling {
|
||||
resolved.remove(path);
|
||||
if dangling {
|
||||
gone.push(*path);
|
||||
}
|
||||
}
|
||||
let mut dropped: BTreeSet<&str> = gone.iter().copied().collect();
|
||||
while let Some(path) = gone.pop() {
|
||||
let id = var_id(path);
|
||||
let Some(left) = makers.get_mut(&id) else { continue };
|
||||
*left -= 1;
|
||||
if *left > 0 {
|
||||
continue;
|
||||
}
|
||||
makers.remove(&id);
|
||||
for &by in aliased_by.get(id.as_str()).into_iter().flatten() {
|
||||
if dropped.insert(by) {
|
||||
gone.push(by);
|
||||
}
|
||||
}
|
||||
}
|
||||
for path in &dropped {
|
||||
resolved.remove(path);
|
||||
}
|
||||
dropped.len()
|
||||
}
|
||||
|
||||
/// Moves a base token whose expression refers to a themed token into that token's collection, so
|
||||
@@ -6515,3 +6539,43 @@ mod tests {
|
||||
assert_eq!(button.interactions.len(), 1, "the prototype link survives the round trip");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dangling_alias_tests {
|
||||
use super::*;
|
||||
|
||||
type Resolved<'a> = BTreeMap<&'a str, (usize, VariableType, BTreeMap<String, VariableValue>)>;
|
||||
|
||||
fn token(alias: Option<&str>) -> (usize, VariableType, BTreeMap<String, VariableValue>) {
|
||||
let value = alias.map_or(VariableValue::Number(1.0), |a| VariableValue::Alias(format!("v:{a}")));
|
||||
(0, VariableType::Number, [("m".to_string(), value)].into())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_ending_in_a_missing_variable_is_dropped_whole() {
|
||||
// t0 → t1 → … → t1999 → missing: every link goes, in one pass along the chain.
|
||||
let names: Vec<String> = (0..2000).map(|i| format!("t{i}")).collect();
|
||||
let mut resolved: Resolved = BTreeMap::new();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
let next = names.get(i + 1).map_or("missing", String::as_str);
|
||||
resolved.insert(n, token(Some(next)));
|
||||
}
|
||||
resolved.insert("plain", token(None));
|
||||
resolved.insert("to-plain", token(Some("plain")));
|
||||
let var_id = |p: &str| format!("v:{p}");
|
||||
assert_eq!(drop_dangling_aliases(&mut resolved, &var_id), 2000);
|
||||
assert_eq!(resolved.keys().copied().collect::<Vec<_>>(), ["plain", "to-plain"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_made_by_two_tokens_stays_while_one_does() {
|
||||
// `a` and `b` both make `v:x`; `a` dangles, `b` doesn't, so `c` (aliasing `v:x`) stays.
|
||||
let mut resolved: Resolved = BTreeMap::new();
|
||||
resolved.insert("a", token(Some("missing")));
|
||||
resolved.insert("b", token(None));
|
||||
resolved.insert("c", token(Some("x")));
|
||||
let var_id = |p: &str| if p == "a" || p == "b" { "v:x".to_string() } else { format!("v:{p}") };
|
||||
assert_eq!(drop_dangling_aliases(&mut resolved, &var_id), 1);
|
||||
assert_eq!(resolved.keys().copied().collect::<Vec<_>>(), ["b", "c"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,10 +626,27 @@ impl<'a> Ctx<'a> {
|
||||
"ELLIPSE" => {
|
||||
let a = j.get("arcData");
|
||||
let arc = |k: &str| a.and_then(|a| num(a, k));
|
||||
let start = arc("startingAngle").unwrap_or(0.0).clamp(-1e3, 1e3);
|
||||
let end = arc("endingAngle").map_or(start + std::f64::consts::TAU, |e| e.clamp(-1e3, 1e3));
|
||||
let tau = std::f64::consts::TAU;
|
||||
NodeKind::Ellipse { start, sweep: (end - start).clamp(-tau, tau), inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0) }
|
||||
// Capped only so the difference stays finite: the sweep is the difference of the
|
||||
// angles as written, wherever they lie.
|
||||
let start = arc("startingAngle").unwrap_or(0.0).clamp(-1e15, 1e15);
|
||||
// No end goes all the way round, however far out the start is.
|
||||
let end = arc("endingAngle").map(|e| e.clamp(-1e15, 1e15));
|
||||
// The angles are 32-bit floats: a whole turn from anywhere but zero comes out a
|
||||
// hair short of 2π as often as not. Within their precision of a turn, it is one.
|
||||
// Far enough out (thousands of radians) that precision is too coarse to tell a
|
||||
// whole turn from a nearly whole arc, the sweep is kept as written.
|
||||
let sweep = end.map_or(tau, |end| {
|
||||
let precision = (start.abs() + end.abs() + tau) * f64::from(f32::EPSILON);
|
||||
let sweep = end - start;
|
||||
let whole = precision <= WHOLE_TURN_SLACK && sweep.abs() >= tau - precision;
|
||||
if whole { tau.copysign(sweep) } else { sweep }
|
||||
});
|
||||
NodeKind::Ellipse {
|
||||
start: start_within_a_turn(start),
|
||||
sweep: sweep.clamp(-tau, tau),
|
||||
inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
// Point counts aren't part of the documented JSON: the outline is exact when present.
|
||||
"REGULAR_POLYGON" | "STAR" if point_count().is_none() && has_geometry => NodeKind::Vector { network: VectorNetwork::default() },
|
||||
@@ -1504,3 +1521,22 @@ pub fn missing_image_placeholder() -> Option<&'static [u8]> {
|
||||
pub fn is_missing_image(data: &ImageData) -> bool {
|
||||
missing_image_placeholder().is_some_and(|png| data.bytes.as_slice() == png)
|
||||
}
|
||||
|
||||
/// The most a sweep may fall short of a turn and still be read as a whole one (radians, about
|
||||
/// 0.06°): far more than 32-bit angles round by within a few hundred turns of zero, and far less
|
||||
/// than any arc anyone draws.
|
||||
const WHOLE_TURN_SLACK: f64 = 1e-3;
|
||||
|
||||
/// An ellipse's start brought within a turn, drawn the same: a start already within one is kept
|
||||
/// as it is, one past a turn comes to [0, 2π). It is reduced the way the renderer's sine and
|
||||
/// cosine see it, exactly, rather than by dividing by a rounded 2π, which drifts a little with
|
||||
/// every turn.
|
||||
pub(crate) fn start_within_a_turn(start: f64) -> f64 {
|
||||
let tau = std::f64::consts::TAU;
|
||||
if !start.is_finite() || start.abs() < tau {
|
||||
return start;
|
||||
}
|
||||
// `rem_euclid` rounds a hair below zero up to 2π itself.
|
||||
let within = start.sin().atan2(start.cos()).rem_euclid(tau);
|
||||
if within < tau { within } else { 0.0 }
|
||||
}
|
||||
|
||||
@@ -756,3 +756,44 @@ fn the_fig_import_sets_its_own_layer_cap() {
|
||||
assert_eq!(doc.node_count(), 5);
|
||||
assert!(warnings.iter().any(|w| w.contains("more than 4 layers")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// Angles written many turns round keep the arc they draw: the sweep is the difference as
|
||||
/// written, and the start comes within a turn of zero, the same way round.
|
||||
#[test]
|
||||
fn an_arc_written_many_turns_round_keeps_its_sweep() {
|
||||
let tau = std::f64::consts::TAU;
|
||||
for (start, end) in [
|
||||
(999.0, 999.0 + tau),
|
||||
(1500.0, 1501.0),
|
||||
(-5000.0, -5000.0 - 2.5),
|
||||
(1e6, 1e6 + tau),
|
||||
(1e6, 1e6 + 5.9),
|
||||
(1e6, 1e6 + 6.1),
|
||||
(3e7, 3e7),
|
||||
(-4e5, -4e5 + tau - 0.01),
|
||||
(-182.212373908208, -182.212373908208 + tau),
|
||||
] {
|
||||
let mut arc = json!({"id": "Arc", "name": "Arc", "type": "ELLIPSE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}});
|
||||
arc["arcData"] = json!({"startingAngle": start, "endingAngle": end, "innerRadius": 0});
|
||||
let (doc, _) = one(json!([arc]));
|
||||
let NodeKind::Ellipse { start: s, sweep, .. } = named(&doc, "Arc").kind else { panic!("{start}: not an ellipse") };
|
||||
let want = end - start;
|
||||
let want = if (want.abs() - tau).abs() < 1e-6 { tau.copysign(want) } else { want };
|
||||
assert!((sweep - want).abs() < 1e-9, "{start}..{end}: sweep {sweep}, not {want}");
|
||||
assert!(s.abs() < tau, "{start}: start {s} left past a turn");
|
||||
let off = (s - start).rem_euclid(tau);
|
||||
assert!(off.min(tau - off) < 1e-6, "{start}: start {s} isn't the same way round");
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc with a start but no end goes all the way round, however far out the start is.
|
||||
#[test]
|
||||
fn an_arc_without_an_end_is_whole_wherever_it_starts() {
|
||||
for start in [0.0, 2.0, -5000.0, 1e8, 1e15] {
|
||||
let mut arc = json!({"id": "Arc", "name": "Arc", "type": "ELLIPSE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}});
|
||||
arc["arcData"] = json!({"startingAngle": start, "innerRadius": 0});
|
||||
let (doc, _) = one(json!([arc]));
|
||||
let NodeKind::Ellipse { sweep, .. } = named(&doc, "Arc").kind else { panic!("{start}: not an ellipse") };
|
||||
assert_eq!(sweep, std::f64::consts::TAU, "start {start}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +319,12 @@ fn line_or_move(path: &mut BezPath, p: Point) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an ellipse of this `sweep` (radians) goes all the way round: a whole ellipse or ring,
|
||||
/// without the sharp corners where an arc's ends meet its sides.
|
||||
pub fn full_sweep(sweep: f64) -> bool {
|
||||
finite(sweep).abs() >= TAU - 1e-9
|
||||
}
|
||||
|
||||
/// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in
|
||||
/// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1.
|
||||
pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath {
|
||||
@@ -327,7 +333,7 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
|
||||
let sweep = finite(sweep).clamp(-TAU, TAU);
|
||||
let start = finite(start);
|
||||
let inner = finite(inner).clamp(0.0, 1.0);
|
||||
let full = sweep.abs() >= TAU - 1e-9;
|
||||
let full = full_sweep(sweep);
|
||||
let mut path = BezPath::new();
|
||||
if full && inner <= 0.0 {
|
||||
return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01);
|
||||
|
||||
+35
-13
@@ -216,8 +216,9 @@ fn coherent_own_sizing(doc: &mut Document, id: NodeId) -> bool {
|
||||
|
||||
/// Padding `(top, right, bottom, left)` of an auto layout frame, sanitised, plus the frame's own
|
||||
/// inside stroke (per side) when strokes are included in the layout; center and outside strokes
|
||||
/// never take up space.
|
||||
fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) {
|
||||
/// never take up space. The frame's size less these is its children's room, the "full available
|
||||
/// space" a child resized to it fills.
|
||||
pub fn insets(f: &figcraft_doc::Node, al: &AutoLayout) -> (f64, f64, f64, f64) {
|
||||
let included = al.strokes_included && f.has_visible_stroke() && f.stroke.align == StrokeAlign::Inside;
|
||||
let stroke = if included { f.stroke.sides.unwrap_or([f.stroke.weight; 4]) } else { [0.0; 4] };
|
||||
let [pt, pr, pb, pl] = al.padding.map(|p| finite_or_zero(p).max(0.0));
|
||||
@@ -327,6 +328,10 @@ struct Item {
|
||||
max_main: Option<f64>,
|
||||
min_cross: Option<f64>,
|
||||
max_cross: Option<f64>,
|
||||
/// The item's own insets along the main axis (an auto layout frame's padding and included
|
||||
/// inside stroke): fill children share the free space by their content area, border-box
|
||||
/// fashion, so these come on top of each one's share.
|
||||
inset_main: f64,
|
||||
/// Baseline alignment: distance from the item's top to its first baseline.
|
||||
baseline: Option<f64>,
|
||||
/// Baseline alignment: offset from the line's top (computed per line).
|
||||
@@ -414,6 +419,10 @@ fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool,
|
||||
let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
|
||||
let (min_main, max_main, min_cross, max_cross) =
|
||||
if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
|
||||
let inset_main = node.layout.as_ref().map_or(0.0, |al| {
|
||||
let (pt, pr, pb, pl) = insets(node, al);
|
||||
if horizontal { pl + pr } else { pt + pb }
|
||||
});
|
||||
items.push(Item {
|
||||
id: *child,
|
||||
main: if horizontal { w } else { h },
|
||||
@@ -425,6 +434,7 @@ fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool,
|
||||
max_main,
|
||||
min_cross,
|
||||
max_cross,
|
||||
inset_main,
|
||||
baseline: None,
|
||||
base_off: 0.0,
|
||||
});
|
||||
@@ -596,19 +606,31 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Share `free` main-axis space among the fill items `open` like CSS flexbox: each takes an equal
|
||||
/// share within its min/max; when clamping adds more than it removes, the items held at their min
|
||||
/// keep it (otherwise those held at their max do) and the rest share what is left again.
|
||||
/// Share `free` among the fill items `open` by their content areas: each one's own insets (an
|
||||
/// auto layout frame's padding and included inside stroke) come off the top and go back on its
|
||||
/// share, so a child with more padding or a thicker inside stroke takes a little more and the
|
||||
/// content areas come out the same, as in the CSS border-box model ("Figma distributes space
|
||||
/// amongst fill container children by the children's content area instead of by their size").
|
||||
/// Min and max sizes still clamp each item's outer size; the clamped ones are frozen and the rest
|
||||
/// share what is left, as before.
|
||||
fn distribute(items: &mut [Item], mut open: Vec<usize>, free: f64) {
|
||||
let mut free = free.max(0.0);
|
||||
while !open.is_empty() {
|
||||
let share = free / open.len() as f64;
|
||||
let sized: Vec<(usize, f64)> =
|
||||
open.iter().filter_map(|i| items.get(*i).map(|it| (*i, clamp_size(share, it.min_main, it.max_main)))).collect();
|
||||
let violation: f64 = sized.iter().map(|(_, v)| v - share).sum();
|
||||
let frozen = |v: f64| violation.abs() < 1e-9 || (violation > 0.0 && v > share + 1e-9) || (violation < 0.0 && v < share - 1e-9);
|
||||
for (i, v) in &sized {
|
||||
if frozen(*v)
|
||||
let insets: f64 = open.iter().filter_map(|i| items.get(*i)).map(|it| it.inset_main).sum();
|
||||
let content_share = ((free - insets) / open.len() as f64).max(0.0);
|
||||
let sized: Vec<(usize, f64, f64)> = open
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
items.get(*i).map(|it| {
|
||||
let want = content_share + it.inset_main;
|
||||
(*i, want, clamp_size(want, it.min_main, it.max_main))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let violation: f64 = sized.iter().map(|(_, want, v)| v - want).sum();
|
||||
let frozen = |want: f64, v: f64| violation.abs() < 1e-9 || (violation > 0.0 && v > want + 1e-9) || (violation < 0.0 && v < want - 1e-9);
|
||||
for (i, want, v) in &sized {
|
||||
if frozen(*want, *v)
|
||||
&& let Some(it) = items.get_mut(*i)
|
||||
{
|
||||
it.main = *v;
|
||||
@@ -616,7 +638,7 @@ fn distribute(items: &mut [Item], mut open: Vec<usize>, free: f64) {
|
||||
}
|
||||
}
|
||||
free = free.max(0.0);
|
||||
open = sized.into_iter().filter(|(_, v)| !frozen(*v)).map(|(i, _)| i).collect();
|
||||
open = sized.into_iter().filter(|(_, want, v)| !frozen(*want, *v)).map(|(i, _, _)| i).collect();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -287,6 +287,48 @@ fn padding_and_included_strokes() {
|
||||
assert_eq!(pos(&d, c), (15.0, 15.0, 70.0, 20.0));
|
||||
}
|
||||
|
||||
/// "Figma distributes space amongst fill container children by the children's content area
|
||||
/// instead of by their size, matching the CSS border-box model. A layer with a thicker stroke will
|
||||
/// take up slightly more of the available width or height, so that its inner content area matches
|
||||
/// its sibling's." (Use auto layout with CSS Flexbox in mind)
|
||||
#[test]
|
||||
fn fill_children_share_by_content_area() {
|
||||
let mut d = Document::new("t");
|
||||
let p = page(&d);
|
||||
let f = al(&mut d, p, LayoutMode::Horizontal, (200.0, 50.0), (Sizing::Fixed, Sizing::Fixed));
|
||||
layout_mut(&mut d, f).gap = 0.0;
|
||||
let a = al(&mut d, f, LayoutMode::Horizontal, (10.0, 50.0), (Sizing::Fill, Sizing::Fixed));
|
||||
let b = al(&mut d, f, LayoutMode::Horizontal, (10.0, 50.0), (Sizing::Fill, Sizing::Fixed));
|
||||
layout_mut(&mut d, b).padding = [0.0, 20.0, 0.0, 20.0];
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
// 200 less b's 40 of padding leaves 160 of content, 80 each: a is 80 wide, b 120.
|
||||
assert_eq!((size(&d, a).0, size(&d, b).0), (80.0, 120.0));
|
||||
assert_eq!(pos(&d, b).0, 80.0);
|
||||
// A 10 px inside stroke counted in b's layout instead: 90 and 110.
|
||||
layout_mut(&mut d, b).padding = [0.0; 4];
|
||||
{
|
||||
let n = d.get_mut(b).unwrap();
|
||||
n.strokes = vec![figcraft_doc::color::Paint::solid(figcraft_doc::color::Color::BLACK)];
|
||||
n.stroke.weight = 10.0;
|
||||
n.stroke.align = StrokeAlign::Inside;
|
||||
}
|
||||
layout_mut(&mut d, b).strokes_included = true;
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
assert_eq!((size(&d, a).0, size(&d, b).0), (90.0, 110.0));
|
||||
// A stroke not counted in the layout is no inset: an even split, as before.
|
||||
layout_mut(&mut d, b).strokes_included = false;
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
assert_eq!((size(&d, a).0, size(&d, b).0), (100.0, 100.0));
|
||||
// Equal insets: still even. A max on a freezes it there and b takes the rest, insets kept.
|
||||
layout_mut(&mut d, a).padding = [0.0, 20.0, 0.0, 20.0];
|
||||
layout_mut(&mut d, b).padding = [0.0, 20.0, 0.0, 20.0];
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
assert_eq!((size(&d, a).0, size(&d, b).0), (100.0, 100.0));
|
||||
d.get_mut(a).unwrap().child_layout.max_w = Some(60.0);
|
||||
layout_frame(&mut d, f, &NoMeasure);
|
||||
assert_eq!((size(&d, a).0, size(&d, b).0), (60.0, 140.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_hug_tracks_and_wrapping_text_rows() {
|
||||
use figcraft_doc::GridTrack;
|
||||
|
||||
@@ -131,7 +131,9 @@ pub fn outline(path: &BezPath, width: f64, props: &StrokeProps, brush: &Brush) -
|
||||
let p = preset(&brush.preset)?;
|
||||
let unit = shape(p.id)?;
|
||||
let lines = polylines(path)?;
|
||||
let scale = |t: f64| width * props.profile().map_or(1.0, |pr| pr.scale_at(t));
|
||||
// Sanitized once: at most 64 points, so each sample's symmetry scan stays cheap.
|
||||
let profile = props.profile().map(|pr| pr.sanitized());
|
||||
let scale = |t: f64| width * profile.as_ref().map_or(1.0, |pr| pr.scale_at(t));
|
||||
let mut out = BezPath::new();
|
||||
match p.kind {
|
||||
BrushKind::Stretch => {
|
||||
|
||||
@@ -72,3 +72,14 @@ fn a_width_profile_scales_the_brush() {
|
||||
let (first, last) = (parts.first().unwrap().bounding_box(), parts.last().unwrap().bounding_box());
|
||||
assert!(last.width() > first.width() * 3.0, "{first:?} {last:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsanitized_profile_is_bounded_before_sampling() {
|
||||
// 100k points, all past the 64-point cap and out of range: the brush reads the clamped profile.
|
||||
let raw = WidthProfile { points: (0..100_000).map(|i| figcraft_doc::WidthPoint::new(f64::from(i), 1e300)).collect() };
|
||||
let b = brush("taper");
|
||||
let mut p = props(1.0, &b);
|
||||
p.width_profile = Some(raw);
|
||||
let out = outline(&line(50.0), 1.0, &p, &b).unwrap();
|
||||
assert!(out.bounding_box().height() <= 2.0 * WidthProfile::MAX_SCALE);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ fn api_from_number_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u
|
||||
.trim()
|
||||
.split('.')
|
||||
.next()
|
||||
.filter(|major| !major.is_empty() && major.bytes().all(|b| b.is_ascii_digit()))
|
||||
.and_then(|major| major.parse().ok())
|
||||
.ok_or_else(|| serde::de::Error::custom(format!("invalid api version `{s}`"))),
|
||||
}
|
||||
|
||||
@@ -472,3 +472,10 @@ fn api_may_be_a_version_string() {
|
||||
assert!(manifest(json!({"api": format!("{}.0.0", crate::API_VERSION + 1)})).is_err());
|
||||
assert!(manifest(json!({"api": "x"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_version_string_rejects_signs() {
|
||||
for bad in ["+2.0.0", "-1.0.0", "+2", ""] {
|
||||
assert!(manifest(json!({"api": bad})).is_err(), "accepted {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
p.apply_affine(Affine::translate((x0, y0)));
|
||||
Some(p)
|
||||
}
|
||||
NodeKind::Ellipse { sweep, inner, .. } if sweep.abs() >= std::f64::consts::TAU - 1e-9 && inner == 0.0 => {
|
||||
NodeKind::Ellipse { sweep, inner, .. } if figcraft_geom::full_sweep(sweep) && inner == 0.0 => {
|
||||
Some(figcraft_geom::kurbo::Ellipse::from_rect(Rect::new(x0, y0, x0 + ww, y0 + hh)).to_path(0.05))
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@@ -44,3 +44,23 @@ fn outside_bevel_join_cuts_outer_corner() {
|
||||
fn outside_miter_join_keeps_square_corner() {
|
||||
assert_ne!(covered(StrokeJoin::Miter), 0);
|
||||
}
|
||||
|
||||
/// An ellipse drawn the other way round (a sweep of −2π) is whole: its inside stroke is the same
|
||||
/// ring of two outlines as a forward one's.
|
||||
#[test]
|
||||
fn a_whole_ellipse_drawn_backwards_strokes_as_a_ring() {
|
||||
let ring = |sweep: f64| {
|
||||
let mut d = Document::new("ring");
|
||||
let p = d.first_page().unwrap();
|
||||
let id = d.create(p, NodeKind::Ellipse { start: 0.0, sweep, inner: 0.0 }, "E", 0.0, 0.0, 60.0, 40.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.strokes = vec![Paint::solid(Color::BLACK)];
|
||||
(n.stroke.weight, n.stroke.align) = (6.0, StrokeAlign::Inside);
|
||||
let outline: BezPath = figcraft_geom::kurbo::Ellipse::from_rect(figcraft_geom::kurbo::Rect::new(0.0, 0.0, 60.0, 40.0)).to_path(0.1);
|
||||
(*crate::draw::stroke_area(d.get(id).unwrap(), &outline, true)).clone()
|
||||
};
|
||||
let forwards = ring(std::f64::consts::TAU);
|
||||
let moves = forwards.elements().iter().filter(|e| matches!(e, figcraft_geom::kurbo::PathEl::MoveTo(_))).count();
|
||||
assert_eq!(moves, 2, "two outlines: {forwards:?}");
|
||||
assert_eq!(ring(-std::f64::consts::TAU), forwards);
|
||||
}
|
||||
|
||||
@@ -79,11 +79,13 @@ fn diamond_pixmap(g: &Gradient, opacity: f32) -> Option<Arc<Pixmap>> {
|
||||
const N: u32 = 256;
|
||||
let mut p = Pixmap::new(N, N)?;
|
||||
let half = f64::from(N) / 2.0;
|
||||
// Sorted once for the 65 536 samples below, not once per sample.
|
||||
let stops = g.sorted_stops();
|
||||
for (i, px) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
let x = ((i as u32 % N) as f64 + 0.5 - half) / half;
|
||||
let y = ((i as u32 / N) as f64 + 0.5 - half) / half;
|
||||
let t = (x.abs() + y.abs()) as f32;
|
||||
let c = g.sample(t);
|
||||
let c = figcraft_color::sample_sorted(&stops, t);
|
||||
let a = c.a * opacity;
|
||||
let q = |v: f32| (v * a * 255.0).round().clamp(0.0, 255.0) as u8;
|
||||
px.copy_from_slice(&[q(c.r), q(c.g), q(c.b), (a * 255.0).round().clamp(0.0, 255.0) as u8]);
|
||||
|
||||
@@ -142,3 +142,53 @@ fn spread_bands_are_cached_and_hostile_values_are_safe() {
|
||||
let _ = render(&d, n);
|
||||
}
|
||||
}
|
||||
|
||||
/// An arc (a pie slice, or a ring cut short) has sharp corners where its ends meet its sides, and
|
||||
/// a spread mitres them out past the spread: its layer makes room for that, as a star's does. A
|
||||
/// clear shadow spread four times as far gives the same layer room to the full mitre: the shadow
|
||||
/// it casts is the same. (Both layers end at the view here, so they line up pixel for pixel;
|
||||
/// layers starting at different pixels can differ by a few levels at their edges.)
|
||||
#[test]
|
||||
fn arc_spread_mitres_are_not_cut_off() {
|
||||
for kind in [
|
||||
NodeKind::Ellipse { start: 1.0, sweep: 0.3, inner: 0.0 },
|
||||
NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.0 },
|
||||
NodeKind::Ellipse { start: 1.0, sweep: 2.0, inner: 0.8 },
|
||||
] {
|
||||
for spread in [25.0, 40.0] {
|
||||
let (d, n) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread))]);
|
||||
let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(spread * 4.0) };
|
||||
let (roomy, m) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread)), EffectKind::DropShadow(clear)]);
|
||||
let (a, b) = (render(&d, n), render(&roomy, m));
|
||||
let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0);
|
||||
assert!(worst <= 2, "{kind:?}, a spread of {spread}: {worst} levels cut off");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mitred stroke on an arc reaches out past its width at the arc's corners too, and a spread
|
||||
/// grows from there: a thin slice with both keeps its whole shadow.
|
||||
#[test]
|
||||
fn arc_stroke_mitres_are_not_cut_off() {
|
||||
use figcraft_doc::{StrokeAlign, StrokeJoin};
|
||||
let kind = NodeKind::Ellipse { start: std::f64::consts::FRAC_PI_2 - 0.015, sweep: 0.03, inner: 0.0 };
|
||||
for align in [StrokeAlign::Center, StrokeAlign::Outside] {
|
||||
let make = |effects: Vec<EffectKind>| {
|
||||
let mut d = Document::new("arc");
|
||||
let p = d.first_page().unwrap();
|
||||
let n = d.create(p, kind.clone(), "S", 0.0, 190.0, 400.0, 20.0).unwrap();
|
||||
let node = d.get_mut(n).unwrap();
|
||||
node.fills = vec![Paint::solid(Color::WHITE)];
|
||||
node.strokes = vec![Paint::solid(Color::WHITE)];
|
||||
(node.stroke.weight, node.stroke.align, node.stroke.join, node.stroke.miter_limit) = (20.0, align, StrokeJoin::Miter, 4.0);
|
||||
node.effects = effects.into_iter().map(|kind| Effect { kind, visible: true }).collect();
|
||||
(d, n)
|
||||
};
|
||||
let (d, n) = make(vec![EffectKind::DropShadow(shadow(10.0))]);
|
||||
let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(80.0) };
|
||||
let (roomy, m) = make(vec![EffectKind::DropShadow(shadow(10.0)), EffectKind::DropShadow(clear)]);
|
||||
let (a, b) = (render(&d, n), render(&roomy, m));
|
||||
let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0);
|
||||
assert!(worst <= 2, "{align:?}: {worst} levels cut off");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,3 +83,45 @@ fn hostile_shadow_values_still_export() {
|
||||
assert!(export_svg(&d, &[n], &SvgOptions::default()).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// The filter a spread shadow is blurred in makes room for the spread as the node's transform
|
||||
/// draws it: on a star scaled three times, the spread's mitres reach three times as far, and on a
|
||||
/// skewed one further across than down.
|
||||
#[test]
|
||||
fn a_scaled_spread_shadow_keeps_its_filter_room() {
|
||||
use figcraft_doc::SPREAD_MITER_LIMIT;
|
||||
use figcraft_geom::{Affine, kurbo};
|
||||
let star = NodeKind::Star { count: 5, inner_ratio: 0.4 };
|
||||
let s =
|
||||
Shadow { color: Color::from_hex("000000").unwrap(), offset: (6.0, -9.0), radius: 4.0, spread: 10.0, show_behind: true, ..Default::default() };
|
||||
for (why, xf) in [
|
||||
("scaled 3", Affine::scale(3.0)),
|
||||
("scaled (3, 1)", Affine::scale_non_uniform(3.0, 1.0)),
|
||||
("skewed", Affine::new([1.0, 0.0, 2.0, 1.0, 0.0, 0.0])),
|
||||
("turned and stretched", Affine::rotate(0.5) * Affine::scale_non_uniform(3.0, 1.0)),
|
||||
("as drawn", Affine::IDENTITY),
|
||||
] {
|
||||
let (mut d, n) = with_effects(star.clone(), vec![EffectKind::DropShadow(s.clone())]);
|
||||
let t = d.get(n).unwrap().transform;
|
||||
d.get_mut(n).unwrap().transform = t * xf;
|
||||
let svg = export(&d, n);
|
||||
let tag = &svg[svg.find("<filter ").unwrap()..];
|
||||
let attr = |name: &str| -> f64 {
|
||||
let at = tag.find(&format!(" {name}=\"")).unwrap() + name.len() + 3;
|
||||
tag[at..at + tag[at..].find('"').unwrap()].parse().unwrap()
|
||||
};
|
||||
let region = kurbo::Rect::new(attr("x"), attr("y"), attr("x") + attr("width"), attr("y") + attr("height"));
|
||||
// What the shadow paints, as the renderer draws it: the outline stroked twice the spread
|
||||
// wide with mitred joins, through the node's transform, moved by the offset (in the
|
||||
// export's frame, which starts at the node's export bounds) and blurred.
|
||||
let bounds = figcraft_render::export_bounds(&d, n).unwrap();
|
||||
let at = Affine::translate((s.offset.0 - bounds.x0, s.offset.1 - bounds.y0)) * d.world_transform(n);
|
||||
let shape = figcraft_render::node_outline(&d, d.get(n).unwrap()).unwrap();
|
||||
let stroke = kurbo::Stroke::new(s.spread * 2.0).with_join(kurbo::Join::Miter).with_miter_limit(SPREAD_MITER_LIMIT);
|
||||
let mut grown = kurbo::stroke(shape.0.iter(), &stroke, &Default::default(), 0.01);
|
||||
grown.apply_affine(at);
|
||||
let blur = s.radius * 1.5;
|
||||
let painted = figcraft_geom::Shape::bounding_box(&grown).inflate(blur, blur);
|
||||
assert!(region.union(painted) == region, "{why}: the filter {region:?} cuts the shadow at {painted:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,7 +608,17 @@ fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine
|
||||
let hash = add_image(cx.doc, bytes, iw, ih);
|
||||
let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h);
|
||||
n.transform = transform;
|
||||
n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)];
|
||||
let mut fill = Paint::image(hash, ImageScaleMode::Fill);
|
||||
// usvg sizes the box to the picture (`meet` and `slice` are in the transform), so a box of
|
||||
// another shape is `preserveAspectRatio="none"`: the picture stretched edge to edge. A Fill
|
||||
// would keep its shape and crop it; a crop of the whole picture stretches it.
|
||||
if (w * ih - h * iw).abs() > 1e-6 * (w * ih).max(h * iw)
|
||||
&& let figcraft_color::PaintKind::Image(ip) = &mut fill.kind
|
||||
{
|
||||
ip.scale_mode = ImageScaleMode::Crop;
|
||||
ip.crop = Some(Affine::IDENTITY.as_coeffs());
|
||||
}
|
||||
n.fills = vec![fill];
|
||||
cx.add(n, parent);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,28 @@ fn strokes_scale_with_their_layer() {
|
||||
assert_eq!((l.width, l.stroke.weight, l.stroke.dashes.clone()), (30.0, 6.0, vec![12.0, 6.0]));
|
||||
}
|
||||
|
||||
/// `preserveAspectRatio="none"` stretches a picture into its box edge to edge. usvg sizes the
|
||||
/// box to the picture for `meet` and `slice` (the fit is in the transform), so a box of another
|
||||
/// shape is a stretch: a Fill, which keeps the picture's shape and crops it, would show the
|
||||
/// middle of the picture instead.
|
||||
#[test]
|
||||
fn an_image_stretched_into_its_box_stays_stretched() {
|
||||
use figcraft_color::{ImageScaleMode, PaintKind};
|
||||
let (d, f) =
|
||||
import(&format!(r##"<image id="pic" x="10" y="20" width="40" height="40" preserveAspectRatio="none" href="data:image/png;base64,{PNG}"/>"##));
|
||||
let n = node(&d, f, "pic");
|
||||
assert_eq!((n.width, n.height), (40.0, 40.0));
|
||||
let Some(PaintKind::Image(ip)) = n.fills.first().map(|p| &p.kind) else { panic!("{:?}", n.fills) };
|
||||
// The 4 × 2 picture's pixels map onto the whole 40 × 40 box: 10 across, 20 down.
|
||||
let m = figcraft_render::paint::image_transform(ip, 4.0, 2.0, n.width, n.height);
|
||||
assert_eq!((m.sx, m.ky, m.kx, m.sy, m.tx, m.ty), (10.0, 0.0, 0.0, 20.0, 0.0, 0.0), "{ip:?}");
|
||||
// With the aspect kept (the default) the box is the picture's own shape and a Fill is exact.
|
||||
let (d, f) = import(&format!(r##"<image id="pic" x="10" y="20" width="40" height="40" href="data:image/png;base64,{PNG}"/>"##));
|
||||
let n = node(&d, f, "pic");
|
||||
assert_eq!((n.width, n.height), (40.0, 20.0));
|
||||
assert!(matches!(n.fills.first().map(|p| &p.kind), Some(PaintKind::Image(i)) if i.scale_mode == ImageScaleMode::Fill), "{:?}", n.fills);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_files_are_never_read() {
|
||||
let dir = std::env::temp_dir().join("figcraft-svg-linked");
|
||||
|
||||
@@ -802,7 +802,7 @@ impl W<'_> {
|
||||
return None;
|
||||
}
|
||||
match n.kind {
|
||||
NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep >= std::f64::consts::TAU - 1e-9 => {
|
||||
NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && figcraft_geom::full_sweep(sweep) => {
|
||||
figcraft_geom::kurbo::Shape::to_path(&figcraft_geom::kurbo::Ellipse::from_rect(r), 0.1)
|
||||
}
|
||||
NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } if n.corners.smoothing == 0.0 => {
|
||||
@@ -1052,10 +1052,14 @@ impl W<'_> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let reach = s.spread.abs() * SPREAD_MITER_LIMIT + s.radius.abs() * 1.5 + 2.0;
|
||||
// The spread grows the outline in its own units (mitres up to the limit), drawn through
|
||||
// `xf`: as far across and down as a local step that long goes. The blur is in page units.
|
||||
let spread = if s.spread.is_finite() { s.spread.abs() * SPREAD_MITER_LIMIT } else { 0.0 };
|
||||
let [a, b_, c, d, _, _] = xf.as_coeffs();
|
||||
let blur = s.radius.abs() * 1.5 + 2.0;
|
||||
let region = b.inflate(spread * a.hypot(c) + blur, spread * b_.hypot(d) + blur);
|
||||
let cover = if s.show_behind { None } else { self.cover_input(n, xf) };
|
||||
let filter =
|
||||
self.blur_filter(s.radius, b.inflate(reach, reach), cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default();
|
||||
let filter = self.blur_filter(s.radius, region, cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default();
|
||||
let op = f64::from(s.color.a * n.opacity.clamp(0.0, 1.0));
|
||||
let blend = if matches!(s.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
String::new()
|
||||
|
||||
@@ -429,3 +429,24 @@ fn an_exported_dashed_line_opens_as_the_same_line() {
|
||||
d.subtree(frame).iter().filter_map(|i| d.node(*i)).filter(|n| !n.stroke.dashes.is_empty()).map(|n| n.stroke.dashes.clone()).collect();
|
||||
assert!(matches!(dashes.as_slice(), [d] if d.len() == 2 && d.iter().all(|v| (v - 10.7).abs() < 1e-4)), "{dashes:?}");
|
||||
}
|
||||
|
||||
/// An ellipse drawn the other way round (a sweep of −2π) is as whole as one drawn forwards: its
|
||||
/// stroke inside or outside exports as a stroke, the same as the forward one's.
|
||||
#[test]
|
||||
fn a_whole_ellipse_drawn_backwards_keeps_its_simple_stroke() {
|
||||
let export = |sweep: f64, align: figcraft_doc::StrokeAlign| {
|
||||
let mut d = Document::new("backwards");
|
||||
let p = d.first_page().unwrap();
|
||||
let n = d.create(p, NodeKind::Ellipse { start: 0.0, sweep, inner: 0.0 }, "Ellipse", 0.0, 0.0, 100.0, 60.0).unwrap();
|
||||
let node = d.get_mut(n).unwrap();
|
||||
node.fills.clear();
|
||||
node.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())];
|
||||
(node.stroke.weight, node.stroke.align) = (8.0, align);
|
||||
export_svg(&d, &[n], &SvgOptions { simplify_stroke: true, ..SvgOptions::default() }).unwrap()
|
||||
};
|
||||
for align in [figcraft_doc::StrokeAlign::Inside, figcraft_doc::StrokeAlign::Outside] {
|
||||
let (forwards, backwards) = (export(std::f64::consts::TAU, align), export(-std::f64::consts::TAU, align));
|
||||
assert!(forwards.contains("stroke-width=\"8\""), "{align:?}: {forwards}");
|
||||
assert_eq!(backwards, forwards, "{align:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use super::*;
|
||||
use crate::breaks::{break_between, grapheme_starts};
|
||||
use figcraft_doc::{LeadingTrim, LetterSpacing, LineHeight, TextAlignH, TextAutoResize, TextData, TextDecoration};
|
||||
use figcraft_doc::{LeadingTrim, LetterSpacing, LineHeight, ListStyle, ParaList, TextAlignH, TextAutoResize, TextData, TextDecoration};
|
||||
|
||||
/// The default face once system fonts are indexed (a Latin face where the system has one, so CJK
|
||||
/// and emoji come from fallback faces), or None (skip) on a machine without fonts.
|
||||
@@ -100,6 +100,36 @@ fn letter_spacing_percent_and_pixels() {
|
||||
}
|
||||
}
|
||||
|
||||
/// "Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the
|
||||
/// same level of indentation" (Create bulleted and numbered lists): a plain paragraph at a list
|
||||
/// level starts where that level's item text starts, even once no list is left; a plain
|
||||
/// paragraph at level 0 starts at the box as always.
|
||||
#[test]
|
||||
fn a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does() {
|
||||
if face().is_none() {
|
||||
return;
|
||||
}
|
||||
let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0 };
|
||||
let mut t = text("a\nb\nc", TextAutoResize::WidthAndHeight);
|
||||
t.paragraphs = vec![level(ListStyle::Bulleted, 1), level(ListStyle::None, 1), level(ListStyle::None, 0)];
|
||||
let l = layout(&t, None, None);
|
||||
// Where a line's text starts: its first character's glyph (the indent is in the glyphs); a
|
||||
// list marker shares that character and sits in the gutter to its left, so the text is the
|
||||
// rightmost of them.
|
||||
let text_x = |l: &TextLayout, li: usize| {
|
||||
l.glyphs.iter().filter(|g| g.char_start == l.lines[li].char_start).map(|g| g.x).fold(f64::NAN, |a, b| if a.is_nan() { b } else { a.max(b) })
|
||||
};
|
||||
let (x0, x1, x2) = (text_x(&l, 0), text_x(&l, 1), text_x(&l, 2));
|
||||
assert!((x1 - x0).abs() < EPS, "the plain paragraph keeps the item's text start: {x1} vs {x0}");
|
||||
assert!(x0 > 20.0, "a nested item's text starts two gutters in: {x0}");
|
||||
assert!(x2.abs() < EPS, "a level-0 plain paragraph starts at the box: {x2}");
|
||||
// No list left at all: the level still indents, by the same gutters.
|
||||
let mut alone = text("b", TextAutoResize::WidthAndHeight);
|
||||
alone.paragraphs = vec![level(ListStyle::None, 1)];
|
||||
let a = layout(&alone, None, None);
|
||||
assert!((text_x(&a, 0) - x1).abs() < EPS, "{} vs {x1}", text_x(&a, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_spacing_and_indent() {
|
||||
if face().is_none() {
|
||||
|
||||
@@ -102,6 +102,11 @@ pub struct Face {
|
||||
pub underline_pos: f64,
|
||||
pub underline_thickness: f64,
|
||||
pub strike_pos: f64,
|
||||
/// Synthesised superscript and subscript (the OS/2 table's values, or the usual ones without
|
||||
/// it): the glyph size as a fraction of the em, and the baseline shift up in font units
|
||||
/// (negative for a subscript, which sits below).
|
||||
pub superscript: (f64, f64),
|
||||
pub subscript: (f64, f64),
|
||||
pub shaper: harfrust::ShaperData,
|
||||
/// OpenType GSUB feature tags the face provides (e.g. `smcp`).
|
||||
gsub_features: Vec<[u8; 4]>,
|
||||
@@ -179,6 +184,17 @@ impl Face {
|
||||
let colr = has_table(b"COLR");
|
||||
let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06));
|
||||
let strike = m.strikeout.map(|d| f64::from(d.offset)).unwrap_or(ascent * 0.3);
|
||||
// Superscript and subscript sizes and offsets: the OS/2 table's, else the usual 0.65 em,
|
||||
// 0.35 em up and 0.14 em down (what common fonts carry); a zero counts as unset, as some
|
||||
// fonts leave these fields.
|
||||
let os2 = f.os2().ok();
|
||||
let script = |size: Option<i16>, offset: Option<i16>, up: bool, usual_offset: f64| -> (f64, f64) {
|
||||
let size = size.map(f64::from).filter(|v| *v > 0.0).map_or(0.65, |v| v / upem);
|
||||
let offset = offset.map(f64::from).filter(|v| *v != 0.0).unwrap_or(usual_offset * upem);
|
||||
(size, if up { offset } else { -offset })
|
||||
};
|
||||
let superscript = script(os2.as_ref().map(|t| t.y_superscript_y_size()), os2.as_ref().map(|t| t.y_superscript_y_offset()), true, 0.35);
|
||||
let subscript = script(os2.as_ref().map(|t| t.y_subscript_y_size()), os2.as_ref().map(|t| t.y_subscript_y_offset()), false, 0.14);
|
||||
let weight = {
|
||||
let w = attrs.weight.value();
|
||||
if w.is_finite() && w > 0.0 { w.round() as u16 } else { figcraft_doc::style_weight(&style) }
|
||||
@@ -200,6 +216,8 @@ impl Face {
|
||||
underline_pos: under.0,
|
||||
underline_thickness: under.1.max(upem * 0.02),
|
||||
strike_pos: strike,
|
||||
superscript,
|
||||
subscript,
|
||||
shaper,
|
||||
gsub_features,
|
||||
track,
|
||||
|
||||
+315
-51
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{
|
||||
DecorationLength, DecorationStyle, LeadingTrim, LetterSpacing, LineHeight, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData,
|
||||
TextDecoration, TextStyle, color::PaintKind, valid_feature_tag,
|
||||
TextDecoration, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag,
|
||||
};
|
||||
use figcraft_geom::{Affine, BezPath, Rect};
|
||||
use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation};
|
||||
@@ -630,7 +630,8 @@ impl SavedStarts<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines (item range, paragraph end, paragraph), wrapped by measure or at `saved` line starts;
|
||||
/// The lines (item range, paragraph end, paragraph), wrapped to `wrap_of` (the width for each line
|
||||
/// of each paragraph; None: no wrapping) or at `saved` line starts;
|
||||
/// None when a saved line would overflow its box by more than [`SAVED_LINE_SLACK`]. Lines break
|
||||
/// where [`crate::breaks::break_between`] allows, and between the words `word_breaks` marks.
|
||||
fn break_lines(
|
||||
@@ -638,7 +639,7 @@ fn break_lines(
|
||||
chars: &[char],
|
||||
cluster_start: &[bool],
|
||||
word_breaks: &[bool],
|
||||
wrap: Option<f64>,
|
||||
wrap_of: &WidthOf<'_>,
|
||||
line_indent_of: &impl Fn(usize, bool, usize) -> f64,
|
||||
rtl_para_at: &impl Fn(usize) -> bool,
|
||||
hang_start: bool,
|
||||
@@ -652,6 +653,7 @@ fn break_lines(
|
||||
let mut last_break: Option<usize> = None;
|
||||
let mut para_first_line = true;
|
||||
let mut para = 0;
|
||||
let mut line_in_para = 0;
|
||||
while i < items.len() {
|
||||
let Some(it) = items.get(i) else { break };
|
||||
if it.ch == '\n' {
|
||||
@@ -662,6 +664,7 @@ fn break_lines(
|
||||
last_break = None;
|
||||
para_first_line = true;
|
||||
para += 1;
|
||||
line_in_para = 0;
|
||||
continue;
|
||||
}
|
||||
// A line separator (a soft break) breaks the line inside its paragraph: no
|
||||
@@ -673,6 +676,7 @@ fn break_lines(
|
||||
x = 0.0;
|
||||
last_break = None;
|
||||
para_first_line = false;
|
||||
line_in_para += 1;
|
||||
continue;
|
||||
}
|
||||
let line_indent = line_indent_of(para, para_first_line, it.char_start);
|
||||
@@ -681,7 +685,7 @@ fn break_lines(
|
||||
let ltr = !rtl_para_at(it.char_start);
|
||||
let hung = items.get(line_start).filter(|f| hang_start && ltr && opens(f.ch)).map_or(0.0, |f| f.adv)
|
||||
+ if hang_end && ltr && closes(it.ch) { w } else { 0.0 };
|
||||
if let Some(maxw) = wrap
|
||||
if let Some(maxw) = wrap_of(para, line_in_para)
|
||||
&& i > line_start
|
||||
&& !it.is_space()
|
||||
{
|
||||
@@ -702,6 +706,7 @@ fn break_lines(
|
||||
x = 0.0;
|
||||
last_break = None;
|
||||
para_first_line = false;
|
||||
line_in_para += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -721,6 +726,220 @@ fn break_lines(
|
||||
Some(lines)
|
||||
}
|
||||
|
||||
/// A line as [`break_lines`] gives it: item range, paragraph end, paragraph.
|
||||
type LineRange = (usize, usize, bool, usize);
|
||||
|
||||
/// The width a line wraps to (its paragraph, the line's index in it); None: no wrapping.
|
||||
type WidthOf<'a> = dyn Fn(usize, usize) -> Option<f64> + 'a;
|
||||
|
||||
/// [`break_lines`] with the widths and the saved starts to follow, the rest bound.
|
||||
type Breaker<'a> = dyn Fn(&WidthOf<'_>, Option<SavedStarts<'_>>) -> Option<Vec<LineRange>> + 'a;
|
||||
|
||||
/// What a paragraph's lines wrap to under Balance or Pretty: the paragraph's width, and a
|
||||
/// narrower one for a single line (the line Pretty has give a word away).
|
||||
#[derive(Clone, Copy)]
|
||||
struct ParaWidth {
|
||||
width: f64,
|
||||
line: Option<(usize, f64)>,
|
||||
}
|
||||
|
||||
/// Whether the line's text is one word: no break opportunity between its items, its trailing
|
||||
/// spaces and the paragraph's line break aside.
|
||||
fn single_word(items: &[Item], word_breaks: &[bool], (start, end, _, _): LineRange) -> bool {
|
||||
let Some(last) = (start..end).rev().find(|&k| items.get(k).is_some_and(|it| !it.is_space() && !is_line_break(it.ch))) else {
|
||||
return false;
|
||||
};
|
||||
!(start..last).any(|k| {
|
||||
let (Some(a), Some(b)) = (items.get(k), items.get(k + 1)) else { return false };
|
||||
crate::breaks::break_between(a.ch, b.ch) || (a.char_end <= b.char_start && word_breaks.get(b.char_start).copied().unwrap_or(false))
|
||||
})
|
||||
}
|
||||
|
||||
/// How many of `lines` end inside a word: emergency breaks, made where no word boundary fitted
|
||||
/// the width. Balance and Pretty never add one (a word that fits the box stays whole).
|
||||
fn emergency_breaks(items: &[Item], word_breaks: &[bool], lines: &[LineRange]) -> usize {
|
||||
lines
|
||||
.iter()
|
||||
.filter(|&&(_, end, para_end, _)| {
|
||||
if para_end {
|
||||
return false;
|
||||
}
|
||||
let (Some(a), Some(b)) = (end.checked_sub(1).and_then(|k| items.get(k)), items.get(end)) else { return false };
|
||||
!(a.is_space()
|
||||
|| is_line_break(a.ch)
|
||||
|| crate::breaks::break_between(a.ch, b.ch)
|
||||
|| (a.char_end <= b.char_start && word_breaks.get(b.char_start).copied().unwrap_or(false)))
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
/// What a line's hanging punctuation adds to the room [`break_lines`] gives it (its opening mark
|
||||
/// at the start, its closing one at the end), so Pretty narrows a line by its text as the breaker
|
||||
/// measures it.
|
||||
fn hung_width(items: &[Item], (s, e, _, _): LineRange, hang_start: bool, hang_end: bool, rtl_para_at: &dyn Fn(usize) -> bool) -> f64 {
|
||||
let ltr = items.get(s).is_none_or(|it| !rtl_para_at(it.char_start));
|
||||
let first = items.get(s).filter(|it| hang_start && ltr && opens(it.ch)).map_or(0.0, |it| it.adv);
|
||||
let last = e.checked_sub(1).filter(|k| *k > s).and_then(|k| items.get(k)).filter(|it| hang_end && ltr && closes(it.ch)).map_or(0.0, |it| it.adv);
|
||||
first + last
|
||||
}
|
||||
|
||||
/// The width of the line's text, its trailing spaces and line break aside, as the breaker
|
||||
/// measures it.
|
||||
fn line_width(items: &[Item], (start, end, _, _): LineRange) -> f64 {
|
||||
let mut x = 0.0;
|
||||
let mut width = 0.0;
|
||||
for it in items.get(start..end).unwrap_or(&[]) {
|
||||
if is_line_break(it.ch) {
|
||||
break;
|
||||
}
|
||||
x += it.adv;
|
||||
if !it.is_space() {
|
||||
width = x - it.spacing;
|
||||
}
|
||||
}
|
||||
width
|
||||
}
|
||||
|
||||
/// Balance and Pretty: the greedy `lines` of a box `w` wide, re-broken paragraph by paragraph.
|
||||
/// Balance narrows a paragraph as far as it goes without another line, so its lines come out
|
||||
/// close to the same length ("distributes text as evenly as possible across every line, so each
|
||||
/// line is close to the same length"; what browsers do for `text-wrap: balance`). Pretty leaves
|
||||
/// lines full but, when the last line is one word, narrows one of the last four lines just enough
|
||||
/// to give a word to the lines after it ("keeps each line full, but adjusts the last four lines to
|
||||
/// avoid an orphan"). `rebreak` lays the text out again with a width per paragraph and line.
|
||||
fn restyle_breaks(
|
||||
style: WrapStyle,
|
||||
w: f64,
|
||||
lines: Vec<LineRange>,
|
||||
items: &[Item],
|
||||
word_breaks: &[bool],
|
||||
line_indent_of: &dyn Fn(usize, bool, usize) -> f64,
|
||||
hung_of: &dyn Fn(LineRange) -> f64,
|
||||
rebreak: &dyn Fn(&WidthOf<'_>) -> Vec<LineRange>,
|
||||
) -> Vec<LineRange> {
|
||||
let paragraphs = lines.last().map_or(0, |l| l.3 + 1);
|
||||
// Every pass lays the whole text out again: a very long text keeps its greedy lines.
|
||||
if items.len().saturating_mul(paragraphs) > 2_000_000 {
|
||||
return lines;
|
||||
}
|
||||
let mut plan = vec![ParaWidth { width: w, line: None }; paragraphs];
|
||||
// The lines with paragraph `p` wrapped to `pw` and the others as planned.
|
||||
let with = |plan: &[ParaWidth], p: usize, pw: ParaWidth| {
|
||||
let width_of = move |q: usize, li: usize| {
|
||||
let pw = if q == p { pw } else { plan.get(q).copied().unwrap_or(ParaWidth { width: w, line: None }) };
|
||||
Some(match pw.line {
|
||||
Some((j, narrow)) if li == j => narrow,
|
||||
_ => pw.width,
|
||||
})
|
||||
};
|
||||
rebreak(&width_of)
|
||||
};
|
||||
let of = |ls: &[LineRange], p: usize| -> Vec<LineRange> { ls.iter().copied().filter(|l| l.3 == p).collect() };
|
||||
let mut current = lines;
|
||||
for p in 0..paragraphs {
|
||||
let para = of(¤t, p);
|
||||
let n = para.len();
|
||||
if n < 2 {
|
||||
continue;
|
||||
}
|
||||
// Words wider than the box break where they must; the re-breaking adds no such break.
|
||||
let base = emergency_breaks(items, word_breaks, ¶);
|
||||
match style {
|
||||
WrapStyle::Auto => {}
|
||||
WrapStyle::Balance => {
|
||||
// The narrowest width that still gives `n` lines, to half a pixel.
|
||||
let (mut lo, mut hi) = (0.0, w);
|
||||
while hi - lo > 0.5 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
let ls = of(&with(&plan, p, ParaWidth { width: mid, line: None }), p);
|
||||
if ls.len() == n && emergency_breaks(items, word_breaks, &ls) <= base {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
let pw = ParaWidth { width: hi, line: None };
|
||||
current = with(&plan, p, pw);
|
||||
if let Some(slot) = plan.get_mut(p) {
|
||||
*slot = pw;
|
||||
}
|
||||
}
|
||||
WrapStyle::Pretty => {
|
||||
if !para.last().is_some_and(|l| single_word(items, word_breaks, *l)) {
|
||||
continue;
|
||||
}
|
||||
// The line before the last, back to the fourth from the end: narrowed to just under
|
||||
// its text, its last word goes to the lines after it (which stay full); a word or
|
||||
// two more if the last line is still alone. A line that would rather start another
|
||||
// line can't give a word away.
|
||||
'lines: for j in (n.saturating_sub(4)..n - 1).rev() {
|
||||
let Some(&line) = para.get(j) else { continue };
|
||||
let indent = line_indent_of(p, j == 0, items.get(line.0).map_or(0, |it| it.char_start));
|
||||
let mut narrow = line_width(items, line) - hung_of(line);
|
||||
for _ in 0..3 {
|
||||
narrow -= 0.5;
|
||||
if narrow <= 0.0 {
|
||||
break;
|
||||
}
|
||||
let pw = ParaWidth { width: w, line: Some((j, narrow + indent)) };
|
||||
let ls = with(&plan, p, pw);
|
||||
let after = of(&ls, p);
|
||||
if after.len() != n || emergency_breaks(items, word_breaks, &after) > base {
|
||||
break;
|
||||
}
|
||||
if !after.last().is_some_and(|l| single_word(items, word_breaks, *l)) {
|
||||
current = ls;
|
||||
if let Some(slot) = plan.get_mut(p) {
|
||||
*slot = pw;
|
||||
}
|
||||
break 'lines;
|
||||
}
|
||||
narrow = after.get(j).map_or(0.0, |l| line_width(items, *l) - hung_of(*l));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
/// Pins a breaking closure to the signature [`wrapped_lines`] takes: a closure whose parameter
|
||||
/// borrows a trait object is otherwise inferred for one lifetime, not any.
|
||||
fn breaker<F>(f: F) -> F
|
||||
where
|
||||
F: Fn(&WidthOf<'_>, Option<SavedStarts<'_>>) -> Option<Vec<LineRange>>,
|
||||
{
|
||||
f
|
||||
}
|
||||
|
||||
/// The lines of a text wrapped to `wrap`: the file's saved breaks while they hold, else greedy
|
||||
/// ones, re-broken for Balance or Pretty. `breaks` runs [`break_lines`] with a width per line and
|
||||
/// the saved starts to follow.
|
||||
fn wrapped_lines(
|
||||
text: &TextData,
|
||||
wrap: Option<f64>,
|
||||
items: &[Item],
|
||||
word_breaks: &[bool],
|
||||
line_indent_of: &dyn Fn(usize, bool, usize) -> f64,
|
||||
hung_of: &dyn Fn(LineRange) -> f64,
|
||||
breaks: &Breaker<'_>,
|
||||
) -> Vec<LineRange> {
|
||||
// Where the file saved its line breaks, they win while the text and box are as saved.
|
||||
let saved = wrap.and_then(|w| text.saved_breaks_for(w)).map(|starts| SavedStarts { starts, next: 0 });
|
||||
let from_saved = saved.and_then(|s| breaks(&|_, _| wrap, Some(s)));
|
||||
let followed = from_saved.is_some();
|
||||
let lines = from_saved.or_else(|| breaks(&|_, _| wrap, None)).unwrap_or_default();
|
||||
// Balance and Pretty re-break the paragraphs at narrower widths (never saved breaks).
|
||||
match wrap {
|
||||
Some(w) if !followed && text.wrap_style != WrapStyle::Auto => {
|
||||
restyle_breaks(text.wrap_style, w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
|
||||
breaks(wrap_of, None).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
_ => lines,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct LayoutFinish<'a> {
|
||||
text: &'a TextData,
|
||||
@@ -798,6 +1017,8 @@ fn truncate_layout(out: &mut TextLayout, finish: &LayoutFinish<'_>) {
|
||||
}
|
||||
}
|
||||
add_ellipsis(out, wrap);
|
||||
// The ellipsis can make the last line the widest: an auto-width box hugs it too.
|
||||
out.width = out.width.max(out.lines.last().map_or(0.0, |l| l.width));
|
||||
// Carets and selections reach every char the line shows, borrowed ones included.
|
||||
if let Some(l) = out.lines.last() {
|
||||
let shown =
|
||||
@@ -951,10 +1172,12 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
// The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`).
|
||||
let line_indent_of = |p: usize, first: bool, ci: usize| {
|
||||
let pl = list_of(p);
|
||||
let level = step * (f64::from(pl.indent) + 1.0) - hang_list;
|
||||
match pl.list {
|
||||
ListStyle::None if first => indent_at(ci),
|
||||
ListStyle::None => 0.0,
|
||||
_ => step * (f64::from(pl.indent) + 1.0) - hang_list,
|
||||
// A plain paragraph that kept its level on leaving a list starts where that level's
|
||||
// item text does; paragraph indent goes on its first line as on any plain paragraph.
|
||||
ListStyle::None => (if pl.indent > 0 { level } else { 0.0 }) + if first { indent_at(ci) } else { 0.0 },
|
||||
_ => level,
|
||||
}
|
||||
};
|
||||
// Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge
|
||||
@@ -966,10 +1189,11 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
TextAutoResize::WidthAndHeight => None,
|
||||
_ => box_width.filter(|w| w.is_finite() && *w > 0.0),
|
||||
};
|
||||
let breaks = |saved| break_lines(&items, &chars, &cluster_start, &word_breaks, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end, saved);
|
||||
// Where the file saved its line breaks, they win while the text and box are as saved.
|
||||
let saved = wrap.and_then(|w| text.saved_breaks_for(w)).map(|starts| SavedStarts { starts, next: 0 });
|
||||
let lines = saved.and_then(|s| breaks(Some(s))).or_else(|| breaks(None)).unwrap_or_default();
|
||||
let breaks = breaker(|wrap_of, saved| {
|
||||
break_lines(&items, &chars, &cluster_start, &word_breaks, wrap_of, &line_indent_of, &rtl_para_at, hang_start, hang_end, saved)
|
||||
});
|
||||
let hung_of = |l: LineRange| hung_width(&items, l, hang_start, hang_end, &rtl_para_at);
|
||||
let lines = wrapped_lines(text, wrap, &items, &word_breaks, &line_indent_of, &hung_of, &breaks);
|
||||
// Line metrics and positions.
|
||||
let default_style = styles.first().cloned().unwrap_or_default();
|
||||
let default_face = faces.first().cloned().flatten();
|
||||
@@ -1165,9 +1389,12 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
|
||||
/// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at
|
||||
/// one level, restarting after a plain paragraph, a shallower item or the other kind of list,
|
||||
/// from the first item's start number; levels go 1. a. i. and • ◦ ▪, then round again.
|
||||
/// from the first item's start number; levels go 1. a. i., then round again; bullets are • at
|
||||
/// every level.
|
||||
pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
|
||||
const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"];
|
||||
// "Figma bullets will keep the same styling at all levels of indentation." (Create bulleted
|
||||
// and numbered lists)
|
||||
const BULLET: &str = "\u{2022}";
|
||||
let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1];
|
||||
lists
|
||||
.iter()
|
||||
@@ -1187,7 +1414,7 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
|
||||
(ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)),
|
||||
(ListStyle::Numbered, 2) => format!("{}.", roman(c.1)),
|
||||
(ListStyle::Numbered, _) => format!("{}.", c.1),
|
||||
_ => BULLETS.get(d % 3).copied().unwrap_or("\u{2022}").to_string(),
|
||||
_ => BULLET.to_string(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -1234,9 +1461,9 @@ fn roman(mut n: usize) -> String {
|
||||
}
|
||||
|
||||
/// The width of one list level's gutter: room for the widest number plus half an em, at least one
|
||||
/// and a half ems (0 without lists).
|
||||
/// and a half ems (0 without lists, or plain paragraphs that kept a list level).
|
||||
fn list_step(lists: &[ParaList], markers: &[String], style: Option<&TextStyle>, face: Option<&Arc<Face>>) -> f64 {
|
||||
if lists.iter().all(|l| l.list == ListStyle::None) {
|
||||
if lists.iter().all(|l| l.list == ListStyle::None && l.indent == 0) {
|
||||
return 0.0;
|
||||
}
|
||||
let size = style.map(|s| sanitize_size(s.font_size)).unwrap_or(12.0);
|
||||
@@ -1411,48 +1638,85 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
|
||||
// Variable fonts: the axis values the style asks for (named instance, implied weight, custom).
|
||||
let values = face.axis_values(style);
|
||||
let coords = face.coords(&values);
|
||||
let mut raw: Vec<(u32, u32, i32, i32, i32)> = Vec::new();
|
||||
if let Some(hb) = face.hb() {
|
||||
let instance = (!coords.is_empty()).then(|| {
|
||||
let vars = face
|
||||
.axes
|
||||
.iter()
|
||||
.zip(&values)
|
||||
.filter_map(|(a, v)| <[u8; 4]>::try_from(a.tag.as_bytes()).ok().map(|t| Variation { tag: Tag::new(&t), value: *v as f32 }));
|
||||
ShaperInstance::from_variations(&hb, vars)
|
||||
});
|
||||
let shaper = face.shaper.shaper(&hb).instance(instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (u, src, _) in &mapped {
|
||||
buf.add(*u, *src as u32);
|
||||
let shape = |feats: &[Feature]| -> Vec<(u32, u32, i32, i32, i32)> {
|
||||
let mut raw = Vec::new();
|
||||
if let Some(hb) = face.hb() {
|
||||
let instance = (!coords.is_empty()).then(|| {
|
||||
let vars = face
|
||||
.axes
|
||||
.iter()
|
||||
.zip(&values)
|
||||
.filter_map(|(a, v)| <[u8; 4]>::try_from(a.tag.as_bytes()).ok().map(|t| Variation { tag: Tag::new(&t), value: *v as f32 }));
|
||||
ShaperInstance::from_variations(&hb, vars)
|
||||
});
|
||||
let shaper = face.shaper.shaper(&hb).instance(instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
for (u, src, _) in &mapped {
|
||||
buf.add(*u, *src as u32);
|
||||
}
|
||||
let rtl = seg.level % 2 == 1;
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(feats));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||||
}
|
||||
// Right-to-left glyphs come out in visual order; items are kept in logical order
|
||||
// (lines put runs back in visual order).
|
||||
if rtl {
|
||||
raw.reverse();
|
||||
}
|
||||
} else {
|
||||
for (u, src, _) in &mapped {
|
||||
let gid = face.glyph_for(*u);
|
||||
raw.push((gid, *src as u32, face.advance_at(gid, &coords) as i32, 0, 0));
|
||||
}
|
||||
}
|
||||
let rtl = seg.level % 2 == 1;
|
||||
buf.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buf.guess_segment_properties();
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(&feats));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
raw.push((info.glyph_id, info.cluster, pos.x_advance, pos.x_offset, pos.y_offset));
|
||||
}
|
||||
// Right-to-left glyphs come out in visual order; items are kept in logical order (lines
|
||||
// put runs back in visual order).
|
||||
if rtl {
|
||||
raw.reverse();
|
||||
}
|
||||
} else {
|
||||
for (u, src, _) in &mapped {
|
||||
let gid = face.glyph_for(*u);
|
||||
raw.push((gid, *src as u32, face.advance_at(gid, &coords) as i32, 0, 0));
|
||||
raw
|
||||
};
|
||||
let mut raw = shape(&feats);
|
||||
// Superscript or subscript (`sups` / `subs` on the node): the face's own glyphs where it has
|
||||
// the feature and every letter or digit here got one; otherwise synthesised from the standard
|
||||
// glyphs, shrunk and shifted ("Figma draws a synthesized version by using the font's standard
|
||||
// version of the character, shrinking it, and positioning it accordingly"; a run mixing
|
||||
// available and unavailable glyphs is synthesised whole, "for consistency").
|
||||
let script = [(b"sups", true), (b"subs", false)].into_iter().find(|(tag, _)| feats.iter().any(|f| f.tag == Tag::new(tag) && f.value != 0));
|
||||
// The glyph a cluster's character has on its own, after the case mapping shaping saw.
|
||||
let plain_gid = |cl: usize| mapped.iter().find(|(_, src, _)| *src == cl).map(|(u, _, _)| face.glyph_for(*u));
|
||||
let unchanged = |raw: &[(u32, u32, i32, i32, i32)]| {
|
||||
raw.iter().any(|&(gid, cl, ..)| {
|
||||
let cl = cl as usize;
|
||||
chars.get(cl).is_some_and(|c| c.is_alphanumeric()) && plain_gid(cl) == Some(gid)
|
||||
})
|
||||
};
|
||||
let synth = script.filter(|(tag, _)| !face.has_feature(tag) || unchanged(&raw));
|
||||
if let Some((tag, _)) = synth
|
||||
&& face.has_feature(tag)
|
||||
{
|
||||
let plain: Vec<Feature> = feats.iter().filter(|f| f.tag != Tag::new(tag)).cloned().collect();
|
||||
raw = shape(&plain);
|
||||
}
|
||||
let (script_scale, script_shift) = synth.map_or((1.0, 0.0), |(_, up)| if up { face.superscript } else { face.subscript });
|
||||
let n = raw.len();
|
||||
// Cluster end: the next larger cluster value, else the segment end. A glyph in the same
|
||||
// cluster as the next one shares its end, so many glyphs of one cluster aren't rescanned.
|
||||
let mut cends = vec![0usize; n];
|
||||
for gi in (0..n).rev() {
|
||||
let Some(&(_, cl, ..)) = raw.get(gi) else { continue };
|
||||
let cl = cl as usize;
|
||||
let shared = raw.get(gi + 1).filter(|r| r.1 as usize == cl).and_then(|_| cends.get(gi + 1).copied());
|
||||
let cend = shared.unwrap_or_else(|| raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1));
|
||||
if let Some(slot) = cends.get_mut(gi) {
|
||||
*slot = cend;
|
||||
}
|
||||
}
|
||||
let n = raw.len();
|
||||
for gi in 0..n {
|
||||
let Some(&(gid, cl, xa, xo, yo)) = raw.get(gi) else { continue };
|
||||
let cl = cl as usize;
|
||||
// Cluster end: the next larger cluster value, else the segment end.
|
||||
let cend = raw.iter().skip(gi + 1).map(|r| r.1 as usize).find(|c| *c > cl).unwrap_or(end).max(cl + 1);
|
||||
let cend = cends.get(gi).copied().unwrap_or(cl + 1);
|
||||
let last_in_cluster = raw.get(gi + 1).is_none_or(|r| r.1 as usize != cl);
|
||||
let ch = chars.get(cl).copied().unwrap_or(' ');
|
||||
let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 };
|
||||
let scale = if small_at(cl) { SMALL_CAP_SCALE } else { 1.0 } * script_scale;
|
||||
let spacing = if last_in_cluster { seg.spacing * (cend - cl) as f64 } else { 0.0 };
|
||||
// The glyph's own advance and what shaping added to it (kerning) each land on the grid.
|
||||
let own = face.advance_at(gid, &coords);
|
||||
@@ -1465,7 +1729,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
|
||||
adv,
|
||||
spacing,
|
||||
dx: f64::from(xo) * k * scale,
|
||||
dy: f64::from(yo) * k * scale,
|
||||
dy: f64::from(yo) * k * scale + script_shift * k,
|
||||
char_start: cl,
|
||||
char_end: cend,
|
||||
style: seg.st_idx,
|
||||
|
||||
@@ -90,8 +90,8 @@ fn list_numbering_by_level() {
|
||||
),
|
||||
(
|
||||
vec![pl(B, 0), pl(B, 1), pl(B, 2), pl(B, 3)],
|
||||
vec!["\u{2022}", "\u{25E6}", "\u{25AA}", "\u{2022}"],
|
||||
"silent: bullets change shape per level, round again",
|
||||
vec!["\u{2022}", "\u{2022}", "\u{2022}", "\u{2022}"],
|
||||
"bullets keep the same styling at all levels of indentation",
|
||||
),
|
||||
(vec![pl(N, 0), pl(N, 0), pl(P, 0), pl(N, 0)], vec!["1.", "2.", "", "1."], "silent: a plain paragraph ends a list, the next starts at 1"),
|
||||
(vec![pl(N, 0), pl(B, 0), pl(N, 0)], vec!["1.", "\u{2022}", "1."], "silent: the other kind of list at the same level starts the count again"),
|
||||
|
||||
@@ -157,6 +157,49 @@ fn small_caps_native_or_synthesised() {
|
||||
assert_eq!(sc.glyphs.iter().find(|g| g.char_start == 0).unwrap().gid, face.glyph_for('A'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_native_or_synthesised_and_never_mixed() {
|
||||
// "Figma tries to use any superscript/subscript glyphs provided by the font. If it isn't
|
||||
// provided, Figma draws a synthesized version by using the font's standard version of the
|
||||
// character, shrinking it, and positioning it accordingly." and a layer mixing both "will all
|
||||
// update to the synthesized form for consistency" (Explore text properties).
|
||||
let Some(face) = inter() else { return };
|
||||
let mut t = TextData::new("x2");
|
||||
t.style.font_size = 20.0;
|
||||
let plain = layout(&t, None, None);
|
||||
t.features.insert("sups".into(), true);
|
||||
let sup = layout(&t, None, None);
|
||||
let at = |l: &TextLayout, i: usize| l.glyphs.iter().find(|g| g.char_start == i).cloned().unwrap();
|
||||
let native = |i: usize| at(&sup, i).gid != at(&plain, i).gid && (at(&sup, i).size - 20.0).abs() < 1e-9;
|
||||
let synthesised = |i: usize| at(&sup, i).gid == at(&plain, i).gid && at(&sup, i).size < 20.0 && at(&sup, i).y < at(&plain, i).y;
|
||||
assert!((native(0) && native(1)) || (synthesised(0) && synthesised(1)), "both glyphs the same way: {:?} {:?}", at(&sup, 0), at(&sup, 1));
|
||||
if !face.has_feature(b"sups") {
|
||||
assert!(synthesised(0) && synthesised(1), "no sups in the face: synthesised");
|
||||
}
|
||||
t.features.insert("sups".into(), false);
|
||||
t.features.insert("subs".into(), true);
|
||||
let sub = layout(&t, None, None);
|
||||
let g = at(&sub, 1);
|
||||
let p = at(&plain, 1);
|
||||
assert!((g.gid != p.gid && (g.size - 20.0).abs() < 1e-9) || (g.size < 20.0 && g.y > p.y), "subscript: native, or shrunk and lowered: {g:?}");
|
||||
// Capitalised, the same holds: the check against the face's own glyphs sees the characters
|
||||
// shaping saw (an X, not an x), so a face with a superscript 2 and no superscript X
|
||||
// synthesises both rather than mixing them.
|
||||
t.features.clear();
|
||||
t.style.case = figcraft_doc::TextCase::Upper;
|
||||
let plain_up = layout(&t, None, None);
|
||||
t.features.insert("sups".into(), true);
|
||||
let up = layout(&t, None, None);
|
||||
let native_up = |i: usize| at(&up, i).gid != at(&plain_up, i).gid && (at(&up, i).size - 20.0).abs() < 1e-9;
|
||||
let synth_up = |i: usize| at(&up, i).gid == at(&plain_up, i).gid && at(&up, i).size < 20.0 && at(&up, i).y < at(&plain_up, i).y;
|
||||
assert!(
|
||||
(native_up(0) && native_up(1)) || (synth_up(0) && synth_up(1)),
|
||||
"upper case: both glyphs the same way: {:?} {:?}",
|
||||
at(&up, 0),
|
||||
at(&up, 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_case_across_runs() {
|
||||
let Some(face) = inter() else { return };
|
||||
|
||||
@@ -288,7 +288,6 @@ status = "draft — needs native review"
|
||||
"Code" = "الكود"
|
||||
"Code syntax" = "صياغة الكود"
|
||||
"Collapse" = "طي"
|
||||
"Collapse panel" = "طي اللوحة"
|
||||
"Collection" = "مجموعة"
|
||||
"Collections" = "المجموعات"
|
||||
"Color" = "اللون"
|
||||
|
||||
@@ -275,7 +275,6 @@ status = "draft — needs native review"
|
||||
"Code" = "কোড"
|
||||
"Code syntax" = "কোড সিনট্যাক্স"
|
||||
"Collapse" = "সংকুচিত করুন"
|
||||
"Collapse panel" = "প্যানেল সংকুচিত করুন"
|
||||
"Collection" = "সংগ্রহ"
|
||||
"Collections" = "সংগ্রহসমূহ"
|
||||
"Color" = "রঙ"
|
||||
|
||||
@@ -288,7 +288,6 @@ status = "draft — needs native review"
|
||||
"Code" = "Kod"
|
||||
"Code syntax" = "Sintaksa koda"
|
||||
"Collapse" = "Skupi"
|
||||
"Collapse panel" = "Skupi panel"
|
||||
"Collection" = "Kolekcija"
|
||||
"Collections" = "Kolekcije"
|
||||
"Color" = "Boja"
|
||||
|
||||
@@ -265,7 +265,6 @@ status = "draft — needs native review"
|
||||
"Code" = "Kód"
|
||||
"Code syntax" = "Syntaxe kódu"
|
||||
"Collapse" = "Sbalit"
|
||||
"Collapse panel" = "Sbalit panel"
|
||||
"Collection" = "Kolekce"
|
||||
"Collections" = "Kolekce"
|
||||
"Color" = "Barva"
|
||||
|
||||
@@ -263,7 +263,6 @@ status = "draft — needs native review"
|
||||
"Code" = "Code"
|
||||
"Code syntax" = "Code-Syntax"
|
||||
"Collapse" = "Einklappen"
|
||||
"Collapse panel" = "Bereich einklappen"
|
||||
"Collection" = "Sammlung"
|
||||
"Collections" = "Sammlungen"
|
||||
"Color" = "Farbe"
|
||||
@@ -1781,6 +1780,198 @@ status = "draft — needs native review"
|
||||
"⌘ click" = "⌘ Klick"
|
||||
"⌘ scroll" = "⌘ Scrollen"
|
||||
"⌥ drag" = "⌥ Ziehen"
|
||||
"1 year" = "1 Jahr"
|
||||
"30 days" = "30 Tage"
|
||||
"90 days" = "90 Tage"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Eine responsive Produktseite: Hero-Bereich, Funktionen, Kennzahlen, ein Zitat und eine Fußzeile"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Ein Titel, eine Agenda, eine große Kennzahl, ein Diagramm und eine Abschlussfolie"
|
||||
"A to Z" = "A bis Z"
|
||||
"All the fonts this file uses are installed" = "Alle Schriften dieser Datei sind installiert"
|
||||
"Allow and download" = "Erlauben und herunterladen"
|
||||
"Allow downloading fonts?" = "Herunterladen von Schriften erlauben?"
|
||||
"Alphabetical" = "Alphabetisch"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Ein Analytics-Bildschirm auf einem Raster: Kennzahlen, aus Vektoren gezeichnete Diagramme und eine Tabelle"
|
||||
"Android Vector Drawable" = "Android Vector Drawable"
|
||||
"Apps" = "Apps"
|
||||
"Apr" = "Apr."
|
||||
"Aug" = "Aug."
|
||||
"Best effort" = "Bestmöglich"
|
||||
"Board grid" = "Boardraster"
|
||||
"Bottom padding" = "Innenabstand unten"
|
||||
"Bound to {name}. Click to detach the variable." = "An {name} gebunden. Klicken Sie, um die Variable zu lösen."
|
||||
"Check for Updates" = "Nach Updates suchen"
|
||||
"Check for updates…" = "Nach Updates suchen…"
|
||||
"Checking the fonts installed on this computer…" = "Auf diesem Computer installierte Schriften werden geprüft…"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Farb- und Textstile, Variablen für Hell und Dunkel, Buttons, Eingabefelder und Karten"
|
||||
"Compatibility" = "Kompatibilität"
|
||||
"Convert to frame" = "In Rahmen umwandeln"
|
||||
"Convert to section" = "In Abschnitt umwandeln"
|
||||
"Dart constants for Flutter…" = "Dart-Konstanten für Flutter…"
|
||||
"Calculated from other variables; it follows them" = "Aus anderen Variablen berechnet; folgt ihnen"
|
||||
"Copied comments for AI" = "Kommentare für KI kopiert"
|
||||
"Copy effect" = "Effekt kopieren"
|
||||
"Copy fill" = "Füllung kopieren"
|
||||
"Copy for AI" = "Für KI kopieren"
|
||||
"Copy stroke" = "Kontur kopieren"
|
||||
"Couldn't download {name}" = "{name} konnte nicht heruntergeladen werden"
|
||||
"Created" = "Erstellt"
|
||||
"Crop image" = "Bild zuschneiden"
|
||||
"Dashboard" = "Dashboard"
|
||||
"Date created" = "Erstellungsdatum"
|
||||
"Dec" = "Dez."
|
||||
"Design system starter" = "Designsystem-Starter"
|
||||
"Detach calculation" = "Berechnung lösen"
|
||||
"Don't show this again" = "Nicht mehr anzeigen"
|
||||
"Download" = "Herunterladen"
|
||||
"Downloading… {percent}%" = "Wird heruntergeladen… {percent} %"
|
||||
"Draw a frame" = "Rahmen zeichnen"
|
||||
"Drop to use this image" = "Loslassen, um dieses Bild zu verwenden"
|
||||
"Edited {when}" = "Bearbeitet {when}"
|
||||
"Exact only" = "Nur exakt"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "Rahmen und Ebenen als PNG, JPG, SVG oder PDF exportieren"
|
||||
"Exported {name}; {n} kinds of content were changed or left out" = { one = "{name} exportiert; {n} Inhaltsart wurde geändert oder ausgelassen", other = "{name} exportiert; {n} Inhaltsarten wurden geändert oder ausgelassen" }
|
||||
"Exported {n} files; some content was changed or left out" = { one = "{n} Datei exportiert; einige Inhalte wurden geändert oder ausgelassen", other = "{n} Dateien exportiert; einige Inhalte wurden geändert oder ausgelassen" }
|
||||
"Feb" = "Feb."
|
||||
"FigCraft stopped because of an internal error." = "FigCraft wurde wegen eines internen Fehlers beendet."
|
||||
"File not found" = "Datei nicht gefunden"
|
||||
"Format" = "Format"
|
||||
"Four screens of a trail app with components and a prototype flow" = "Vier Bildschirme einer Wander-App mit Komponenten und einem Prototyp-Ablauf"
|
||||
"Free fonts come from Google Fonts and Noto, through jsDelivr and GitHub. Each file is checked against the font index before it is installed into the font folder the Craft apps share. Nothing is downloaded until you allow it." = "Kostenlose Schriften stammen von Google Fonts und Noto und werden über jsDelivr und GitHub geladen. Jede Datei wird anhand des Schriftenindex geprüft, bevor sie in den Schriftenordner installiert wird, den die Craft-Apps gemeinsam nutzen. Nichts wird heruntergeladen, bevor Sie es erlauben."
|
||||
"Getting started" = "Erste Schritte"
|
||||
"Graph paper" = "Rasterpapier"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "Komponenten in Graustufen und zwei Beispielbildschirme, um einen Ablauf zu skizzieren"
|
||||
"Hide UI" = "Oberfläche ausblenden"
|
||||
"Hide these tips (Getting started in the Help menu shows them again)" = "Diese Tipps ausblenden („Erste Schritte“ im Hilfemenü zeigt sie wieder an)"
|
||||
"Hide when publishing" = "Beim Veröffentlichen ausblenden"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "Icon-Komponenten auf einem 24-px-Raster mit Keylines und einer Größenvorschau"
|
||||
"Icon set starter" = "Icon-Set-Starter"
|
||||
"Ignore unsupported" = "Nicht unterstützte ignorieren"
|
||||
"Individual padding" = "Einzelne Innenabstände"
|
||||
"Interface size" = "Größe der Oberfläche"
|
||||
"Jan" = "Jan."
|
||||
"Joining live session…" = "Live-Sitzung wird beigetreten…"
|
||||
"Jul" = "Jul."
|
||||
"Jun" = "Jun."
|
||||
"Kits" = "Kits"
|
||||
"Landing page" = "Landingpage"
|
||||
"Last modified" = "Zuletzt geändert"
|
||||
"Leaving the prototype" = "Prototyp wird verlassen"
|
||||
"Left padding" = "Innenabstand links"
|
||||
"Link frames on the Prototype tab, then present the flow" = "Verknüpfen Sie Rahmen im Tab „Prototyp“ und präsentieren Sie dann den Ablauf"
|
||||
"Make a component" = "Komponente erstellen"
|
||||
"Past 30 days" = "Letzte 30 Tage"
|
||||
"Past 90 days" = "Letzte 90 Tage"
|
||||
"Past year" = "Letztes Jahr"
|
||||
"Mar" = "Mär."
|
||||
"May" = "Mai"
|
||||
"Missing fonts" = "Fehlende Schriften"
|
||||
"Mobile app" = "Mobile App"
|
||||
"Newest first" = "Neueste zuerst"
|
||||
"No comments to copy" = "Keine Kommentare zum Kopieren"
|
||||
"No files or templates match your search" = "Keine Dateien oder Vorlagen passen zu Ihrer Suche"
|
||||
"No other pages" = "Keine weiteren Seiten"
|
||||
"No templates match your search" = "Keine Vorlagen passen zu Ihrer Suche"
|
||||
"Not now" = "Nicht jetzt"
|
||||
"Nothing counted yet" = "Noch nichts gezählt"
|
||||
"Nov" = "Nov."
|
||||
"Rotate a degree (Shift: 15)" = "Um ein Grad drehen (Umschalt: 15)"
|
||||
"⌥⌘ ← ↑ → ↓" = "⌥⌘ ← ↑ → ↓"
|
||||
"Ctrl+Alt+← ↑ → ↓" = "Strg+Alt+← ↑ → ↓"
|
||||
"Oct" = "Okt."
|
||||
"Oldest first" = "Älteste zuerst"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "Eine Ankündigung in fünf gängigen Größen: Quadrat, Hochformat, Story, Link und Banner"
|
||||
"Order" = "Reihenfolge"
|
||||
"Other" = "Sonstige"
|
||||
"Paste effect" = "Effekt einfügen"
|
||||
"Paste fill" = "Füllung einfügen"
|
||||
"Paste stroke" = "Kontur einfügen"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Wählen Sie das Rahmen-Werkzeug und ziehen Sie auf der Arbeitsfläche oder wählen Sie eine Gerätegröße"
|
||||
"Plugin" = "Plugin"
|
||||
"Plugins & widgets" = "Plugins & Widgets"
|
||||
"Presentation slides" = "Präsentationsfolien"
|
||||
"Playground" = "Testumgebung"
|
||||
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativ zur Anzeigeskalierung Ihres Systems (100 % ist die Größe des Systems). Der Dokumentzoom ist davon unabhängig."
|
||||
"Reload the page to get back the unsaved files the browser had room for." = "Laden Sie die Seite neu, um die ungespeicherten Dateien zurückzuerhalten, für die der Browser Platz hatte."
|
||||
"Reload the page to get your unsaved files back." = "Laden Sie die Seite neu, um Ihre ungespeicherten Dateien zurückzuerhalten."
|
||||
"Reload the page to start again." = "Laden Sie die Seite neu, um von vorn zu beginnen."
|
||||
"Remove all auto layout" = "Gesamtes Auto-Layout entfernen"
|
||||
"Remove from recents" = "Aus „Zuletzt verwendet“ entfernen"
|
||||
"Reorder selection" = "Auswahl neu anordnen"
|
||||
"Reset grid" = "Raster zurücksetzen"
|
||||
"Right padding" = "Innenabstand rechts"
|
||||
"Search templates" = "Vorlagen suchen"
|
||||
"See all templates" = "Alle Vorlagen ansehen"
|
||||
"Select layers and they space themselves as their content changes" = "Wählen Sie Ebenen aus, und ihre Abstände passen sich an, wenn sich der Inhalt ändert"
|
||||
"Search files and templates" = "Dateien und Vorlagen suchen"
|
||||
"Search results" = "Suchergebnisse"
|
||||
"This server doesn't count insertions" = "Dieser Server zählt keine Einfügungen"
|
||||
"This server doesn't count styles and variables" = "Dieser Server zählt keine Stile und Variablen"
|
||||
"Upload from computer" = "Vom Computer hochladen"
|
||||
"Search pages" = "Seiten suchen"
|
||||
"Sep" = "Sep."
|
||||
"Server key" = "Serverschlüssel"
|
||||
"Set corner smoothing to 60%" = "Eckenglättung auf 60 % setzen"
|
||||
"Share with a code" = "Mit einem Code teilen"
|
||||
"Show all templates" = "Alle Vorlagen anzeigen"
|
||||
"The bars are hidden. {shortcut} shows them again" = "Die Leisten sind ausgeblendet. {shortcut} blendet sie wieder ein"
|
||||
"Show in Explorer" = "Im Explorer anzeigen"
|
||||
"Show in Finder" = "Im Finder anzeigen"
|
||||
"Show in folder" = "Im Ordner anzeigen"
|
||||
"Social and slides" = "Social Media und Folien"
|
||||
"Social post sizes" = "Größen für Social-Media-Beiträge"
|
||||
"Sort by" = "Sortieren nach"
|
||||
"Sort by {column}" = "Nach {column} sortieren"
|
||||
"Start from a template" = "Mit einer Vorlage beginnen"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "Starten Sie das Teilen mit der Schaltfläche „Teilen“ und geben Sie dann den angezeigten Code weiter"
|
||||
"Strong line every" = "Starke Linie alle"
|
||||
"Text on a path" = "Text auf Pfad"
|
||||
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Das Fenster ist zu klein für {size} %, daher wird die Oberfläche mit {drawn} % dargestellt. Vergrößern Sie das Fenster, um {size} % zu verwenden."
|
||||
"This file is too large to join here (up to {mb} MB)" = "Diese Datei ist zu groß, um hier beizutreten (bis zu {mb} MB)"
|
||||
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Diese Datei verwendet Schriften, die auf diesem Computer nicht installiert sind. Ihr Text wird in einer Ersatzschrift dargestellt, bis Sie sie installieren."
|
||||
"This link opens a website outside the prototype:" = "Dieser Link öffnet eine Website außerhalb des Prototyps:"
|
||||
"This link opens your mail app to write a message:" = "Dieser Link öffnet Ihre Mail-App, um eine Nachricht zu schreiben:"
|
||||
"This server doesn't count detached instances" = "Dieser Server zählt keine gelösten Instanzen"
|
||||
"Top padding" = "Innenabstand oben"
|
||||
"Try another search, or browse templates." = "Versuchen Sie eine andere Suche oder stöbern Sie in den Vorlagen."
|
||||
"Try another word, or look at every kind." = "Versuchen Sie ein anderes Wort oder sehen Sie sich alle Arten an."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Wandeln Sie eine Ebene in eine Komponente um, verwenden Sie sie wieder und ändern Sie alle Kopien auf einmal"
|
||||
"Update check is not supported in the web browser." = "Die Update-Suche wird im Webbrowser nicht unterstützt."
|
||||
"Use your browser's zoom to change the interface size." = "Ändern Sie die Größe der Oberfläche mit dem Zoom Ihres Browsers."
|
||||
"Uses variables: each layer shows them in its own mode" = "Verwendet Variablen: Jede Ebene zeigt sie in ihrem eigenen Modus"
|
||||
"Websites" = "Websites"
|
||||
"What you try here changes this preview and the code below, not the file" = "Was Sie hier ausprobieren, ändert diese Vorschau und den Code darunter, nicht die Datei"
|
||||
"Widget" = "Widget"
|
||||
"Wireframe kit" = "Wireframe-Kit"
|
||||
"Your unsaved files were too large for the browser to keep." = "Ihre ungespeicherten Dateien waren zu groß, als dass der Browser sie behalten konnte."
|
||||
"Z to A" = "Z bis A"
|
||||
"connector labels without a saved snapshot are centred on the line, without their background" = "Verbinder-Beschriftungen ohne gespeicherten Schnappschuss werden mittig auf der Linie und ohne Hintergrund dargestellt"
|
||||
"elbowed and curved connectors without a saved path follow FigCraft's own routing" = "Abgewinkelte und gebogene Verbinder ohne gespeicherten Pfad folgen dem eigenen Routing von FigCraft"
|
||||
"instance swap properties whose default component was past the layer limit were skipped" = "Instanztausch-Eigenschaften, deren Standardkomponente über dem Ebenenlimit lag, wurden übersprungen"
|
||||
"instances whose component was past the layer limit were imported as frames" = "Instanzen, deren Komponente über dem Ebenenlimit lag, wurden als Rahmen importiert"
|
||||
"invalid vector region fill paint list; layer fill used" = "Ungültige Füllungsliste der Vektorregion; Füllung der Ebene verwendet"
|
||||
"invalid vector region paints were skipped" = "Ungültige Füllungen der Vektorregion wurden übersprungen"
|
||||
"just now" = "gerade eben"
|
||||
"linked vector region fill styles keep resolved appearance; live style links were left out" = "Verknüpfte Füllstile von Vektorregionen behalten ihr aufgelöstes Aussehen; Live-Stilverknüpfungen wurden ausgelassen"
|
||||
"note or shape generated child has invalid bounds; child skipped" = "Vom Notiz- oder Formgenerator erzeugtes Kindobjekt hat ungültige Grenzen; Kindobjekt übersprungen"
|
||||
"note or shape generated child is missing its style or bounds; child skipped" = "Dem vom Notiz- oder Formgenerator erzeugten Kindobjekt fehlen Stil oder Grenzen; Kindobjekt übersprungen"
|
||||
"note or shape generated children exceed the layer or nesting limit; remaining children skipped" = "Vom Notiz- oder Formgenerator erzeugte Kindobjekte überschreiten das Ebenen- oder Verschachtelungslimit; übrige Kindobjekte übersprungen"
|
||||
"note or shape generated layout is missing or unsupported; using its outer frame" = "Das vom Notiz- oder Formgenerator erzeugte Layout fehlt oder wird nicht unterstützt; äußerer Rahmen verwendet"
|
||||
"only in a text style or variable" = "nur in einem Textstil oder einer Variable"
|
||||
"shape generated outline is missing or damaged; using its rectangle" = "Die vom Formgenerator erzeugte Kontur fehlt oder ist beschädigt; Rechteck verwendet"
|
||||
"slot layers without a matching slot property were imported as frames" = "Slot-Ebenen ohne passende Slot-Eigenschaft wurden als Rahmen importiert"
|
||||
"variable-bound vector region paints keep resolved appearance; live bindings were left out" = "An Variablen gebundene Füllungen von Vektorregionen behalten ihr aufgelöstes Aussehen; Live-Bindungen wurden ausgelassen"
|
||||
"vector region paint data exceeded import limits and was skipped" = "Füllungsdaten der Vektorregion überschritten die Importgrenzen und wurden übersprungen"
|
||||
"vector region references a missing fill style; layer fill used" = "Vektorregion verweist auf einen fehlenden Füllstil; Füllung der Ebene verwendet"
|
||||
"yesterday" = "gestern"
|
||||
"{insertions} · {detachments}" = "{insertions} · {detachments}"
|
||||
"{month} {day}, {year}" = "{day}. {month} {year}"
|
||||
"{n} detached" = { one = "{n} gelöst", other = "{n} gelöst" }
|
||||
"{n} inserted" = { one = "{n} eingefügt", other = "{n} eingefügt" }
|
||||
"{n} text layers" = { one = "{n} Textebene", other = "{n} Textebenen" }
|
||||
"{percent}%" = "{percent} %"
|
||||
"{period}: {counts}" = "{period}: {counts}"
|
||||
"{used} · {detached}" = "{used} · {detached}"
|
||||
"{used} · {inserted}" = "{used} · {inserted}"
|
||||
|
||||
[commands]
|
||||
"align.bottom" = "Unten ausrichten"
|
||||
|
||||
@@ -293,7 +293,6 @@ name = "English"
|
||||
"Code" = "Code"
|
||||
"Code syntax" = "Code syntax"
|
||||
"Collapse" = "Collapse"
|
||||
"Collapse panel" = "Collapse panel"
|
||||
"Collection" = "Collection"
|
||||
"Collections" = "Collections"
|
||||
"Color" = "Color"
|
||||
@@ -407,6 +406,17 @@ name = "English"
|
||||
"Decoration style" = "Decoration style"
|
||||
"Decoration" = "Decoration"
|
||||
"Deep select" = "Deep select"
|
||||
"Wrap style" = "Wrap style"
|
||||
"Balance" = "Balance"
|
||||
"Pretty" = "Pretty"
|
||||
"Auto: as many words as fit on each line" = "Auto: as many words as fit on each line"
|
||||
"Balance: every line close to the same length" = "Balance: every line close to the same length"
|
||||
"Pretty: full lines, no one word alone on the last" = "Pretty: full lines, no one word alone on the last"
|
||||
"Set hug contents" = "Set hug contents"
|
||||
"Set fill container" = "Set fill container"
|
||||
"Double-click an edge of an auto layout frame" = "Double-click an edge of an auto layout frame"
|
||||
"⌥ double-click an edge of an auto layout child" = "⌥ double-click an edge of an auto layout child"
|
||||
"Alt+double-click an edge of an auto layout child" = "Alt+double-click an edge of an auto layout child"
|
||||
"Default" = "Default"
|
||||
"Default content" = "Default content"
|
||||
"Default value (the top-left variant)" = "Default value (the top-left variant)"
|
||||
@@ -531,6 +541,7 @@ name = "English"
|
||||
"Exclusion" = "Exclusion"
|
||||
"Exit focus view (Esc)" = "Exit focus view (Esc)"
|
||||
"Expand" = "Expand"
|
||||
"Expand the interface" = "Expand the interface"
|
||||
"Export" = "Export"
|
||||
"Export all" = "Export all"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "Export frames and layers as PNG, JPG, SVG or PDF"
|
||||
@@ -656,6 +667,7 @@ name = "English"
|
||||
"Help page" = "Help page"
|
||||
"Here: {names}" = "Here: {names}"
|
||||
"Hidden" = "Hidden"
|
||||
"Hide UI" = "Hide UI"
|
||||
"Hide contents" = "Hide contents"
|
||||
"Hide from publishing" = "Hide from publishing"
|
||||
"Hide section contents" = "Hide section contents"
|
||||
@@ -993,6 +1005,9 @@ name = "English"
|
||||
"Nov" = "Nov"
|
||||
"Now" = "Now"
|
||||
"Nudge (Shift: big nudge)" = "Nudge (Shift: big nudge)"
|
||||
"Rotate a degree (Shift: 15)" = "Rotate a degree (Shift: 15)"
|
||||
"⌥⌘ ← ↑ → ↓" = "⌥⌘ ← ↑ → ↓"
|
||||
"Ctrl+Alt+← ↑ → ↓" = "Ctrl+Alt+← ↑ → ↓"
|
||||
"Nudge amount" = "Nudge amount"
|
||||
"Number" = "Number"
|
||||
"Number ↑" = "Number ↑"
|
||||
@@ -1187,6 +1202,7 @@ name = "English"
|
||||
"Remove" = "Remove"
|
||||
"Remove all" = "Remove all"
|
||||
"Remove annotation" = "Remove annotation"
|
||||
"Remove all auto layout" = "Remove all auto layout"
|
||||
"Remove auto layout" = "Remove auto layout"
|
||||
"Remove column" = "Remove column"
|
||||
"Remove dev resource" = "Remove dev resource"
|
||||
@@ -1384,6 +1400,7 @@ name = "English"
|
||||
"Show device frame" = "Show device frame"
|
||||
"Show dimensions on objects" = "Show dimensions on objects"
|
||||
"Show hotspot hints" = "Show hotspot hints"
|
||||
"The bars are hidden. {shortcut} shows them again" = "The bars are hidden. {shortcut} shows them again"
|
||||
"Show in Explorer" = "Show in Explorer"
|
||||
"Show in Finder" = "Show in Finder"
|
||||
"Show in folder" = "Show in folder"
|
||||
|
||||
@@ -293,7 +293,6 @@ status = "draft — needs native review"
|
||||
"Code" = "Código"
|
||||
"Code syntax" = "Sintaxis de código"
|
||||
"Collapse" = "Contraer"
|
||||
"Collapse panel" = "Contraer panel"
|
||||
"Collection" = "Colección"
|
||||
"Collections" = "Colecciones"
|
||||
"Color" = "Color"
|
||||
|
||||
@@ -281,7 +281,6 @@ status = "draft — needs native review"
|
||||
"Code or invite link" = "Koodi tai kutsulinkki"
|
||||
"Code syntax" = "Koodin syntaksi"
|
||||
"Collapse" = "Tiivistä muunnokset"
|
||||
"Collapse panel" = "Supista paneeli"
|
||||
"Collection" = "Kokoelma"
|
||||
"Collections" = "Kokoelmat"
|
||||
"Color" = "Väri"
|
||||
|
||||
@@ -269,7 +269,6 @@ status = "draft — needs native review"
|
||||
"Code" = "Code"
|
||||
"Code syntax" = "Syntaxe du code"
|
||||
"Collapse" = "Réduire"
|
||||
"Collapse panel" = "Réduire le panneau"
|
||||
"Collection" = "Collection"
|
||||
"Collections" = "Collections"
|
||||
"Color" = "Couleur"
|
||||
|
||||
@@ -281,7 +281,6 @@ status = "draft — needs native review"
|
||||
"Code" = "Kode"
|
||||
"Code syntax" = "Sintaks kode"
|
||||
"Collapse" = "Ciutkan"
|
||||
"Collapse panel" = "Ciutkan panel"
|
||||
"Collection" = "Koleksi"
|
||||
"Collections" = "Koleksi"
|
||||
"Color" = "Warna"
|
||||
|
||||
@@ -259,7 +259,6 @@ status = "draft — needs native review"
|
||||
"Code" = "Codice"
|
||||
"Code syntax" = "Sintassi del codice"
|
||||
"Collapse" = "Comprimi"
|
||||
"Collapse panel" = "Comprimi pannello"
|
||||
"Collection" = "Collezione"
|
||||
"Collections" = "Collezioni"
|
||||
"Color" = "Colore"
|
||||
|
||||
@@ -270,7 +270,6 @@ status = "draft — needs native review"
|
||||
"Code" = "コード"
|
||||
"Code syntax" = "コード構文"
|
||||
"Collapse" = "折りたたむ"
|
||||
"Collapse panel" = "パネルを折りたたむ"
|
||||
"Collection" = "コレクション"
|
||||
"Collections" = "コレクション"
|
||||
"Color" = "カラー"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user