M1: paint depth: gradient stops on the canvas, colour models, colours on this page (#7)

- Gradient stops on the canvas: drag a stop along the gradient line, click the line to add a
  stop (sampled colour), the selected stop is ringed and shared with the picker; Delete removes
  it (two always stay).
- Colour picker value row: a model dropdown (Hex, RGB, HSL, HSB, CSS) with its fields and the
  alpha field; scrubbing works on the numeric fields.
- "On this page" swatches: the page's most used solid colours (cached per document snapshot);
  swatches set a solid colour or the selected gradient stop.
- New command `paint.documentColors {page?, limit?}`; control channel `ui.set {colorPicker}`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 02:57:40 +07:00 committed by GitHub
parent 14435acb63
commit d30ee21566
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 303 additions and 45 deletions

View File

@ -122,7 +122,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Selection & transform | 8 | 60% | rotated multi-select handles, scale tool polish, measure hover polish | 10–15 |
| Layers panel | 5 | 70% | drag polish (auto-scroll while dragging, drop into collapsed groups), keyboard row navigation | 4–6 |
| Shapes & vector | 8 | 55% | eraser, variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 15–22 |
| Paint | 7 | 55% | gradient handles on canvas, image crop mode, document colours, colour models | 12–18 |
| Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 |
| Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 |
| Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 |
| Frames, groups, sections, masks | 5 | 55% | section tooling, mask types (luminance/outline), frame presets UI | 6–10 |
@ -135,7 +135,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 |
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 |
Weighted feature depth ≈ **49%**.
Weighted feature depth ≈ **50%**.
## Milestones

View File

@ -12,6 +12,19 @@ use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"paint.documentColors",
"Document colours",
"{page?, limit? (default 21)} → solid colours used on the page, most used first",
false,
|s, p| {
let page = id(p, "page").unwrap_or(s.page()?);
let limit = num(p, "limit").map(|l| l.clamp(1.0, 500.0) as usize).unwrap_or(21);
let colors = document_colors(s.doc()?, page);
let out: Vec<Value> = colors.into_iter().take(limit).map(|(c, n)| json!({"color": c.to_hex(), "count": n})).collect();
Ok(json!({"colors": out}))
}
),
spec!(
"style.create",
"Create style",
@ -461,3 +474,28 @@ fn unique_id<'a>(prefix: &str, existing: impl Iterator<Item = &'a str>) -> Strin
let max = existing.filter_map(|e| e.strip_prefix(prefix)?.strip_prefix(':')?.parse::<u64>().ok()).max().unwrap_or(0);
format!("{prefix}:{}", max + 1)
}
/// Solid fill and stroke colours used under `page` (alpha includes the paint's opacity), most used
/// first.
pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> {
let mut counts: BTreeMap<[u8; 4], (Color, usize)> = BTreeMap::new();
let mut stack = vec![page];
let mut seen = 0usize;
while let Some(id) = stack.pop() {
seen += 1;
if seen > 200_000 {
break;
}
let Some(n) = doc.node(id) else { continue };
stack.extend(n.children.iter().copied());
for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) {
if let Some(c) = paint.solid_color() {
let c = c.with_alpha(c.a * paint.opacity);
counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1;
}
}
}
let mut out: Vec<(Color, usize)> = counts.into_values().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.to_hex().cmp(&b.0.to_hex())));
out
}

View File

@ -382,3 +382,24 @@ fn arrows_reorder_auto_layout_children_and_cmd_arrows_resize() {
let r = s.doc().unwrap().node(row).unwrap().clone();
assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug);
}
#[test]
fn document_colors_are_counted_per_page() {
let mut s = sess();
for (i, c) in ["#FF0000", "#FF0000", "#00FF00"].iter().enumerate() {
let r = id(&s.execute("shape.rectangle", &json!({"x": i * 20, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("node.set", &json!({"id": r, "props": {"fills": [c], "strokes": ["#0000FF"]}})).unwrap();
}
let v = s.execute("paint.documentColors", &json!({})).unwrap();
let list: Vec<(String, u64)> =
v["colors"].as_array().unwrap().iter().map(|c| (c["color"].as_str().unwrap().to_string(), c["count"].as_u64().unwrap())).collect();
assert_eq!(list.first(), Some(&("0000FF".to_string(), 3)));
assert!(list.contains(&("FF0000".to_string(), 2)));
assert!(list.contains(&("00FF00".to_string(), 1)));
let v = s.execute("paint.documentColors", &json!({"limit": 1})).unwrap();
assert_eq!(v["colors"].as_array().unwrap().len(), 1);
// Another page is separate.
s.execute("page.add", &json!({})).unwrap();
let v = s.execute("paint.documentColors", &json!({})).unwrap();
assert!(v["colors"].as_array().unwrap().is_empty());
}

View File

@ -76,6 +76,133 @@ fn finish(app: &mut FigApp) {
}
}
/// The selected gradient stop in the picker (shared with the on-canvas stops).
pub fn stop_id() -> Id {
Id::new("cp_stop")
}
/// Apply a swatch colour: a solid paint's colour, or the selected gradient stop's.
fn set_color(app: &mut FigApp, target: &PickerTarget, paint: &mut Paint, c: Color, stop: Id, ui: &Ui) {
match &mut paint.kind {
PaintKind::Solid { color } => {
*color = c.with_alpha(1.0);
paint.opacity = c.a;
}
PaintKind::Gradient(g) => {
let sel: usize = ui.data(|d| d.get_temp(stop)).unwrap_or(0);
if let Some(s) = g.stops.get_mut(sel) {
s.color = c;
}
}
PaintKind::Image(_) => return,
}
write(app, target, paint, false);
}
/// The page's most used solid colours, recomputed only when the document snapshot changes.
fn page_colors(app: &FigApp, ui: &Ui) -> Vec<Color> {
let Ok(ds) = app.session.ds() else { return vec![] };
let key = (std::sync::Arc::as_ptr(&ds.doc) as usize, ds.page);
let cache_id = Id::new("cp_doc_colors");
if let Some((k, colors)) = ui.data(|d| d.get_temp::<((usize, figcraft_doc::NodeId), Vec<Color>)>(cache_id))
&& k == key
{
return colors;
}
let colors: Vec<Color> = figcraft_engine::cmd::styles::document_colors(&ds.doc, ds.page).into_iter().take(21).map(|(c, _)| c).collect();
ui.data_mut(|d| d.insert_temp(cache_id, (key, colors.clone())));
colors
}
/// Colour models the value row can show.
const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"];
/// The model dropdown with its fields and the alpha field. Returns a committed colour.
fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
let model_id = Id::new("cp_model");
let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1);
let mut out: Option<Color> = None;
ui.horizontal(|ui| {
ui.add_space(16.0);
ui.spacing_mut().item_spacing.x = 4.0;
let resp = widgets::dropdown(ui, MODELS.get(model).copied().unwrap_or("Hex"), 52.0, None);
egui::Popup::menu(&resp).show(|ui| {
for (i, m) in MODELS.iter().enumerate() {
if crate::right::menu_item(ui, m, "") {
ui.data_mut(|d| d.insert_temp(model_id, i));
ui.close();
}
}
});
let num = |ui: &mut Ui, id: &str, v: f64, w: f32| match widgets::number_field(
ui,
Id::new(("cp_f", id)),
widgets::Lead::None,
Some(v.round()),
"",
w,
1.0,
) {
Some(widgets::FieldEdit::Set(x)) | Some(widgets::FieldEdit::Scrub(x, _)) => Some(x),
None => None,
};
match MODELS.get(model).copied().unwrap_or("Hex") {
"RGB" => {
let [r, g, b, _] = c.to_rgba8();
let vals = [f64::from(r), f64::from(g), f64::from(b)];
for (k, v) in vals.iter().enumerate() {
if let Some(x) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) {
let mut rgb = vals;
if let Some(slot) = rgb.get_mut(k) {
*slot = x.clamp(0.0, 255.0);
}
out = Some(Color::new(rgb[0] as f32 / 255.0, rgb[1] as f32 / 255.0, rgb[2] as f32 / 255.0, c.a));
}
}
}
"HSL" | "HSB" => {
let hsl = model == 2;
let (h, s2, l2) = if hsl {
let x = figcraft_color::Hsl::from_color(c);
(x.h, x.s, x.l)
} else {
let x = Hsb::from_color(c);
(x.h, x.s, x.b)
};
let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0];
for (k, v) in vals.iter().enumerate() {
if let Some(x) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) {
let mut v3 = vals;
if let Some(slot) = v3.get_mut(k) {
*slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) };
}
let (h, a, b) = (v3[0] as f32, v3[1] as f32 / 100.0, v3[2] as f32 / 100.0);
let col = if hsl { figcraft_color::Hsl { h, s: a, l: b }.to_color() } else { Hsb { h, s: a, b }.to_color() };
out = Some(col.with_alpha(c.a));
}
}
}
"CSS" => {
if let Some(text) = widgets::text_field(ui, Id::new("cp_css"), &c.to_css(), 152.0, "") {
out = Color::parse_css(&text);
}
}
_ => {
if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &c.to_hex(), 101.0, "") {
out = Color::from_hex(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a }));
}
}
}
if MODELS.get(model) != Some(&"CSS")
&& let Some(widgets::FieldEdit::Set(v)) =
widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(out.unwrap_or(c).a) * 100.0).round()), "%", 44.0, 1.0)
{
out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32));
}
});
out
}
fn hue_color(h: f32) -> Color32 {
c32(Hsb { h, s: 1.0, b: 1.0 }.to_color())
}
@ -265,7 +392,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),
_ => None,
};
let sel_stop_id = Id::new("cp_stop");
let sel_stop_id = stop_id();
if let PaintKind::Gradient(g) = &paint.kind {
let mut g = g.clone();
let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));
@ -451,38 +578,13 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
changed = true;
done |= d;
}
// Hex + alpha fields.
let mut hex_set = None;
let mut alpha_set = None;
ui.horizontal(|ui| {
ui.add_space(16.0);
widgets::label(ui, "Hex", t.text_secondary);
if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &nc.to_hex(), 96.0, "") {
hex_set = Color::from_hex(&h);
}
if let Some(widgets::FieldEdit::Set(v)) =
widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(nc.a) * 100.0).round()), "%", 56.0, 1.0)
{
alpha_set = Some((v / 100.0).clamp(0.0, 1.0) as f32);
}
});
if let Some(h) = hex_set {
nc = h.with_alpha(if h.a < 1.0 { h.a } else { nc.a });
// Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha.
if let Some(c) = model_fields(ui, nc) {
nc = c;
hsb = Hsb::from_color(nc);
changed = true;
done = true;
}
if let Some(a) = alpha_set {
nc = nc.with_alpha(a);
changed = true;
done = true;
}
// RGB readout.
let [r, g, b, _] = nc.to_rgba8();
ui.horizontal(|ui| {
ui.add_space(16.0);
widgets::label(ui, &format!("RGB {r} {g} {b}"), t.text_secondary);
});
ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
if changed {
match &mut paint.kind {
@ -504,7 +606,23 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
finish(app);
}
}
// Document / recent colours.
// Colours used on this page.
let doc_colors = page_colors(app, ui);
if !doc_colors.is_empty() {
ui.horizontal(|ui| {
ui.add_space(16.0);
widgets::label(ui, "On this page", t.text_secondary);
});
ui.horizontal_wrapped(|ui| {
ui.add_space(16.0);
for c in &doc_colors {
if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {
set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
}
}
});
}
// Recent colours.
let recent = app.session.recent_colors.clone();
if !recent.is_empty() {
ui.horizontal(|ui| {
@ -515,11 +633,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
ui.add_space(16.0);
for c in recent.iter().take(14) {
if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
if let PaintKind::Solid { color } = &mut paint.kind {
*color = c.with_alpha(1.0);
paint.opacity = c.a;
}
write(app, &target, &paint, false);
set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
}
}
});

View File

@ -232,6 +232,14 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) {
app.ui.comments.show_resolved = v;
}
// Colour picker on the selection's paint `{fills?, index?}`; null closes it.
if let Some(v) = p.get("colorPicker") {
app.ui.picker = v.is_object().then(|| crate::colorpicker::PickerTarget::Paint {
fills: v.get("fills").and_then(Value::as_bool).unwrap_or(true),
index: v.get("index").and_then(Value::as_u64).unwrap_or(0).min(1000) as usize,
ids: app.session.selection(),
});
}
// Layers panel search: a string opens it with that filter, null closes it.
if let Some(v) = p.get("layerFilter") {
app.ui.layer_search = v.is_string();

View File

@ -17,7 +17,7 @@ const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
#[derive(Clone, Debug)]
enum Drag {
/// Gradient handle 0 = from, 1 = to, 2 = width.
/// Gradient handle 0 = from, 1 = to, 2 = width; 3 + i = stop `i` (slides along the line).
Gradient { id: NodeId, fills: bool, index: usize, which: usize },
/// Padding side 0 top, 1 right, 2 bottom, 3 left.
Padding { id: NodeId, side: usize, start: Pos2, value: f64 },
@ -76,6 +76,41 @@ fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color:
}
}
/// Where `p` projects onto segment a→b: (t in 0..1, distance in points).
fn line_param(a: Pos2, b: Pos2, p: Pos2) -> Option<(f64, f32)> {
let ab = b - a;
let len2 = ab.length_sq();
if len2 < 1e-6 {
return None;
}
let t = ((p - a).dot(ab) / len2).clamp(0.0, 1.0);
Some((f64::from(t), (a + ab * t - p).length()))
}
fn set_gradient(app: &mut FigApp, id: NodeId, fills: bool, index: usize, g: figcraft_color::Gradient) {
let Some(n) = app.session.doc().ok().and_then(|d| d.node(id)).cloned() else { return };
let list = if fills { &n.fills } else { &n.strokes };
let Some(mut paint) = list.get(index).cloned() else { return };
paint.kind = PaintKind::Gradient(g);
let cmd = if fills { "fill.set" } else { "stroke.set" };
let _ = app.session.execute(cmd, &json!({"ids": [id], "index": index, "paint": paint}));
}
/// Delete/Backspace while a gradient is open: remove its selected stop (two always stay).
pub fn remove_selected_stop(app: &mut FigApp, ctx: &egui::Context) -> bool {
let Some((id, fills, index, mut g)) = gradient_target(app) else { return false };
let sel: usize = ctx.data(|d| d.get_temp(crate::colorpicker::stop_id())).unwrap_or(0);
if g.stops.len() <= 2 || sel >= g.stops.len() {
return g.stops.len() <= 2;
}
g.stops.remove(sel);
ctx.data_mut(|d| d.insert_temp(crate::colorpicker::stop_id(), sel.saturating_sub(1)));
let _ = app.session.begin_interaction("Remove gradient stop");
set_gradient(app, id, fills, index, g);
let _ = app.session.commit_interaction();
true
}
fn unit_to_screen(app: &FigApp, id: NodeId, u: (f64, f64)) -> Option<Pos2> {
let doc = app.session.doc().ok()?;
let n = doc.node(id)?;
@ -210,11 +245,15 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
if g.kind != figcraft_color::GradientKind::Linear {
painter.add(Shape::dashed_line(&[a, c], Stroke::new(1.0, Color32::WHITE), 4.0, 3.0));
}
for s in g.sorted_stops() {
let selected: usize = painter.ctx().data(|d| d.get_temp(crate::colorpicker::stop_id())).unwrap_or(0);
for (i, s) in g.stops.iter().enumerate() {
let p = a + (b - a) * s.position;
let r = Rect::from_center_size(p, vec2(12.0, 12.0));
painter.rect_filled(r, CornerRadius::same(2), crate::theme::c32(s.color.with_alpha(1.0)));
painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
if i == selected {
painter.rect_stroke(r.expand(2.0), CornerRadius::same(3), Stroke::new(1.5, ACCENT), StrokeKind::Outside);
}
}
for (i, p) in [a, b, c].iter().enumerate() {
if i == 2 && g.kind == figcraft_color::GradientKind::Linear {
@ -321,7 +360,34 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool {
return true;
}
}
return false;
// Stops: select and slide them; a click elsewhere on the line adds one there.
let (Some(a), Some(b)) = (pts[0], pts[1]) else { return false };
let stop_hit = g.stops.iter().position(|st| (a + (b - a) * st.position - pos).length() <= 8.0);
let t = line_param(a, b, pos);
let on_line = t.is_some_and(|(_, d)| d <= 5.0);
if stop_hit.is_none() && !on_line {
return false;
}
ctx.set_cursor_icon(if stop_hit.is_some() { egui::CursorIcon::Grab } else { egui::CursorIcon::Copy });
if pressed {
let stop = match stop_hit {
Some(i) => {
let _ = app.session.begin_interaction("Edit gradient");
i
}
None => {
let pos01 = t.map(|(t, _)| t).unwrap_or(0.5) as f32;
let mut g2 = g.clone();
g2.stops.push(figcraft_color::ColorStop { position: pos01, color: g.sample(pos01) });
let _ = app.session.begin_interaction("Add gradient stop");
set_gradient(app, id, fills, index, g2);
g.stops.len()
}
};
ctx.data_mut(|d| d.insert_temp(crate::colorpicker::stop_id(), stop));
ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Gradient { id, fills, index, which: 3 + stop }));
}
return true;
}
if let Some((id, fills, index, _, rect)) = crop_target(app) {
let Some(c) = crop_corners(app, id, rect) else { return false };
@ -387,7 +453,13 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) {
match which {
0 => g.from = u,
1 => g.to = u,
_ => g.width = u,
2 => g.width = u,
stop => {
let (Some(a), Some(b)) = (unit_to_screen(app, *id, g.from), unit_to_screen(app, *id, g.to)) else { return };
if let (Some((t, _)), Some(st)) = (line_param(a, b, pos), g.stops.get_mut(stop - 3)) {
st.position = t as f32;
}
}
}
}
let cmd = if *fills { "fill.set" } else { "stroke.set" };

View File

@ -77,7 +77,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context) {
crate::menus::invoke(app, "edit.paste");
}
}
Event::Key { key, pressed: true, modifiers, repeat, .. } if key_event(app, *key, *modifiers, *repeat) => {
Event::Key { key, pressed: true, modifiers, repeat, .. } if key_event(app, *key, *modifiers, *repeat, ctx) => {
// handled
}
_ => {}
@ -85,7 +85,11 @@ pub fn handle(app: &mut FigApp, ctx: &Context) {
}
}
fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool) -> bool {
fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, ctx: &Context) -> bool {
// A gradient open in the colour picker: Delete removes its selected stop.
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) {
return true;
}
// Vector edit mode has its own keys (tools, nudging points, delete, join); the rest fall through.
if app.session.ds().is_ok_and(|d| d.vector_edit.is_some()) {
match figcraft_engine::vectoredit::key(&mut app.session, key.name(), m.shift, m.alt, m.command) {

View File

@ -88,10 +88,11 @@ text tool).
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, layerFilter?, collapseLayers?}`
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, layerFilter?, collapseLayers?}`
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `layerFilter` (string:
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `colorPicker`
(`{fills?, index?}`: open the colour picker on the selection's paint; `null` closes it), `layerFilter` (string:
open the layers search with that filter; `null` closes it), `collapseLayers` (true: collapse the
layers tree). Returns `ui.inspect`.