Merge main into #861

This commit is contained in:
bflatastic
2026-10-20 01:33:51 +07:00
68 changed files with 1981 additions and 284 deletions
+5 -1
View File
@@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool {
return true;
}
let Some((name, port)) = s.rsplit_once(':') else { return false };
port.parse::<u16>().is_ok_and(|p| p != 0)
port.bytes().all(|b| b.is_ascii_digit())
&& port.parse::<u16>().is_ok_and(|p| p != 0)
&& (1..=253).contains(&name.len())
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
&& !name.starts_with(['-', '.'])
@@ -239,6 +240,9 @@ fn percent_decode(s: &str) -> Option<String> {
while let Some(&b) = bytes.get(i) {
if b == b'%' {
let hex = bytes.get(i + 1..i + 3)?;
if !hex.iter().all(u8::is_ascii_hexdigit) {
return None;
}
out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
i += 3;
} else {
+16
View File
@@ -123,3 +123,19 @@ proptest! {
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
}
}
#[test]
fn plus_or_sign_inside_a_percent_escape_is_broken() {
assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok());
for bad in ["%+1", "%-1", "%1", "%+a"] {
assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}");
}
}
#[test]
fn port_must_be_all_digits() {
assert!(Endpoint::parse("ws://localhost:80/").is_ok());
for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] {
assert!(Endpoint::parse(bad).is_err(), "accepted {bad}");
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ impl<'de> Deserialize<'de> for Color {
}
fn clamp01(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }
}
impl Default for Color {
+9 -1
View File
@@ -80,7 +80,7 @@ fn blend_names() {
#[test]
fn nan_inputs_are_sanitized() {
let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY);
assert_eq!(c.to_rgba8(), [0, 255, 0, 0]);
assert_eq!(c.to_rgba8(), [0, 255, 0, 255]);
}
#[test]
@@ -106,3 +106,11 @@ fn crop_defaults_to_fill_and_roundtrips() {
assert!((a - b).abs() < 1e-9);
}
}
#[test]
fn infinite_channels_clamp() {
let c = Color::parse_css("rgb(1e40, 0, 0)").unwrap();
assert_eq!(c.r, 1.0);
let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY);
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
}
+74
View File
@@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() {
ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7}));
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
}
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
}
fn brand_id(s: &Session) -> String {
s.doc().unwrap().variables.get("brand").unwrap().id.clone()
}
#[test]
fn explicit_paint_edits_detach_the_colour_binding() {
// An edit that reported success keeps its colour: the binding it overwrote lets go (as
// selection colours already do), instead of putting the variable's colour back.
let edits = [
("fill.set", json!({"color": "#00FF00"})),
("node.set", json!({"props": {"fill": "#00FF00"}})),
("node.set", json!({"props": {"fills": ["#00FF00"]}})),
];
for (cmd, mut p) in edits {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p.clone());
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}");
assert!(bound(&s, r).is_empty(), "{cmd} {p}");
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable");
}
for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] {
let (mut s, r) = setup();
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p);
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}");
assert!(n.bound.is_empty(), "{cmd}");
}
}
#[test]
fn clearing_bound_fills_adds_no_paint_back() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
assert!(bound(&s, r).is_empty());
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back");
}
#[test]
fn paint_opacity_edits_keep_the_colour_binding() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5}));
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable");
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5);
// The same colour with another opacity, through node.set, keeps it too.
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}}));
assert_eq!(bound(&s, r).len(), 1);
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25);
}
#[test]
fn paints_set_with_their_binding_in_the_same_call_keep_it() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
let brand = brand_id(&s);
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}}));
assert_eq!(bound(&s, r), [("fill".to_string(), brand)]);
assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies");
}
+1 -1
View File
@@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result
}
return Ok(json!({"ids": ids, "operation": op}));
}
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?;
// Operands of a new group follow its operation.
if let Some(g) = crate::params::id_of(&out["id"]) {
let doc = s.doc_mut()?;
+2
View File
@@ -528,6 +528,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0));
let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0);
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let n = doc.get(i)?;
let (ow, oh) = (n.width, n.height);
@@ -551,6 +552,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}
super::props::restore_worlds(doc, &kept)?;
}
super::styles::detach_edited(doc, &before);
ok()
}
+7 -2
View File
@@ -608,7 +608,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result<Value> {
let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?;
let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name));
let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null);
add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
}
fn remove_slot(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1039,7 +1039,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result<String> {
}
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.addProperty";
add_prop_for("component.addProperty", s, p)
}
/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its
/// slot property here).
fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?;
let t = req_string(cmd, p, "type")?;
+12 -6
View File
@@ -261,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
let i = req_index("effect.set", p, "index")?;
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
for id in req_targets("effect.set", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
if let Some(slot) = n.effects.get_mut(i) {
@@ -931,9 +931,13 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
let digits = req_string("object.opacityKeys", p, "digits")?;
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
for i in req_targets("object.opacityKeys", s, p)? {
s.doc_mut()?.get_mut(i)?.opacity = o;
let ids = req_targets("object.opacityKeys", s, p)?;
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
doc.get_mut(i)?.opacity = o;
}
super::styles::detach_edited(doc, &before);
Ok(json!({"opacity": o}))
}
@@ -1181,10 +1185,12 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let props = if skipped.contains(&i) { &without_layout } else { &props };
super::props::apply("node.set", doc, i, props)?;
}
super::styles::detach_edited(doc, &before);
if let Some(o) = pivot {
for (i, q) in pinned {
let d = o - doc.world_transform(i) * q;
@@ -1342,7 +1348,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
if let Some(b) = blend(p, "blend") {
slot.blend = b;
}
super::lists::remap_stop_bindings(n, fills, at, &old);
super::lists::rebind_edited_paint(n, fills, at, &old);
if fills {
n.styles.fill = None;
} else {
@@ -1376,8 +1382,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
}
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
let e: Effect = match get(p, "effect") {
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
let e: Effect = match super::props::effect_from("effect.add", p, "effect")? {
Some(e) => e,
None => {
let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into());
Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))?
+39 -3
View File
@@ -92,6 +92,20 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
let from = req_index("file.moveTab", p, "from")?;
let to = req_index("file.moveTab", p, "to")?;
if from >= s.docs.len() || to >= s.docs.len() {
return Err(bad("file.moveTab", "no such document"));
}
if from != to {
let active_uid = s.docs.get(s.active).map(|d| d.uid);
let doc = s.docs.remove(from);
s.docs.insert(to, doc);
s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0);
}
Ok(json!({"index": to}))
}),
spec!(
"file.recover",
"Recover file",
@@ -1374,14 +1388,36 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
}
let uid = ds.uid;
let active_uid = s.docs.get(s.active).map(|d| d.uid);
s.docs.remove(i);
if s.active >= s.docs.len() {
s.active = s.docs.len().saturating_sub(1);
}
// Removing a tab before the active one shifts its index; keep the same document selected.
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
crate::branches::close_tabs(s, uid, None);
ok()
}
#[cfg(test)]
mod tab_order_tests {
use super::*;
#[test]
fn moving_and_closing_inactive_tabs_preserves_the_active_document() {
let mut session = Session::new();
for name in ["Two", "Three", "Four"] {
session.execute("file.new", &json!({"name": name})).unwrap();
}
session.execute("doc.switch", &json!({"index": 2})).unwrap();
let active_uid = session.docs[session.active].uid;
let moved_uid = session.docs[0].uid;
session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap();
assert_eq!(session.docs[2].uid, moved_uid);
assert_eq!(session.docs[session.active].uid, active_uid);
session.execute("file.close", &json!({"index": 0})).unwrap();
assert_eq!(session.docs[session.active].uid, active_uid);
assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err());
}
}
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
let n = s.docs.len() as i64;
+40 -3
View File
@@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option<usize> {
/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after
/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's
/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices.
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
/// Returns the removed stop's index.
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option<usize> {
use figcraft_color::PaintKind;
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) };
@@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
// The first stop that differs is the removed one when the rest moved down unchanged.
let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len());
if a.stops.get(k + 1..) != b.stops.get(k..) {
return;
return None;
}
Some(k)
}
(_, Some(PaintKind::Gradient(_))) => return,
(_, Some(PaintKind::Gradient(_))) => return None,
_ => None,
};
let keys: Vec<(String, Option<String>)> = n
@@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
}
}
n.bound.extend(kept);
removed
}
/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop
/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the
/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the
/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings.
pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
let removed = remap_stop_bindings(n, fills, index, old);
let mut old = old.clone();
if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind)
&& k < g.stops.len()
{
g.stops.remove(k);
}
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned();
n.bound.retain(|key, _| match list_binding(key) {
Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)),
_ => true,
});
}
/// Whether a paint's bound `field` (`color`, `opacity`, `stops/<j>/color`, …) has the same value in
/// `new` as in `old`.
fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool {
use figcraft_color::PaintKind;
match field {
"color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b),
"opacity" => old.opacity == new.opacity,
_ => match (stop_binding(field), &old.kind, &new.kind) {
(Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color),
(Some(_), _, _) => false,
_ => old == new,
},
}
}
/// Where item `k` lands when the item at `from` moves to `to`.
+82 -14
View File
@@ -61,6 +61,48 @@ pub fn snake(v: &Value) -> Value {
}
}
/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word,
/// so a shadow given `offsetY` kept the default offset.
pub fn effects_from(cmd: &str, v: &Value) -> Result<Vec<Effect>> {
let effects: Vec<Effect> = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?;
for (i, given) in v.as_array().into_iter().flatten().enumerate() {
refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?;
}
Ok(effects)
}
/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read.
pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
let Some(v) = get(p, key) else { return Ok(None) };
let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?;
Ok(Some(effect))
}
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
let Some(fields) = given.as_object() else { return Ok(()) };
let parsed = parse(given);
let ignored: Vec<&str> = fields
.iter()
.filter(|(_, v)| !v.is_null())
.filter(|(k, _)| {
let mut probe = fields.clone();
probe.insert((*k).clone(), Value::Null);
parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed)
})
.map(|(k, _)| k.as_str())
.collect();
if ignored.is_empty() {
return Ok(());
}
let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default();
let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string();
let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", "))))
}
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let s = v.as_str()?;
// Accept camelCase, snake/upper-case and spaced variants.
@@ -297,6 +339,12 @@ pub const LAYOUT_KEYS: &[&str] = &[
"gridRowTracks",
];
/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or
/// re-applying them would bring it back.
pub fn drop_layout_bindings(n: &mut Node) {
n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str()));
}
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
/// (`node.set` on several layers skips them instead, see `edit::set`).
@@ -315,18 +363,32 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
Ok(ensure_layout(n))
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
// Geometry keys first so rotation applies to the final size.
let order = |k: &str| match k {
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
for (i, p) in old.iter().enumerate() {
super::lists::rebind_edited_paint(n, fills, i, p);
}
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
/// bindings last so paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"boundVariables" => 4,
_ => 1,
};
}
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| order(k));
keys.sort_by_key(|(k, _)| apply_order(k));
for (k, v) in keys {
let k = k.as_str();
match k {
@@ -398,15 +460,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
doc.get_mut(id)?.blend = b;
}
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
"fill" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
}
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
"stroke" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
}
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
@@ -462,7 +524,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
}
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"devStatus" => {
@@ -486,8 +548,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
return Err(bad(cmd, "auto layout needs a frame"));
}
match v {
Value::Null => n.layout = None,
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
Value::Null => {
n.layout = None;
drop_layout_bindings(n);
}
Value::String(s) if s.eq_ignore_ascii_case("none") => {
n.layout = None;
drop_layout_bindings(n);
}
Value::String(s) => {
let mode = match s.to_ascii_lowercase().as_str() {
"horizontal" | "row" => LayoutMode::Horizontal,
+22 -17
View File
@@ -10,11 +10,13 @@ use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
/// add/remove, move to page and vector path edits.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)),
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)),
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
spec!(
"object.convertToGroup",
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Wrap in new section",
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
true,
|s, p| wrap(s, p, Wrap::Section)
|s, p| wrap(s, p, "object.wrapInSection", Wrap::Section)
),
spec!(
"object.resizeToFit",
@@ -71,7 +73,9 @@ pub fn specs() -> Vec<CommandSpec> {
// Hugging needs auto layout and the children that filled have nothing to fill: both
// become fixed (the frame keeps filling its own auto layout parent).
let doc = s.doc_mut()?;
doc.get_mut(i)?.layout = None;
let n = doc.get_mut(i)?;
n.layout = None;
super::props::drop_layout_bindings(n);
figcraft_layout::coherent_sizing(doc, i);
}
ok()
@@ -128,26 +132,27 @@ const SECTION_PAD: f64 = 40.0;
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
/// Wrap the selection in a new boolean group doing `op`.
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
wrap(s, p, Wrap::Bool(op))
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result<Value> {
wrap(s, p, cmd, Wrap::Bool(op))
}
/// Wrap the selection in a new container at the topmost selected layer's place.
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
let ids = req_targets("object.group", s, p)?;
/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`,
/// the command that asked.
fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result<Value> {
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?;
// Sections sit on the page or in sections, never in frames or groups.
let board = |doc: &Document, i: NodeId| {
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
};
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section"));
}
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
return Err(bad("object.group", "sections can't go in groups or frames"));
return Err(bad(cmd, "sections can't go in groups or frames"));
}
let index = doc.index_in_parent(top).map(|i| i + 1);
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
@@ -285,7 +290,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
n.grids.clear();
n.guides.clear();
n.styles.grid = None;
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
super::props::drop_layout_bindings(n);
let kids = n.children.clone();
for k in kids {
// Out of the flow there is nothing to fill or to be absolute against.
@@ -338,7 +343,7 @@ fn mask(s: &mut Session, p: &Value) -> Result<Value> {
return ok();
}
// Group, then the bottom-most child is the mask.
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?;
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
let doc = s.doc_mut()?;
if let Some(first) = doc.get(gid)?.children.first().copied() {
@@ -472,7 +477,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let f = doc.get_mut(fill_id)?;
f.strokes.clear();
}
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?;
if let Some(g) = id_of(&r["id"]) {
out.push(g);
}
@@ -519,7 +524,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
ids
} else {
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?;
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
let f = s.doc_mut()?.get_mut(fid)?;
f.fills.clear();
+121 -6
View File
@@ -110,16 +110,26 @@ pub fn specs() -> Vec<CommandSpec> {
))
}),
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
let cmd = "variable.createCollection";
let name = req_string(cmd, p, "name")?.trim().to_string();
if name.is_empty() {
return Err(bad(cmd, "name can't be empty"));
}
let id = doc.new_collection_id();
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(),
_ => vec!["Mode 1".into()],
};
if modes.is_empty() {
return Err(bad(cmd, "a collection needs at least one mode"));
}
if modes.iter().any(String::is_empty) {
return Err(bad(cmd, "mode names can't be empty"));
}
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
}
let id = doc.new_collection_id();
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None });
Ok(json!({"id": id}))
@@ -950,6 +960,104 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
apply_bindings_matching(doc, only, |_| true);
}
/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings).
pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec<std::sync::Arc<Node>> {
ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect()
}
/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit
/// changed drops its binding, which would otherwise write the variable's value back (#809).
/// Compared by value, so keys written unchanged keep their bindings and fields moved along with
/// another (proportions, min/max) lose theirs.
pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc<Node>]) {
for old in before {
let Some(n) = doc.node(old.id) else { continue };
let edited: Vec<String> = n
.bound
.keys()
// A binding the edit itself made (`boundVariables`) stays.
.filter(|f| old.bound.get(*f) == n.bound.get(*f))
.filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING))
.filter(|f| bound_field(old, f) != bound_field(n, f))
.cloned()
.collect();
if edited.is_empty() {
continue;
}
if let Ok(n) = doc.get_mut(old.id) {
for f in edited {
n.bound.remove(&f);
}
}
}
}
/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field).
fn bound_field(n: &Node, field: &str) -> Option<String> {
let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind {
NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::<Vec<_>>().join(",")),
_ => None,
};
let layout = n.layout.as_ref();
let cl = &n.child_layout;
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
Some(match field {
"width" => format!("{}", n.width),
"height" => format!("{}", n.height),
"minWidth" => format!("{:?}", cl.min_w),
"maxWidth" => format!("{:?}", cl.max_w),
"minHeight" => format!("{:?}", cl.min_h),
"maxHeight" => format!("{:?}", cl.max_h),
"opacity" => format!("{}", n.opacity),
"visible" => format!("{}", n.visible),
"gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)),
"crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)),
"padding" => format!("{:?}", layout.map(|l| l.padding)),
"paddingTop" => format!("{:?}", pad(0)),
"paddingRight" => format!("{:?}", pad(1)),
"paddingBottom" => format!("{:?}", pad(2)),
"paddingLeft" => format!("{:?}", pad(3)),
"paddingHorizontal" => format!("{:?}", (pad(1), pad(3))),
"paddingVertical" => format!("{:?}", (pad(0), pad(2))),
"cornerRadius" => format!("{:?}", n.corners.radii),
"topLeftRadius" => format!("{:?}", n.corners.radii.first()),
"topRightRadius" => format!("{:?}", n.corners.radii.get(1)),
"bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)),
"bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)),
"strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)),
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides),
"arcStart" | "arcSweep" | "arcInner" => match &n.kind {
NodeKind::Ellipse { start, sweep, inner } => format!(
"{}",
if field == "arcStart" {
start
} else if field == "arcSweep" {
sweep
} else {
inner
}
),
_ => return None,
},
"characters" => match &n.kind {
NodeKind::Text(t) => t.characters.clone(),
_ => return None,
},
"paragraphSpacing" | "paragraphIndent" => match &n.kind {
NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }),
_ => return None,
},
"fontFamily" => text(&|s| s.font_family.clone())?,
"fontStyle" => text(&|s| s.font_style.clone())?,
"fontWeight" => text(&|s| s.weight().to_string())?,
"fontSize" => text(&|s| s.font_size.to_string())?,
"lineHeight" => text(&|s| format!("{:?}", s.line_height))?,
"letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?,
// shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice.
_ => return None,
})
}
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
@@ -1007,6 +1115,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
doc.mark_dirty(pending);
}
/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound
/// node when `None`). Auto layout fields wait while their node has no auto layout.
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
use super::lists::{BoundList, list_binding};
if doc.variables.variables.is_empty() {
@@ -1021,6 +1131,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
let modes = doc.modes_for(id);
let Some(n) = doc.node(id) else { continue };
let binds = n.bound.clone();
let has_layout = n.layout.is_some();
let mut sets = serde_json::Map::new();
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
let mut paint_sets: Vec<(bool, usize, Option<usize>, Color)> = Vec::new();
@@ -1030,6 +1141,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
if !include(&field) {
continue;
}
// A binding never creates an auto layout: without one it waits for the next.
if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) {
continue;
}
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
// One list item's value: a paint's colour, an effect's or a layout grid's field.
if let Some((list, i, prop)) = list_binding(&field) {
+13 -6
View File
@@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
let ds = s.ds_mut()?;
let before = ds.doc.clone();
let doc = ds.doc_mut();
let bound = super::styles::bound_before(doc, &[id]);
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
if a == b {
t.restyle(0, t.char_count(), &f);
@@ -25,19 +26,21 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
t.restyle(a, b, &f);
}
}
super::styles::detach_edited(doc, &bound);
crate::normalize(doc, &before);
return ok();
}
let ids = req_targets("text", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
for d in doc.subtree(i) {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
let before = super::styles::bound_before(doc, &ids);
for d in ids {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
}
super::styles::detach_edited(doc, &before);
ok()
}
@@ -348,11 +351,13 @@ pub fn specs() -> Vec<CommandSpec> {
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
let ids = req_targets("text.set", s, p)?;
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let mut m = Map::new();
m.insert("characters".into(), json!(c));
super::props::apply("text.set", doc, i, &m)?;
}
super::styles::detach_edited(doc, &before);
ok()
}),
spec!(
@@ -364,6 +369,7 @@ pub fn specs() -> Vec<CommandSpec> {
let ids = req_targets("text.style", s, p)?;
let m = p.as_object().cloned().unwrap_or_default();
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let n = doc.get_mut(i)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
@@ -377,6 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
super::props::apply_text_style("text.style", t, a, b, k, v)?;
}
}
super::styles::detach_edited(doc, &before);
ok()
}
),
+50 -5
View File
@@ -259,6 +259,25 @@ fn stroke_color(n: &Node) -> Option<Color> {
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
}
/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border
/// on each side that has a weight.
fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> {
let Some(c) = stroke_color(n) else { return Vec::new() };
let Some(sides) = n.stroke.sides else {
let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t);
return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))];
};
let color = tok(doc, n, &["stroke"], c.to_css(), t);
let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES);
named
.zip(sides)
.filter(|(_, weight)| *weight > 0.0)
.map(|((side, field), weight)| {
(format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t)))
})
.collect()
}
fn drop_shadow(n: &Node) -> Option<&Shadow> {
n.effects.iter().filter(|e| e.visible).find_map(|e| match &e.kind {
EffectKind::DropShadow(s) => Some(s),
@@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
}
}
if let Some(c) = stroke_color(n) {
let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t);
put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)));
for (key, value) in css_borders(doc, n, t) {
put(&key, value);
}
let shadows: Vec<String> = n
.effects
@@ -858,11 +876,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra
swift_layer(ctx, n, depth, out, extra);
}
/// A layer name safe for a single-line `//` comment: line breaks become spaces.
fn one_line(name: &str) -> String {
name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
}
fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
let doc = ctx.doc;
let pad = indent(depth);
if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
return;
}
// Nested instances are calls; an instance swap parameter is a view.
@@ -1035,7 +1058,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
let doc = ctx.doc;
let pad = indent(depth);
if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
return;
}
// Nested instances are calls; an instance swap parameter is a composable.
@@ -1168,4 +1191,26 @@ mod tests {
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
}
#[test]
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
let mut s = Session::new();
let name = "deep\nevil()\r\u{2028}more()";
let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap();
let root = parent;
for _ in 0..16 {
parent =
id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"])
.unwrap();
}
let doc = s.doc().unwrap();
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
let code = code_for(doc, root, lang);
assert!(code.contains("// deep evil() more() …\n"), "{code}");
for line in code.lines() {
let t = line.trim_start();
assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}");
}
}
}
}
@@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() {
assert!(doc.node(glyph).unwrap().overrides.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() {
// Without unbinding first: the explicit colour detaches the variable on the instance layer only,
// and stays through main edits until the override is reset.
let mut s = Session::new();
let (chip, _) = bound_chip(&mut s);
let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap();
let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}}));
let n = s.doc().unwrap().node(bg).unwrap().clone();
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF");
assert!(!n.bound.contains_key("fill"));
assert!(n.overrides.contains("fills"));
let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}}));
ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"}));
let doc = s.doc().unwrap();
healthy(doc);
assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding");
ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"}));
let n = s.doc().unwrap().node(bg).unwrap();
assert!(n.bound.contains_key("fill"));
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566");
}
@@ -194,3 +194,26 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() {
assert!(err.contains("holds sections"), "{err}");
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
}
#[test]
fn wrapping_errors_name_the_command_that_was_run() {
// Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names
// the command that asked, not `object.group`.
let mut s = Session::new();
let page = s.page().unwrap();
for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] {
let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string();
assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}");
}
let r = rect(&mut s, page, 0.0, 0.0);
let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string();
assert!(err.contains("`object.mask`"), "{err}");
let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}}));
let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string();
assert!(err.contains("`vector.outlineStroke`"), "{err}");
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100})));
let inner = rect(&mut s, f, 10.0, 10.0);
let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string();
assert!(err.contains("`object.wrapInSection`"), "{err}");
}
+26
View File
@@ -51,6 +51,32 @@ fn effect_set_edits_noise_texture_and_glass() {
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err());
}
/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word,
/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset.
#[test]
fn effects_refuse_keys_they_dont_have() {
let mut s = Session::new();
let r = rect(&mut s);
let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3});
let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string();
assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}");
assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored");
let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string();
assert!(err.contains("radious"), "{err}");
s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string();
assert!(err.contains("spred"), "{err}");
// Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports.
for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] {
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
}
let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone();
s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap();
let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true});
let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false});
s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap();
}
#[test]
fn scaling_scales_grain_and_glass() {
let mut s = Session::new();
+17 -5
View File
@@ -62,7 +62,7 @@ impl Roots {
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
Ok(_) => {}
// Why a path can't be resolved says what exists there: only told for paths inside a root.
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(_) => {}
}
if roots.is_empty() {
@@ -79,9 +79,10 @@ impl Roots {
}
}
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
let Ok(abs) = std::path::absolute(path) else { return false };
let mut lexical = PathBuf::new();
for c in abs.components() {
@@ -93,7 +94,18 @@ fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
c => lexical.push(c),
}
}
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
let mut rest: Vec<&OsStr> = Vec::new();
let mut at = lexical.as_path();
let resolved = loop {
if let Ok(mut base) = std::fs::canonicalize(at) {
base.extend(rest.iter().rev());
break base;
}
let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false };
rest.push(name);
at = parent;
};
roots.iter().any(|r| resolved.starts_with(r))
}
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
+17
View File
@@ -348,6 +348,10 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
outside.push(f.p("outside/dangling.txt"));
// Reached through a link in the root, it is outside all the same.
if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) {
outside.push(f.p("root/escape/dangling.txt"));
}
}
for p in &outside {
let e = refusal(p);
@@ -359,6 +363,19 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
}
}
#[test]
fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() {
let f = Fixture::new("linked-root");
if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
eprintln!("skipped: this system doesn't let tests make symbolic links");
return;
}
// Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`.
let r = Roots::new(&[], &[f.p("via")]).unwrap();
let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err();
assert!(e.contains("leads nowhere"), "{e}");
}
#[test]
fn only_resolved_paths_count_as_the_apps_own_folder() {
let f = Fixture::new("own");
@@ -214,6 +214,26 @@ fn css_for_common_layers() {
}
}
/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't
/// boxed in, and a side bound to a variable names it.
#[test]
fn css_borders_follow_individual_stroke_sides() {
let mut s = Session::new();
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]}));
let c = css(&mut s, r);
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}");
let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"}));
let col = col["id"].as_str().unwrap().to_string();
ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
let c = css(&mut s, r);
assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}");
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
}
#[test]
fn native_code_for_a_simple_layer() {
let mut s = Session::new();
+3
View File
@@ -1098,8 +1098,11 @@ impl Session {
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
let mut doc = (*it.doc).clone();
let mut sel = it.selection.clone();
// What the gesture changes on the selected layers is an explicit edit.
let before = cmd::styles::bound_before(&doc, &it.selection);
let res = f(&mut doc, &mut sel);
if res.is_ok() {
cmd::styles::detach_edited(&mut doc, &before);
normalize(&mut doc, &it.doc);
// The same preview again (a handle held still) keeps the document: a new one would
// re-render the canvas for nothing.
+1 -1
View File
@@ -81,7 +81,7 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97
/// `#RRGGBB` → (r, g, b); `None` for anything else.
pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> {
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?;
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
Some((byte(0)?, byte(2)?, byte(4)?))
}
+8
View File
@@ -507,3 +507,11 @@ fn hosting_commands_check_what_they_are_given() {
assert!(e.contains("join a file"), "{e}");
assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words"));
}
#[test]
fn parse_color_rejects_signs_and_non_hex() {
use crate::live::parse_color;
assert_eq!(parse_color("#+1+2+3"), None);
assert_eq!(parse_color("#-1-2-3"), None);
assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255)));
}
+7 -34
View File
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::params::*;
use crate::{CommandSpec, Result, Session, bad, spec};
@@ -153,37 +153,6 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>
Ok(ts)
}
/// Detach variables only where the explicit edit changed their value. In particular, an
/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings.
fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) {
// Range fills override the node's paints; the node binding still belongs to other chars.
if t.range.is_some() {
return;
}
let list = if t.fills { BoundList::Fills } else { BoundList::Strokes };
n.bound.retain(|key, _| {
let Some((l, index, field)) = list_binding(key) else { return true };
if l != list || index != t.index {
return true;
}
match field {
"color" => match (&old.kind, &new.kind) {
(PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b,
_ => false,
},
"opacity" => old.opacity == new.opacity,
_ => {
let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::<usize>().ok());
match (stop, &old.kind, &new.kind) {
(Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color),
(Some(_), _, _) => false,
_ => old == new,
}
}
}
});
}
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.setPaint";
let targets = checked_targets(s, p, CMD)?;
@@ -197,18 +166,22 @@ fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
let old = slot.clone();
*slot = paint.clone();
let n = s.doc_mut()?.get_mut(target.id)?;
unbind_changed(n, target, &old, &paint);
// Variables detach only where the edit changed their value; range fills override the node's
// paints, whose bindings still belong to the other characters.
if let Some((start, end)) = target.range {
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
}
} else if target.fills {
continue;
}
if target.fills {
n.fills = paints;
n.styles.fill = None;
} else {
n.strokes = paints;
n.styles.stroke = None;
}
rebind_edited_paint(n, target.fills, target.index, &old);
}
Ok(json!({"ok": true}))
}
+11
View File
@@ -41,6 +41,17 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec<Value> {
ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default()
}
/// A refused slot is reported by `component.makeSlot`, the command that was run, not by the
/// property command it adds its slot property through.
#[test]
fn a_refused_slot_names_make_slot() {
let mut s = Session::new();
let (c, ..) = slot_card(&mut s);
let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Other"})));
let err = s.execute("component.makeSlot", &json!({"id": other, "name": "Content"})).unwrap_err().to_string();
assert!(err.contains("`component.makeSlot`") && err.contains("a property named `Content` already exists"), "{err}");
}
#[test]
fn slots_are_frames_of_a_main_with_a_slot_property() {
let mut s = Session::new();
@@ -279,6 +279,34 @@ fn bindings_by_field_and_type() {
}
}
#[test]
fn auto_layout_bindings_go_with_the_auto_layout() {
// Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings,
// and a binding never brings an auto layout back. One made without auto layout waits for it.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12}));
let removals =
[("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))];
for (cmd, mut p) in removals {
let f = frame(&mut s, None);
run(&mut s, "layout.add", json!({"ids": [f]}));
for field in ["gap", "crossGap", "paddingTop"] {
run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"}));
}
p["ids"] = json!([f]);
run(&mut s, cmd, p.clone());
let n = node(&s, f);
assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed");
assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped");
}
let f = frame(&mut s, None);
run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"}));
assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout");
run(&mut s, "layout.add", json!({"ids": [f]}));
assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout");
}
#[test]
fn deleting_a_bound_variable_keeps_the_last_value() {
// Documented: deleting a variable unbinds what used it, which keeps showing its last value.
@@ -365,3 +393,90 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() {
s.undo().unwrap();
assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together");
}
#[test]
fn create_collection_keeps_at_least_one_named_mode() {
// A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode),
// so createCollection refuses an empty mode list, blank mode names and a blank collection name.
let mut s = Session::new();
for p in [
json!({"name": "C", "modes": []}),
json!({"name": "C", "modes": [1]}),
json!({"name": "C", "modes": ["", " "]}),
json!({"name": "C", "modes": ["A", " "]}),
json!({"name": " "}),
json!({"name": ""}),
] {
assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused");
}
assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing");
run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]}));
let names: Vec<String> = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect();
assert_eq!(names, ["Light", "Dark"]);
}
#[test]
fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() {
// #809: an edit of a bound field detaches its binding (as a paint edit does) instead of
// the variable's value coming back; bindings whose value the edit kept stay.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48})));
let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40})));
let r = rect(&mut s, None);
for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] {
run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v}));
}
assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4));
run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4}));
let n = node(&s, r);
assert_eq!(n.width, 100.0);
assert!(!n.bound.contains_key("width"));
assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings");
run(&mut s, "transform.resize", json!({"ids": [r], "height": 30}));
assert_eq!(node(&s, r).height, 30.0);
assert!(!node(&s, r).bound.contains_key("height"));
run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"}));
assert_eq!(node(&s, r).opacity, 0.9);
assert!(node(&s, r).bound.is_empty());
// A value set with its binding in the same call keeps the new binding.
run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}}));
assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w)));
}
#[test]
fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8})));
let r = rect(&mut s, None);
run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v}));
s.begin_interaction("Edit").unwrap();
let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]);
s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap();
s.commit_interaction().unwrap();
assert_eq!(node(&s, r).corners.radii, [2.0; 4]);
assert!(node(&s, r).bound.is_empty());
let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"})));
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
run(&mut s, "text.style", json!({"id": t, "fontSize": 20}));
assert!(node(&s, t).bound.is_empty());
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0);
// The font size/weight/tracking shortcuts and text content edits detach too.
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0);
run(&mut s, "text.sizeUp", json!({"ids": [t]}));
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0);
assert!(node(&s, t).bound.is_empty());
let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"})));
run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c}));
run(&mut s, "text.set", json!({"id": t, "characters": "Bye"}));
assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye");
assert!(node(&s, t).bound.is_empty());
}
+26
View File
@@ -371,3 +371,29 @@ fn variant_properties_follow_string_variables() {
assert_eq!(main(&s), variants[1]);
healthy(&s);
}
#[test]
fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
let mut s = Session::new();
let r = gradient_rect(&mut s);
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"}));
let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone();
// Removing the first stop moves both bindings down with their stops.
let mut p = cur(&s);
if let PaintKind::Gradient(g) = &mut p.kind {
g.stops.remove(0);
}
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]);
// Recolouring one bound stop detaches that stop only.
let mut p = cur(&s);
if let PaintKind::Gradient(g) = &mut p.kind {
g.stops[0].color = Color::WHITE;
}
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]);
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"}));
assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]);
healthy(&s);
}
+3
View File
@@ -310,6 +310,7 @@ status = "draft — needs native review"
"Corner radius" = "Poloměr rohu"
"Corner smoothing" = "Vyhlazení rohů"
"Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači."
"Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst."
"Count" = "Počet"
"Create" = "Vytvořit"
"Create a variable collection first" = "Nejprve vytvořte kolekci proměnných"
@@ -1630,6 +1631,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}"
"{name} is on the server." = "{name} je na serveru."
"{name} is spotlighting" = "{name} sdílí pohled"
"{name} is too large to open in the browser (over 512 MB)." = "Soubor {name} je příliš velký na otevření v prohlížeči (více než 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji."
"{name} (from {library})" = "{name} (z {library})"
@@ -1850,6 +1852,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exportovat vrstvy"
"file.exportSvg" = "Exportovat výběr jako SVG"
"file.import" = "Importovat"
"file.moveTab" = "Přesunout kartu"
"file.new" = "Nový soubor návrhu"
"file.newFromTemplate" = "Nový ze šablony"
"file.open" = "Otevřít"
+3
View File
@@ -306,6 +306,7 @@ status = "draft — needs native review"
"Corner smoothing" = "Eckenglättung"
"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden"
"Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat."
"Couldn't read {name}." = "{name} konnte nicht gelesen werden."
"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}"
"Count" = "Anzahl"
"Create" = "Erstellen"
@@ -1584,6 +1585,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} wurde auf diesem Computer vergessen; die Datei bleibt auf {server}"
"{name} is on the server." = "{name} liegt auf dem Server."
"{name} is spotlighting" = "{name} hat das Spotlight"
"{name} is too large to open in the browser (over 512 MB)." = "{name} ist zu groß, um im Browser geöffnet zu werden (über 512 MB)."
"{name} was removed from the server" = "{name} wurde vom Server entfernt"
"{name} · deleted on main" = "{name} · in der Hauptdatei gelöscht"
"{name} · deleted on the branch" = "{name} · im Branch gelöscht"
@@ -1796,6 +1798,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Ebenen exportieren"
"file.exportSvg" = "Auswahl als SVG exportieren"
"file.import" = "Importieren"
"file.moveTab" = "Tab verschieben"
"file.new" = "Neue Designdatei"
"file.open" = "Öffnen"
"file.openRecent" = "Zuletzt verwendete öffnen"
+2
View File
@@ -319,6 +319,7 @@ name = "English"
"Corner smoothing" = "Corner smoothing"
"Couldn't download the variables" = "Couldn't download the variables"
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
"Couldn't read {name}." = "Couldn't read {name}."
"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}"
"Count" = "Count"
"Create" = "Create"
@@ -1669,6 +1670,7 @@ name = "English"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}"
"{name} is on the server." = "{name} is on the server."
"{name} is spotlighting" = "{name} is spotlighting"
"{name} is too large to open in the browser (over 512 MB)." = "{name} is too large to open in the browser (over 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it."
"{name} (from {library})" = "{name} (from {library})"
+3
View File
@@ -320,6 +320,7 @@ status = "draft — needs native review"
"Corner radius" = "Radio de esquina"
"Corner smoothing" = "Suavizado de esquinas"
"Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda."
"Couldn't read {name}." = "No se pudo leer {name}."
"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}"
"Count" = "Número"
"Create" = "Crear"
@@ -1670,6 +1671,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}"
"{name} is on the server." = "{name} está en el servidor."
"{name} is spotlighting" = "{name} está en el foco"
"{name} is too large to open in the browser (over 512 MB)." = "{name} es demasiado grande para abrirlo en el navegador (más de 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla."
"{name} (from {library})" = "{name} (de {library})"
@@ -1892,6 +1894,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exportar capas"
"file.exportSvg" = "Exportar selección como SVG"
"file.import" = "Importar"
"file.moveTab" = "Mover pestaña"
"file.new" = "Nuevo archivo de diseño"
"file.newFromTemplate" = "Nuevo desde plantilla"
"file.open" = "Abrir"
+3
View File
@@ -321,6 +321,7 @@ status = "draft — needs native review"
"Corner smoothing" = "Kulman pehmennys"
"Couldn't download the variables" = "Muuttujia ei voitu ladata"
"Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot."
"Couldn't read {name}." = "Tiedostoa {name} ei voitu lukea."
"Couldn't save automatically: {error}" = "Automaattinen tallennus epäonnistui: {error}"
"Count" = "Määrä"
"Create" = "Luo"
@@ -1677,6 +1678,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} on unohdettu tällä tietokoneella. Tiedosto jää palvelimelle {server}."
"{name} is on the server." = "{name} on palvelimella."
"{name} is spotlighting" = "{name} nostaa näkymäänsä esiin"
"{name} is too large to open in the browser (over 512 MB)." = "{name} on liian suuri avattavaksi selaimessa (yli 512 Mt)."
"{name} was removed from the server" = "{name} poistettiin palvelimelta"
"{name} · deleted on main" = "{name} · poistettu päähaarassa"
"{name} · deleted on the branch" = "{name} · poistettu haarassa"
@@ -1892,6 +1894,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Vie tasot"
"file.exportSvg" = "Vie valinta SVG-muodossa"
"file.import" = "Tuo"
"file.moveTab" = "Siirrä välilehti"
"file.new" = "Uusi suunnittelutiedosto"
"file.newFromTemplate" = "Uusi mallista"
"file.open" = "Avaa"
+3
View File
@@ -318,6 +318,7 @@ status = "draft — needs native review"
"Corner smoothing" = "Lissage des angles"
"Couldn't download the variables" = "Impossible de télécharger les variables"
"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient."
"Couldn't read {name}." = "Impossible de lire {name}."
"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}"
"Count" = "Nombre"
"Create" = "Créer"
@@ -1666,6 +1667,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}"
"{name} is on the server." = "{name} est sur le serveur."
"{name} is spotlighting" = "{name} est sous les projecteurs"
"{name} is too large to open in the browser (over 512 MB)." = "{name} est trop volumineux pour être ouvert dans le navigateur (plus de 512 Mo)."
"{name} was removed from the server" = "{name} a été retiré du serveur"
"{name} · deleted on main" = "{name} · supprimé dans le principal"
"{name} · deleted on the branch" = "{name} · supprimé dans la branche"
@@ -1880,6 +1882,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exporter les calques"
"file.exportSvg" = "Exporter la sélection en SVG"
"file.import" = "Importer"
"file.moveTab" = "Déplacer l’onglet"
"file.new" = "Nouveau fichier de design"
"file.newFromTemplate" = "Nouveau à partir d’un modèle"
"file.open" = "Ouvrir"
+3
View File
@@ -314,6 +314,7 @@ status = "draft — needs native review"
"Corner radius" = "Radius sudut"
"Corner smoothing" = "Penghalusan sudut"
"Couldn't reach {server}: showing what this computer remembers." = "Tidak dapat terhubung ke {server}: menampilkan data yang diingat komputer ini."
"Couldn't read {name}." = "Tidak dapat membaca {name}."
"Count" = "Jumlah"
"Create" = "Buat"
"Create a variable collection first" = "Buat koleksi variabel terlebih dahulu"
@@ -1634,6 +1635,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} dilupakan di komputer ini; file tetap ada di {server}"
"{name} is on the server." = "{name} ada di server."
"{name} is spotlighting" = "{name} sedang spotlight"
"{name} is too large to open in the browser (over 512 MB)." = "{name} terlalu besar untuk dibuka di browser (lebih dari 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nKlik untuk menerapkan ke pilihan"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nSeret ke kanvas, atau klik untuk menaruh di tengah tampilan.\nTahan Alt (Option) saat melepas di atas instance untuk menukarnya."
"{name} (from {library})" = "{name} (dari {library})"
@@ -1854,6 +1856,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Ekspor layer"
"file.exportSvg" = "Ekspor pilihan sebagai SVG"
"file.import" = "Impor"
"file.moveTab" = "Pindahkan tab"
"file.new" = "File desain baru"
"file.newFromTemplate" = "Baru dari templat"
"file.open" = "Buka"
+3
View File
@@ -316,6 +316,7 @@ status = "draft — needs native review"
"Corner radius" = "角の半径"
"Corner smoothing" = "角のスムージング"
"Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。"
"Couldn't read {name}." = "{name} を読み込めませんでした。"
"Couldn't save automatically: {error}" = "自動保存できませんでした:{error}"
"Count" = "数"
"Create" = "作成"
@@ -1651,6 +1652,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} の記録をこのコンピューターから消去済み。ファイルは {server} に残る"
"{name} is on the server." = "{name} はサーバーにある。"
"{name} is spotlighting" = "{name} がスポットライト中"
"{name} is too large to open in the browser (over 512 MB)." = "{name} は大きすぎるため、ブラウザーで開けません(512 MB 超)。"
"{name} was removed from the server" = "{name} をサーバーから削除済み"
"{name} · deleted on main" = "{name} · メインで削除"
"{name} · deleted on the branch" = "{name} · ブランチで削除"
@@ -1865,6 +1867,7 @@ status = "draft — needs native review"
"file.exportLayers" = "レイヤーを書き出し"
"file.exportSvg" = "選択範囲を SVG で書き出し"
"file.import" = "インポート"
"file.moveTab" = "タブを移動"
"file.new" = "新規デザインファイル"
"file.newFromTemplate" = "テンプレートから新規作成"
"file.open" = "開く"
+3
View File
@@ -305,6 +305,7 @@ status = "draft — needs native review"
"Corner radius" = "모서리 반경"
"Corner smoothing" = "모서리 스무딩"
"Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함."
"Couldn't read {name}." = "{name}을(를) 읽을 수 없습니다."
"Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}"
"Count" = "개수"
"Create" = "만들기"
@@ -1575,6 +1576,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name}의 기록을 이 컴퓨터에서 지움. 파일은 {server}에 남음"
"{name} is on the server." = "{name}이(가) 서버에 있음."
"{name} is spotlighting" = "{name} 님이 스포트라이트 중"
"{name} is too large to open in the browser (over 512 MB)." = "{name}은(는) 너무 커서 브라우저에서 열 수 없습니다(512MB 초과)."
"{name} was removed from the server" = "{name}을(를) 서버에서 제거함"
"{name} · deleted on main" = "{name} · 메인에서 삭제됨"
"{name} · deleted on the branch" = "{name} · 브랜치에서 삭제됨"
@@ -1787,6 +1789,7 @@ status = "draft — needs native review"
"file.exportLayers" = "레이어 내보내기"
"file.exportSvg" = "선택 항목을 SVG로 내보내기"
"file.import" = "가져오기"
"file.moveTab" = "탭 이동"
"file.new" = "새 디자인 파일"
"file.open" = "열기"
"file.openRecent" = "최근 파일 열기"
+3
View File
@@ -321,6 +321,7 @@ status = "draft — needs native review"
"Corner smoothing" = "Hoekafvlakking"
"Couldn't download the variables" = "De variabelen konden niet worden gedownload"
"Couldn't reach {server}: showing what this computer remembers." = "Kon {server} niet bereiken: toont wat deze computer onthoudt."
"Couldn't read {name}." = "Kon {name} niet lezen."
"Couldn't save automatically: {error}" = "Kan niet automatisch opslaan: {error}"
"Count" = "Aantal"
"Create" = "Maken"
@@ -1674,6 +1675,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} is op deze computer vergeten; het bestand blijft op {server}"
"{name} is on the server." = "{name} staat op de server."
"{name} is spotlighting" = "{name} staat in de spotlight"
"{name} is too large to open in the browser (over 512 MB)." = "{name} is te groot om in de browser te openen (meer dan 512 MB)."
"{name} was removed from the server" = "{name} is van de server verwijderd"
"{name} · deleted on main" = "{name} · verwijderd op main"
"{name} · deleted on the branch" = "{name} · verwijderd op de branch"
@@ -1891,6 +1893,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Lagen exporteren"
"file.exportSvg" = "Selectie exporteren als SVG"
"file.import" = "Importeren"
"file.moveTab" = "Tabblad verplaatsen"
"file.new" = "Nieuw ontwerpbestand"
"file.newFromTemplate" = "Nieuw vanuit sjabloon"
"file.open" = "Openen"
+3
View File
@@ -304,6 +304,7 @@ status = "draft — needs native review"
"Corner smoothing" = "Suavização de cantos"
"Couldn't download the variables" = "Não foi possível baixar as variáveis"
"Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra."
"Couldn't read {name}." = "Não foi possível ler {name}."
"Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}"
"Count" = "Quantidade"
"Create" = "Criar"
@@ -1574,6 +1575,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} foi esquecido neste computador; o arquivo continua em {server}"
"{name} is on the server." = "{name} está no servidor."
"{name} is spotlighting" = "{name} está em destaque"
"{name} is too large to open in the browser (over 512 MB)." = "{name} é grande demais para abrir no navegador (mais de 512 MB)."
"{name} was removed from the server" = "{name} foi removido do servidor"
"{name} · deleted on main" = "{name} · excluído no principal"
"{name} · deleted on the branch" = "{name} · excluído na branch"
@@ -1786,6 +1788,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exportar camadas"
"file.exportSvg" = "Exportar seleção como SVG"
"file.import" = "Importar"
"file.moveTab" = "Mover aba"
"file.new" = "Novo arquivo de design"
"file.open" = "Abrir"
"file.openRecent" = "Abrir recente"
+3
View File
@@ -311,6 +311,7 @@ name = "Русский"
"Corner radius" = "Радиус углов"
"Corner smoothing" = "Сглаживание углов"
"Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере."
"Couldn't read {name}." = "Не удалось прочитать {name}."
"Count" = "Количество"
"Create" = "Создать"
"Create a variable collection first" = "Сначала создайте коллекцию переменных"
@@ -1636,6 +1637,7 @@ name = "Русский"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} забыт на этом компьютере; файл остаётся на {server}"
"{name} is on the server." = "{name} находится на сервере."
"{name} is spotlighting" = "{name} показывает экран всем"
"{name} is too large to open in the browser (over 512 MB)." = "Файл {name} слишком велик для открытия в браузере (больше 512 МБ)."
"{name}\nClick to apply to the selection" = "{name}\nНажмите, чтобы применить к выделению"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nПеретащите на холст или нажмите, чтобы разместить в центре вида.\nУдерживайте Alt (Option) при переносе на экземпляр, чтобы заменить его."
"{name} (from {library})" = "{name} (из {library})"
@@ -1856,6 +1858,7 @@ name = "Русский"
"file.exportLayers" = "Экспортировать слои"
"file.exportSvg" = "Экспортировать выделение в SVG"
"file.import" = "Импорт"
"file.moveTab" = "Переместить вкладку"
"file.new" = "Новый файл дизайна"
"file.newFromTemplate" = "Создать из шаблона"
"file.open" = "Открыть"
+3
View File
@@ -315,6 +315,7 @@ name = "Svenska"
"Corner smoothing" = "Hörnutjämning"
"Couldn't download the variables" = "Det gick inte att hämta variablerna"
"Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns."
"Couldn't read {name}." = "Det gick inte att läsa {name}."
"Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}"
"Count" = "Antal"
"Create" = "Skapa"
@@ -1651,6 +1652,7 @@ name = "Svenska"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} har glömts på den här datorn; filen finns kvar på {server}"
"{name} is on the server." = "{name} finns på servern."
"{name} is spotlighting" = "{name} har spotlight"
"{name} is too large to open in the browser (over 512 MB)." = "{name} är för stor för att öppnas i webbläsaren (över 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nKlicka för att använda på markeringen"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDra till arbetsytan, eller klicka för att placera i mitten av vyn.\nHåll ned Alt (Option) när du släpper på en instans för att byta ut den."
"{name} (from {library})" = "{name} (från {library})"
@@ -1873,6 +1875,7 @@ name = "Svenska"
"file.exportLayers" = "Exportera lager"
"file.exportSvg" = "Exportera som SVG"
"file.import" = "Importera"
"file.moveTab" = "Flytta flik"
"file.new" = "Nytt"
"file.newFromTemplate" = "Nytt från mall"
"file.open" = "Öppna"
+3
View File
@@ -314,6 +314,7 @@ name = "Türkçe"
"Corner radius" = "Köşe yarıçapı"
"Corner smoothing" = "Köşe yumuşatma"
"Couldn't reach {server}: showing what this computer remembers." = "{server} sunucusuna ulaşılamadı: bu bilgisayarın hatırladıkları gösteriliyor."
"Couldn't read {name}." = "{name} okunamadı."
"Count" = "Sayı"
"Create" = "Oluştur"
"Create a variable collection first" = "Önce bir değişken koleksiyonu oluşturun"
@@ -1634,6 +1635,7 @@ name = "Türkçe"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} bu bilgisayarda unutuldu; dosya {server} üzerinde kalır"
"{name} is on the server." = "{name} sunucuda."
"{name} is spotlighting" = "{name} öne çıkarıyor"
"{name} is too large to open in the browser (over 512 MB)." = "{name}, tarayıcıda açılamayacak kadar büyük (512 MB'tan fazla)."
"{name}\nClick to apply to the selection" = "{name}\nSeçime uygulamak için tıklayın"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nTuvale sürükleyin veya görünümün ortasına yerleştirmek için tıklayın.\nBir örneği değiştirmek için üzerine bırakırken Alt (Option) tuşunu basılı tutun."
"{name} (from {library})" = "{name} ({library} kitaplığından)"
@@ -1854,6 +1856,7 @@ name = "Türkçe"
"file.exportLayers" = "Katmanları dışa aktar"
"file.exportSvg" = "Seçimi SVG olarak dışa aktar"
"file.import" = "İçe aktar"
"file.moveTab" = "Sekmeyi taşı"
"file.new" = "Yeni tasarım dosyası"
"file.newFromTemplate" = "Şablondan yeni"
"file.open" = "Aç"
+3
View File
@@ -306,6 +306,7 @@ status = "draft — needs native review"
"Corner radius" = "圆角半径"
"Corner smoothing" = "圆角平滑"
"Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。"
"Couldn't read {name}." = "无法读取 {name}。"
"Couldn't save automatically: {error}" = "无法自动保存:{error}"
"Count" = "数量"
"Create" = "创建"
@@ -1569,6 +1570,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "已在这台电脑上忘记 {name};文件仍保留在 {server} 上"
"{name} is on the server." = "{name} 已在服务器上。"
"{name} is spotlighting" = "{name} 正在聚焦"
"{name} is too large to open in the browser (over 512 MB)." = "{name} 太大,无法在浏览器中打开(超过 512 MB)。"
"{name}\nClick to apply to the selection" = "{name}\n点击应用于所选项"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\n拖到画布上,或点击放置在视图中央。\n拖放到实例上时按住 Alt(Option)可替换该实例。"
"{name} (from {library})" = "{name}(来自 {library})"
@@ -1788,6 +1790,7 @@ status = "draft — needs native review"
"file.exportLayers" = "导出图层"
"file.exportSvg" = "将所选项导出为 SVG"
"file.import" = "导入"
"file.moveTab" = "移动标签页"
"file.new" = "新建设计文件"
"file.open" = "打开"
"file.openRecent" = "打开最近的文件"
+69 -1
View File
@@ -2,7 +2,7 @@ use std::path::PathBuf;
use figcraft_engine::Session;
use crate::{DialogFuture, FigApp, Services};
use crate::{ContentsFuture, DialogFuture, FigApp, Services};
struct OpenFile(PathBuf);
@@ -79,6 +79,74 @@ fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front
let _ = std::fs::remove_dir_all(dir);
}
/// The browser: the picker hands over a file's name and contents, never a path.
struct Contents(&'static str, Vec<u8>);
impl Services for Contents {
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
panic!("a platform that picks contents is never asked for a path")
}
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
Some(Box::pin(std::future::ready(Some(Ok((self.0.to_string(), self.1.clone()))))))
}
}
/// The browser, with a picked file it won't read.
struct Unreadable(String);
impl Services for Unreadable {
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
Some(Box::pin(std::future::ready(Some(Err(self.0.clone())))))
}
}
#[test]
fn a_picked_file_that_cannot_be_read_says_why_and_opens_nothing() {
let why = crate::browser_can_read("Huge.fig", 600.0 * 1024.0 * 1024.0).unwrap_err();
let mut app = FigApp::new(Session::empty(), Box::new(Unreadable(why.clone())));
app.open_dialog();
app.poll_dialog(&egui::Context::default());
assert!(app.session.docs.is_empty());
assert_eq!(app.ui.last_error.as_deref(), Some(why.as_str()));
assert!(why.contains("Huge.fig"), "{why}");
}
#[test]
fn the_browser_reads_files_up_to_512_mib() {
let max = (512u64 << 20) as f64;
assert!(crate::browser_can_read("a", 0.0).is_ok());
assert!(crate::browser_can_read("a", max).is_ok());
assert!(crate::browser_can_read("a", max + 1.0).is_err());
assert!(crate::browser_can_read("a", f64::NAN).is_err());
assert!(crate::browser_can_read("a", f64::INFINITY).is_err());
}
#[test]
fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() {
let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap();
let mut app = FigApp::new(Session::empty(), Box::new(Contents("Picked.figcraft", bytes)));
app.ui.home = true;
app.open_dialog();
assert!(app.session.docs.is_empty(), "opening waits for the picker result");
app.poll_dialog(&egui::Context::default());
assert_eq!(app.session.docs.len(), 1);
assert_eq!(app.session.docs[0].path, None, "no path to save over");
assert_eq!(app.session.docs[0].doc.name, "Picked");
assert!(!app.ui.home, "and the editor shows it");
}
#[test]
fn import_dialog_imports_picked_contents_into_the_file_in_front() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#.to_vec();
let mut app = FigApp::new(Session::new(), Box::new(Contents("Logo.svg", svg)));
let rectangles = |app: &FigApp| app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)).count();
assert_eq!(rectangles(&app), 0);
app.import_dialog(false);
app.poll_dialog(&egui::Context::default());
assert_eq!(app.session.docs.len(), 1, "imported, not opened");
assert_eq!(rectangles(&app), 1, "the SVG's content");
}
#[test]
fn a_dialog_whose_file_closed_meanwhile_does_nothing() {
let (mut app, dir, path) = two_files("closed");
+4 -8
View File
@@ -509,21 +509,17 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
let path = f.path().to_path_buf();
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
let params = if path.exists() {
json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y})
} else if let Some(bytes) = crate::dropped_bytes(&f) {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
json!({"data": b64, "name": name, "x": pt.x, "y": pt.y})
let (params, read) = if path.exists() {
(json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None)
} else {
continue;
(json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name)))
};
let Some(target) =
app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() })
else {
continue;
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) });
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read });
}
let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else {
// A drag released off the canvas, or off the window, ends where the pointer was last seen.
+4 -8
View File
@@ -187,16 +187,12 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
}
// `opens` saw an extension, so there is a file name to tell the format by.
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let params = if path.exists() {
json!({"path": path.to_string_lossy()})
} else if let Some(bytes) = crate::dropped_bytes(&f) {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
json!({"data": data, "name": name})
let (params, read) = if path.exists() {
(json!({"path": path.to_string_lossy()}), None)
} else {
continue;
(json!({"name": name}), Some(crate::dropped_bytes(&f, &name)))
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None });
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read });
}
}
+65
View File
@@ -573,6 +573,71 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() {
assert!(!path.exists(), "nothing is written");
}
/// What a drop's read gives, once it has arrived.
type Arrival = std::rc::Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
/// A drop whose contents arrive later, as every drop in the browser does: pending until `arrived`
/// holds them.
fn later(arrived: &Arrival) -> crate::ReadFuture {
let arrived = arrived.clone();
Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() {
Some(bytes) => std::task::Poll::Ready(bytes),
None => std::task::Poll::Pending,
}))
}
#[test]
fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#.to_vec();
let arrived = std::rc::Rc::new(std::cell::RefCell::new(None));
let mut u = first_run();
u.app.ui.home = true;
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
label: "Browser drop.svg".into(),
params: json!({"name": "Browser drop.svg"}),
command: "file.open",
target: None,
read: Some(later(&arrived)),
});
// egui lays a new card out invisibly on its first frame.
u.frame(vec![]);
for _ in 0..5 {
let s = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read");
}
assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive");
*arrived.borrow_mut() = Some(Ok(svg));
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.pending_opens.is_empty());
assert_eq!(u.app.ui.last_error, None);
assert_eq!(u.app.session.docs.len(), 1, "the contents open");
assert_eq!(u.app.session.docs[0].path, None);
assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content");
assert!(!u.app.ui.home);
}
#[test]
fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() {
let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(Err(crate::unreadable("Unreadable.figcraft")))));
let mut u = first_run();
u.app.ui.home = true;
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
label: "Unreadable.figcraft".into(),
params: json!({"name": "Unreadable.figcraft"}),
command: "file.open",
target: None,
read: Some(later(&arrived)),
});
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.pending_opens.is_empty());
assert!(u.app.session.docs.is_empty());
assert_eq!(u.app.ui.last_error.as_deref(), Some("Couldn't read Unreadable.figcraft."));
}
#[test]
fn dropping_a_native_file_on_the_home_screen_opens_it() {
let dir = Temp::new();
+73 -11
View File
@@ -421,6 +421,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
draw(&p, icon);
}
/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box.
fn draw(p: &P, icon: Icon) {
use Icon::*;
match icon {
@@ -446,18 +447,23 @@ fn draw(p: &P, icon: Icon) {
p.path(&h, false);
}
Frame => {
p.line(5.5, 2.0, 5.5, 14.0);
p.line(10.5, 2.0, 10.5, 14.0);
p.line(2.0, 5.5, 14.0, 5.5);
p.line(2.0, 10.5, 14.0, 10.5);
// Inset a unit from the box: four long hinted strokes read heavier than the
// anti-aliased outlines beside them at full size (#737).
p.line(5.5, 3.0, 5.5, 13.0);
p.line(10.5, 3.0, 10.5, 13.0);
p.line(3.0, 5.5, 13.0, 5.5);
p.line(3.0, 10.5, 13.0, 10.5);
}
Section | Section2 => {
p.rect(2.5, 5.5, 13.5, 13.5, 1.0);
p.fill_rect(2.0, 2.0, 9.0, 6.0, 1.0);
}
Slice => {
p.rect(2.5, 2.5, 13.5, 13.5, 0.0);
p.line(2.5, 13.5, 13.5, 2.5);
// A knife, one silhouette (#739): a slim blade to the top right, outlined like the
// rest of the row, and a solid handle whose end overlaps the blade's base so the two
// join at every size.
p.poly(&[(13.5, 2.5), (5.0, 8.0), (8.0, 11.0)], true);
p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3));
}
Bend => {
// A curve with a smooth point and its handle line.
@@ -523,11 +529,12 @@ fn draw(p: &P, icon: Icon) {
p.line(9.0, 4.5, 11.5, 7.0);
}
Text => {
p.line(2.0, 3.5, 13.0, 3.5);
p.line(7.5, 3.0, 7.5, 13.0);
p.line(2.5, 3.0, 2.5, 6.0);
p.line(12.5, 3.0, 12.5, 6.0);
p.line(5.0, 12.5, 10.0, 12.5);
// Inset like the Frame mark, so its long strokes don't outweigh the outlines (#737).
p.line(3.0, 3.5, 12.0, 3.5);
p.line(7.5, 3.0, 7.5, 12.5);
p.line(3.5, 3.0, 3.5, 5.5);
p.line(11.5, 3.0, 11.5, 5.5);
p.line(5.5, 12.5, 9.5, 12.5);
}
Comment => {
let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect();
@@ -1226,4 +1233,59 @@ mod tests {
assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon");
assert_ne!(rotation, radius);
}
/// The shapes `icon` paints in a 32-point box at the origin: two points per grid unit.
fn shapes_at_32(icon: Icon) -> Vec<Shape> {
let ctx = egui::Context::default();
let mut out =
ctx.run_ui(egui::RawInput::default(), |ui| paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(32.0)), icon, Color32::WHITE));
out.textures_delta.clear();
out.shapes.into_iter().map(|c| c.shape).collect()
}
/// Frame and Text stay a grid unit inside the box, so their long hinted strokes don't
/// outweigh the outlines beside them in the toolbar (#737).
#[test]
fn frame_and_text_marks_are_inset() {
for icon in [Icon::Frame, Icon::Text] {
let pts: Vec<Pos2> = shapes_at_32(icon)
.iter()
.flat_map(|s| match s {
Shape::LineSegment { points, .. } => points.to_vec(),
other => panic!("{icon:?} draws straight strokes only: {other:?}"),
})
.collect();
assert!(!pts.is_empty());
for p in pts {
// Grid units 3..13, two points each.
assert!((6.0..=26.0).contains(&p.x) && (6.0..=26.0).contains(&p.y), "{icon:?} reaches {p:?}");
}
}
}
/// Slice is one knife (#739): an outlined blade and a solid handle that covers the middle of
/// the blade's base, so the two read as one mark rather than separate pieces.
#[test]
fn slice_is_one_connected_knife() {
let shapes = shapes_at_32(Icon::Slice);
let paths: Vec<_> = shapes
.iter()
.map(|s| match s {
Shape::Path(p) => p,
other => panic!("Slice draws paths only: {other:?}"),
})
.collect();
let blade = paths.iter().find(|p| p.closed && p.fill == Color32::TRANSPARENT).expect("an outlined blade");
let handle = paths.iter().find(|p| p.fill != Color32::TRANSPARENT).expect("a solid handle");
assert_eq!((paths.len(), blade.points.len()), (2, 3), "a blade and a handle, nothing else");
// The blade's base runs between its second and third points; the handle is convex.
let base = blade.points[1] + (blade.points[2] - blade.points[1]) / 2.0;
let h = &handle.points;
let side = |i: usize| {
let (a, b) = (h[i], h[(i + 1) % h.len()]);
(b - a).x * (base - a).y - (b - a).y * (base - a).x
};
let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0);
assert!(inside, "the handle covers the blade's base at {base:?}");
}
}
@@ -177,6 +177,7 @@ fn a_dropped_file_shows_its_report_once_it_opens() {
params: json!({"path": path.to_string_lossy()}),
command: "file.open",
target: None,
read: None,
});
for _ in 0..6 {
u.frame(vec![]);
+127 -31
View File
@@ -236,6 +236,11 @@ pub trait Services {
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
Box::pin(std::future::ready(None))
}
/// Pick a file and read it, where the platform hands over a file's name and contents rather
/// than a path to it (the browser). `None`: the platform picks paths ([`Services::pick_open`]).
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
None
}
fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture {
Box::pin(std::future::ready(None))
}
@@ -290,14 +295,61 @@ pub trait Services {
}
pub type DialogFuture = Pin<Box<dyn Future<Output = Option<PathBuf>>>>;
type DialogDone = Box<dyn FnOnce(&mut FigApp, PathBuf)>;
/// A picked file's name and contents ([`Services::pick_open_contents`]); `None` when the picker
/// was cancelled, `Err` with what to tell the user when the file couldn't be read.
pub type ContentsFuture = Pin<Box<dyn Future<Output = Option<Result<(String, Vec<u8>), String>>>>>;
/// A dropped file's contents, read once they arrive (`Err`: what to tell the user when they
/// couldn't be read).
pub type ReadFuture = Pin<Box<dyn Future<Output = Result<Vec<u8>, String>>>>;
type PickFuture = Pin<Box<dyn Future<Output = Option<Picked>>>>;
type DialogDone = Box<dyn FnOnce(&mut FigApp, Picked)>;
/// What a dialog picked: a path, or a file's name and contents where there are no paths, or why
/// the picked file couldn't be read.
enum Picked {
Path(PathBuf),
Contents(String, Vec<u8>),
Unreadable(String),
}
impl Picked {
/// The file as `file.open` and `file.import` take it (`Err`: what to tell the user instead).
fn file_params(self) -> Result<Value, String> {
match self {
Picked::Path(p) => Ok(json!({"path": p.to_string_lossy()})),
Picked::Contents(name, bytes) => {
use base64::Engine as _;
Ok(json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name}))
}
Picked::Unreadable(why) => Err(why),
}
}
}
/// What the user is told about a file whose contents couldn't be read.
pub fn unreadable(name: &str) -> String {
i18n::trf("Couldn't read {name}.", &[("name", &name)])
}
/// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents
/// are held about three times on their way to `file.open` (the bytes, base64, decoded again).
/// As large as one member of a `.figcraft` file may be.
const MAX_BROWSER_FILE: f64 = (512u64 << 20) as f64;
/// Checks a file of `size` bytes before the browser reads it (`Err`: what to tell the user).
pub fn browser_can_read(name: &str, size: f64) -> Result<(), String> {
if size.is_nan() || size > MAX_BROWSER_FILE {
return Err(i18n::trf("{name} is too large to open in the browser (over 512 MB).", &[("name", &name)]));
}
Ok(())
}
/// A native dialog in flight. The app keeps working while it is open, so its result goes to the
/// file and window it was opened for, not to whichever is in front when it closes.
struct PendingDialog {
future: DialogFuture,
/// The path picked, kept until the window that holds `doc` draws.
path: Option<PathBuf>,
future: PickFuture,
/// What was picked, kept until the window that holds `doc` draws.
picked: Option<Picked>,
done: DialogDone,
/// The file it acts on (`None`: none, such as Open).
doc: Option<u64>,
@@ -790,6 +842,11 @@ impl FigApp {
fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) {
let msg = e.to_string();
log::warn!("{id}: {msg}");
self.show_error(msg);
}
/// Show an error as a toast, kept as the last error.
pub(crate) fn show_error(&mut self, msg: String) {
self.toast(msg.clone());
self.ui.last_error = Some(msg);
}
@@ -1109,16 +1166,11 @@ impl FigApp {
pub fn open_dialog(&mut self) {
// Opening needs no file in front: it goes ahead even if that file closed meanwhile.
self.start_dialog(
|services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS),
false,
|app, p| {
let ps = p.to_string_lossy().to_string();
if app.run_user("file.open", json!({"path": ps})).is_some() {
app.ui.home = false;
}
},
);
self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| {
if app.run_user("file.open", file).is_some() {
app.ui.home = false;
}
});
}
pub fn import_dialog(&mut self, place: bool) {
@@ -1128,12 +1180,35 @@ impl FigApp {
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"]
};
let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") };
self.pick_open(title, exts, move |app, p| {
let ps = p.to_string_lossy().to_string();
app.run_user("file.import", json!({"path": ps}));
self.pick_file(title, exts, true, |app, file| {
app.run_user("file.import", file);
});
}
/// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a
/// path, or the name and contents where the platform has no paths). `pin`: as
/// [`FigApp::pick_open`]'s file in front.
fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) {
self.start_pick(
|services| -> PickFuture {
match services.pick_open_contents(title, exts) {
Some(read) => {
Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) })
}
None => {
let pick = services.pick_open(title, exts);
Box::pin(async move { pick.await.map(Picked::Path) })
}
}
},
pin,
move |app, picked| match picked.file_params() {
Ok(file) => done(app, file),
Err(why) => app.show_error(why),
},
);
}
/// Pick a file to open. `done` runs with the file in front now back in front (and not at all
/// if that file closed meanwhile).
pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
@@ -1152,12 +1227,28 @@ impl FigApp {
/// Open a native dialog from the window drawing now, for the file in front (`pin`).
fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
self.start_pick(
|services| -> PickFuture {
let pick = start(services);
Box::pin(async move { pick.await.map(Picked::Path) })
},
pin,
move |app, picked| {
if let Picked::Path(p) = picked {
done(app, p);
}
},
);
}
/// [`FigApp::start_dialog`] for any kind of pick.
fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) {
if self.pending_dialog.is_some() {
return;
}
let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None };
let future = start(self.services.as_mut());
self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current });
self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current });
if let Some(ctx) = &self.ui_context {
ctx.request_repaint();
}
@@ -1167,12 +1258,12 @@ impl FigApp {
/// that file in front; then the tab in front before comes back (unless the dialog opened a file).
fn poll_dialog(&mut self, ctx: &egui::Context) {
let Some(d) = &mut self.pending_dialog else { return };
if d.path.is_none() {
if d.picked.is_none() {
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return };
d.path = path;
let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return };
d.picked = picked;
}
let (doc, origin, picked) = (d.doc, d.window, d.path.is_some());
let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some());
let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u));
// Cancelled, or its file closed meanwhile.
if !picked || index == Some(None) {
@@ -1188,12 +1279,12 @@ impl FigApp {
ctx.request_repaint_of(windows::viewport(window));
return;
}
let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return };
let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return };
let before = self.session.ds().ok().map(|d| d.uid);
if let Some(Some(i)) = index {
self.session.active = i;
}
done(self, path);
done(self, picked);
if doc.is_some()
&& self.session.ds().ok().map(|d| d.uid) == doc
&& let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u))
@@ -1703,17 +1794,22 @@ pub(crate) fn opens(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// The contents of a dropped file that has no file on disk to read by path.
/// Reads the contents of dropped file `name` that has no file on disk to read by path.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option<Vec<u8>> {
f.bytes().ok()
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
Box::pin(std::future::ready(f.bytes().map_err(|_| unreadable(name))))
}
/// The contents of a dropped file that has no file on disk to read by path.
/// Reads the contents of dropped file `name`; the browser hands them over asynchronously.
#[cfg(target_arch = "wasm32")]
pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option<Vec<u8>> {
// Browser drops arrive asynchronously; not supported yet.
None
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
let (f, name) = (f.clone(), name.to_string());
Box::pin(async move {
if let Some(file) = f.web_file() {
browser_can_read(&name, file.size())?;
}
f.bytes_async().await.map_err(|_| unreadable(&name))
})
}
/// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle,
+16 -5
View File
@@ -112,6 +112,8 @@ pub fn record_recent(app: &mut FigApp, key: &str) {
r.truncate(figcraft_engine::MAX_RECENT_ACTIONS);
}
/// Run a menu, shortcut or palette action by id: actions that need the UI (dialogs, panels, the
/// clipboard) are handled here, the rest go to the engine.
pub fn invoke(app: &mut FigApp, id: &str) {
match id {
// Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab
@@ -219,12 +221,13 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"collab.publish" => crate::live::open_publish(app),
"edit.copy" | "edit.cut" => {
let ids = app.session.selection();
// SVG for other apps, taken before a cut removes the layers.
let svg = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids));
// For other apps: text layers' text, other layers as SVG; taken before a cut removes them.
let doc = app.session.doc().ok();
let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids)));
app.run(id, json!({}));
// Mirror to the system clipboard (SVG text, else a marker) and remember it, so a later
// paste knows whether the clipboard still holds our layers.
let text = svg.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len()));
// Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a
// later paste knows whether the clipboard still holds our layers.
let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len()));
app.services.clipboard_set_text(&text);
app.ui.last_copy = Some(text);
}
@@ -252,6 +255,14 @@ fn export_frames_pdf(app: &mut FigApp) {
});
}
/// The text of a selection made only of text layers, one layer per line (None for any other
/// selection, or when there is no text).
fn text_layers_text(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option<String> {
let texts: Option<Vec<&str>> = ids.iter().map(|i| doc.node(*i).and_then(|n| n.kind.text()).map(|t| t.characters.as_str())).collect();
texts.map(|t| t.join("\n")).filter(|t| !t.is_empty())
}
/// The layers as SVG text for other apps (None when the export fails).
fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option<String> {
let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?;
String::from_utf8(bytes).ok()
+47 -9
View File
@@ -1,13 +1,17 @@
//! Files dropped on the home screen or the canvas open a frame later, behind a card that says what
//! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen
//! before it does (rather than a window that freezes without a word).
//! before it does (rather than a window that freezes without a word). A file with no path to read
//! (every drop in the browser) waits behind the card until its contents arrive.
use std::sync::Arc;
use std::task::{Context as TaskContext, Poll, Waker};
use figcraft_doc::NodeId;
use serde_json::Value;
use serde_json::{Value, json};
use crate::FigApp;
use crate::i18n::{tr, trf, trn};
use crate::theme::{Tokens, semibold};
use crate::{FigApp, ReadFuture, RepaintWaker};
/// A dropped file waiting to be opened (`file.open`, a document of its own) or imported
/// (`file.import`, into the current document).
@@ -18,6 +22,8 @@ pub struct PendingOpen {
pub command: &'static str,
/// Canvas imports stay with the document and page that received the drop.
pub target: Option<ImportTarget>,
/// The file's contents still on their way; they go into `params` as `data` once read.
pub read: Option<ReadFuture>,
}
pub struct ImportTarget {
@@ -66,23 +72,55 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option<Value> {
/// An import that has no destination left: nothing is imported, and the user is told why.
fn cancel(app: &mut FigApp, message: String) {
app.ui.last_error = Some(message.clone());
app.toast(message);
app.show_error(message);
}
/// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first
/// frame, so the second one is the first the window shows.
const FRAMES_BEFORE_OPENING: u8 = 2;
/// The card while files wait, then (once it has been on screen) open them. Called before the import
/// report, so a report for these files shows after them.
/// Polls the contents still being read: those that arrived go into their file's parameters, files
/// that couldn't be read are dropped with a message. Whether any are still on their way.
fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool {
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
let mut cx = TaskContext::from_waker(&waker);
let mut reading = false;
let mut unread = Vec::new();
app.ui.pending_opens.retain_mut(|pending| {
let Some(read) = pending.read.as_mut() else { return true };
match read.as_mut().poll(&mut cx) {
Poll::Pending => {
reading = true;
true
}
Poll::Ready(Ok(bytes)) => {
use base64::Engine as _;
pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes));
pending.read = None;
true
}
Poll::Ready(Err(why)) => {
unread.push(why);
false
}
}
});
for why in unread {
app.show_error(why);
}
reading
}
/// The card while files wait, then (once it has been on screen and their contents are read) open
/// them. Called before the import report, so a report for these files shows after them.
pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) {
let reading = read_contents(app, ctx);
if app.ui.pending_opens.is_empty() {
app.ui.pending_open_frames = 0;
return;
}
ctx.request_repaint();
if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING {
if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING && !reading {
app.ui.pending_open_frames = 0;
for pending in std::mem::take(&mut app.ui.pending_opens) {
let command = pending.command;
@@ -92,7 +130,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) {
}
return;
}
app.ui.pending_open_frames += 1;
app.ui.pending_open_frames = app.ui.pending_open_frames.saturating_add(1);
let title = match app.ui.pending_opens.first().map(|p| p.command) {
Some("file.import") => {
let name = app
+274 -59
View File
@@ -12,22 +12,93 @@ use crate::theme::{Tokens, regular};
use crate::{FigApp, TitleBar};
pub const HEIGHT: f32 = 40.0;
const MIN_TAB_WIDTH: f32 = 96.0;
const OVERFLOW_WIDTH: f32 = 32.0;
const OPEN_TIME: f64 = 0.18;
/// Which tabs were open last frame and when the newly opened ones appeared. It is updated in place
/// in egui's memory; it only allocates when the set of tabs changes.
#[derive(Clone, Default)]
struct TabMotion {
initialized: bool,
known: Vec<u64>,
opening: Vec<(u64, f64)>,
}
impl TabMotion {
/// Track tabs added since the previous frame and expire their animations.
fn begin_frame(&mut self, current: impl Iterator<Item = u64> + Clone, now: f64, reduce_motion: bool) {
if !self.known.iter().copied().eq(current.clone()) {
if self.initialized && !reduce_motion {
for uid in current.clone().filter(|uid| !self.known.contains(uid)) {
self.opening.push((uid, now));
}
}
self.known = current.collect();
}
self.initialized = true;
if reduce_motion {
self.opening.clear();
} else {
let known = &self.known;
self.opening.retain(|(uid, started)| known.contains(uid) && now - *started < OPEN_TIME);
}
}
}
/// The entering tab's progress among `opening` (from [`TabMotion`]), or 1 when it is not animating.
fn open_progress(opening: &[(u64, f64)], uid: u64, now: f64) -> f32 {
opening.iter().find(|(u, _)| *u == uid).map_or(1.0, |(_, started)| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32)
}
struct TabLayout {
first: usize,
widths: Vec<f32>,
overflow: bool,
}
/// Shrink open tabs before moving the excess into the overflow menu.
fn tab_layout(preferred: &[f32], available: f32, active: Option<usize>) -> TabLayout {
let count = preferred.len();
let overflow = count as f32 * MIN_TAB_WIDTH > available;
let visible = if overflow { ((available - OVERFLOW_WIDTH).max(0.0) / MIN_TAB_WIDTH).floor() as usize } else { count }.min(count);
let first = active.unwrap_or(0).saturating_sub(visible / 2).min(count - visible);
let mut widths = preferred[first..first + visible].to_vec();
let budget = (available - if overflow { OVERFLOW_WIDTH } else { 0.0 }).max(0.0);
let mut excess = (widths.iter().sum::<f32>() - budget).max(0.0);
while excess > 0.01 {
let flexible = widths.iter().filter(|w| **w > MIN_TAB_WIDTH + 0.01).count();
if flexible == 0 {
break;
}
let share = excess / flexible as f32;
let mut removed = 0.0;
for width in &mut widths {
let shrink = (*width - MIN_TAB_WIDTH).max(0.0).min(share);
*width -= shrink;
removed += shrink;
}
excess -= removed;
}
TabLayout { first, widths, overflow }
}
/// Final document index and insertion marker for a tab dropped among the visible tabs.
fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> Option<(usize, f32)> {
let others: Vec<_> = visible.iter().copied().filter(|(index, _)| *index != from).collect();
let slot = others.partition_point(|(_, rect)| rect.center().x < pointer_x);
if let Some(&(index, rect)) = others.get(slot) {
Some((index - usize::from(from < index), rect.min.x))
} else {
let &(index, rect) = others.last()?;
Some((index + usize::from(from > index), rect.max.x))
}
}
pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
let rect = ui.max_rect();
// Empty space moves the window (first, so the tabs and buttons on top take their clicks).
if app.ui.title_bar != TitleBar::Native {
let drag = ui.interact(rect, ui.id().with("titlebar_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
}
let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 };
let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 };
// Home.
@@ -49,28 +120,70 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
x += 44.0;
ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border));
let document_tabs: Vec<usize> = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect();
let now = ui.input(|input| input.time);
let motion_id = ui.id().with("tab_motion");
let current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid));
let reduce_motion = app.reduce_motion();
// Cloning `opening` is free while nothing animates (an empty Vec).
let opening = ui.ctx().data_mut(|data| {
let motion = data.get_temp_mut_or_default::<TabMotion>(motion_id);
motion.begin_frame(current_uids, now, reduce_motion);
motion.opening.clone()
});
let preferred: Vec<f32> = document_tabs
.iter()
.map(|&i| {
let d = &app.session.docs[i];
let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
(regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
})
.collect();
// Leave the new-tab button, theme toggle, window controls and a draggable gap free.
let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 };
let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0);
let active_tab = document_tabs.iter().position(|&i| i == app.session.active);
let layout = tab_layout(&preferred, available, active_tab);
let mut tab_rects = Vec::with_capacity(layout.widths.len());
let mut tab_x = x;
for &width in &layout.widths {
tab_rects.push(Rect::from_min_size(pos2(tab_x, rect.min.y), vec2(width, HEIGHT)));
tab_x += width;
}
let mut switch = None;
let mut close = None;
// A tab dragged off the strip, or "Open in new window", moves to a window of its own.
let mut detach = None;
let mut dragging = None;
let mut dropped = None;
let several = !ui.ctx().embed_viewports();
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) {
for (slot, &w) in layout.widths.iter().enumerate() {
let i = document_tabs[layout.first + slot];
let d = &app.session.docs[i];
let active = i == app.session.active && !app.ui.home && app.ui.present.is_none();
let name = d.doc.name.clone();
let name = &d.doc.name;
// In a live session: a mark after the name says whether others get in.
let live = live_mark(app, d.uid);
let mark_w = if live.is_some() { 18.0 } else { 0.0 };
// (A file in a session is rarely saved here: its unsaved dot gets room beside the mark.)
let dirty = d.is_dirty();
let dot_w = if dirty && live.is_some() { 12.0 } else { 0.0 };
let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0);
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
let resp = ui.interact(r, ui.id().with(("tab", d.uid)), if several { Sense::click_and_drag() } else { Sense::click() });
if resp.drag_stopped()
let r = tab_rects[slot];
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
let close_r = Rect::from_min_max(body_r.right_top(), r.max);
let resp = ui.interact(body_r, ui.id().with(("tab", d.uid)), Sense::click_and_drag());
let cresp = ui.interact(close_r, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
if (resp.dragged() || resp.drag_stopped())
&& let Some(p) = ui.input(|inp| inp.pointer.latest_pos())
&& (p.y - rect.center().y).abs() > HEIGHT * 2.0
{
detach = Some((i, Some(p)));
if resp.drag_stopped() {
if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 {
detach = Some((i, Some(p)));
} else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) {
dropped = Some((i, p.x));
}
} else {
dragging = Some((i, p));
}
}
if several {
resp.context_menu(|ui| {
@@ -82,49 +195,52 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
});
}
let label = match live {
Some(true) => trf("{name}, in a live session", &[("name", &name)]),
Some(false) => trf("{name}, online, open only to you", &[("name", &name)]),
Some(true) => trf("{name}, in a live session", &[("name", name)]),
Some(false) => trf("{name}, online, open only to you", &[("name", name)]),
None => name.clone(),
};
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
crate::a11y::tab(&resp, &label, active);
let progress = open_progress(&opening, d.uid, now);
let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
let painter = ui.painter().with_clip_rect(r);
if active {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress));
} else if resp.hovered() || cresp.hovered() {
painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress));
}
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)),
&painter,
Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)),
Icon::Logo,
crate::theme::ACCENT,
crate::theme::ACCENT.linear_multiply(progress),
);
// The unsaved-changes dot follows the name on screen, whichever way the name reads.
let shown = crate::bidi::paint_name(
ui.painter(),
pos2(r.min.x + 32.0, r.center().y),
&name,
&painter,
pos2(paint_r.min.x + 32.0, paint_r.center().y),
name,
regular(12.0),
t.text,
w - mark_w - if dirty { 72.0 } else { 60.0 },
t.text.linear_multiply(progress),
(w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
);
if let Some(shared) = live {
let at = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(14.0, 14.0));
icons::paint(ui.painter(), at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary });
let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
}
if dirty {
ui.painter().text(pos2(shown.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text);
painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress));
}
let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0));
if resp.hovered() || active {
let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0));
// The close target never overlaps the draggable body, including on inactive tabs.
if resp.hovered() || cresp.hovered() || active {
if cresp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed);
}
icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary);
if cresp.clicked() {
close = Some(i);
painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress));
}
icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress));
}
if cresp.clicked() {
close = Some(i);
}
if resp.clicked() && close.is_none() {
switch = Some(i);
@@ -135,35 +251,66 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
x += w;
ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border));
}
let visible: Vec<(usize, Rect)> = tab_rects.iter().copied().enumerate().map(|(slot, r)| (document_tabs[layout.first + slot], r)).collect();
if let Some((from, p)) = dragging
&& (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y)
&& let Some((_, at)) = tab_drop_position(&visible, from, p.x)
{
ui.painter().line_segment([pos2(at, rect.min.y + 5.0), pos2(at, rect.max.y - 5.0)], Stroke::new(3.0, crate::theme::ACCENT));
}
let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to)));
if layout.overflow {
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT));
let resp = ui.interact(r, ui.id().with("tab_overflow"), Sense::click()).tip(tr("Open files"));
crate::a11y::menu_button(&resp, tr("Open files"), crate::a11y::menu_open(&resp));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
crate::right::menu_popup(&resp).show(|ui| {
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
for &i in &document_tabs {
let Some(d) = app.session.docs.get(i) else { continue };
let on = i == app.session.active && !app.ui.home && app.ui.present.is_none();
let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() };
if crate::right::menu_check(ui, on, &label, "") {
switch = Some(i);
ui.close();
}
}
});
});
x += OVERFLOW_WIDTH;
}
// Presentations live in their own tab. Switching back to the design keeps the player
// state (current screen, navigation history and scroll position) for the return trip.
if app.ui.present.is_some() || app.ui.paused_present.is_some() {
let w = 128.0;
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
let resp = ui.interact(r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present"));
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
let close_r = Rect::from_min_max(body_r.right_top(), r.max);
let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present"));
let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview"));
let active = app.ui.present.is_some();
crate::a11y::tab(&resp, tr("Present"), active);
if active {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
} else if resp.hovered() {
} else if resp.hovered() || close_resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon);
ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text);
let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0));
if resp.hovered() || active {
let close_resp = ui.interact(cr, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview"));
let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0));
if resp.hovered() || close_resp.hovered() || active {
if close_resp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed);
}
icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary);
if close_resp.clicked() {
app.ui.present = None;
app.ui.paused_present = None;
app.ui.present_uid = None;
} else if resp.clicked() && !active {
resume_presentation(app);
}
}
if close_resp.clicked() {
app.ui.present = None;
app.ui.paused_present = None;
app.ui.present_uid = None;
} else if resp.clicked() && !active {
resume_presentation(app);
}
@@ -195,6 +342,18 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
app.run(cmd, json!({}));
}
// Only the free space drags the window. A full-bar drag target steals tab drags.
if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x {
let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y));
let drag = ui.interact(free, ui.id().with("titlebar_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
}
if let Some(i) = switch {
if let Some(p) = app.ui.present.take() {
app.ui.paused_present = Some(p);
@@ -209,6 +368,11 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.toast(e);
}
}
if let Some((from, to)) = reorder
&& from != to
{
app.run("file.moveTab", json!({"from": from, "to": to}));
}
if let Some(i) = close {
// A branch tab's edits live on in its main file: closing it loses nothing.
let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
@@ -230,8 +394,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.ui.present_uid = None;
}
}
let _ = Color32::TRANSPARENT;
let _ = Align2::LEFT_TOP;
if !opening.is_empty() {
ui.ctx().request_repaint();
}
});
}
@@ -325,3 +490,53 @@ pub fn resize_edges(ctx: &egui::Context) {
}
});
}
#[cfg(test)]
mod layout_tests {
use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout};
use egui::{Rect, pos2};
#[test]
fn tabs_shrink_to_fit_before_overflowing() {
let preferred = vec![120.0; 10];
let layout = tab_layout(&preferred, 1100.0, Some(5));
assert!(!layout.overflow);
assert_eq!(layout.first, 0);
assert_eq!(layout.widths.len(), preferred.len());
assert!(layout.widths.iter().all(|&width| (width - 110.0).abs() < 0.01));
}
#[test]
fn excess_tabs_leave_room_for_overflow_and_keep_the_active_tab_visible() {
let preferred = vec![120.0; 24];
let available = 1100.0;
let layout = tab_layout(&preferred, available, Some(23));
assert!(layout.overflow);
assert!(layout.first <= 23 && 23 < layout.first + layout.widths.len());
assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH));
assert!(layout.widths.iter().sum::<f32>() + OVERFLOW_WIDTH <= available + 0.01);
}
#[test]
fn drop_position_places_a_tab_between_its_neighbors() {
let visible = (0..4)
.map(|index| (index, Rect::from_min_max(pos2(index as f32 * 120.0, 0.0), pos2((index + 1) as f32 * 120.0, 40.0))))
.collect::<Vec<_>>();
assert_eq!(tab_drop_position(&visible, 0, 330.0).map(|(to, _)| to), Some(2));
assert_eq!(tab_drop_position(&visible, 3, 30.0).map(|(to, _)| to), Some(0));
}
#[test]
fn new_tabs_animate_unless_motion_is_reduced() {
let mut motion = TabMotion::default();
motion.begin_frame([1].into_iter(), 0.0, false);
assert!(motion.opening.is_empty(), "the tabs open at start-up don't animate");
motion.begin_frame([1, 2].into_iter(), 1.0, false);
assert_eq!(open_progress(&motion.opening, 2, 1.0), 0.0);
assert_eq!(open_progress(&motion.opening, 1, 1.0), 1.0);
motion.begin_frame([1, 2].into_iter(), 1.0 + 2.0 * OPEN_TIME, false);
assert!(motion.opening.is_empty(), "the animation ends");
motion.begin_frame([1, 2, 3].into_iter(), 2.0, true);
assert!(motion.opening.is_empty(), "Reduce motion opens tabs at once");
}
}
+75
View File
@@ -158,6 +158,81 @@ fn tabs_and_new_tab_keep_their_clicks() {
assert!(opened, "the new-tab button took no click");
}
#[test]
fn many_tabs_do_not_cover_the_window_buttons() {
let mut u = Ui::new(TitleBar::Custom);
for _ in 0..24 {
u.app.session.execute("file.new", &json!({})).unwrap();
}
u.frame(vec![]);
let before = u.app.session.docs.len();
u.click(pos2(SIZE.x - 222.0, 20.0));
assert_eq!(u.app.session.docs.len(), before + 1, "the new-tab button must stay reachable");
assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true)));
assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true)));
u.click(window_button(2));
assert!(u.frame(vec![]).contains(&ViewportCommand::Close));
}
#[test]
fn close_button_on_inactive_tab_closes_it_without_switching() {
let mut u = Ui::new(TitleBar::Custom);
u.app.session.execute("file.new", &json!({})).unwrap();
u.app.session.execute("file.new", &json!({})).unwrap();
let active_uid = u.app.session.docs[u.app.session.active].uid;
let inactive_uid = u.app.session.docs[1].uid;
let close_button = pos2(270.0, 20.0);
u.frame(vec![Event::PointerMoved(close_button)]);
u.click(close_button);
assert!(
!u.app.session.docs.iter().any(|doc| doc.uid == inactive_uid),
"docs={}, active={}, last_error={:?}, target_dirty={}",
u.app.session.docs.len(),
u.app.session.active,
u.app.ui.last_error,
u.app.session.docs.iter().find(|doc| doc.uid == inactive_uid).is_some_and(|doc| doc.is_dirty())
);
assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid);
}
#[test]
fn closing_a_tab_before_the_active_one_keeps_the_active_file() {
let mut u = Ui::new(TitleBar::Custom);
for _ in 0..3 {
u.app.session.execute("file.new", &json!({})).unwrap();
}
u.app.session.execute("doc.switch", &json!({"index": 2})).unwrap();
let active_uid = u.app.session.docs[u.app.session.active].uid;
let closed_uid = u.app.session.docs[0].uid;
let close_button = pos2(156.0, 20.0);
u.frame(vec![Event::PointerMoved(close_button)]);
u.click(close_button);
assert!(!u.app.session.docs.iter().any(|doc| doc.uid == closed_uid));
assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid);
}
#[test]
fn dragging_a_tab_along_the_bar_reorders_it_without_detaching() {
let mut u = Ui::new(TitleBar::Custom);
for name in ["Two", "Three", "Four"] {
u.app.session.execute("file.new", &json!({"name": name})).unwrap();
}
let moved_uid = u.app.session.docs[0].uid;
let active_uid = u.app.session.docs[u.app.session.active].uid;
u.frame(vec![]);
let commands = u.drag(pos2(100.0, 20.0), vec2(310.0, 0.0));
assert!(!commands.contains(&ViewportCommand::StartDrag), "{commands:?}");
assert_eq!(
u.app.session.docs[2].uid,
moved_uid,
"order={:?}, active={}, error={:?}",
u.app.session.docs.iter().map(|d| d.uid).collect::<Vec<_>>(),
u.app.session.active,
u.app.ui.last_error
);
assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid);
}
#[test]
fn a_system_title_bar_leaves_the_window_alone() {
let mut u = Ui::new(TitleBar::Native);
+67 -4
View File
@@ -11,6 +11,18 @@ use crate::i18n::{tr, tr_noop, trf};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular};
/// Every mark in the toolbar row is painted in a box this size, so they share one optical size
/// and one stroke width (#737, #738).
const GLYPH: f32 = 18.0;
/// The flyout carets: smaller than the marks, but on a box whose stroke rounds to the same width
/// as theirs at 1×, 1.5× and 2× (a 10-point box drew them one physical pixel thinner).
const CARET: f32 = 14.0;
/// `GLYPH`'s box centred in `r`.
fn glyph_rect(r: Rect) -> Rect {
Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH))
}
struct Group {
key: &'static str,
/// The flyout button's name for screen readers.
@@ -82,6 +94,8 @@ pub(crate) fn main_width() -> f32 {
GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0
}
/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the
/// Draw · Design · Prototype · Inspect mode switcher.
fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
let (cell, gap) = (32.0, 4.0);
let width = main_width();
@@ -121,7 +135,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
if cresp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary);
if cresp.clicked() {
app.ui.tool_flyout = if app.ui.tool_flyout == Some(g.key) { None } else { Some(g.key) };
}
@@ -149,7 +163,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon });
icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { Color32::WHITE } else { t.icon });
if resp.clicked() {
if id == "actions" {
crate::menus::toggle_palette(&mut app.ui);
@@ -181,7 +195,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), t.hover);
}
icons::paint(ui.painter(), r.shrink(9.0), *icon, if i == active { t.accent_text } else { t.icon_secondary });
icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary });
if resp.clicked() {
match i {
0 => {
@@ -379,7 +393,7 @@ fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Ic
Color32::TRANSPARENT
};
ui.painter().rect_filled(r, CornerRadius::same(5), bg);
icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon });
icons::paint(ui.painter(), glyph_rect(r), icon, if active { Color32::WHITE } else { t.icon });
resp
}
@@ -452,3 +466,52 @@ fn flyout_id(key: &str) -> egui::Id {
pub fn pretty_shortcut(s: &str) -> String {
figcraft_engine::keymap::display(s, crate::shortcuts::platform())
}
#[cfg(test)]
mod tests {
use super::*;
use egui::Shape;
/// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point.
fn widths(ppp: f32, icon: Icon, size: f32) -> Vec<f32> {
let ctx = egui::Context::default();
let input = || {
let mut i = egui::RawInput::default();
i.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp);
i
};
ctx.run_ui(input(), |_| {}).textures_delta.clear();
let mut out =
ctx.run_ui(input(), |ui| icons::paint(ui.painter(), Rect::from_center_size(pos2(50.0, 50.0), vec2(size, size)), icon, Color32::WHITE));
out.textures_delta.clear();
assert_eq!(ctx.pixels_per_point(), ppp);
let mut w: Vec<f32> = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
Shape::LineSegment { stroke, .. } => Some(stroke.width),
Shape::Path(p) => Some(p.stroke.width),
Shape::Rect(r) => Some(r.stroke.width),
_ => None,
})
.filter(|w| *w > 0.0)
.collect();
w.dedup();
w
}
/// The flyout carets and the marks beside them draw with one stroke width (#737), and the
/// mode switcher's marks are the tool marks' size (#738).
#[test]
fn toolbar_marks_share_one_stroke_width() {
for ppp in [1.0, 1.5, 2.0] {
let mark = widths(ppp, Icon::Rectangle, GLYPH);
assert_eq!(mark.len(), 1, "one width at {ppp}×: {mark:?}");
assert_eq!(widths(ppp, Icon::Chevron, CARET), mark, "caret at {ppp}×");
assert_eq!(widths(ppp, Icon::Code, GLYPH), mark, "mode mark at {ppp}×");
for icon in [Icon::Frame, Icon::Text] {
assert_eq!(widths(ppp, icon, GLYPH), mark, "{icon:?} at {ppp}×");
}
}
}
}
+18
View File
@@ -141,6 +141,24 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() {
assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer");
}
#[test]
fn copying_text_layers_puts_their_text_on_the_clipboard() {
// #808: other apps get a text layer's text, not its SVG; FigCraft still pastes the layers.
let mut p = Page::new();
let a = p.app.session.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 200})).unwrap();
let b = p.app.session.execute("text.create", &json!({"text": "World", "x": 0, "y": 300})).unwrap();
p.app.session.execute("select.set", &json!({"ids": [a["id"], b["id"]]})).unwrap();
p.frame(vec![]);
p.copy();
assert_eq!(p.clipboard, "Hello\nWorld", "one line per text layer");
p.paste();
assert_eq!(p.count(text), 4, "the copied text layers were pasted, not one layer of their text");
let r = p.app.session.doc().unwrap().nodes().find(|n| rect(&n.kind)).map(|n| n.id.0).unwrap();
p.app.session.execute("select.set", &json!({"ids": [r, a["id"]]})).unwrap();
p.copy();
assert!(p.clipboard.contains("<svg"), "a selection with other layers stays SVG");
}
#[test]
fn shift_cmd_v_pastes_over_once_and_no_text() {
for foreign in [false, true] {