mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-24 10:27:00 -06:00
M3: add Selection colors for mixed selections (#403)
This commit is contained in:
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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"}),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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`) |
|
||||
|
||||
Reference in New Issue
Block a user