mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
Merge main into #862
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]));
|
||||
|
||||
@@ -56,3 +56,6 @@ sync_github_name = true
|
||||
|
||||
[people.pechnikov2026-star]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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}`")))?
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)?))
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1866,6 +1866,7 @@ status = "draft — needs native review"
|
||||
"file.exportLayers" = "レイヤーを書き出し"
|
||||
"file.exportSvg" = "選択範囲を SVG で書き出し"
|
||||
"file.import" = "インポート"
|
||||
"file.moveTab" = "タブを移動"
|
||||
"file.new" = "新規デザインファイル"
|
||||
"file.newFromTemplate" = "テンプレートから新規作成"
|
||||
"file.open" = "開く"
|
||||
|
||||
@@ -1788,6 +1788,7 @@ status = "draft — needs native review"
|
||||
"file.exportLayers" = "레이어 내보내기"
|
||||
"file.exportSvg" = "선택 항목을 SVG로 내보내기"
|
||||
"file.import" = "가져오기"
|
||||
"file.moveTab" = "탭 이동"
|
||||
"file.new" = "새 디자인 파일"
|
||||
"file.open" = "열기"
|
||||
"file.openRecent" = "최근 파일 열기"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1857,6 +1857,7 @@ name = "Русский"
|
||||
"file.exportLayers" = "Экспортировать слои"
|
||||
"file.exportSvg" = "Экспортировать выделение в SVG"
|
||||
"file.import" = "Импорт"
|
||||
"file.moveTab" = "Переместить вкладку"
|
||||
"file.new" = "Новый файл дизайна"
|
||||
"file.newFromTemplate" = "Создать из шаблона"
|
||||
"file.open" = "Открыть"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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ç"
|
||||
|
||||
@@ -1789,6 +1789,7 @@ status = "draft — needs native review"
|
||||
"file.exportLayers" = "导出图层"
|
||||
"file.exportSvg" = "将所选项导出为 SVG"
|
||||
"file.import" = "导入"
|
||||
"file.moveTab" = "移动标签页"
|
||||
"file.new" = "新建设计文件"
|
||||
"file.open" = "打开"
|
||||
"file.openRecent" = "打开最近的文件"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}×");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user