diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6579494c..b942fc25 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 60f99136..245e08f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection_colors.rs new file mode 100644 index 00000000..e604c798 --- /dev/null +++ b/crates/engine/src/selection_colors.rs @@ -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> { + 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 { + self.paints(doc)?.get(self.index).cloned() + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct SelectionColor { + pub paint: Paint, + pub style: Option, + pub variable: Option, + pub targets: Vec, +} + +/// 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 { + let mut groups: Vec = Vec::new(); + let mut seen = BTreeSet::new(); + let mut by_paint: BTreeMap<(Option, Option, 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 { + 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> { + let ts: Vec = 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::().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 { + 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 { + 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 { + let ts = checked_targets(s, p, "selection.selectColor")?; + let ids: Vec<_> = ts.iter().map(|t| t.id).collect::>().into_iter().collect(); + crate::cmd::edit::select_and_page(s, ids.clone())?; + Ok(json!({"ids": ids})) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dbf432d4..d8ebfc9e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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"}), diff --git a/crates/engine/tests/selection_colors.rs b/crates/engine/tests/selection_colors.rs new file mode 100644 index 00000000..e13f9f59 --- /dev/null +++ b/crates/engine/tests/selection_colors.rs @@ -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, ¶ms).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"); +} diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 2459d104..cc0e66fd 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -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" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b679feb7..ba9c90bd 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -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" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 9ce38a22..15c095f7 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -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" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 2849f4ec..e4d7595e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -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" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 1c561f15..6edfbd03 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -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" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 7a0a3c3c..99d6cff4 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -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" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index b09db3bd..788ed68b 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -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" = "線" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index f2cfcfff..a6bfbf6e 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -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" = "선" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index e9e1300c..75bbb301 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -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" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 2023b692..782aa225 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -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" = "Линия" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 51f16508..f751ecb5 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -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" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index cc002654..788d7142 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -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" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 702cae7b..3434832d 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -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" = "直线" diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index bb18a0b2..de3b05ca 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -18,6 +18,11 @@ use crate::widgets; #[derive(Clone, Debug, PartialEq)] pub enum PickerTarget { PageBackground, + SelectionColor { + targets: Vec, + selection: Vec, + 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 { 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::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 91de12db..80065b11 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 48bd142e..f1e20e62 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -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 } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index fcb13724..47f3e5d3 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -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); diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs new file mode 100644 index 00000000..f96fc609 --- /dev/null +++ b/crates/ui-egui/src/selection_colors.rs @@ -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, + ids: Vec, + colors: Arc>, +} + +fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacting: bool) -> Arc> { + let key = Id::new("selection_colors_cache"); + if let Some(cache) = ui.data(|d| d.get_temp::(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) { + 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, 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::() + && 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) -> 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::>(), [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::(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::(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::(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::(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::(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); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index e9b621f1..f685ba10 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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