Merge fix/575

This commit is contained in:
bflatastic
2026-10-17 20:23:25 +07:00
24 changed files with 989 additions and 1 deletions
+1
View File
@@ -71,6 +71,7 @@ fn registry() -> &'static Registry {
list.extend(create::specs());
list.extend(edit::specs());
list.extend(lists::specs());
list.extend(crate::selection_colors::specs());
list.extend(arrange::specs());
list.extend(structure::specs());
list.extend(section::specs());
+1
View File
@@ -43,6 +43,7 @@ pub mod plugins;
pub mod proto_run;
pub mod rename;
pub mod rotation_origin;
pub mod selection_colors;
pub mod shape_handles;
pub mod shapebuilder;
pub mod snap;
+245
View File
@@ -0,0 +1,245 @@
//! Paints used by the selection, including descendants and effective text-range fills.
//! Targets name paint slots rather than colors: a live edit must not capture other slots when
//! its color happens to become equal to another selection color.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_color::{Paint, PaintKind};
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::params::*;
use crate::{CommandSpec, Result, Session, bad, spec};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PaintTarget {
pub id: NodeId,
pub fills: bool,
pub index: usize,
/// Character offsets, not byte offsets. None means the node's fill or stroke list.
pub range: Option<(usize, usize)>,
}
impl PaintTarget {
pub fn paints(&self, doc: &Document) -> Option<Vec<Paint>> {
let n = doc.node(self.id)?;
if let Some((start, end)) = self.range {
let t = n.kind.text()?;
if !self.fills || start > end || end > t.char_count() || (start == end && end != 0) {
return None;
}
Some(t.style_at(start).fills.clone().unwrap_or_else(|| n.fills.clone()))
} else {
Some(if self.fills { n.fills.clone() } else { n.strokes.clone() })
}
}
pub fn paint(&self, doc: &Document) -> Option<Paint> {
self.paints(doc)?.get(self.index).cloned()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct SelectionColor {
pub paint: Paint,
pub style: Option<String>,
pub variable: Option<String>,
pub targets: Vec<PaintTarget>,
}
/// Deterministic order, one target per slot even when a parent and child are both selected.
/// Images and patterns are assets rather than selection colors. Hidden paints are omitted.
pub fn collect(doc: &Document, ids: &[NodeId]) -> Vec<SelectionColor> {
let mut groups: Vec<SelectionColor> = Vec::new();
let mut seen = BTreeSet::new();
let mut by_paint: BTreeMap<(Option<String>, Option<String>, String), usize> = BTreeMap::new();
let mut pending: Vec<_> = ids.iter().rev().copied().collect();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
let Some(n) = doc.node(id) else { continue };
if n.is_mask {
continue;
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
pending.extend(n.children.iter().rev().copied());
}
let mut add = |paints: &[Paint], fills: bool, range: Option<(usize, usize)>| {
for (index, paint) in paints.iter().enumerate() {
if !paint.visible || !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) {
continue;
}
let target = PaintTarget { id, fills, index, range };
let inherited = range.is_none_or(|(start, _)| n.kind.text().is_some_and(|t| t.style_at(start).fills.is_none()));
let style = inherited.then(|| if fills { n.styles.fill.clone() } else { n.styles.stroke.clone() }).flatten();
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let variable = inherited
.then(|| {
n.bound.iter().find_map(|(key, var)| {
list_binding(key).filter(|(l, i, field)| *l == list && *i == index && *field == "color").map(|_| var.clone())
})
})
.flatten();
let Ok(value) = serde_json::to_string(paint) else { continue };
let key = (style.clone(), variable.clone(), value);
if let Some(index) = by_paint.get(&key).copied() {
if let Some(g) = groups.get_mut(index) {
g.targets.push(target);
}
} else {
by_paint.insert(key, groups.len());
groups.push(SelectionColor { paint: paint.clone(), style, variable, targets: vec![target] });
}
}
};
if let NodeKind::Text(t) = &n.kind {
let spans = t.spans();
if spans.iter().all(|(_, _, style)| style.fills.is_none()) {
add(&n.fills, true, None);
} else {
for (start, end, style) in spans {
add(style.fills.as_deref().unwrap_or(&n.fills), true, Some((start, end)));
}
}
} else {
add(&n.fills, true, None);
}
add(&n.strokes, false, None);
}
groups
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"selection.colors",
"Selection colors",
"{ids?} → {colors: [{paint, targets}]}; includes descendants and text ranges",
false,
|s, p| { Ok(json!({"colors": collect(s.doc()?, &targets(s, p))})) }
),
spec!("selection.setPaint", "Change selection color", "{targets: [{id, fills, index, range: [start,end] | null}], paint}", true, set_paint),
spec!(
"selection.applyColorStyle",
"Apply selection color style",
"{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
true,
apply_style
),
spec!(
"selection.selectColor",
"Select layers with color",
"{targets} → {ids}; selects the layers owning these paint slots",
false,
select_color
),
]
}
fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>> {
let ts: Vec<PaintTarget> = parse(cmd, p, "targets")?.ok_or_else(|| bad(cmd, "`targets` is required"))?;
if ts.is_empty() {
return Err(bad(cmd, "`targets` must not be empty"));
}
for t in &ts {
if t.paint(s.doc()?).is_none() {
return Err(bad(cmd, "a selection color target no longer exists"));
}
}
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)?;
let paint = get(p, "paint").and_then(paint_of).ok_or_else(|| bad(CMD, "`paint` must be a paint"))?;
for target in &targets {
let mut paints = target.paints(s.doc()?).ok_or_else(|| bad(CMD, "invalid target"))?;
let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) };
if *slot == paint {
continue;
}
let old = slot.clone();
*slot = paint.clone();
let n = s.doc_mut()?.get_mut(target.id)?;
unbind_changed(n, target, &old, &paint);
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 {
n.fills = paints;
n.styles.fill = None;
} else {
n.strokes = paints;
n.styles.stroke = None;
}
}
Ok(json!({"ok": true}))
}
fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.applyColorStyle";
let targets = checked_targets(s, p, CMD)?;
let name = req_string(CMD, p, "style")?;
let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?;
if !matches!(style.kind, StyleKind::Paint { .. }) {
return Err(bad(CMD, "a color style is required"));
}
for target in &targets {
if let Some((start, end)) = target.range {
// Text ranges have typography-style links, not paint-style links. Copy resolved paint
// values just as an explicit range fill does; never replace its font or size.
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue };
let n = s.doc_mut()?.get_mut(target.id)?;
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |st| st.fills = Some(paints.clone()));
}
} else {
crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?;
}
}
Ok(json!({"ok": true}))
}
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
let ts = checked_targets(s, p, "selection.selectColor")?;
let ids: Vec<_> = ts.iter().map(|t| t.id).collect::<BTreeSet<_>>().into_iter().collect();
crate::cmd::edit::select_and_page(s, ids.clone())?;
Ok(json!({"ids": ids}))
}
+3
View File
@@ -415,6 +415,9 @@ fn every_command_survives_junk_params() {
// Online files: ids, servers, refreshes, actions and names as junk.
json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}),
json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}),
// Selection color slots: malformed indexes, ranges and paints.
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
// Rotation origins and placing with a tool as junk.
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
+274
View File
@@ -0,0 +1,274 @@
#![allow(clippy::unwrap_used)]
use figcraft_color::Paint;
use figcraft_doc::NodeId;
use figcraft_engine::{
Session,
selection_colors::{SelectionColor, collect},
};
use serde_json::{Value, json};
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap()
}
fn rectangle(s: &mut Session, color: &str) -> NodeId {
NodeId(run(s, "shape.rectangle", json!({"width":40,"height":40,"props":{"fill":color}}))["id"].as_u64().unwrap())
}
fn group(s: &Session, ids: &[NodeId], hex: &str) -> SelectionColor {
collect(s.doc().unwrap(), ids).into_iter().find(|g| g.paint.solid_color().is_some_and(|c| c.to_hex() == hex)).unwrap()
}
fn fill(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
}
#[test]
fn aggregate_descendants_deduplicate_roots_and_ignore_images_and_hidden_paints() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#00ff00");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
let parent = NodeId(run(&mut s, "object.group", json!({"ids":[a,b]}))["id"].as_u64().unwrap());
let colors = collect(s.doc().unwrap(), &[parent, a]);
assert_eq!(colors.len(), 2);
assert_eq!(group(&s, &[parent, a], "FF0000").targets.len(), 2);
run(&mut s, "fill.add", json!({"id":a,"paint":Paint::image("synthetic",figcraft_color::ImageScaleMode::Fill)}));
run(&mut s, "fill.add", json!({"id":a,"paint":{"type":"solid","color":"#0000ff","visible":false}}));
assert_eq!(collect(s.doc().unwrap(), &[parent]).len(), 2);
run(&mut s, "select.set", json!({"ids":[parent]}));
assert_eq!(run(&mut s, "selection.colors", json!({}))["colors"].as_array().unwrap().len(), 2);
}
#[test]
fn editing_only_matching_slots_is_one_undo_step_and_keeps_selection() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
let c = rectangle(&mut s, "#00ff00");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
run(&mut s, "select.set", json!({"ids":[a,b]}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":"#ffff00"}));
assert_eq!(fill(&s, a), "FFFF00");
assert_eq!(fill(&s, b), "0000FF");
assert_eq!(fill(&s, c), "00FF00");
assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "FFFF00");
assert_eq!(s.selection(), vec![a, b]);
assert_eq!(s.undo_depth().0, before.0 + 1);
s.undo().unwrap();
assert_eq!(fill(&s, a), "FF0000");
s.redo().unwrap();
assert_eq!(fill(&s, a), "FFFF00");
}
#[test]
fn live_picker_targets_do_not_expand_when_colors_merge() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
s.begin_interaction("Color").unwrap();
for hex in ["#0000ff", "#00ff00"] {
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":hex}));
}
s.commit_interaction().unwrap();
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(fill(&s, b), "0000FF");
assert_eq!(s.undo_depth().0, before.0 + 1);
s.undo().unwrap();
assert_eq!(fill(&s, a), "FF0000");
}
#[test]
fn text_range_colors_use_character_offsets_and_preserve_typography() {
let mut s = Session::new();
let id = NodeId(run(&mut s, "text.create", json!({"text":"Яab","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"fontSize":30,"textFills":["#0000ff"]}));
let blue = group(&s, &[id], "0000FF");
assert_eq!(blue.targets[0].range, Some((1, 2)));
run(&mut s, "selection.setPaint", json!({"targets":blue.targets,"paint":"#00ff00"}));
let n = s.doc().unwrap().node(id).unwrap();
let t = n.kind.text().unwrap();
assert_eq!(t.characters, "Яab");
assert_eq!(t.style_at(1).font_size, 30.0);
assert_eq!(t.style_at(1).fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "00FF00");
assert_eq!(t.style_at(0).fills.as_deref().unwrap_or(&n.fills)[0].solid_color().unwrap().to_hex(), "FF0000");
assert_eq!(t.style_at(2).fills.as_deref().unwrap_or(&n.fills)[0].solid_color().unwrap().to_hex(), "FF0000");
}
#[test]
fn selecting_color_selects_its_unique_owners_without_document_history() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"}));
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
run(&mut s, "selection.selectColor", json!({"targets":red.targets}));
assert_eq!(s.selection(), vec![a]);
assert_eq!(s.undo_depth(), before);
}
#[test]
fn applying_color_styles_links_matching_fill_and_stroke_lists_only() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
let red = group(&s, &[a, b], "FF0000");
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}))["id"].clone();
let before = s.undo_depth();
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":style}));
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(fill(&s, b), "0000FF");
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), style.as_str());
assert_eq!(s.doc().unwrap().node(b).unwrap().styles.stroke.as_deref(), style.as_str());
assert_eq!(s.undo_depth().0, before.0 + 1);
run(&mut s, "style.update", json!({"style":style,"value":"#ffff00"}));
assert_eq!(fill(&s, a), "FFFF00");
assert_eq!(fill(&s, b), "0000FF");
}
#[test]
fn explicit_color_detaches_its_binding_but_keeps_other_bindings() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
let red = group(&s, &[a], "FF0000");
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":"#0000ff"}));
assert_eq!(fill(&s, a), "0000FF");
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
s.undo().unwrap();
assert_eq!(fill(&s, a), "FF0000");
assert!(!s.doc().unwrap().node(a).unwrap().bound.is_empty());
}
#[test]
fn invalid_or_stale_targets_roll_back_atomically() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let mut red = group(&s, &[a], "FF0000");
let mut bad = red.targets[0].clone();
bad.index = 999;
red.targets.push(bad);
let before = s.undo_depth();
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
assert_eq!(fill(&s, a), "FF0000");
assert_eq!(s.undo_depth(), before);
for command in ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle"] {
for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] {
assert!(s.execute(command, &params).is_err());
}
}
}
#[test]
fn equal_resolved_colors_from_different_styles_or_variables_stay_separate() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
let c = rectangle(&mut s, "#ff0000");
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}))["id"].clone();
run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"}));
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
let variable = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#ff0000"}))["id"].clone();
run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":variable}));
let colors = collect(s.doc().unwrap(), &[a, b, c]);
assert_eq!(colors.len(), 3);
assert_eq!(colors[0].style.as_deref(), style.as_str());
assert_eq!(colors[1].variable.as_deref(), variable.as_str());
assert!(colors[2].style.is_none() && colors[2].variable.is_none());
}
#[test]
fn gradients_and_multiple_paints_are_edited_without_replacing_other_slots() {
use figcraft_color::{Color, Gradient, GradientKind};
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let paint = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK));
run(&mut s, "fill.add", json!({"id":a,"paint":paint}));
let colors = collect(s.doc().unwrap(), &[a]);
assert_eq!(colors.len(), 2);
let gradient = colors.iter().find(|g| g.paint == paint).unwrap();
run(&mut s, "selection.setPaint", json!({"targets":gradient.targets,"paint":"#00ff00"}));
let paints = &s.doc().unwrap().node(a).unwrap().fills;
assert_eq!(paints.len(), 2);
assert_eq!(paints[0].solid_color().unwrap().to_hex(), "FF0000");
assert_eq!(paints[1].solid_color().unwrap().to_hex(), "00FF00");
}
#[test]
fn masks_and_boolean_operands_are_not_selection_colors() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#00ff00");
let boolean = NodeId(run(&mut s, "bool.union", json!({"ids":[a,b]}))["id"].as_u64().unwrap());
run(&mut s, "fill.set", json!({"id":boolean,"color":"#0000ff"}));
let colors = collect(s.doc().unwrap(), &[boolean]);
assert_eq!(colors.len(), 1);
assert_eq!(colors[0].paint.solid_color().unwrap().to_hex(), "0000FF");
s.doc_mut().unwrap().get_mut(boolean).unwrap().is_mask = true;
assert!(collect(s.doc().unwrap(), &[boolean]).is_empty());
}
#[test]
fn range_edit_keeps_a_node_binding_that_other_characters_inherit() {
let mut s = Session::new();
let id = NodeId(run(&mut s, "text.create", json!({"text":"abc","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
run(&mut s, "variable.bind", json!({"id":id,"field":"fill","variable":v}));
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"textFills":["#0000ff"]}));
let blue = group(&s, &[id], "0000FF");
run(&mut s, "selection.setPaint", json!({"targets":blue.targets,"paint":"#00ff00"}));
assert!(!s.doc().unwrap().node(id).unwrap().bound.is_empty());
assert_eq!(fill(&s, id), "FF0000");
assert_eq!(
s.doc().unwrap().node(id).unwrap().kind.text().unwrap().style_at(1).fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(),
"00FF00"
);
}
#[test]
fn selection_color_edits_respect_read_only_version_previews() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "file.saveVersion", json!({"name":"Before"}));
run(&mut s, "version.preview", json!({"index":0}));
let red = group(&s, &[a], "FF0000");
let before = s.undo_depth();
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
assert_eq!(fill(&s, a), "FF0000");
assert_eq!(s.undo_depth(), before);
}
#[test]
fn opacity_edits_keep_the_color_variable_binding() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
let mut red = group(&s, &[a], "FF0000");
red.paint.opacity = 0.5;
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":red.paint}));
let n = s.doc().unwrap().node(a).unwrap();
assert_eq!(n.fills[0].opacity, 0.5);
assert!(!n.bound.is_empty());
}
#[test]
fn whole_text_fill_keeps_a_color_style_link() {
let mut s = Session::new();
let id = NodeId(run(&mut s, "text.create", json!({"text":"Text","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
let red = group(&s, &[id], "FF0000");
assert_eq!(red.targets[0].range, None);
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}))["id"].clone();
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":style}));
assert_eq!(s.doc().unwrap().node(id).unwrap().styles.fill.as_deref(), style.as_str());
run(&mut s, "style.update", json!({"style":style,"value":"#00ff00"}));
assert_eq!(fill(&s, id), "00FF00");
}
+8
View File
@@ -638,6 +638,7 @@ status = "draft — needs native review"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Návrh rozhraní a prototypování. Svobodný nástroj s otevřeným zdrojovým kódem, napsaný od základu v jazyce Rust nezávisle na kódu jiných produktů. Součást sady ArtCraft Crafting Apps."
"Intersect" = "Průnik"
"Intersects" = "Průniky"
"Invalid color" = "Neplatná barva"
"Invite link" = "Odkaz s pozvánkou"
"Invite link copied" = "Odkaz s pozvánkou zkopírován"
"It asks permission to:" = "Žádá o oprávnění:"
@@ -804,6 +805,7 @@ status = "draft — needs native review"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Žádné větve. Větev je kopie souboru pro vyzkoušení změn, které pak zkontrolujete a sloučíte."
"No category" = "Bez kategorie"
"No changes" = "Žádné změny"
"No color styles" = "Žádné barevné styly"
"No colour variables fit here." = "Žádné vhodné barevné proměnné."
"No colour variables yet. Create them in Local variables." = "Zatím žádné barevné proměnné. Vytvořte je v Místních proměnných."
"No comments yet" = "Zatím žádné komentáře"
@@ -1161,12 +1163,14 @@ status = "draft — needs native review"
"Select all with same" = "Vybrat vše se stejným"
"Select layer" = "Vybrat vrstvu"
"Select layers to apply the style to" = "Vyberte vrstvy, na které chcete použít styl"
"Select layers with this color" = "Vybrat vrstvy s touto barvou"
"Select overlapping shapes" = "Vyberte překrývající se tvary"
"Select points to edit their position, radius and mirroring" = "Vyberte body a upravte jejich polohu, poloměr a zrcadlení"
"Select slot" = "Vybrat slot"
"Selection" = "Výběr"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru, {w} × {h} na pozici {x}, {y}: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší"
"Selection colors" = "Barvy výběru"
"Send backward" = "Přesunout o úroveň níž"
"Send report" = "Odeslat hlášení"
"Send to back" = "Přesunout do pozadí"
@@ -1992,6 +1996,10 @@ status = "draft — needs native review"
"select.sameStroke" = "Vybrat stejný tah"
"select.sameText" = "Vybrat stejné vlastnosti textu"
"select.set" = "Vybrat"
"selection.applyColorStyle" = "Použít barevný styl na výběr"
"selection.colors" = "Barvy výběru"
"selection.selectColor" = "Vybrat vrstvy s touto barvou"
"selection.setPaint" = "Změnit barvu výběru"
"shape.arrow" = "Šipka"
"shape.ellipse" = "Elipsa"
"shape.line" = "Čára"
+8
View File
@@ -623,6 +623,7 @@ status = "draft — needs native review"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-Design und Prototyping. Ein kostenloses Open-Source-Designwerkzeug, als Clean-Room-Entwicklung von Grund auf in Rust geschrieben, Teil der ArtCraft Crafting Apps."
"Intersect" = "Schnittmenge"
"Intersects" = "Schnittmengen"
"Invalid color" = "Ungültige Farbe"
"Invite link" = "Einladungslink"
"Invite link copied" = "Einladungslink kopiert"
"It asks permission to:" = "Es möchte:"
@@ -777,6 +778,7 @@ status = "draft — needs native review"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Keine Branches. Ein Branch ist eine Kopie der Datei, in der Sie Änderungen ausprobieren, prüfen und dann zusammenführen."
"No category" = "Keine Kategorie"
"No changes" = "Keine Änderungen"
"No color styles" = "Keine Farbstile"
"No colour variables fit here." = "Hier passen keine Farbvariablen."
"No colour variables yet. Create them in Local variables." = "Noch keine Farbvariablen. Erstellen Sie sie unter Lokale Variablen."
"No comments yet" = "Noch keine Kommentare"
@@ -1125,12 +1127,14 @@ status = "draft — needs native review"
"Select all with same" = "Alle auswählen mit gleicher"
"Select layer" = "Ebene auswählen"
"Select layers to apply the style to" = "Wählen Sie die Ebenen aus, auf die der Stil angewendet werden soll"
"Select layers with this color" = "Ebenen mit dieser Farbe auswählen"
"Select overlapping shapes" = "Wählen Sie überlappende Formen aus"
"Select points to edit their position, radius and mirroring" = "Wählen Sie Punkte aus, um Position, Radius und Spiegelung zu bearbeiten"
"Select slot" = "Slot auswählen"
"Selection" = "Auswahl"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen, {w} × {h} bei {x}, {y}: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab"
"Selection colors" = "Auswahlfarben"
"Send backward" = "Eine Ebene nach hinten"
"Send report" = "Meldung senden"
"Send to back" = "In den Hintergrund"
@@ -1935,6 +1939,10 @@ status = "draft — needs native review"
"select.sameStroke" = "Gleiche Kontur auswählen"
"select.sameText" = "Gleiche Texteigenschaften auswählen"
"select.set" = "Auswählen"
"selection.applyColorStyle" = "Farbstil auf Auswahl anwenden"
"selection.colors" = "Auswahlfarben"
"selection.selectColor" = "Ebenen mit dieser Farbe auswählen"
"selection.setPaint" = "Farbe der Auswahl ändern"
"shape.arrow" = "Pfeil"
"shape.ellipse" = "Ellipse"
"shape.line" = "Linie"
+4
View File
@@ -651,6 +651,7 @@ name = "English"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
"Intersect" = "Intersect"
"Intersects" = "Intersects"
"Invalid color" = "Invalid color"
"Invite link" = "Invite link"
"Invite link copied" = "Invite link copied"
"It asks permission to:" = "It asks permission to:"
@@ -817,6 +818,7 @@ name = "English"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No branches. A branch is a copy of the file to try changes in, then review and merge."
"No category" = "No category"
"No changes" = "No changes"
"No color styles" = "No color styles"
"No colour variables fit here." = "No colour variables fit here."
"No colour variables yet. Create them in Local variables." = "No colour variables yet. Create them in Local variables."
"No comments yet" = "No comments yet"
@@ -1182,9 +1184,11 @@ name = "English"
"Select all with same" = "Select all with same"
"Select layer" = "Select layer"
"Select layers to apply the style to" = "Select layers to apply the style to"
"Select layers with this color" = "Select layers with this color"
"Select overlapping shapes" = "Select overlapping shapes"
"Select points to edit their position, radius and mirroring" = "Select points to edit their position, radius and mirroring"
"Select slot" = "Select slot"
"Selection colors" = "Selection colors"
"Selection" = "Selection"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels"
+8
View File
@@ -652,6 +652,7 @@ status = "draft — needs native review"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
"Intersect" = "Intersecar"
"Intersects" = "Intersecciones"
"Invalid color" = "Color no válido"
"Invite link" = "Enlace de invitación"
"Invite link copied" = "Enlace de invitación copiado"
"It asks permission to:" = "Pide permiso para:"
@@ -818,6 +819,7 @@ status = "draft — needs native review"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No hay ramas. Una rama es una copia del archivo para probar cambios y luego revisarlos y fusionarlos."
"No category" = "Sin categoría"
"No changes" = "Sin cambios"
"No color styles" = "No hay estilos de color"
"No colour variables fit here." = "Ninguna variable de color encaja aquí."
"No colour variables yet. Create them in Local variables." = "Aún no hay variables de color. Créalas en Variables locales."
"No comments yet" = "Aún no hay comentarios"
@@ -1183,9 +1185,11 @@ status = "draft — needs native review"
"Select all with same" = "Seleccionar todo con el mismo"
"Select layer" = "Seleccionar capa"
"Select layers to apply the style to" = "Selecciona capas para aplicarles el estilo"
"Select layers with this color" = "Seleccionar capas con este color"
"Select overlapping shapes" = "Selecciona formas superpuestas"
"Select points to edit their position, radius and mirroring" = "Selecciona puntos para editar su posición, radio y reflejo"
"Select slot" = "Seleccionar ranura"
"Selection colors" = "Colores de la selección"
"Selection" = "Selección"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección, {w} por {h} en {x}, {y}: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Intro selecciona, Esc cancela"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Intro selecciona, Esc cancela"
@@ -2024,6 +2028,10 @@ status = "draft — needs native review"
"select.sameStroke" = "Seleccionar mismo trazo"
"select.sameText" = "Seleccionar mismas propiedades de texto"
"select.set" = "Seleccionar"
"selection.applyColorStyle" = "Aplicar estilo de color a la selección"
"selection.colors" = "Colores de la selección"
"selection.selectColor" = "Seleccionar capas con este color"
"selection.setPaint" = "Cambiar color de la selección"
"shape.arrow" = "Flecha"
"shape.ellipse" = "Elipse"
"shape.line" = "Línea"
+8
View File
@@ -619,6 +619,7 @@ status = "draft — needs native review"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft."
"Intersect" = "Intersection"
"Intersects" = "Intersections"
"Invalid color" = "Couleur non valide"
"Invite link" = "Lien d’invitation"
"Invite link copied" = "Lien d’invitation copié"
"It asks permission to:" = "Il demande l’autorisation de :"
@@ -773,6 +774,7 @@ status = "draft — needs native review"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Aucune branche. Une branche est une copie du fichier pour essayer des modifications, puis les relire et les fusionner."
"No category" = "Sans catégorie"
"No changes" = "Aucune modification"
"No color styles" = "Aucun style de couleur"
"No colour variables fit here." = "Aucune variable de couleur ne convient ici."
"No colour variables yet. Create them in Local variables." = "Aucune variable de couleur pour l’instant. Créez-en dans Variables locales."
"No comments yet" = "Aucun commentaire pour l’instant"
@@ -1119,12 +1121,14 @@ status = "draft — needs native review"
"Select all with same" = "Sélectionner tout ce qui a le même"
"Select layer" = "Sélectionner le calque"
"Select layers to apply the style to" = "Sélectionnez les calques auxquels appliquer le style"
"Select layers with this color" = "Sélectionner les calques de cette couleur"
"Select overlapping shapes" = "Sélectionnez des formes qui se chevauchent"
"Select points to edit their position, radius and mirroring" = "Sélectionnez des points pour modifier leur position, leur rayon et leur symétrie"
"Select slot" = "Sélectionner l’emplacement"
"Selection" = "Sélection"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection, {w} sur {h} à {x}, {y} : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule"
"Selection colors" = "Couleurs de la sélection"
"Send backward" = "Reculer"
"Send report" = "Envoyer le signalement"
"Send to back" = "Mettre à l’arrière-plan"
@@ -1926,6 +1930,10 @@ status = "draft — needs native review"
"select.sameStroke" = "Sélectionner le même contour"
"select.sameText" = "Sélectionner les mêmes propriétés de texte"
"select.set" = "Sélectionner"
"selection.applyColorStyle" = "Appliquer un style de couleur à la sélection"
"selection.colors" = "Couleurs de la sélection"
"selection.selectColor" = "Sélectionner les calques de cette couleur"
"selection.setPaint" = "Changer la couleur de la sélection"
"shape.arrow" = "Flèche"
"shape.ellipse" = "Ellipse"
"shape.line" = "Ligne"
+8
View File
@@ -642,6 +642,7 @@ status = "draft — needs native review"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Desain antarmuka dan prototyping. Alat desain clean-room gratis dan open-source yang ditulis dari nol dengan Rust, bagian dari ArtCraft Crafting Apps."
"Intersect" = "Irisan"
"Intersects" = "Beririsan"
"Invalid color" = "Warna tidak valid"
"Invite link" = "Link undangan"
"Invite link copied" = "Link undangan disalin"
"It asks permission to:" = "Plugin ini meminta izin untuk:"
@@ -808,6 +809,7 @@ status = "draft — needs native review"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Tidak ada branch. Branch adalah salinan file untuk mencoba perubahan, lalu ditinjau dan di-merge."
"No category" = "Tanpa kategori"
"No changes" = "Tidak ada perubahan"
"No color styles" = "Tidak ada gaya warna"
"No colour variables fit here." = "Tidak ada variabel warna yang cocok di sini."
"No colour variables yet. Create them in Local variables." = "Belum ada variabel warna. Buat di Variabel lokal."
"No comments yet" = "Belum ada komentar"
@@ -1165,12 +1167,14 @@ status = "draft — needs native review"
"Select all with same" = "Pilih semua yang sama"
"Select layer" = "Pilih layer"
"Select layers to apply the style to" = "Pilih layer untuk menerapkan gaya"
"Select layers with this color" = "Pilih layer dengan warna ini"
"Select overlapping shapes" = "Pilih bentuk yang tumpang tindih"
"Select points to edit their position, radius and mirroring" = "Pilih titik untuk mengedit posisi, radius, dan pencerminannya"
"Select slot" = "Pilih slot"
"Selection" = "Pilihan"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan, {w} x {h} di {x}, {y}: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan"
"Selection colors" = "Warna pilihan"
"Send backward" = "Bawa mundur"
"Send report" = "Kirim laporan"
"Send to back" = "Bawa ke paling belakang"
@@ -1996,6 +2000,10 @@ status = "draft — needs native review"
"select.sameStroke" = "Pilih stroke yang sama"
"select.sameText" = "Pilih properti teks yang sama"
"select.set" = "Pilih"
"selection.applyColorStyle" = "Terapkan gaya warna ke pilihan"
"selection.colors" = "Warna pilihan"
"selection.selectColor" = "Pilih layer dengan warna ini"
"selection.setPaint" = "Ubah warna pilihan"
"shape.arrow" = "Panah"
"shape.ellipse" = "Elips"
"shape.line" = "Garis"
+8
View File
@@ -647,6 +647,7 @@ status = "draft — needs native review"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "インターフェースデザインとプロトタイピング。Rust でゼロから書かれた、無料でオープンソースのクリーンルーム設計のデザインツール。ArtCraft Crafting Apps の一つ。"
"Intersect" = "交差"
"Intersects" = "交差"
"Invalid color" = "無効なカラー"
"Invite link" = "招待リンク"
"Invite link copied" = "招待リンクをコピー済み"
"It asks permission to:" = "次の権限を求めている:"
@@ -813,6 +814,7 @@ status = "draft — needs native review"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "ブランチはない。ブランチはファイルのコピーで、変更を試してからレビューしてマージできる。"
"No category" = "カテゴリなし"
"No changes" = "変更なし"
"No color styles" = "カラースタイルがありません"
"No colour variables fit here." = "ここに使えるカラー変数はない。"
"No colour variables yet. Create them in Local variables." = "カラー変数はまだない。ローカル変数で作成。"
"No comments yet" = "コメントはまだない"
@@ -1172,12 +1174,14 @@ status = "draft — needs native review"
"Select layer" = "レイヤーを選択"
"Select layers and they space themselves as their content changes" = "レイヤーを選択すると、内容の変化に合わせて間隔を自動調整"
"Select layers to apply the style to" = "スタイルを適用するレイヤーを選択"
"Select layers with this color" = "このカラーのレイヤーを選択"
"Select overlapping shapes" = "重なったシェイプを選択"
"Select points to edit their position, radius and mirroring" = "ポイントを選択すると位置、半径、対称を編集できる"
"Select slot" = "スロットを選択"
"Selection" = "選択範囲"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選択ボックス、{w} × {h}、位置 {x}, {y}:矢印キーで移動、Shift+矢印キーまたは {cmd}+矢印キーでサイズ変更、Enter で選択、Escape でキャンセル"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選択ボックス:矢印キーで移動、Shift+矢印キーまたは {cmd}+矢印キーでサイズ変更、Enter で選択、Escape でキャンセル"
"Selection colors" = "選択範囲のカラー"
"Send backward" = "背面へ"
"Send report" = "報告を送信"
"Send to back" = "最背面へ"
@@ -2008,6 +2012,10 @@ status = "draft — needs native review"
"select.sameStroke" = "同じ線を選択"
"select.sameText" = "同じテキストプロパティを選択"
"select.set" = "選択"
"selection.applyColorStyle" = "選択範囲にカラースタイルを適用"
"selection.colors" = "選択範囲のカラー"
"selection.selectColor" = "このカラーのレイヤーを選択"
"selection.setPaint" = "選択範囲のカラーを変更"
"shape.arrow" = "矢印"
"shape.ellipse" = "楕円"
"shape.line" = "線"
+8
View File
@@ -619,6 +619,7 @@ status = "draft — needs native review"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "인터페이스 디자인과 프로토타이핑. Rust로 처음부터 작성한 무료 오픈 소스 클린룸 디자인 도구이며 ArtCraft Crafting Apps의 하나."
"Intersect" = "교차"
"Intersects" = "교차"
"Invalid color" = "잘못된 색상"
"Invite link" = "초대 링크"
"Invite link copied" = "초대 링크 복사됨"
"It asks permission to:" = "다음 권한을 요청함:"
@@ -773,6 +774,7 @@ status = "draft — needs native review"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "브랜치 없음. 브랜치는 변경을 시험해 본 뒤 검토하고 병합하는 파일 사본임."
"No category" = "카테고리 없음"
"No changes" = "변경 사항 없음"
"No color styles" = "색상 스타일 없음"
"No colour variables fit here." = "여기에 맞는 색상 변수가 없음."
"No colour variables yet. Create them in Local variables." = "아직 색상 변수가 없음. 로컬 변수에서 만드세요."
"No comments yet" = "아직 댓글 없음"
@@ -1119,12 +1121,14 @@ status = "draft — needs native review"
"Select all with same" = "같은 항목 모두 선택"
"Select layer" = "레이어 선택"
"Select layers to apply the style to" = "스타일을 적용할 레이어를 선택하세요"
"Select layers with this color" = "이 색상의 레이어 선택"
"Select overlapping shapes" = "겹친 도형 선택"
"Select points to edit their position, radius and mirroring" = "점을 선택해 위치, 반경, 대칭 편집"
"Select slot" = "슬롯 선택"
"Selection" = "선택 항목"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "선택 상자, {w} × {h}, 위치 {x}, {y}: 화살표로 이동, Shift+화살표 또는 {cmd}+화살표로 크기 조정, Enter로 선택, Escape로 취소"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "선택 상자: 화살표로 이동, Shift+화살표 또는 {cmd}+화살표로 크기 조정, Enter로 선택, Escape로 취소"
"Selection colors" = "선택 항목 색상"
"Send backward" = "뒤로 보내기"
"Send report" = "신고 보내기"
"Send to back" = "맨 뒤로 보내기"
@@ -1926,6 +1930,10 @@ status = "draft — needs native review"
"select.sameStroke" = "같은 선 선택"
"select.sameText" = "같은 텍스트 속성 선택"
"select.set" = "선택"
"selection.applyColorStyle" = "선택 항목에 색상 스타일 적용"
"selection.colors" = "선택 항목 색상"
"selection.selectColor" = "이 색상의 레이어 선택"
"selection.setPaint" = "선택 항목 색상 변경"
"shape.arrow" = "화살표"
"shape.ellipse" = "타원"
"shape.line" = "선"
+8
View File
@@ -618,6 +618,7 @@ status = "draft — needs native review"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design de interfaces e prototipagem. Uma ferramenta de design gratuita e de código aberto, com implementação independente (clean-room), escrita do zero em Rust, parte dos Crafting Apps da ArtCraft."
"Intersect" = "Interseção"
"Intersects" = "Interseções"
"Invalid color" = "Cor inválida"
"Invite link" = "Link de convite"
"Invite link copied" = "Link de convite copiado"
"It asks permission to:" = "Ele pede permissão para:"
@@ -772,6 +773,7 @@ status = "draft — needs native review"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Nenhuma branch. Uma branch é uma cópia do arquivo para testar alterações e depois revisá-las e mesclá-las."
"No category" = "Sem categoria"
"No changes" = "Nenhuma alteração"
"No color styles" = "Nenhum estilo de cor"
"No colour variables fit here." = "Nenhuma variável de cor se encaixa aqui."
"No colour variables yet. Create them in Local variables." = "Ainda não há variáveis de cor. Crie-as em Variáveis locais."
"No comments yet" = "Ainda não há comentários"
@@ -1118,12 +1120,14 @@ status = "draft — needs native review"
"Select all with same" = "Selecionar tudo com o mesmo"
"Select layer" = "Selecionar camada"
"Select layers to apply the style to" = "Selecione as camadas às quais aplicar o estilo"
"Select layers with this color" = "Selecionar camadas com esta cor"
"Select overlapping shapes" = "Selecione formas sobrepostas"
"Select points to edit their position, radius and mirroring" = "Selecione pontos para editar posição, raio e espelhamento"
"Select slot" = "Selecionar slot"
"Selection" = "Seleção"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção, {w} por {h} em {x}, {y}: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela"
"Selection colors" = "Cores da seleção"
"Send backward" = "Enviar para trás"
"Send report" = "Enviar denúncia"
"Send to back" = "Enviar para o plano de fundo"
@@ -1925,6 +1929,10 @@ status = "draft — needs native review"
"select.sameStroke" = "Selecionar mesmo traço"
"select.sameText" = "Selecionar mesmas propriedades de texto"
"select.set" = "Selecionar"
"selection.applyColorStyle" = "Aplicar estilo de cor à seleção"
"selection.colors" = "Cores da seleção"
"selection.selectColor" = "Selecionar camadas com esta cor"
"selection.setPaint" = "Alterar cor da seleção"
"shape.arrow" = "Seta"
"shape.ellipse" = "Elipse"
"shape.line" = "Linha"
+8
View File
@@ -639,6 +639,7 @@ name = "Русский"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps."
"Intersect" = "Пересечение"
"Intersects" = "Пересечения"
"Invalid color" = "Недопустимый цвет"
"Invite link" = "Ссылка приглашения"
"Invite link copied" = "Ссылка приглашения скопирована"
"It asks permission to:" = "Запрашивает разрешение на:"
@@ -805,6 +806,7 @@ name = "Русский"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Веток нет. Ветка — копия файла, в которой можно попробовать изменения, затем проверить и объединить их."
"No category" = "Без категории"
"No changes" = "Нет изменений"
"No color styles" = "Нет цветовых стилей"
"No colour variables fit here." = "Нет подходящих цветовых переменных."
"No colour variables yet. Create them in Local variables." = "Цветовых переменных пока нет. Создайте их в разделе «Локальные переменные»."
"No comments yet" = "Комментариев пока нет"
@@ -1163,12 +1165,14 @@ name = "Русский"
"Select all with same" = "Выделить всё с одинаковым"
"Select layer" = "Выделить слой"
"Select layers to apply the style to" = "Выделите слои для применения стиля"
"Select layers with this color" = "Выделить слои с этим цветом"
"Select overlapping shapes" = "Выделите пересекающиеся фигуры"
"Select points to edit their position, radius and mirroring" = "Выделите точки для изменения положения, радиуса и зеркальности"
"Select slot" = "Выделить слот"
"Selection" = "Выделение"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения {w} на {h} в {x}, {y}: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить"
"Selection colors" = "Цвета выделения"
"Send backward" = "На уровень назад"
"Send report" = "Отправить жалобу"
"Send to back" = "На задний план"
@@ -1994,6 +1998,10 @@ name = "Русский"
"select.sameStroke" = "Выделить с одинаковой обводкой"
"select.sameText" = "Выделить с одинаковыми свойствами текста"
"select.set" = "Выделить"
"selection.applyColorStyle" = "Применить цветовой стиль к выделению"
"selection.colors" = "Цвета выделения"
"selection.selectColor" = "Выделить слои с этим цветом"
"selection.setPaint" = "Изменить цвет выделения"
"shape.arrow" = "Стрелка"
"shape.ellipse" = "Эллипс"
"shape.line" = "Линия"
+8
View File
@@ -649,6 +649,7 @@ name = "Svenska"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps."
"Intersect" = "Skärning"
"Intersects" = "Skär"
"Invalid color" = "Ogiltig färg"
"Invite link" = "Inbjudningslänk"
"Invite link copied" = "Inbjudningslänken har kopierats"
"It asks permission to:" = "Den ber om behörighet att:"
@@ -815,6 +816,7 @@ name = "Svenska"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Inga grenar. En gren är en kopia av filen där du kan prova ändringar och sedan granska och sammanfoga dem."
"No category" = "Ingen kategori"
"No changes" = "Inga ändringar"
"No color styles" = "Inga färgformatmallar"
"No colour variables fit here." = "Inga färgvariabler passar här."
"No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler."
"No comments yet" = "Inga kommentarer än"
@@ -1176,12 +1178,14 @@ name = "Svenska"
"Select all with same" = "Markera alla med samma"
"Select layer" = "Markera lager"
"Select layers to apply the style to" = "Markera de lager som formatmallen ska användas på"
"Select layers with this color" = "Markera lager med den här färgen"
"Select overlapping shapes" = "Markera överlappande former"
"Select points to edit their position, radius and mirroring" = "Markera punkter för att redigera deras position, radie och spegling"
"Select slot" = "Markera plats"
"Selection" = "Markering"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta, {w} gånger {h} vid {x}, {y}: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
"Selection colors" = "Markeringsfärger"
"Send backward" = "Flytta bakåt"
"Send report" = "Skicka rapport"
"Send to back" = "Flytta längst bak"
@@ -2016,6 +2020,10 @@ name = "Svenska"
"select.sameStroke" = "Markera samma kontur"
"select.sameText" = "Markera samma textegenskaper"
"select.set" = "Markera"
"selection.applyColorStyle" = "Använd färgformatmall på markeringen"
"selection.colors" = "Markeringsfärger"
"selection.selectColor" = "Markera lager med den här färgen"
"selection.setPaint" = "Ändra markeringens färg"
"shape.arrow" = "Pil"
"shape.ellipse" = "Ellips"
"shape.line" = "Linje"
+8
View File
@@ -642,6 +642,7 @@ name = "Türkçe"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Arayüz tasarımı ve prototipleme. Rust ile sıfırdan yazılmış, ücretsiz, açık kaynaklı, temiz oda yöntemiyle geliştirilmiş bir tasarım aracı; ArtCraft Crafting Apps ailesinin bir parçası."
"Intersect" = "Kesiştir"
"Intersects" = "Kesişir"
"Invalid color" = "Geçersiz renk"
"Invite link" = "Davet bağlantısı"
"Invite link copied" = "Davet bağlantısı kopyalandı"
"It asks permission to:" = "Şunlar için izin istiyor:"
@@ -808,6 +809,7 @@ name = "Türkçe"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Dal yok. Dal, değişiklikleri denemek, sonra gözden geçirip birleştirmek için dosyanın bir kopyasıdır."
"No category" = "Kategori yok"
"No changes" = "Değişiklik yok"
"No color styles" = "Renk stili yok"
"No colour variables fit here." = "Buraya uyan renk değişkeni yok."
"No colour variables yet. Create them in Local variables." = "Henüz renk değişkeni yok. Onları Yerel değişkenler'de oluşturun."
"No comments yet" = "Henüz yorum yok"
@@ -1165,12 +1167,14 @@ name = "Türkçe"
"Select all with same" = "Aynı olanların tümünü seç"
"Select layer" = "Katman seç"
"Select layers to apply the style to" = "Stilin uygulanacağı katmanları seçin"
"Select layers with this color" = "Bu renkteki katmanları seç"
"Select overlapping shapes" = "Çakışan şekilleri seç"
"Select points to edit their position, radius and mirroring" = "Konumlarını, yarıçaplarını ve yansıtmalarını düzenlemek için noktaları seçin"
"Select slot" = "Yuvayı seç"
"Selection" = "Seçim"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu, {x}, {y} konumunda {w} × {h}: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder"
"Selection colors" = "Seçim renkleri"
"Send backward" = "Geriye gönder"
"Send report" = "Raporu gönder"
"Send to back" = "En arkaya gönder"
@@ -1996,6 +2000,10 @@ name = "Türkçe"
"select.sameStroke" = "Aynı konturu seç"
"select.sameText" = "Aynı metin özelliklerini seç"
"select.set" = "Seç"
"selection.applyColorStyle" = "Seçime renk stili uygula"
"selection.colors" = "Seçim renkleri"
"selection.selectColor" = "Bu renkteki katmanları seç"
"selection.setPaint" = "Seçimin rengini değiştir"
"shape.arrow" = "Ok"
"shape.ellipse" = "Elips"
"shape.line" = "Çizgi"
+8
View File
@@ -620,6 +620,7 @@ status = "draft — needs native review"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "界面设计与原型制作。一款以 Rust 从零编写的免费、开源、净室设计工具,属于 ArtCraft Crafting Apps 系列。"
"Intersect" = "相交"
"Intersects" = "相交"
"Invalid color" = "无效颜色"
"Invite link" = "邀请链接"
"Invite link copied" = "已复制邀请链接"
"It asks permission to:" = "它请求以下权限:"
@@ -774,6 +775,7 @@ status = "draft — needs native review"
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "没有分支。分支是文件的副本,可在其中尝试更改,再审阅并合并。"
"No category" = "无类别"
"No changes" = "无更改"
"No color styles" = "没有颜色样式"
"No colour variables fit here." = "没有适用于此处的颜色变量。"
"No colour variables yet. Create them in Local variables." = "还没有颜色变量。请在“本地变量”中创建。"
"No comments yet" = "还没有评论"
@@ -1120,12 +1122,14 @@ status = "draft — needs native review"
"Select all with same" = "选择所有相同"
"Select layer" = "选择图层"
"Select layers to apply the style to" = "选择要应用样式的图层"
"Select layers with this color" = "选择使用此颜色的图层"
"Select overlapping shapes" = "选择重叠的形状"
"Select points to edit their position, radius and mirroring" = "选择点以编辑其位置、半径和镜像"
"Select slot" = "选择插槽"
"Selection" = "所选项"
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "选框,{w} × {h},位于 {x}, {y}:方向键移动,Shift+方向键或 {cmd}+方向键调整大小,Enter 选择,Esc 取消"
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "选框:方向键移动,Shift+方向键或 {cmd}+方向键调整大小,Enter 选择,Esc 取消"
"Selection colors" = "所选项颜色"
"Send backward" = "下移一层"
"Send report" = "发送举报"
"Send to back" = "置于底层"
@@ -1927,6 +1931,10 @@ status = "draft — needs native review"
"select.sameStroke" = "选择相同描边"
"select.sameText" = "选择相同文本属性"
"select.set" = "选择"
"selection.applyColorStyle" = "将颜色样式应用于所选项"
"selection.colors" = "所选项颜色"
"selection.selectColor" = "选择使用此颜色的图层"
"selection.setPaint" = "更改所选项颜色"
"shape.arrow" = "箭头"
"shape.ellipse" = "椭圆"
"shape.line" = "直线"
+65
View File
@@ -18,6 +18,11 @@ use crate::widgets;
#[derive(Clone, Debug, PartialEq)]
pub enum PickerTarget {
PageBackground,
SelectionColor {
targets: Vec<figcraft_engine::selection_colors::PaintTarget>,
selection: Vec<NodeId>,
document: u64,
},
Paint {
fills: bool,
index: usize,
@@ -72,6 +77,13 @@ pub fn range_fills(doc: &figcraft_doc::Document, id: NodeId, start: usize) -> Op
fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
let doc = app.session.doc().ok()?;
match target {
PickerTarget::SelectionColor { targets, selection, document } => {
let ds = app.session.ds().ok()?;
if ds.uid != *document || ds.selection != *selection {
return None;
}
targets.first()?.paint(doc)
}
PickerTarget::PageBackground => match &doc.node(app.session.page().ok()?)?.kind {
NodeKind::Page { background, .. } => Some(Paint::solid(*background)),
_ => None,
@@ -121,6 +133,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
let _ = app.session.begin_interaction("Color");
}
let r = match target {
PickerTarget::SelectionColor { targets, .. } => app.session.execute("selection.setPaint", &json!({"targets": targets, "paint": paint})),
PickerTarget::PageBackground => {
let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE);
app.session.execute("page.setBackground", &json!({"color": c.to_css_hex()}))
@@ -434,6 +447,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
};
let current = if stale { None } else { current_paint(app, &target) };
let Some(mut paint) = current else {
if matches!(target, PickerTarget::SelectionColor { .. }) {
finish(app);
}
app.ui.picker = None;
return;
};
@@ -872,6 +888,7 @@ fn pattern_sources(app: &FigApp, target: &PickerTarget) -> Vec<(NodeId, String)>
let Ok(doc) = app.session.doc() else { return Vec::new() };
let painted = match target {
PickerTarget::Paint { ids, .. } => ids.clone(),
PickerTarget::SelectionColor { targets, .. } => targets.iter().map(|t| t.id).collect(),
PickerTarget::TextRange { id, .. } => vec![*id],
_ => Vec::new(),
};
@@ -1129,6 +1146,54 @@ impl WithStops for Gradient {
}
}
#[cfg(test)]
mod selection_color_tests {
use super::*;
use crate::NoServices;
use figcraft_engine::{Session, selection_colors::collect};
fn setup() -> (FigApp, NodeId, NodeId) {
let mut s = Session::new();
let a = NodeId(s.execute("shape.rectangle", &json!({"width":40,"height":40,"props":{"fill":"#ff0000"}})).unwrap()["id"].as_u64().unwrap());
let b = NodeId(s.execute("shape.rectangle", &json!({"width":40,"height":40,"props":{"fill":"#0000ff"}})).unwrap()["id"].as_u64().unwrap());
s.execute("select.set", &json!({"ids":[a,b]})).unwrap();
let mut app = FigApp::new(s, Box::new(NoServices));
let targets = collect(app.session.doc().unwrap(), &[a, b])[0].targets.clone();
crate::selection_colors::open_picker(&mut app, targets);
(app, a, b)
}
#[test]
fn picker_live_changes_keep_frozen_targets_and_commit_once() {
let (mut app, a, b) = setup();
let target = app.ui.picker.clone().unwrap();
let before = app.session.undo_depth();
for color in ["#0000ff", "#00ff00"] {
write(&mut app, &target, &Paint::solid(Color::from_hex(color).unwrap()), true);
}
assert!(app.session.recent_colors.is_empty(), "previews must not be recorded");
finish(&mut app);
assert_eq!(app.session.recent_colors, vec![Color::from_hex("#00ff00").unwrap()]);
assert_eq!(app.session.undo_depth().0, before.0 + 1);
assert_eq!(current_paint(&app, &target).unwrap().solid_color().unwrap().to_hex(), "00FF00");
assert_eq!(app.session.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_hex(), "0000FF");
app.session.undo().unwrap();
assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
}
#[test]
fn picker_does_not_follow_selection_or_document_switches() {
let (mut app, _, _) = setup();
let target = app.ui.picker.clone().unwrap();
app.session.execute("select.none", &json!({})).unwrap();
assert!(current_paint(&app, &target).is_none());
let (mut app, _, _) = setup();
let target = app.ui.picker.clone().unwrap();
app.session.execute("file.new", &json!({})).unwrap();
assert!(current_paint(&app, &target).is_none());
}
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -51,6 +51,7 @@ pub mod render_worker;
pub mod right;
pub mod scale_panel;
pub mod section_panel;
pub mod selection_colors;
pub mod shortcuts;
pub mod shortcuts_panel;
pub mod style_picker;
+1 -1
View File
@@ -163,7 +163,7 @@ impl Win {
}
}
const CMD: Modifiers = Modifiers::COMMAND.plus(Modifiers::CTRL);
const CMD: Modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
+1
View File
@@ -380,6 +380,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
widgets::separator(ui);
effects(app, ui, &doc, &sel);
widgets::separator(ui);
crate::selection_colors::section(app, ui, &doc, &sel);
if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) {
grids(app, ui, &doc, &sel);
widgets::separator(ui);
+276
View File
@@ -0,0 +1,276 @@
//! Selection colors lives below Effects. A picker edits frozen paint slots, so color groups
//! merging while scrubbing cannot accidentally recolor other objects.
use std::sync::Arc;
use egui::{Id, Ui};
use figcraft_color::Color;
use figcraft_doc::{Document, NodeId, StyleKind};
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, collect};
use serde_json::json;
use crate::a11y::Tip;
use crate::colorpicker::PickerTarget;
use crate::i18n::tr;
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::{FigApp, widgets};
#[derive(Clone)]
struct Cache {
document: u64,
doc: Arc<Document>,
ids: Vec<NodeId>,
colors: Arc<Vec<SelectionColor>>,
}
fn colors(ui: &Ui, document: u64, doc: &Arc<Document>, ids: &[NodeId], interacting: bool) -> Arc<Vec<SelectionColor>> {
let key = Id::new("selection_colors_cache");
if let Some(cache) = ui.data(|d| d.get_temp::<Cache>(key))
&& cache.document == document
&& cache.ids == ids
&& (interacting || Arc::ptr_eq(&cache.doc, doc))
{
return cache.colors;
}
// A drag replaces the document snapshot every frame. Keep its existing rows until the
// gesture ends; a cold cache or a changed document/selection waits too. Engine commands
// still discover and change the complete selection, independently of this panel cache.
if interacting {
return Arc::new(Vec::new());
}
let colors = Arc::new(collect(doc, ids));
ui.data_mut(|d| d.insert_temp(key, Cache { document, doc: doc.clone(), ids: ids.to_vec(), colors: colors.clone() }));
colors
}
pub(crate) fn open_picker(app: &mut FigApp, targets: Vec<PaintTarget>) {
let Ok(ds) = app.session.ds() else { return };
app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid });
}
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId]) {
let Ok(ds) = app.session.ds() else { return };
let interacting = ds.interaction.is_some();
let colors = colors(ui, ds.uid, doc, ids, interacting);
if colors.len() < 2 {
return;
}
let t = Tokens::get(ui.ctx());
widgets::section_header(ui, tr("Selection colors"), &[], false);
// Bound the panel's height; a document with many distinct colors stays scrollable.
ui.add_enabled_ui(!interacting, |ui| {
egui::ScrollArea::vertical().id_salt("selection_colors_rows").max_height(240.0).show_rows(ui, 32.0, colors.len(), |ui, range| {
for group in &colors[range] {
ui.push_id((&group.targets, "selection_color"), |ui| {
crate::right::row(ui, 32.0, |ui| {
let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour"));
if swatch.clicked() {
open_picker(app, group.targets.clone());
}
let width = (ui.available_width() - 116.0).max(40.0);
let linked_name = group
.style
.as_ref()
.and_then(|id| doc.style(id).map(|s| s.name.as_str()))
.or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str())));
if let Some(name) = linked_name {
if widgets::dropdown(ui, name, width, None).clicked() {
open_picker(app, group.targets.clone());
}
} else if let Some(color) = group.paint.solid_color() {
if let Some(hex) = widgets::text_field_named(ui, ui.id().with("hex"), &color.to_hex(), width, tr("Color")) {
if let Some(color) = Color::from_hex(&hex) {
let mut paint = group.paint.clone();
paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) };
if color.a < 1.0 {
paint.opacity = color.a;
}
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
} else {
app.toast(tr("Invalid color"));
}
}
} else {
let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None);
if response.clicked() {
open_picker(app, group.targets.clone());
}
}
let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0));
if let Some(value) = widgets::text_field_named(ui, ui.id().with("opacity"), &alpha, 44.0, tr("Opacity"))
&& let Ok(value) = value.trim().trim_end_matches('%').parse::<f32>()
&& value.is_finite()
{
let mut paint = group.paint.clone();
paint.opacity = (value / 100.0).clamp(0.0, 1.0);
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
}
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
crate::right::menu_popup(&styles).show(|ui| {
let mut any = false;
for style in &doc.styles {
if !matches!(style.kind, StyleKind::Paint { .. }) {
continue;
}
any = true;
if crate::right::menu_item(ui, &style.name, "") {
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
app.ui.picker = None;
ui.close();
}
}
if !any {
widgets::label(ui, tr("No color styles"), t.text_secondary);
}
});
if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() {
app.run("selection.selectColor", json!({"targets": group.targets}));
}
});
});
}
});
});
ui.add_space(8.0);
widgets::separator(ui);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NoServices;
use egui::{Event, Pos2, RawInput, Rect, Shape, vec2};
use figcraft_engine::Session;
struct Panel {
ctx: egui::Context,
app: FigApp,
time: f64,
}
impl Panel {
fn new() -> (Self, NodeId, NodeId) {
let mut s = Session::new();
let mut ids = Vec::new();
for color in ["#ff0000", "#0000ff"] {
let value = s.execute("shape.rectangle", &json!({"width":40,"height":40,"props":{"fill":color}})).unwrap();
ids.push(NodeId(value["id"].as_u64().unwrap()));
}
s.execute("select.set", &json!({"ids":ids})).unwrap();
let mut p = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 };
crate::theme::install_fonts(&p.ctx, false, 0);
crate::theme::apply(&p.ctx, crate::theme::Tokens::light());
for _ in 0..3 {
p.frame(vec![]);
}
(p, ids[0], ids[1])
}
fn frame(&mut self, events: Vec<Event>) -> Vec<(String, Pos2)> {
self.time += 1.0 / 60.0;
let input = RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 600.0))),
time: Some(self.time),
events,
..Default::default()
};
let mut output = self.ctx.run_ui(input, |ui| {
let ds = self.app.session.ds().unwrap();
let doc = ds.doc.clone();
let ids = ds.selection.clone();
section(&mut self.app, ui, &doc, &ids);
});
output.textures_delta.clear();
fn texts(shape: &Shape, out: &mut Vec<(String, Pos2)>) {
match shape {
Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)),
Shape::Vec(v) => {
for s in v {
texts(s, out)
}
}
_ => {}
}
}
let mut text = Vec::new();
for s in &output.shapes {
texts(&s.shape, &mut text);
}
text
}
fn click(&mut self, pos: Pos2) {
for pressed in [true, false] {
self.frame(vec![
Event::PointerMoved(pos),
Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
]);
}
}
}
#[test]
fn selection_colors_panel_opens_the_existing_picker_and_hides_for_one_color() {
let (mut p, a, b) = Panel::new();
let texts = p.frame(vec![]);
assert!(texts.iter().any(|(s, _)| s == "Selection colors"));
let red = texts.iter().find(|(s, _)| s == "FF0000").unwrap().1;
p.click(Pos2::new(28.0, red.y));
let Some(PickerTarget::SelectionColor { targets, .. }) = &p.app.ui.picker else { panic!("the swatch must open its picker") };
assert_eq!(targets.iter().map(|t| t.id).collect::<Vec<_>>(), [a]);
p.app.run("fill.set", json!({"id":b,"color":"#ff0000"}));
assert!(!p.frame(vec![]).iter().any(|(s, _)| s == "Selection colors"));
}
#[test]
fn panel_keeps_cached_colors_during_interaction_and_refreshes_after_commit() {
let (mut p, a, _) = Panel::new();
let key = Id::new("selection_colors_cache");
let before = p.ctx.data(|d| d.get_temp::<Cache>(key)).unwrap();
p.app.session.begin_interaction("Color").unwrap();
for hex in ["#00ff00", "#ffff00", "#00ffff"] {
p.app.run("fill.set", json!({"id":a,"color":hex}));
p.frame(vec![]);
let during = p.ctx.data(|d| d.get_temp::<Cache>(key)).unwrap();
assert!(Arc::ptr_eq(&before.colors, &during.colors), "a live edit must not rediscover paints");
}
p.app.session.commit_interaction().unwrap();
let texts = p.frame(vec![]);
assert!(texts.iter().any(|(s, _)| s == "00FFFF"));
let after = p.ctx.data(|d| d.get_temp::<Cache>(key)).unwrap();
assert!(!Arc::ptr_eq(&before.colors, &after.colors));
}
#[test]
fn panel_defers_discovery_without_a_matching_cache_during_interaction() {
let (mut p, a, _) = Panel::new();
let key = Id::new("selection_colors_cache");
p.ctx.data_mut(|d| d.remove::<Cache>(key));
p.app.session.begin_interaction("Color").unwrap();
p.app.run("fill.set", json!({"id":a,"color":"#00ff00"}));
assert!(!p.frame(vec![]).iter().any(|(s, _)| s == "Selection colors"));
assert!(p.ctx.data(|d| d.get_temp::<Cache>(key)).is_none());
p.app.session.cancel_interaction().unwrap();
assert!(p.frame(vec![]).iter().any(|(s, _)| s == "FF0000"));
}
#[test]
fn select_color_button_selects_only_the_matching_layers() {
let (mut p, a, _) = Panel::new();
let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1;
p.click(Pos2::new(252.0, red.y));
assert_eq!(p.app.session.selection(), [a]);
assert!(p.app.ui.last_error.is_none());
}
#[test]
fn panel_displays_the_linked_style_name_and_the_other_color() {
let (mut p, a, b) = Panel::new();
p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}));
p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"}));
let texts = p.frame(vec![]);
assert!(texts.iter().any(|(s, _)| s == "Brand"));
assert!(texts.iter().any(|(s, _)| s == "0000FF"));
let colors = collect(p.app.session.doc().unwrap(), &[a, b]);
assert_eq!(colors.len(), 2);
}
}
+21
View File
@@ -1356,6 +1356,27 @@ through the outlines of its layers.
clears it; no `preset` reads it). The desktop app reads pressure from pen input on Windows; in the
browser from stylus touch events that report a force; macOS and Linux tablets report none.
### Selection colors
`selection.colors {ids?}` returns `{colors: [{paint, style, variable, targets}]}` across the
selection and its descendants, deduplicating overlapping roots. Only visible solid and gradient
paints are listed; masks and boolean operands are omitted. Styles and variables distinguish
otherwise equal paints. Each target is `{id, fills, index, range}`; `range` is null for a node
paint or `[start, end]` in character offsets for an effective text-range fill.
Pass the returned target list to:
- `selection.setPaint {targets, paint}` to replace only those slots (same paint syntax as `fill.set`).
- `selection.selectColor {targets}` to select their distinct owning layers, without a document undo step.
- `selection.applyColorStyle {targets, style}` to apply a document paint style. As with `style.apply`,
this replaces the matching node's entire fill/stroke list and links the style. Text ranges copy
resolved paints and keep their typography, without a paint-style link.
Keep the targets fixed throughout a live interaction. A color becoming equal to another color
must not add slots to the gesture. Invalid or missing targets fail atomically. Direct paint edits
detach bindings on changed paint values (opacity alone keeps the color binding); a text-range edit preserves the node bindings other
characters still inherit. Each mutating command is one undo step (an interaction can group them).
### Paint, effect and layout grid lists
A layer's fills, strokes, effects and layout grids are lists kept **bottom first**: index 0 paints