M3: add Selection colors for mixed selections (#403)

This commit is contained in:
Ivan Shishkanov
2026-10-15 19:05:24 +03:00
parent 102404e8c3
commit 7a5885cde9
14 changed files with 879 additions and 2 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Selection colors for mixed selections (#403); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Selection colors (#403): inspect and edit solid/gradient fills and strokes across descendants and text ranges, apply document color styles, select matching layers; one Undo step per picker drag. See docs/ui-parity.md for the bounded style behavior and remaining parity; headline estimates unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
+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");
}
+5
View File
@@ -412,6 +412,7 @@ name = "English"
"Duplicate by dragging" = "Duplicate by dragging"
"Duplicate mode" = "Duplicate mode"
"Duplicate page" = "Duplicate page"
"Edit color" = "Edit color"
"Edit" = "Edit"
"Edit as an expression" = "Edit as an expression"
"Edit colour" = "Edit colour"
@@ -635,6 +636,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:"
@@ -801,6 +803,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"
@@ -1152,9 +1155,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"
+9
View File
@@ -413,6 +413,7 @@ status = "draft — needs native review"
"Duplicate by dragging" = "Duplicar arrastrando"
"Duplicate mode" = "Duplicar modo"
"Duplicate page" = "Duplicar página"
"Edit color" = "Editar color"
"Edit" = "Editar"
"Edit as an expression" = "Editar como expresión"
"Edit colour" = "Editar color"
@@ -636,6 +637,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:"
@@ -802,6 +804,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"
@@ -1153,9 +1156,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"
@@ -1918,6 +1923,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"
+63
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()}))
@@ -401,6 +414,9 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let Some(target) = app.ui.picker.clone() else { return };
let Some(mut paint) = current_paint(app, &target) else {
if matches!(target, PickerTarget::SelectionColor { .. }) {
finish(app);
}
app.ui.picker = None;
return;
};
@@ -839,6 +855,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(),
};
@@ -1094,3 +1111,49 @@ impl WithStops for Gradient {
self
}
}
#[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);
}
finish(&mut app);
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());
}
}
+1
View File
@@ -49,6 +49,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
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);
+232
View File
@@ -0,0 +1,232 @@
//! 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 {
doc: Arc<Document>,
ids: Vec<NodeId>,
colors: Arc<Vec<SelectionColor>>,
}
fn colors(ui: &Ui, doc: &Arc<Document>, ids: &[NodeId]) -> Arc<Vec<SelectionColor>> {
let key = Id::new("selection_colors_cache");
if let Some(cache) = ui.data(|d| d.get_temp::<Cache>(key))
&& Arc::ptr_eq(&cache.doc, doc)
&& cache.ids == ids
{
return cache.colors;
}
let colors = Arc::new(collect(doc, ids));
ui.data_mut(|d| d.insert_temp(key, Cache { 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 colors = colors(ui, doc, ids);
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.
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 color"));
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 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
@@ -1342,6 +1342,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
+21 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Selection colors for mixed selections (#403); the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -107,6 +107,25 @@ interaction feel not yet compared.
| Cursors per tool and modifier | done | the engine names a cursor for every tool, modifier and hover target (`tool.overlay.cursor`, tabled in `cursor_tests`) and the UI shows the system's or draws its own at the pointer (`cursors.rs`, `cursors_tests`; original glyphs from lines and the code-drawn icons). Arrow for Move and Scale; two-headed arrows on the handles that follow the handle and the box's rotation; outside a corner a curved rotate arrow turned to that corner, turning with the pointer while rotating; crosshair for the frame, section, slice, shape, pencil and Text tools; the I-beam over a text with the Text tool and over the text being edited; the pen as a cross with what a click does beside it (nothing: a new point; a ring: closes, connects to or goes on from a point; a plus: adds a point on a segment, also Alt in vector edit mode; a curve: bends, the Bend tool or Cmd/Ctrl); open and closed hand for the Hand tool, Space and the middle button; zoom in and out (Alt) while Z is held; eyedropper; comment pin; the copy cursor with Alt over a selected layer and while Alt-dragging; not-allowed while a drag resizes, rotates or moves a layer inside an instance (it is undone on release) and over a layers row that takes no drop. Ruler guides and gradient, crop and padding handles keep their own cursors (the tool's used to replace them). `ui.inspect` reports `cursor`. Left: no cursor for removing a point with the pen (the pen doesn't remove points); the drawn cursors follow the pointer a frame behind the system's; not compared with Figma's side by side |
| Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions |
### Selection colors
The Design panel shows Selection colors below Effects when the selection contains multiple
paint groups. It includes solid fills and gradients, strokes, descendants and effective text
range fills; images, patterns, hidden paints and masks are omitted. A boolean group's own paints
are included, without its operands. Equal paints from different styles or variables stay separate.
Each row edits the matching slots through the ordinary color picker or hex/opacity fields and
can select their owning layers. A picker drag is one undo step and retains its original targets
even when two colors become equal. Paint discovery is cached per document snapshot and selection;
the rows are virtualized.
Color styles already in the document (including imported styles) can be applied from a row.
As with the existing Fill/Stroke style controls, applying a style replaces the matching layer's
whole fill or stroke list and links it. A text range takes the style's resolved paints while
keeping its typography; the document model has no paint-style link on character ranges.
Browsing enabled libraries, binding/detaching variables from this section and a side-by-side
comparison against the running incumbent remain unchecked or unfinished; this is **partial** UI
parity. Behavior reference: [Figma's public selection-colors guide](https://help.figma.com/hc/en-us/articles/360042553434-View-and-adjust-colors-in-a-mixed-selection).
## Keyboard shortcuts
One keymap (`figcraft_engine::keymap`) drives the menus, the canvas, the shortcuts panel, the
@@ -164,6 +183,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | Selection colors (#403): mixed-selection paint editing, color styles and select-by-color; engine and egui regression tests. Bounds and remaining parity are documented above; headline estimates unchanged. |
| 2026-10-14 | minor | Type settings: the Small caps button draws its sample as a capital and a smaller capital, no longer as a box where the UI font lacks the small-capital letter (egui's default fonts) |
| 2026-10-14 | minor | Home screen recent files as cards with thumbnails on their page colour, a sortable table view, selection, keyboard and a context menu |
| 2026-10-13 | minor | Vector edit mode regions: with the Move tool the filled area under the pointer is hatched with "/" lines in the selection colour (about 1.4 px), clipped by the area's own fill rule so holes stay clear, and the hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); the spacing follows octave folding, fitted to measurements at several zoom levels (8 px at every power-of-two zoom, the gap growing with the zoom and stepping back at √2 · 2ⁿ), anchored to the vector so it doesn't slide when panning, one continuous pattern across selected areas; a click selects an area with its points (they move with it but keep the unselected look), Shift adds an area or takes a selected one out, a press on a selected area drags everything selected, drags show no hover hatch, a click on an empty spot (such as a hole) clears the areas and stays in edit mode, the last area listed wins where areas overlap, only filled vectors have areas; `vector.edit.select {regions}` and `vector.edit.state` report them (`regions_tests`, `vector_regions_tests`) |