diff --git a/ROADMAP.md b/ROADMAP.md index ace7702e..85d1a36f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | 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 42423dbc..04090a3b 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 a43959d3..be5b4216 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/en.toml b/crates/ui-egui/locales/en.toml index 08ac64ba..7b615b02 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -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" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3200e891..32b92d3f 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -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" diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index e55130c5..7035676f 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()})) @@ -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()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f166dfa..2693cebb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 6be28780..cc6e64b0 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..89a8adb9 --- /dev/null +++ b/crates/ui-egui/src/selection_colors.rs @@ -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, + ids: Vec, + colors: Arc>, +} + +fn colors(ui: &Ui, doc: &Arc, ids: &[NodeId]) -> Arc> { + let key = Id::new("selection_colors_cache"); + if let Some(cache) = ui.data(|d| d.get_temp::(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) { + 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 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::() + && 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 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 38d821f4..ea23305b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0cdc94e3..a27e6f35 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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`) |