mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 02:34:12 +00:00
Merge upstream/main into fix/padding-all-sides
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Generated
+22
-22
@@ -1763,7 +1763,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-menu",
|
||||
@@ -1795,7 +1795,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -1813,7 +1813,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1831,7 +1831,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1854,7 +1854,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1863,7 +1863,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1876,7 +1876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1910,7 +1910,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-layout",
|
||||
@@ -1927,7 +1927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1935,7 +1935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1943,7 +1943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1959,7 +1959,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1968,7 +1968,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1985,7 +1985,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1998,7 +1998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -2017,7 +2017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
@@ -2042,7 +2042,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -2058,7 +2058,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"craft-font-catalog",
|
||||
@@ -2076,7 +2076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -2104,7 +2104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-vectordrawable"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -2118,7 +2118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"eframe",
|
||||
@@ -6739,7 +6739,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.0"
|
||||
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.14.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -98,3 +98,6 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.aleksei-suholita]
|
||||
real_name = "Oleksii Sukholita"
|
||||
|
||||
+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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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());
|
||||
|
||||
@@ -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 {
|
||||
@@ -466,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>,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"}),
|
||||
] {
|
||||
|
||||
@@ -623,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" => {
|
||||
@@ -953,6 +961,9 @@ 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"))?,
|
||||
"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)?;
|
||||
@@ -1197,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;
|
||||
@@ -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();
|
||||
|
||||
@@ -308,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(['[', ']']))),
|
||||
|
||||
@@ -371,6 +371,72 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
|
||||
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
|
||||
|
||||
@@ -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))]),
|
||||
|
||||
@@ -1611,3 +1611,27 @@ fn a_whole_ellipse_starting_anywhere_comes_back_whole() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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,15 +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;
|
||||
// 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.
|
||||
let precision = (start.abs() + end.abs() + tau) * f64::from(f32::EPSILON);
|
||||
let sweep = end - start;
|
||||
let sweep = if sweep.abs() >= tau - precision { tau.copysign(sweep) } else { sweep };
|
||||
NodeKind::Ellipse { start, sweep: sweep.clamp(-tau, tau), inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0) }
|
||||
// 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() },
|
||||
@@ -1509,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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
+228
-8
@@ -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,
|
||||
@@ -970,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();
|
||||
|
||||
@@ -408,6 +408,12 @@ 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"
|
||||
|
||||
@@ -299,10 +299,13 @@ fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &To
|
||||
let (r, cell) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click());
|
||||
let cell = cell.on_hover_text(tr(b.label()));
|
||||
a11y::menu_item(&cell, tr(b.label()), "", Some(b == shape));
|
||||
if b == shape || cell.hovered() {
|
||||
let lit = b == shape || cell.hovered();
|
||||
if lit {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
|
||||
}
|
||||
paint_choice(ui.painter(), r.shrink(6.0), &Mark::Shape(b), t.menu_text);
|
||||
// The menu is the theme's popup (white in the light theme): the theme's icon
|
||||
// colour, white only on the green highlight.
|
||||
paint_choice(ui.painter(), r.shrink(6.0), &Mark::Shape(b), if lit { t.menu_text } else { t.icon });
|
||||
if cell.clicked() {
|
||||
app.run("board.setStyle", json!({"shape": b.id()}));
|
||||
ui.close();
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
//! Panel dropdowns (the prototype device picker, Preferences, blend modes…) draw on the theme's
|
||||
//! popup fill, so their rows' text must read on it in both themes (#972): drawn headless.
|
||||
//! Panel dropdowns (the prototype device picker, Preferences, blend modes…), a file tab's menu and
|
||||
//! the board bar's shape menu draw on the theme's popup fill, so what is in them must read on it
|
||||
//! in both themes (#972): drawn headless.
|
||||
|
||||
use egui::accesskit::Node;
|
||||
use egui::epaint::ColorMode;
|
||||
use egui::{Color32, Event, Order, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2};
|
||||
use figcraft_doc::BoardShape;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::present_tests::Ui;
|
||||
use crate::right::{menu_check, menu_item, menu_popup};
|
||||
use crate::theme::{self, Tokens};
|
||||
|
||||
@@ -82,3 +89,100 @@ fn dropdown_rows_read_on_the_popup_in_both_themes() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole app headless on `s` in the `dark` or light theme, with windows of their own allowed
|
||||
/// (a tab offers Open in new window only then); the theme's tokens.
|
||||
fn app(mut s: Session, dark: bool) -> (Ui, Tokens) {
|
||||
s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.set_embed_viewports(false);
|
||||
u.frame(vec![]);
|
||||
let t = Tokens::get(&u.ctx);
|
||||
assert_eq!(t.dark, dark, "the app took the theme");
|
||||
(u, t)
|
||||
}
|
||||
|
||||
fn click(u: &mut Ui, at: Pos2, button: PointerButton) {
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button, pressed, modifiers: Default::default() }]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pointer away from the open menu until its fade-in is over: that frame's output.
|
||||
fn settle(u: &mut Ui) -> egui::FullOutput {
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![Event::PointerMoved(pos2(1430.0, 600.0))]);
|
||||
}
|
||||
u.frame_output(vec![])
|
||||
}
|
||||
|
||||
/// The AccessKit nodes on screen.
|
||||
fn nodes(u: &mut Ui) -> Vec<Node> {
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
|
||||
}
|
||||
|
||||
fn bounds(n: &Node) -> Rect {
|
||||
let b = n.bounds().unwrap();
|
||||
Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))
|
||||
}
|
||||
|
||||
/// The colours of the lines painted inside `r`.
|
||||
fn lines_in(out: &egui::FullOutput, r: Rect) -> Vec<Color32> {
|
||||
fn walk(s: &Shape, r: Rect, out: &mut Vec<Color32>) {
|
||||
match s {
|
||||
Shape::Vec(v) => v.iter().for_each(|s| walk(s, r, out)),
|
||||
Shape::Path(p) if r.contains_rect(s.visual_bounding_rect()) => {
|
||||
if let ColorMode::Solid(c) = p.stroke.color {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut lines = Vec::new();
|
||||
for c in &out.shapes {
|
||||
walk(&c.shape, r, &mut lines);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_tabs_menu_reads_on_its_popup_in_both_themes() {
|
||||
for dark in [false, true] {
|
||||
let (mut u, t) = app(Session::new(), dark);
|
||||
click(&mut u, pos2(100.0, 20.0), PointerButton::Secondary);
|
||||
let texts = menu_texts(&settle(&mut u));
|
||||
let Some((_, col)) = texts.iter().find(|(s, _)| s == "Open in new window") else { panic!("the tab's menu opened (dark {dark}): {texts:?}") };
|
||||
let ratio = color(*col).contrast(color(t.panel));
|
||||
assert!(ratio >= 4.5, "Open in new window at {ratio:.2}:1 on the popup (dark {dark})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_board_bars_shape_menu_shows_every_shape_in_both_themes() {
|
||||
for dark in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newBoard", &json!({})).unwrap();
|
||||
s.execute("board.shape", &json!({"shape": "rectangle", "x": 0, "y": 0})).unwrap();
|
||||
let (mut u, t) = app(s, dark);
|
||||
let all = nodes(&mut u);
|
||||
let button = all.iter().find(|n| n.label() == Some("Shape")).expect("the board bar's Shape button");
|
||||
click(&mut u, bounds(button).center(), PointerButton::Primary);
|
||||
let all = nodes(&mut u);
|
||||
let out = settle(&mut u);
|
||||
// Every shape but the picked one (white on the green highlight) on the popup itself.
|
||||
for b in BoardShape::ALL.into_iter().filter(|b| *b != BoardShape::Rectangle) {
|
||||
let label = crate::i18n::tr(b.label());
|
||||
let cell = all.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("{label} in the menu (dark {dark})"));
|
||||
let lines = lines_in(&out, bounds(cell));
|
||||
assert!(!lines.is_empty(), "{label}'s outline painted (dark {dark})");
|
||||
for c in lines {
|
||||
// Non-text contrast (WCAG 1.4.11): at least 3:1.
|
||||
let ratio = color(c).contrast(color(t.panel));
|
||||
assert!(ratio >= 3.0, "{label}'s outline at {ratio:.2}:1 on the popup (dark {dark})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +385,18 @@ fn type_settings(
|
||||
if let Some(i) = text_seg(ui, "trim", &items, trim, w) {
|
||||
app.set_props(ids, json!({"leadingTrim": if i == 1 { "capHeight" } else { "none" }}));
|
||||
}
|
||||
// Where lines break when the text wraps (Explore text properties).
|
||||
caption(ui, tr("Wrap style"));
|
||||
let wrap = common(doc, ids, |n| n.kind.text().map(|t| t.wrap_style as usize)).flatten();
|
||||
let items = [
|
||||
(tr("Auto"), tr_noop("Auto: as many words as fit on each line")),
|
||||
(tr("Balance"), tr_noop("Balance: every line close to the same length")),
|
||||
(tr("Pretty"), tr_noop("Pretty: full lines, no one word alone on the last")),
|
||||
];
|
||||
if let Some(i) = text_seg(ui, "wrap", &items, wrap, w) {
|
||||
let style = ["auto", "balance", "pretty"].get(i).copied().unwrap_or("auto");
|
||||
app.set_props(ids, json!({"wrapStyle": style}));
|
||||
}
|
||||
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
|
||||
// Auto-height text with a line limit truncates too.
|
||||
let truncating = common(doc, ids, |n| n.kind.text().map(TextData::truncates)).flatten();
|
||||
|
||||
@@ -162,9 +162,10 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
None => {}
|
||||
}
|
||||
if several {
|
||||
resp.context_menu(|ui| {
|
||||
ui.set_width(200.0);
|
||||
if crate::menus::item(ui, tr("Open in new window"), "", true, false, &t) {
|
||||
// The tab strip's own menu takes the theme like its overflow menus (the in-app
|
||||
// menus stay dark, #843): its rows read on the white popup of the light theme.
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Open in new window"), "") {
|
||||
detach = Some((i, None));
|
||||
ui.close();
|
||||
}
|
||||
|
||||
+5
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user