Merge main into #862

This commit is contained in:
bflatastic
2026-10-20 02:04:32 +07:00
74 changed files with 1586 additions and 328 deletions
+1
View File
@@ -25,6 +25,7 @@ Release builds embed fonts from the optional craft-fonts build input
| Family | Licence | Upstream |
|---|---|---|
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
+3 -3
View File
@@ -9,7 +9,7 @@
/// What the app was started with.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Args {
/// `--control <port>`: the JSON-lines control server.
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
pub control: Option<u16>,
pub files: Vec<String>,
/// The last invite link given.
@@ -71,8 +71,8 @@ pub fn parse(args: &[String]) -> Args {
};
if let Some(roots) = roots {
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
} else if a == "--control" {
out.control = it.next().and_then(|p| p.parse().ok());
} else if flag == "--control" {
out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok());
} else if is_invite(a) {
out.join = Some(a.clone());
} else if let Some(path) = file_url_path(a) {
+8
View File
@@ -16,6 +16,14 @@ fn files_and_the_control_port() {
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
}
#[test]
fn the_control_port_accepts_the_equals_form() {
let a = parse(&args(&["--control=7980", "a.figcraft"]));
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
// A bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
}
#[test]
fn automation_roots_repeat_and_are_never_files() {
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
+3
View File
@@ -56,3 +56,6 @@ sync_github_name = true
[people.pechnikov2026-star]
sync_github_name = true
[people.MortenHusted]
sync_github_name = true
+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
@@ -622,7 +622,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> {
@@ -1053,7 +1053,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();
@@ -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);
}
+2 -3
View File
@@ -65,9 +65,8 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, Stri
if cjk && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() {
continue;
}
// Web builds have a size budget: Inter, plus Noto Sans CJK (every CJK script, for CJK UI
// text) when asked.
if wasm && *family != "Inter" && !cjk {
// Web builds have a size budget: UI fonts, only the SemiBold wordmark, and optionally CJK.
if wasm && *family != "Inter" && !(*family == "Archivo" && *style == "SemiBold") && !cjk {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
+1
View File
@@ -1851,6 +1851,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"
+1
View File
@@ -1797,6 +1797,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"
+1
View File
@@ -1892,6 +1892,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"
+1
View File
@@ -1893,6 +1893,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"
+1
View File
@@ -1881,6 +1881,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"
+1
View File
@@ -1855,6 +1855,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"
+1
View File
@@ -1866,6 +1866,7 @@ status = "draft — needs native review"
"file.exportLayers" = "レイヤーを書き出し"
"file.exportSvg" = "選択範囲を SVG で書き出し"
"file.import" = "インポート"
"file.moveTab" = "タブを移動"
"file.new" = "新規デザインファイル"
"file.newFromTemplate" = "テンプレートから新規作成"
"file.open" = "開く"
+1
View File
@@ -1788,6 +1788,7 @@ status = "draft — needs native review"
"file.exportLayers" = "레이어 내보내기"
"file.exportSvg" = "선택 항목을 SVG로 내보내기"
"file.import" = "가져오기"
"file.moveTab" = "탭 이동"
"file.new" = "새 디자인 파일"
"file.open" = "열기"
"file.openRecent" = "최근 파일 열기"
+1
View File
@@ -1892,6 +1892,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"
+1
View File
@@ -1787,6 +1787,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"
+1
View File
@@ -1857,6 +1857,7 @@ name = "Русский"
"file.exportLayers" = "Экспортировать слои"
"file.exportSvg" = "Экспортировать выделение в SVG"
"file.import" = "Импорт"
"file.moveTab" = "Переместить вкладку"
"file.new" = "Новый файл дизайна"
"file.newFromTemplate" = "Создать из шаблона"
"file.open" = "Открыть"
+1
View File
@@ -1874,6 +1874,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"
+1
View File
@@ -1855,6 +1855,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ç"
+1
View File
@@ -1789,6 +1789,7 @@ status = "draft — needs native review"
"file.exportLayers" = "导出图层"
"file.exportSvg" = "将所选项导出为 SVG"
"file.import" = "导入"
"file.moveTab" = "移动标签页"
"file.new" = "新建设计文件"
"file.open" = "打开"
"file.openRecent" = "打开最近的文件"
+1 -1
View File
@@ -316,7 +316,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String {
);
crate::widgets::select_all_on_focus(ui, &r, &filter);
if r.has_focus() {
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), egui::StrokeKind::Inside);
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
filter.clear();
}
+55 -41
View File
@@ -12,7 +12,7 @@ use serde_json::json;
use crate::FigApp;
use crate::i18n::{tr, trf, trn};
use crate::render_worker::{Done, Job, Worker};
use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold};
use crate::theme::{COMPONENT, Tokens, doc_c32, regular, semibold};
pub struct CanvasState {
pub rect: Rect,
@@ -233,6 +233,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
})
.unwrap_or(t.canvas);
painter.rect_filled(rect, CornerRadius::ZERO, bg);
crate::theme::set_canvas_page(&ctx, bg);
if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) {
// Re-project the last render onto the current view.
let tl = app.to_screen(v.origin.0, v.origin.1);
@@ -247,8 +248,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) {
let area = Rect::from_two_pos(start, p);
if area.width() > 4.0 || area.height() > 4.0 {
painter.rect_filled(area, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1));
painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside);
painter.rect_filled(area, CornerRadius::ZERO, accent(&painter).gamma_multiply(0.1));
painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, accent(&painter)), StrokeKind::Inside);
}
}
crate::dev_mode::draw(app, &painter);
@@ -805,11 +806,11 @@ fn swap_highlight(app: &FigApp, painter: &egui::Painter, doc: &Document, id: Nod
}
/// The outline colour for a layer: purple for components, sets and instances and every layer in
/// them, blue otherwise.
fn outline_colour(doc: &Document, id: NodeId) -> Color32 {
/// them, the page's overlay `green` otherwise.
fn outline_colour(doc: &Document, id: NodeId, green: Color32) -> Color32 {
use figcraft_engine::components::{owning_component, owning_instance};
let set = matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::ComponentSet { .. }));
if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { ACCENT }
if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { green }
}
/// The outline path of shapes (ellipses, vectors) for hover/selection outlines.
@@ -942,20 +943,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
&& app.session.prefs.highlight_on_hover
&& app.session.tool.overlay.measure_box.is_none()
{
draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h), 1.5));
draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h, accent(painter)), 1.5));
}
// Inside a boolean group (an operand selected or in vector edit mode): every operand's outline,
// so the shapes making the live result stay visible while one of them changes.
let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id));
for id in operands.iter().filter(|o| !sel.contains(o)) {
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0));
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id, accent(painter)).gamma_multiply(0.6), 1.0));
}
// Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows.
if view.mask_outlines || view.frame_outlines || view.show_slices {
for id in doc.subtree(page) {
let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
if view.mask_outlines && n.is_mask {
draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0));
draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0));
} else if view.frame_outlines && n.kind.is_frame_like() {
draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0));
} else if view.show_slices
@@ -971,20 +972,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
if let Some(d) = ov.drop_target
&& let Some(q) = node_quad(app, &doc, d)
{
quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { ACCENT }, 1.0));
quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0));
}
slot_badges(app, painter, &doc, &sel, ov.drop_target);
if let Some(t) = ov.swap_target {
swap_highlight(app, painter, &doc, t);
}
if let Some([x0, y0, x1, y1]) = ov.insert_line {
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0));
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 2.0));
}
if let Some([x0, y0, x1, y1]) = ov.drop_cell {
let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1));
painter.rect_filled(r, CornerRadius::same(2), ACCENT.gamma_multiply(0.12));
painter.rect_filled(r, CornerRadius::same(2), accent(painter).gamma_multiply(0.12));
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.5), 4.0, 3.0));
painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.5), 4.0, 3.0));
}
// Vector edit mode, with the transform box around several selected points.
// The selection's size label, which measurement labels keep clear of.
@@ -1007,13 +1008,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
width_overlay(app, painter, &doc, &ve, zoom);
} else if let Some(c) = app.session.vector_edit_box() {
let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot()));
size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label);
size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, label);
}
}
// Selection.
let editing = ds.text_edit.clone();
if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() {
let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT };
let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
for id in &sel {
draw_outline(app, painter, &doc, *id, stroke(col, 1.0));
}
@@ -1082,12 +1083,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
{
let layout = &text.layout;
if let Some(q) = node_quad(app, edoc, te.id) {
quad(painter, q, stroke(ACCENT, 1.0));
quad(painter, q, stroke(accent(painter), 1.0));
}
let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
if a != b && preedit.is_none() {
for q in text.selection(app, a, b) {
painter.add(Shape::convex_polygon(q.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE));
painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE));
}
}
if let Some(p) = preedit {
@@ -1118,8 +1119,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
// Marquee.
if let Some([x0, y0, x1, y1]) = ov.marquee.or(app.ui.key_box) {
let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1));
painter.rect_filled(r, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1));
painter.rect_stroke(r, CornerRadius::ZERO, stroke(ACCENT, 1.0), StrokeKind::Inside);
painter.rect_filled(r, CornerRadius::ZERO, accent(painter).gamma_multiply(0.1));
painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside);
}
// Pen path.
if !ov.pen.is_empty() {
@@ -1134,7 +1135,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
}
for p in &pts {
painter.circle_filled(*p, 3.5, Color32::WHITE);
painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0));
painter.circle_stroke(*p, 3.5, stroke(accent(painter), 1.0));
}
if let Some([x0, y0, x1, y1]) = ov.rubber {
let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1));
@@ -1146,11 +1147,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
[a, h, b, b],
false,
Color32::TRANSPARENT,
stroke(ACCENT, 1.0),
stroke(accent(painter), 1.0),
));
}
None => {
painter.line_segment([a, b], stroke(ACCENT, 1.0));
painter.line_segment([a, b], stroke(accent(painter), 1.0));
}
}
}
@@ -1161,7 +1162,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
let c = app.to_screen(x, y);
let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
painter.circle_stroke(c, rs, stroke(ACCENT, 1.5));
painter.circle_stroke(c, rs, stroke(accent(painter), 1.5));
}
// Lasso (vector edit mode).
if ov.lasso.len() > 1 {
@@ -1169,7 +1170,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
if let Some(first) = pts.first().copied() {
pts.push(first);
}
painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.0), 4.0, 3.0));
painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0));
}
// Guides (under the rulers).
crate::guides::draw(app, painter);
@@ -1203,7 +1204,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color
fn pill(painter: &egui::Painter, at: Pos2, align: Align2, text: String, col: Color32) -> Rect {
let galley = painter.layout_no_wrap(text, semibold(11.0), Color32::WHITE);
let r = align.anchor_size(at, galley.size() + vec2(8.0, 4.0));
painter.rect_filled(r, CornerRadius::same(3), col);
painter.rect_filled(r, CornerRadius::same(3), crate::theme::under_white(col));
painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE);
r
}
@@ -1260,7 +1261,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]);
let draw = |h: &figcraft_engine::vectoredit::WidthHandle, ghost: bool| {
let (c, a, b) = (sp(&h.at), sp(&h.sides[0]), sp(&h.sides[1]));
let col = if ghost { ACCENT.gamma_multiply(0.5) } else { ACCENT };
let col = if ghost { accent(painter).gamma_multiply(0.5) } else { accent(painter) };
painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0));
painter.line_segment([a, b], stroke(col, 1.0));
for (e, side) in [(a, WidthSide::Right), (b, WidthSide::Left)] {
@@ -1272,7 +1273,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
let on = !ghost && ve.width_point == Some(h.index);
let hovered = !ghost && hover == Some(WHit::Point { index: h.index });
let r = if hovered { 5.0 } else { 4.0 };
painter.circle_filled(c, r, if on { ACCENT } else { Color32::WHITE });
painter.circle_filled(c, r, if on { accent(painter) } else { Color32::WHITE });
painter.circle_stroke(c, r, stroke(col, 1.0));
};
for h in width_handles(doc, ve, zoom) {
@@ -1288,9 +1289,9 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig
/// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of
/// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike).
fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) {
painter.line_segment([v, e], stroke(ACCENT, 1.0));
painter.line_segment([v, e], stroke(accent(painter), 1.0));
painter.circle_filled(e, r, fill);
painter.circle_stroke(e, r, stroke(ACCENT, 1.0));
painter.circle_stroke(e, r, stroke(accent(painter), 1.0));
}
/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and
@@ -1309,7 +1310,7 @@ fn vector_overlay(
// Paint bucket target: hatched like a fill to come.
if active && !ov.face.is_empty() {
let polys: Vec<Vec<Pos2>> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect();
hatched(painter, &polys, ACCENT);
hatched(painter, &polys, accent(painter));
}
if active {
region_hatch(app, painter);
@@ -1327,7 +1328,7 @@ fn vector_overlay(
} else {
1.5
};
painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(ACCENT, w)));
painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(accent(painter), w)));
}
if !points {
return;
@@ -1335,7 +1336,7 @@ fn vector_overlay(
for h in &o.handles {
let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start });
let on = o.selected_handles.contains(&(h.seg, h.start));
bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE });
bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { accent(painter) } else { Color32::WHITE });
}
for (i, v) in o.vertices.iter().enumerate() {
let p = sp(v);
@@ -1343,14 +1344,14 @@ fn vector_overlay(
let size = if hovered { 9.0 } else { 7.0 };
let r = Rect::from_center_size(p, vec2(size, size));
let on = o.selected_vertices.contains(&i);
painter.rect_filled(r, CornerRadius::same(5), if on { ACCENT } else { Color32::WHITE });
painter.rect_stroke(r, CornerRadius::same(5), stroke(ACCENT, 1.0), StrokeKind::Inside);
painter.rect_filled(r, CornerRadius::same(5), if on { accent(painter) } else { Color32::WHITE });
painter.rect_stroke(r, CornerRadius::same(5), stroke(accent(painter), 1.0), StrokeKind::Inside);
if o.anchor == Some(i) {
painter.circle_stroke(p, 7.5, stroke(ACCENT, 1.0));
painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0));
}
}
if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) {
painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0), 4.0, 3.0));
painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0));
}
}
@@ -1367,7 +1368,7 @@ fn region_hatch(app: &FigApp, painter: &egui::Painter) {
let r = painter.clip_rect();
let clip = figcraft_geom::Rect::new(f64::from(r.min.x), f64::from(r.min.y), f64::from(r.max.x), f64::from(r.max.y));
for [a, b] in app.session.vector_region_hatch(view, Some(clip)) {
painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(ACCENT, REGION_HATCH_WIDTH));
painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(accent(painter), REGION_HATCH_WIDTH));
}
}
@@ -1382,10 +1383,10 @@ fn builder_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, arr: &figc
for r in &arr.regions {
for poly in screen(r) {
painter.add(Shape::closed_line(poly.clone(), stroke(Color32::from_white_alpha(150), 2.5)));
painter.add(Shape::closed_line(poly, stroke(ACCENT, 1.0)));
painter.add(Shape::closed_line(poly, stroke(accent(painter), 1.0)));
}
}
let col = if ov.builder_remove { t.danger } else { ACCENT };
let col = if ov.builder_remove { t.danger } else { accent(painter) };
for r in ov.builder_regions.iter().filter_map(|i| arr.regions.get(*i)) {
hatched(painter, &screen(r), col);
}
@@ -1495,8 +1496,16 @@ fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel
// Highlight the selection's span.
if let Some(b) = doc.bounds_of(sel) {
let (a, bb) = (app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
painter.rect_filled(Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15));
painter.rect_filled(Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15));
painter.rect_filled(
Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)),
CornerRadius::ZERO,
accent(painter).gamma_multiply(0.15),
);
painter.rect_filled(
Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)),
CornerRadius::ZERO,
accent(painter).gamma_multiply(0.15),
);
}
let mut x = (p0.x / step).floor() * step;
while x <= p1.x {
@@ -1524,3 +1533,8 @@ pub fn fmt(v: f64) -> String {
#[cfg(test)]
#[path = "zoom_preview_tests.rs"]
mod zoom_preview_tests;
/// The overlay green for the page this painter draws on ([`crate::theme::canvas_accent`]).
fn accent(p: &egui::Painter) -> Color32 {
crate::theme::canvas_accent(p.ctx())
}
+1 -1
View File
@@ -1013,7 +1013,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option<PresetAction> {
let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset"));
let hot = resp.hovered() || del_resp.hovered();
if hot {
ui.painter().rect_filled(r, CornerRadius::same(5), crate::theme::ACCENT);
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
let col = if hot { Color32::WHITE } else { t.text };
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.5), col);
+6 -5
View File
@@ -189,10 +189,10 @@ pub fn cluster(points: &[Pos2], r: f32) -> Vec<Vec<usize>> {
}
/// A message's text with its @mentions of `people` picked out, wrapped at `width`.
pub fn rich_text(text: &str, people: &[&str], color: Color32, width: f32) -> LayoutJob {
pub fn rich_text(text: &str, people: &[&str], color: Color32, mention_color: Color32, width: f32) -> LayoutJob {
let mut job = LayoutJob::default();
let plain = TextFormat { font_id: regular(12.0), color, ..Default::default() };
let mention = TextFormat { font_id: semibold(12.0), color: ACCENT, ..Default::default() };
let mention = TextFormat { font_id: semibold(12.0), color: mention_color, ..Default::default() };
let mut at = 0;
for r in mention_ranges(text, people) {
job.append(text.get(at..r.start).unwrap_or(""), 0.0, plain.clone());
@@ -400,7 +400,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
if let Some((x, y, _)) = &app.ui.comments.draft {
let r = pin_rect(app.to_screen(*x, *y));
painter.rect_filled(r, BUBBLE, ACCENT);
painter.circle_filled(r.center(), 3.0, Color32::WHITE);
painter.circle_filled(r.center(), 3.0, crate::theme::ACCENT_ON);
}
}
@@ -729,7 +729,7 @@ fn message(
ui.add_space(indent);
let mut names: Vec<&str> = cx.people.iter().map(String::as_str).collect();
names.push(cx.me);
ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, ui.available_width())).wrap());
ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, t.accent_text, ui.available_width())).wrap());
});
}
}
@@ -1062,7 +1062,8 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
ui.add_space(16.0);
ui.label(egui::RichText::new(tr(app.ui.comments.filter.label())).font(regular(11.0)).color(t.text_secondary));
ui.add_space(4.0);
if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(ACCENT)).sense(Sense::click())).clicked() {
if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click())).clicked()
{
app.ui.comments.filter = Filter::Open;
}
});
+1 -1
View File
@@ -628,7 +628,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id:
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Link, t.icon_secondary);
let x = r.min.x + PAD + 22.0;
ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), ACCENT);
ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), t.accent_text);
let below = match item.from.and_then(|f| doc.node(f)) {
Some(m) => trf("From {name}", &[("name", &m.name)]),
None => url.clone(),
+5 -6
View File
@@ -3,7 +3,7 @@
use std::path::Path;
use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2};
use egui::{Align2, Context, CornerRadius, RichText, Sense, Stroke, vec2};
use figcraft_color::ColorProfile;
use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId};
use figcraft_engine::cmd::file::{export_jobs, exportable_layers};
@@ -12,8 +12,7 @@ use serde_json::{Value, json};
use crate::FigApp;
use crate::export_panel::{export_layers, save_one, suggested_name};
use crate::i18n::{self, tr, tr_noop, trf, trn};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::theme::{Tokens, regular, semibold};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -122,10 +121,10 @@ pub(crate) fn tab_strip<T: Copy + PartialEq>(ui: &mut egui::Ui, t: &Tokens, tabs
fn about_body(ui: &mut egui::Ui, t: &Tokens) {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(48.0, 48.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(10), ACCENT);
icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE);
crate::theme::paint_app_icon(ui, r);
ui.vertical(|ui| {
ui.label(RichText::new("FigCraft").font(semibold(18.0)));
let (wordmark, _) = ui.allocate_exact_size(vec2(130.0, 23.0), Sense::hover());
crate::theme::paint_wordmark(ui.painter(), egui::pos2(wordmark.min.x, wordmark.center().y), 18.0, t);
ui.label(RichText::new(trf("Version {version}", &[("version", &VERSION)])).color(t.text_secondary));
});
});
+1 -1
View File
@@ -103,7 +103,7 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod
let col = if is_comp {
COMPONENT
} else if selected || hovered {
ACCENT
crate::theme::canvas_accent(painter.ctx())
} else {
t.text_secondary
};
+12 -7
View File
@@ -11,7 +11,7 @@ use figcraft_geom::Point;
use serde_json::json;
use crate::colorpicker::PickerTarget;
use crate::theme::{ACCENT, semibold};
use crate::theme::semibold;
use crate::{FigApp, RightTab};
pub(crate) const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
@@ -293,7 +293,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
painter.rect_filled(r, CornerRadius::same(2), crate::theme::doc_c32(painter.ctx(), s.color.with_alpha(1.0)));
painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
if i == selected {
painter.rect_stroke(r.expand(2.0), CornerRadius::same(3), Stroke::new(1.5, ACCENT), StrokeKind::Outside);
painter.rect_stroke(
r.expand(2.0),
CornerRadius::same(3),
Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())),
StrokeKind::Outside,
);
}
}
for (i, p) in [a, b, c].iter().enumerate() {
@@ -301,7 +306,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
continue;
}
painter.circle_filled(*p, 5.0, Color32::WHITE);
painter.circle_stroke(*p, 5.0, Stroke::new(1.5, ACCENT));
painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())));
}
return;
}
@@ -311,8 +316,8 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120)));
// The start is hollow (least blur), the end filled (most blur by default).
painter.circle_filled(a, 5.0, Color32::WHITE);
painter.circle_stroke(a, 5.0, Stroke::new(1.5, ACCENT));
painter.circle_filled(b, 5.0, ACCENT);
painter.circle_stroke(a, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())));
painter.circle_filled(b, 5.0, crate::theme::canvas_accent(painter.ctx()));
painter.circle_stroke(b, 5.0, Stroke::new(1.5, Color32::WHITE));
return;
}
@@ -320,11 +325,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
let Some(c) = crop_corners(app, id, r) else { return };
let outline = vec![c[0], c[1], c[2], c[3], c[0]];
painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE)));
painter.add(Shape::line(outline, Stroke::new(1.0, ACCENT)));
painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx()))));
for p in c {
let hr = Rect::from_center_size(p, vec2(8.0, 8.0));
painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE);
painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside);
painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside);
}
return;
}
+5 -5
View File
@@ -198,9 +198,9 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
let mut y = left.min.y + 20.0;
icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT);
ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text);
y += 48.0;
crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0)));
crate::theme::paint_wordmark(ui.painter(), pos2(left.min.x + 52.0, y + 12.0), 16.0, t);
y += 52.0;
let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H));
ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| {
let response =
@@ -320,8 +320,8 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b
}
card_frame(ui, t, r, &resp);
let ir = Rect::from_min_size(pos2(r.min.x + 16.0, r.center().y - 16.0), vec2(32.0, 32.0));
ui.painter().rect_filled(ir, CornerRadius::same(6), ACCENT);
icons::paint(ui.painter(), ir.shrink(8.0), icon, Color32::WHITE);
ui.painter().rect_filled(ir, CornerRadius::same(6), t.accent);
icons::paint(ui.painter(), ir.shrink(8.0), icon, t.accent_on);
ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title_shown, semibold(12.0), t.text);
ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub_shown, regular(11.0), t.text_secondary);
resp.clicked()
+2 -2
View File
@@ -738,8 +738,8 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) {
let r = Rect::from_center_size(center, vec2(16.0, 16.0));
match FileKind::of(Path::new(path)) {
FileKind::FigCraft | FileKind::Fig => {
ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT);
icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE);
ui.painter().rect_filled(r, CornerRadius::same(4), t.accent);
icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, t.accent_on);
}
kind => {
ui.painter().rect_filled(r, CornerRadius::same(4), t.field);
+4 -2
View File
@@ -256,9 +256,11 @@ fn rects_of(s: &[Shape], c: egui::Color32) -> usize {
s.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == c)).count()
}
/// Images drawn (meshes with a texture of their own).
/// Preview images in the main content area, excluding the branded sidebar icon.
fn images(s: &[Shape]) -> usize {
s.iter().filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())).count()
s.iter()
.filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default() && m.vertices.iter().any(|v| v.pos.x > 240.0)))
.count()
}
#[test]
+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:?}");
}
}
+2 -2
View File
@@ -332,7 +332,7 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) {
if active {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
}
let (width, colour) = if active { (2.0, t.accent) } else { (1.0, t.border) };
let (width, colour) = if active { (2.0, ACCENT) } else { (1.0, t.border) };
ui.painter().line_segment([line.left_center(), line.right_center()], Stroke::new(width, colour));
let asked = if resp.double_clicked() {
Some(None)
@@ -642,7 +642,7 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) {
);
crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter);
if r.has_focus() {
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, ACCENT), egui::StrokeKind::Inside);
}
if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.layer_search = false;
+3 -3
View File
@@ -1089,7 +1089,7 @@ impl FigApp {
let resp = ui.interact(rect, id, egui::Sense::click_and_drag());
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::Tokens::get(ui.ctx()).accent));
ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT));
}
if resp.double_clicked() {
return Some(PANEL_DEFAULT);
@@ -1812,11 +1812,11 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) {
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop_overlay")));
let area = rect.shrink(4.0);
painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12));
painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, theme::ACCENT), egui::StrokeKind::Inside);
let galley = painter.layout_no_wrap(label.to_owned(), theme::semibold(14.0), t.text);
let pill = egui::Rect::from_center_size(area.center(), galley.size() + egui::vec2(32.0, 16.0));
painter.rect_filled(pill, egui::CornerRadius::same(8), t.panel);
painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, theme::ACCENT), egui::StrokeKind::Inside);
painter.galley(pill.center() - galley.size() / 2.0, galley, t.text);
// Hovering sends no pointer events on every platform: keep the overlay current.
ctx.request_repaint();
+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
@@ -206,12 +208,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);
}
@@ -239,6 +242,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()
+3 -3
View File
@@ -2223,7 +2223,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
}
let on = (*act == BarAction::Comments && p.view.comments) || (*act == BarAction::Inspect && p.view.inspect);
if on {
ui.painter().rect_filled(br, CornerRadius::same(5), ACCENT);
ui.painter().rect_filled(br, CornerRadius::same(5), t.accent);
} else if resp.hovered() {
ui.painter().rect_filled(br, CornerRadius::same(5), hover);
}
@@ -2234,9 +2234,9 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
resp = resp.tip(tr("Inspect the layer under the pointer: size, colours, typography"));
}
if chrome.pane {
icons::paint(ui.painter(), br.shrink(5.0), *icon, t.icon);
icons::paint(ui.painter(), br.shrink(5.0), *icon, if on { t.accent_on } else { t.icon });
} else {
ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), text);
ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), if on { t.accent_on } else { text });
}
if *act == BarAction::Options {
let menu = crate::right::menu_popup(&resp).width(220.0).show(|ui| options_menu(p, ui, device, settings.responsive, now));
+20 -7
View File
@@ -8,7 +8,6 @@ use egui::{Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos
use figcraft_doc::{Document, NodeId};
use serde_json::{Value, json};
use crate::theme::ACCENT;
use crate::{FigApp, RightTab};
const HANDLE_R: f32 = 7.0;
@@ -140,7 +139,7 @@ fn arrow(painter: &egui::Painter, pts: &[Pos2; 4], col: Color32) {
}
fn plus_handle(painter: &egui::Painter, c: Pos2, hot: bool) {
painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, ACCENT);
painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, crate::theme::canvas_accent(painter.ctx()));
painter.circle_stroke(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, Stroke::new(1.5, Color32::WHITE));
painter.line_segment([c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], Stroke::new(1.5, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)], Stroke::new(1.5, Color32::WHITE));
@@ -161,7 +160,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
let selected = sel.contains(&nd.source);
let is_open = open == Some((nd.source, nd.index));
let near = dragging.is_none() && hover.is_some_and(|q| bezier_distance(&nd.pts, q) < 5.0);
let col = if selected || is_open || near { ACCENT } else { ACCENT.gamma_multiply(0.55) };
let col = if selected || is_open || near {
crate::theme::canvas_accent(painter.ctx())
} else {
crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.55)
};
let w = if is_open || near {
2.5
} else if selected {
@@ -177,7 +180,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
.and_then(|it| figcraft_doc::proto::walk(&it.actions).find_map(|a| a.dest()))
&& let Some(r) = screen_rect(app, doc, dest)
{
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, ACCENT.gamma_multiply(0.7)), StrokeKind::Outside);
painter.rect_stroke(
r,
CornerRadius::ZERO,
Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.7)),
StrokeKind::Outside,
);
}
painter.add(egui::epaint::CubicBezierShape::from_points_stroke(nd.pts, false, Color32::TRANSPARENT, Stroke::new(w, col)));
painter.circle_filled(nd.pts[0], 3.5, col);
@@ -190,12 +198,17 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
&& doc.top_level_frame(d.source) != Some(f)
&& let Some(r) = screen_rect(app, doc, f)
{
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, ACCENT), StrokeKind::Outside);
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Outside);
}
let dx = ((d.pos.x - d.from.x).abs() * 0.5).max(30.0) * if d.pos.x >= d.from.x { 1.0 } else { -1.0 };
let pts = [d.from, d.from + vec2(dx, 0.0), d.pos - vec2(dx, 0.0), d.pos];
painter.add(egui::epaint::CubicBezierShape::from_points_stroke(pts, false, Color32::TRANSPARENT, Stroke::new(2.0, ACCENT)));
arrow(painter, &pts, ACCENT);
painter.add(egui::epaint::CubicBezierShape::from_points_stroke(
pts,
false,
Color32::TRANSPARENT,
Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())),
));
arrow(painter, &pts, crate::theme::canvas_accent(painter.ctx()));
plus_handle(painter, d.from, true);
}
None => {
+2 -2
View File
@@ -763,7 +763,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
let id = capture_id().with("field");
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
if cap {
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside);
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, ACCENT), StrokeKind::Inside);
ui.memory_mut(|m| m.request_focus(id));
let _ = ui.interact(r, id, Sense::focusable_noninteractive());
let pressed = ui.input(|i| {
@@ -1048,7 +1048,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In
let w = ui.available_width().max(200.0);
let (r, rr) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
if rr.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT);
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
let col = if rr.hovered() { Color32::WHITE } else { t.text };
curve(ui.painter(), Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(28.0, 18.0)), *e, col, None);
+7 -7
View File
@@ -11,7 +11,7 @@ use crate::i18n::{tr, trf, trn};
use crate::icons::{self, Icon};
use crate::list_drag::{ListDrag, move_flags, remove_flags};
use crate::style_picker::{self, StyleSlot};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets::{self, Edit, FIELD_H, FieldEdit, Lead, PAD};
use crate::{FigApp, RightTab};
@@ -84,12 +84,12 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
// Top bar: people (you and the others in a live session), present, share.
let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover());
// As wide as its label needs (longer in some languages).
let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), Color32::WHITE);
let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), t.accent_on);
let sw = (label.size().x + 24.0).max(56.0);
let share = Rect::from_min_size(pos2(top.max.x - 14.0 - sw, top.center().y - 16.0), vec2(sw, 32.0));
let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip(tr("Share: live session, export"));
ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { ACCENT });
ui.painter().galley(share.center() - label.size() / 2.0, label, Color32::WHITE);
ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { t.accent });
ui.painter().galley(share.center() - label.size() / 2.0, label, t.accent_on);
crate::live::share_popover(app, &sresp);
// Play presents; its chevron opens the play menu (present, or preview in a pane).
let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0));
@@ -284,13 +284,13 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl
a11y::own_focus(&resp);
let hovered = resp.hovered() || resp.has_focus();
if hovered {
ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT);
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
let col = if hovered { Color32::WHITE } else { t.text };
let col = t.menu_text;
ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col);
if let Some(k) = keys {
let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0);
ui.painter().galley(at, k, if hovered { Color32::WHITE } else { t.text_secondary });
ui.painter().galley(at, k, if hovered { t.menu_text } else { t.menu_dim });
}
art(ui.painter(), r, col);
resp
+1 -1
View File
@@ -482,7 +482,7 @@ fn style_grid(ui: &mut Ui, list: &[(&Style, Key)], applied: Option<&Key>, editab
None => preview(ui, r, s, StyleSlot::Fill),
}
if applied == Some(key) {
ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Inside);
} else if resp.hovered() && !text {
ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.menu_text), egui::StrokeKind::Inside);
}
+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");
}
}
+115 -23
View File
@@ -1,6 +1,6 @@
//! Design tokens (UI3 light and dark) and fonts.
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use egui::{Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals};
@@ -23,6 +23,7 @@ pub struct Tokens {
pub field_hover_border: Color32,
pub accent: Color32,
pub accent_hover: Color32,
pub accent_on: Color32,
pub accent_text: Color32,
pub selected_row: Color32,
pub selected_row_child: Color32,
@@ -44,11 +45,38 @@ pub struct Tokens {
pub focus: Color32,
}
pub const ACCENT: Color32 = Color32::from_rgb(0x0D, 0x99, 0xFF);
/// FigCraft's brand green for solid surfaces.
pub const BRAND: Color32 = Color32::from_rgb(0x02, 0xDA, 0x96);
/// Contrast-safe green for thin selection outlines and white-on-green labels.
/// It reaches 4.5:1 on white and 3:1 on the dark canvas.
pub const ACCENT: Color32 = Color32::from_rgb(0x00, 0x87, 0x5F);
/// Ink on the bright green brand surface (white would not have enough contrast).
pub const ACCENT_ON: Color32 = Color32::from_rgb(0x07, 0x35, 0x2A);
/// Id of the canvas overlays' green for this frame ([`set_canvas_page`]).
const CANVAS_ACCENT: &str = "figcraft-canvas-accent";
/// Remember the page colour the canvas shows. Its overlays (outlines, handles, arrows) take
/// whichever green contrasts more with it: the bright one on a dark page, the darker on a light one.
pub fn set_canvas_page(ctx: &Context, page: Color32) {
let c = |x: Color32| figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0);
let pick = if c(BRAND).contrast(c(page)) > c(ACCENT).contrast(c(page)) { BRAND } else { ACCENT };
ctx.data_mut(|d| d.insert_temp(egui::Id::new(CANVAS_ACCENT), pick));
}
/// The green for overlays drawn on the page ([`set_canvas_page`]); [`ACCENT`] before a page shows.
pub fn canvas_accent(ctx: &Context) -> Color32 {
ctx.data(|d| d.get_temp(egui::Id::new(CANVAS_ACCENT))).unwrap_or(ACCENT)
}
/// A fill under white text: the darker green stands in for the bright one.
pub fn under_white(c: Color32) -> Color32 {
if c == BRAND { ACCENT } else { c }
}
pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF);
/// Handoff status badges (white text on these, in both themes).
pub const DEV_READY: Color32 = Color32::from_rgb(0x14, 0xAE, 0x5C);
pub const DEV_CHANGED: Color32 = Color32::from_rgb(0xD9, 0x7A, 0x06);
pub const DEV_READY: Color32 = Color32::from_rgb(0x08, 0x7D, 0x4F);
pub const DEV_CHANGED: Color32 = Color32::from_rgb(0xA7, 0x5B, 0x00);
pub const DEV_COMPLETED: Color32 = Color32::from_rgb(0x6E, 0x6E, 0x73);
impl Tokens {
@@ -70,16 +98,17 @@ impl Tokens {
pressed: Color32::from_rgb(0xE6, 0xE6, 0xE6),
field: Color32::from_rgb(0xF5, 0xF5, 0xF5),
field_hover_border: Color32::from_rgb(0xE6, 0xE6, 0xE6),
accent: ACCENT,
accent_hover: Color32::from_rgb(0x00, 0x7B, 0xE5),
// 5.2:1 on white, 4.6:1 on selected rows: WCAG AA for text.
accent_text: Color32::from_rgb(0x00, 0x6B, 0xD1),
selected_row: Color32::from_rgb(0xE5, 0xF4, 0xFF),
selected_row_child: Color32::from_rgb(0xF2, 0xF9, 0xFF),
accent: BRAND,
accent_hover: Color32::from_rgb(0x00, 0xAD, 0x78),
accent_on: ACCENT_ON,
// Darker green keeps links and labels legible on white and selected rows.
accent_text: Color32::from_rgb(0x00, 0x7A, 0x56),
selected_row: Color32::from_rgb(0xE0, 0xFB, 0xF1),
selected_row_child: Color32::from_rgb(0xF1, 0xFC, 0xF7),
component: COMPONENT,
component_row: Color32::from_rgb(0xF1, 0xE9, 0xFF),
danger: Color32::from_rgb(0xF2, 0x48, 0x22),
success: Color32::from_rgb(0x14, 0xAE, 0x5C),
success: DEV_READY,
warning: Color32::from_rgb(0xFF, 0xCD, 0x29),
guide: Color32::from_rgb(0xF2, 0x48, 0x22),
tab_bar: Color32::from_rgb(0xE8, 0xE8, 0xE8),
@@ -89,7 +118,7 @@ impl Tokens {
menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF),
menu_hover: ACCENT,
menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A),
focus: Color32::from_rgb(0x00, 0x6B, 0xD1),
focus: Color32::from_rgb(0x00, 0x7A, 0x56),
}
}
@@ -110,15 +139,16 @@ impl Tokens {
pressed: Color32::from_rgb(0x44, 0x44, 0x44),
field: Color32::from_rgb(0x38, 0x38, 0x38),
field_hover_border: Color32::from_rgb(0x4D, 0x4D, 0x4D),
accent: ACCENT,
accent_hover: Color32::from_rgb(0x3D, 0xAD, 0xFF),
accent_text: Color32::from_rgb(0x7C, 0xC4, 0xF8),
selected_row: Color32::from_rgb(0x2A, 0x45, 0x5C),
selected_row_child: Color32::from_rgb(0x2D, 0x37, 0x41),
accent: BRAND,
accent_hover: Color32::from_rgb(0x52, 0xE9, 0xB7),
accent_on: ACCENT_ON,
accent_text: Color32::from_rgb(0x52, 0xE9, 0xB7),
selected_row: Color32::from_rgb(0x16, 0x42, 0x36),
selected_row_child: Color32::from_rgb(0x12, 0x37, 0x2D),
component: Color32::from_rgb(0xD2, 0x7C, 0xFF),
component_row: Color32::from_rgb(0x3E, 0x30, 0x52),
danger: Color32::from_rgb(0xF2, 0x48, 0x22),
success: Color32::from_rgb(0x14, 0xAE, 0x5C),
success: DEV_READY,
warning: Color32::from_rgb(0xFF, 0xCD, 0x29),
guide: Color32::from_rgb(0xF2, 0x48, 0x22),
tab_bar: Color32::from_rgb(0x1E, 0x1E, 0x1E),
@@ -128,7 +158,7 @@ impl Tokens {
menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF),
menu_hover: ACCENT,
menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A),
focus: ACCENT,
focus: BRAND,
}
}
@@ -186,7 +216,7 @@ fn visuals(t: Tokens) -> Visuals {
v.window_corner_radius = egui::CornerRadius::same(13);
v.menu_corner_radius = egui::CornerRadius::same(13);
v.selection.bg_fill = t.accent.gamma_multiply(0.3);
v.selection.stroke = Stroke::new(1.0, t.accent);
v.selection.stroke = Stroke::new(1.0, ACCENT);
v.hyperlink_color = t.accent_text;
for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open, &mut v.widgets.noninteractive] {
w.corner_radius = egui::CornerRadius::same(5);
@@ -202,10 +232,10 @@ fn visuals(t: Tokens) -> Visuals {
v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_hover_border);
v.widgets.active.bg_fill = t.pressed;
v.widgets.active.weak_bg_fill = t.pressed;
v.widgets.active.bg_stroke = Stroke::new(1.0, t.accent);
v.widgets.active.bg_stroke = Stroke::new(1.0, ACCENT);
v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow };
v.window_shadow = egui::Shadow { offset: [0, 8], blur: 24, spread: 0, color: t.shadow };
v.text_cursor.stroke = Stroke::new(1.0, t.accent);
v.text_cursor.stroke = Stroke::new(1.0, ACCENT);
v
}
@@ -226,7 +256,8 @@ pub fn on_dark<R>(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R {
r
}
/// UI fonts: Inter (from the craft-fonts build input) when present, else egui's defaults; with
/// UI fonts: Inter and the Archivo wordmark (from the craft-fonts build input) when present;
/// otherwise egui's defaults. With
/// `system`, also system faces for scripts those lack (Hebrew, joined Arabic) so names in them show,
/// and faces for the CJK scripts in `cjk` ([`crate::cjk`]).
pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) {
@@ -252,6 +283,18 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) {
}
semi.extend(fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default());
fonts.families.insert(FontFamily::Name("semibold".into()), semi);
// Keep the logo's typeface independent of UI text. System Archivo is used when available;
// releases with craft-fonts embed it so the wordmark does not depend on installation.
let archivo = find("Archivo", "SemiBold").map(FontData::from_static).or_else(|| {
system.then(|| figcraft_text::find_face("Archivo", "SemiBold")).flatten().filter(|face| face.family == "Archivo").map(|face| font_data(&face))
});
let mut brand = Vec::new();
if let Some(data) = archivo {
fonts.font_data.insert("archivo".into(), Arc::new(data));
brand.push("archivo".into());
}
brand.extend(fonts.families.get(&FontFamily::Name("semibold".into())).cloned().unwrap_or_default());
fonts.families.insert(FontFamily::Name("brand".into()), brand);
// CJK and other scripts from craft-fonts as fallbacks (Noto Sans CJK only with FIGCRAFT_EMBED_CJK).
const BUILT_IN: [&str; 4] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic"];
let lang = crate::i18n::current().map_or("en", |l| l.code);
@@ -339,6 +382,40 @@ pub fn semibold(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("semibold".into()))
}
pub fn brand(size: f32) -> FontId {
FontId::new(size, FontFamily::Name("brand".into()))
}
/// The two-tone Archivo wordmark used in the home sidebar and About window.
pub fn paint_wordmark(painter: &egui::Painter, left_center: egui::Pos2, size: f32, t: &Tokens) {
let font = brand(size);
let fig = painter.layout_no_wrap("Fig".into(), font.clone(), t.text);
let fig_width = fig.size().x;
painter.galley(egui::pos2(left_center.x, left_center.y - fig.size().y / 2.0), fig, t.text);
painter.text(egui::pos2(left_center.x + fig_width, left_center.y), egui::Align2::LEFT_CENTER, "Craft", font, t.accent_text);
}
/// Paint the owner's original chameleon icon from the existing app asset.
pub fn paint_app_icon(ui: &egui::Ui, rect: egui::Rect) {
static ICON: OnceLock<Option<egui::ColorImage>> = OnceLock::new();
let image = ICON.get_or_init(|| {
let rgba = image::load_from_memory(include_bytes!("../../../assets/app-icon/figcraft-64.png")).ok()?.to_rgba8();
Some(egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()))
});
let Some(image) = image else {
ui.painter().rect_filled(rect, egui::CornerRadius::same(5), BRAND);
crate::icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), crate::icons::Icon::Logo, ACCENT_ON);
return;
};
let id = egui::Id::new("figcraft_original_app_icon");
let texture = ui.ctx().data(|data| data.get_temp::<egui::TextureHandle>(id)).unwrap_or_else(|| {
let texture = ui.ctx().load_texture("figcraft-original-app-icon", image.clone(), egui::TextureOptions::LINEAR);
ui.ctx().data_mut(|data| data.insert_temp(id, texture.clone()));
texture
});
ui.painter().image(texture.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
}
pub fn regular(size: f32) -> FontId {
FontId::new(size, FontFamily::Proportional)
}
@@ -370,6 +447,21 @@ pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 {
mod tests {
use super::*;
#[test]
fn canvas_overlays_take_the_green_that_stands_out_on_the_page() {
let ctx = Context::default();
assert_eq!(canvas_accent(&ctx), ACCENT, "before any page shows");
set_canvas_page(&ctx, Color32::from_rgb(0x1E, 0x1E, 0x1E));
assert_eq!(canvas_accent(&ctx), BRAND, "the bright green on a dark page");
set_canvas_page(&ctx, Color32::WHITE);
assert_eq!(canvas_accent(&ctx), ACCENT, "the darker green on a light page");
set_canvas_page(&ctx, Color32::from_rgb(0xF5, 0xF5, 0xF5));
assert_eq!(canvas_accent(&ctx), ACCENT);
// White text never sits on the bright green.
assert_eq!(under_white(BRAND), ACCENT);
assert_eq!(under_white(COMPONENT), COMPONENT);
}
#[test]
fn ui_font_draws_every_mac_shortcut_glyph() {
figcraft_text::scan_system_fonts();
+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);
+72 -9
View File
@@ -9,7 +9,19 @@ use crate::FigApp;
use crate::a11y::{self, Nav, Region, Tip};
use crate::i18n::{tr, tr_noop, trf};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular};
use crate::theme::{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,
@@ -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) };
}
@@ -145,11 +159,11 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
// The Actions button reads on while its panel is open; clicking it again closes it.
let actions_open = id == "actions" && app.ui.palette.is_some();
if actions_open {
ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT);
ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);
} 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 { t.accent_on } 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 => {
@@ -372,14 +386,14 @@ fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Ic
});
a11y::toggle(&resp, tip, active);
let bg = if active {
ACCENT
t.accent
} else if resp.hovered() {
t.hover
} else {
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 { t.accent_on } else { t.icon });
resp
}
@@ -395,8 +409,8 @@ fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool {
divider(ui, t, x, cy);
let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0));
let resp = ui.interact(done, ui.id().with("tool_done"), Sense::click()).tip(tip);
ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { ACCENT });
ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), Color32::WHITE);
ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent });
ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on);
resp.clicked()
}
@@ -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}×");
}
}
}
}
+7 -7
View File
@@ -587,7 +587,7 @@ fn decoration_details(app: &mut FigApp, ui: &mut Ui, styles: &[TextStyle], apply
if !auto
&& ui
.add(
egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent))
egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent_text))
.sense(Sense::click()),
)
.on_hover_text(tip)
@@ -713,7 +713,7 @@ fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextSt
}
if styles.iter().any(|s| !s.variations.is_empty()) {
ui.add_space(8.0);
let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent)).sense(Sense::click()));
let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click()));
if r.on_hover_text(tr("Back to the values the font style implies")).clicked() {
app.run("text.setVariations", json!({"ids": ids, "variations": {}, "replace": true}));
}
@@ -789,7 +789,7 @@ fn axis_slider(ui: &mut Ui, id: Id, axis: &figcraft_text::Axis, value: Option<f6
let x = x_of(v);
p.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), t.accent);
p.circle_filled(pos2(x, track.center().y), 6.0, Color32::WHITE);
let ring = if resp.hovered() || resp.dragged() { t.accent } else { t.border_strong };
let ring = if resp.hovered() || resp.dragged() { crate::theme::ACCENT } else { t.border_strong };
p.circle_stroke(pos2(x, track.center().y), 6.0, Stroke::new(1.0, ring));
}
if resp.double_clicked() {
@@ -899,14 +899,14 @@ fn feature_row(ui: &mut Ui, tag: &str, label: &str, on: Option<bool>, supported:
match on {
Some(true) => {
ui.painter().rect_filled(b, CornerRadius::same(3), t.accent);
icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE);
icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on);
}
Some(false) => {
ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
}
None => {
ui.painter().rect_filled(b, CornerRadius::same(3), t.accent);
ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, Color32::WHITE));
ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, t.accent_on));
}
}
let col = if supported { t.text } else { t.text_tertiary };
@@ -1025,7 +1025,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti
crate::a11y::text(&resp, tip, value);
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
if editing.is_some() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside);
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
}
@@ -1274,7 +1274,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
if hovered {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent);
}
let col = if hovered { Color32::WHITE } else { t.text };
let col = if hovered { t.accent_on } else { t.text };
if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) {
icons::paint(
ui.painter(),
+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] {
+9 -9
View File
@@ -217,7 +217,7 @@ fn number_field_impl(
let hovered = field_resp.hovered() || lead_resp.hovered() || editing.is_some();
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
if editing.is_some() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside);
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside);
} else if hovered {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
}
@@ -507,7 +507,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str
a11y::text(&resp, name, value);
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
if editing.is_some() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside);
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside);
} else if resp.hovered() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
}
@@ -570,7 +570,7 @@ pub fn text_input(ui: &mut Ui, id: Id, text: &mut String, width: f32, hint: &str
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
let r = field_editor(ui, rect, id, text, hint);
if r.has_focus() {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside);
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside);
} else if ui.rect_contains_pointer(rect) {
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
}
@@ -974,7 +974,7 @@ pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool {
let b = Rect::from_min_size(pos2(rect.min.x, rect.center().y - 7.0), vec2(14.0, 14.0));
if on {
ui.painter().rect_filled(b, CornerRadius::same(3), t.accent);
icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE);
icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on);
} else {
ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
}
@@ -990,9 +990,9 @@ pub fn radio(ui: &mut Ui, on: bool, label: &str) -> bool {
let c = pos2(rect.min.x + 7.0, rect.center().y);
if on {
ui.painter().circle_filled(c, 7.0, t.accent);
ui.painter().circle_filled(c, 2.5, Color32::WHITE);
ui.painter().circle_filled(c, 2.5, t.accent_on);
} else {
ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border_strong }));
ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { crate::theme::ACCENT } else { t.border_strong }));
}
ui.painter().text(pos2(rect.min.x + 22.0, rect.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text);
resp.clicked()
@@ -1008,8 +1008,8 @@ pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Respons
a11y::button(&resp, label);
let hot = enabled && resp.hovered();
let (fill, text) = match (primary, hot) {
(true, false) => (t.accent, Color32::WHITE),
(true, true) => (t.accent_hover, Color32::WHITE),
(true, false) => (t.accent, t.accent_on),
(true, true) => (t.accent_hover, t.accent_on),
(false, false) => (Color32::TRANSPARENT, t.text),
(false, true) => (t.hover, t.text),
};
@@ -1046,7 +1046,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu
match buf {
Some(mut text) => {
ui.painter().rect_filled(rect.expand(2.0), CornerRadius::same(4), t.panel);
ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.accent), StrokeKind::Inside);
ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect));
let r = crate::bidi::single_line(&mut child, te_id, &mut text, font, rect.width());
a11y::own_focus(&r);
+5 -1
View File
@@ -267,7 +267,11 @@ Or in a project's `.mcp.json`:
`style.duplicate {style}` adds "<name> copy" right below, `style.renameFolder {folder, name}`.
- **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on
`width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the
layers using them (they keep their last value) and give aliases to them their values.
layers using them (they keep their last value) and give aliases to them their values. An
explicit edit that changes a bound field's value (`node.set`, `transform.resize`,
`object.opacityKeys`, `text.style`, `text.set`, the text size, weight and
tracking shortcuts, a canvas drag) detaches that field's variable; fields the edit
left as they were stay bound.
`variable.addMode {from}` duplicates a mode, `variable.moveMode {collection, mode, to}` reorders
(index 0 is the default mode, which layers on Auto follow). `variable.update {codeSyntax: {WEB?,
iOS?, ANDROID?}}` sets code names (`""` clears); generated CSS uses the Web one.