M1: the colour picker's Image tab has a placeholder, a preview and takes a dropped file (#1682) (#1713)

Choosing Image shows a checkered placeholder and Upload from computer instead of a dialog at once; an image paint shows its asset preview. A file dropped on the open picker replaces the image, keeping the fill mode, crop, position and adjustments (image.replace, one undo step; paths capped as image.place's). The browser's file picker reaches the tab too. Also fixes a race in library_detach_tests.
This commit is contained in:
BFlat
2026-10-31 06:32:59 +07:00
committed by GitHub
parent 1ea19482d3
commit 920aafb805
16 changed files with 562 additions and 41 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@ fn id(v: &Value) -> NodeId {
/// `a`'s analytics of its file's library once `done` holds for them (the reports arrive from
/// another app's background requests).
fn usage_when(a: &mut Session, done: impl Fn(&Value) -> bool) -> Value {
fn usage_when(a: &mut Session, mut done: impl FnMut(&Value) -> bool) -> Value {
let start = Instant::now();
loop {
let u = ex(a, "library.usage", json!({}));
@@ -73,8 +73,11 @@ fn detaches_are_reported_and_counted_beside_instances() {
// Detaching the other one too: no instances left, still listed with its detaches.
ex(&mut b, "component.detach", json!({"ids": [one]}));
b.refresh_server_libraries();
let u = usage_when(&mut a, |u| u["detached"] == 2 && u["instances"] == 0);
// Again each time: a check asked for while the last one still runs is dropped.
let u = usage_when(&mut a, |u| {
b.refresh_server_libraries();
u["detached"] == 2 && u["instances"] == 0
});
assert_eq!(u["instances"], 0, "{u}");
assert_eq!((u["components"][0]["name"].clone(), u["components"][0]["detached"].clone()), (json!("Button"), json!(2)), "{u}");
server.stop();
+14 -9
View File
@@ -425,6 +425,19 @@ fn read_path(path: &str) -> Result<Vec<u8>> {
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
}
/// The image `cmd` was given: `data` (base64) or a `path`, whose size is capped.
pub(super) fn image_bytes(cmd: &str, p: &Value) -> Result<Vec<u8>> {
if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))
} else if let Some(path) = string(p, "path") {
read_path(&path)
} else {
Err(bad(cmd, "pass `data` (base64) or `path`"))
}
}
/// Layers that can show an image inside their own outline.
pub fn accepts_image_fill(kind: &NodeKind) -> bool {
kind.is_shape() || kind.is_frame_like() || matches!(kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. })
@@ -447,15 +460,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`target` must be a layer that can have a fill"));
}
}
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
} else if let Some(path) = string(p, "path") {
read_path(&path)?
} else {
return Err(bad(cmd, "pass `data` (base64) or `path`"));
};
let bytes = image_bytes(cmd, p)?;
let mode = match string(p, "mode").unwrap_or_default().to_ascii_lowercase().as_str() {
"fit" => ImageScaleMode::Fit,
"crop" => ImageScaleMode::Crop,
+43 -1
View File
@@ -1,6 +1,6 @@
//! Image paints: adjustment presets, built in and saved in the file, and Crop image.
use figcraft_color::{ImageFilters, ImageScaleMode, PaintKind};
use figcraft_color::{ImageFilters, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::{Document, ImagePreset, Node};
use serde_json::{Value, json};
@@ -17,6 +17,13 @@ pub fn specs() -> Vec<CommandSpec> {
true,
adjust
),
spec!(
"image.replace",
"Replace image",
"{ids?, target? (fill|stroke, default fill), index? (default each layer's top image paint), data (base64) | path} puts the image in the paint, keeping its fill mode, crop, position and adjustments; a paint at `index` that isn't an image becomes one, filling the layer; returns {image, width, height, changed}",
true,
replace
),
spec!(
"image.savePreset",
"Save image preset",
@@ -142,6 +149,41 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"changed": changed}))
}
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.replace";
let ids = req_targets(cmd, s, p)?;
let stroke = strokes(cmd, p)?;
let index = match get(p, "index") {
None | Some(Value::Null) => None,
Some(_) => Some(req_index(cmd, p, "index")?),
};
let bytes = super::create::image_bytes(cmd, p)?;
let doc = s.doc_mut()?;
let (image, width, height) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
let mut changed = 0;
for id in ids {
let n = doc.get_mut(id)?;
let list = if stroke { &mut n.strokes } else { &mut n.fills };
let at = index.or_else(|| list.iter().rposition(|x| matches!(x.kind, PaintKind::Image(_))));
let Some(paint) = at.and_then(|i| list.get_mut(i)) else { continue };
match &mut paint.kind {
PaintKind::Image(img) => img.image.clone_from(&image),
kind => *kind = Paint::image(image.clone(), ImageScaleMode::Fill).kind,
}
// Like any paint edit, the layer leaves its paint style.
if stroke {
n.styles.stroke = None;
} else {
n.styles.fill = None;
}
changed += 1;
}
if changed == 0 {
return Err(bad(cmd, if index.is_some() { "no paint at that index" } else { "the layers have no image paints" }));
}
Ok(json!({"image": image, "width": width, "height": height, "changed": changed}))
}
/// The index of a layer's top visible image fill (fills paint bottom to top).
pub fn top_image_fill(n: &Node) -> Option<usize> {
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
+50
View File
@@ -108,3 +108,53 @@ fn crop_image_is_refused_without_one_image_layer() {
assert_eq!(s.doc().unwrap(), &doc, "nothing changes");
assert!(s.ui_requests.is_empty(), "no crop mode");
}
/// `image.replace`: the image changes and everything set on the paint stays; a paint that isn't an
/// image becomes one; one undo step. Bytes that aren't an image are refused and nothing changes.
#[test]
fn replacing_an_image_keeps_its_fill_mode_crop_and_adjustments() {
let mut s = Session::new();
let layer = image_layer(&mut s);
ex(&mut s, "image.crop", json!({"ids": [layer]}));
ex(&mut s, "image.adjust", json!({"ids": [layer], "filters": {"contrast": 0.25}}));
let image_of = |s: &Session| match &fills(s, layer)[0].kind {
PaintKind::Image(i) => i.clone(),
other => panic!("not an image: {other:?}"),
};
let before = image_of(&s);
let png = |r: u8| {
let image = image::RgbaImage::from_pixel(10, 10, image::Rgba([r, 0, 0, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
base64::engine::general_purpose::STANDARD.encode(bytes.into_inner())
};
let r = ex(&mut s, "image.replace", json!({"ids": [layer], "data": png(1)}));
assert_eq!((r["changed"].clone(), r["width"].clone()), (json!(1), json!(10)), "{r}");
let after = image_of(&s);
assert_ne!(after.image, before.image);
assert_eq!(r["image"], json!(after.image));
assert_eq!((after.scale_mode, after.crop, after.filters), (ImageScaleMode::Crop, before.crop, before.filters));
assert!(s.doc().unwrap().images.contains_key(&after.image));
s.undo().unwrap();
assert_eq!(image_of(&s).image, before.image, "one undo step");
// A solid paint at `index` becomes an image filling the layer; a stroke by `target`.
let solid = crate::params::id_of(
&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 40, "height": 40, "props": {"fill": "#ff0000"}}))["id"],
)
.unwrap();
assert!(s.execute("image.replace", &json!({"ids": [solid], "data": png(2)})).is_err(), "no image paint to replace");
ex(&mut s, "image.replace", json!({"ids": [solid], "index": 0, "data": png(2)}));
assert!(matches!(&fills(&s, solid)[0].kind, PaintKind::Image(i) if i.scale_mode == ImageScaleMode::Fill));
ex(&mut s, "node.set", json!({"ids": [solid], "props": {"stroke": "#000000"}}));
ex(&mut s, "image.replace", json!({"ids": [solid], "target": "stroke", "index": 0, "data": png(3)}));
assert!(matches!(s.doc().unwrap().node(solid).unwrap().strokes[0].kind, PaintKind::Image(_)));
// Refused: nothing that decodes, no paint there, neither data nor path.
let junk = base64::engine::general_purpose::STANDARD.encode(b"not an image");
let now = fills(&s, solid);
assert!(s.execute("image.replace", &json!({"ids": [solid], "index": 0, "data": junk})).is_err());
assert!(s.execute("image.replace", &json!({"ids": [solid], "index": 9, "data": png(4)})).is_err());
assert!(s.execute("image.replace", &json!({"ids": [solid], "index": 0})).is_err());
assert_eq!(fills(&s, solid), now);
}
+1 -1
View File
@@ -6,7 +6,7 @@ use super::{
/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`.
fn command_carries_import_bytes(id: &str, params: &Value) -> bool {
match id {
"file.import" => params::get(params, "data").is_some(),
"file.import" | "image.replace" => params::get(params, "data").is_some(),
"command.batch" => params.get("commands").and_then(Value::as_array).is_some_and(|commands| {
commands.iter().any(|c| {
c.get("command").and_then(Value::as_str).zip(c.get("params")).is_some_and(|(id, params)| command_carries_import_bytes(id, params))
+2
View File
@@ -481,6 +481,7 @@ name = "English"
"Drop to import" = "Drop to import"
"Drop to open" = "Drop to open"
"Drop shadow" = "Drop shadow"
"Drop to use this image" = "Drop to use this image"
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community."
"Duo" = "Duo"
"Duplicate" = "Duplicate"
@@ -1320,6 +1321,7 @@ name = "English"
"This server doesn't count insertions" = "This server doesn't count insertions"
"Someone who left" = "Someone who left"
"Take the results off the board; they stay in the file" = "Take the results off the board; they stay in the file"
"Upload from computer" = "Upload from computer"
"Use fonts installed on this computer…" = "Use fonts installed on this computer…"
"Search layers" = "Search layers"
"Search pages" = "Search pages"
+1 -1
View File
@@ -86,7 +86,7 @@ impl FigApp {
/// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a
/// path, or the name and contents where the platform has no paths). `pin`: as
/// [`FigApp::pick_open`]'s file in front.
fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) {
pub(crate) fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) {
self.start_pick(
|services| -> PickFuture {
match services.pick_open_contents(title, exts) {
+4 -1
View File
@@ -223,7 +223,10 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
} else {
tr("Drop to import")
};
crate::drop_overlay(&ctx, ctx.content_rect(), label);
match crate::colorpicker::image_drop_target(app, &ctx).filter(|_| !documents && app.ui.right_tab != crate::RightTab::Inspect) {
Some((rect, _)) => crate::drop_overlay(&ctx, rect, tr("Drop to use this image")),
None => crate::drop_overlay(&ctx, ctx.content_rect(), label),
}
crate::guides::end_released(&ctx);
crate::live::track_pointer(app, &ctx);
app.sync_preedit();
+12 -4
View File
@@ -106,6 +106,8 @@ pub(crate) fn swap_wheel(wheel: egui::Vec2, zoom: f32, shift: bool, speed: f32)
fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option<egui::Pos2>) {
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
let mut refused = false;
// The first image goes to the colour picker that shows an image; the rest to the canvas.
let mut well = crate::colorpicker::image_drop_target(app, ctx).map(|(_, params)| params);
for f in dropped {
let path = f.path().to_path_buf();
if crate::is_document(&path) {
@@ -118,17 +120,23 @@ fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option<egui:
}
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
let (params, read) = if path.exists() {
(json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None)
let (command, mut params) = match well.take() {
Some(params) => ("image.replace", params),
None => ("file.import", json!({"name": name, "x": pt.x, "y": pt.y})),
};
let read = if path.exists() {
params["path"] = json!(path.to_string_lossy());
params.as_object_mut().map(|o| o.remove("name"));
None
} else {
(json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name)))
Some(crate::dropped_bytes(&f, &name))
};
let Some(target) =
app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() })
else {
continue;
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read });
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command, target: Some(target), read });
}
}
+1 -1
View File
@@ -160,7 +160,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) {
}
app.ui.pending_open_frames = app.ui.pending_open_frames.saturating_add(1);
let title = match app.ui.pending_opens.first().map(|p| p.command) {
Some("file.import") => {
Some("file.import" | "image.replace") => {
let name = app
.ui
.pending_opens
+132 -17
View File
@@ -551,6 +551,116 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
slider_knob(ui, rect, color.a, Id::unique("cp_alpha"))
}
/// The image files the picker offers.
const IMAGE_FILES: [&str; 7] = ["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
fn image_pending_id() -> Id {
Id::unique("cp_image_pending")
}
/// Whether the Image tab of `target` shows its placeholder: chosen, with no file yet.
fn image_pending(ctx: &egui::Context, target: &PickerTarget) -> bool {
ctx.data(|d| d.get_temp::<String>(image_pending_id())).is_some_and(|k| k == format!("{target:?}"))
}
/// Show the Image tab's placeholder for `target`, or (`None`) for nothing.
fn set_image_pending(ctx: &egui::Context, target: Option<&PickerTarget>) {
ctx.data_mut(|d| {
d.remove::<String>(image_pending_id());
if let Some(t) = target {
d.insert_temp(image_pending_id(), format!("{t:?}"));
}
});
}
/// `image.replace`'s parameters for layers' paint `index`.
fn image_params(fills: bool, index: usize, ids: &[NodeId]) -> serde_json::Value {
json!({"ids": ids, "target": if fills { "fill" } else { "stroke" }, "index": index})
}
/// Where a file dragged over the window would become the open picker's image, and the parameters
/// that does it: the picker, while it shows layers' image paint or the Image tab's placeholder. A
/// platform doesn't say where a dragged file is until it can: an unknown position is the picker's.
pub(crate) fn image_drop_target(app: &FigApp, ctx: &egui::Context) -> Option<(Rect, serde_json::Value)> {
let target = app.ui.picker.as_ref()?;
let PickerTarget::Paint { fills, index, ids } = target else { return None };
if !image_pending(ctx, target) && !matches!(current_paint(app, target)?.kind, PaintKind::Image(_)) {
return None;
}
let rect = ctx.memory(|m| m.area_rect(window_id()))?;
ctx.input(|i| i.pointer.hover_pos()).is_none_or(|p| rect.contains(p)).then(|| (rect, image_params(*fills, *index, ids)))
}
/// The bytes of a picked `file`: its `path`, or its base64 `data` where there are no paths.
fn picked_bytes(file: &serde_json::Value) -> Option<Vec<u8>> {
use base64::Engine as _;
match file["path"].as_str() {
Some(path) => figcraft_engine::file_access::read_capped(path, figcraft_doc::ImageData::MAX_BYTES).ok(),
None => base64::engine::general_purpose::STANDARD.decode(file["data"].as_str()?).ok(),
}
}
/// Ask for an image file for `target`: layers' paints keep their fill mode, crop and position.
fn upload_image(app: &mut FigApp, target: &PickerTarget) {
let target = target.clone();
app.pick_file(tr("Choose image"), &IMAGE_FILES, true, move |app, file| {
if let PickerTarget::Paint { fills, index, ids } = &target {
let mut params = image_params(*fills, *index, ids);
for key in ["path", "data"] {
if let Some(v) = file.get(key) {
params[key] = v.clone();
}
}
app.run("image.replace", params);
return;
}
let Some(bytes) = picked_bytes(&file) else { return };
let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
if let Some(mut paint) = current_paint(app, &target) {
paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
write(app, &target, &paint, false);
}
});
}
/// The image `hash` as a texture at most `side` pixels across, kept while it stays the one shown.
fn image_texture(app: &FigApp, ctx: &egui::Context, hash: &str, side: f64) -> Option<egui::TextureHandle> {
let id = Id::unique("cp_image_texture");
if let Some((_, tex)) = ctx.data(|d| d.get_temp::<(String, egui::TextureHandle)>(id)).filter(|(h, _)| h == hash) {
return Some(tex);
}
let doc = app.session.doc().ok()?;
let pix = figcraft_render::images::shown(hash, doc.images.get(hash), &ImageFilters::default(), doc.color_profile, side)?;
let tex = ctx.load_texture("cp-image", crate::render_worker::screen_image(&pix, doc.color_profile), egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (hash.to_string(), tex.clone())));
Some(tex)
}
/// The asset preview: the paint's image, or checkers until there is one, and the upload button. A
/// file dropped on it takes the image's place (`image_drop_target`).
fn image_well(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &Paint, t: &Tokens) {
let w = ui.available_width();
let (r, _) = ui.allocate_exact_size(vec2(w, 120.0), Sense::hover());
let frame = r.shrink2(vec2(16.0, 0.0));
widgets::checker(&ui.painter().with_clip_rect(frame), frame, 8.0);
let ppp = f64::from(ui.ctx().pixels_per_point());
if let PaintKind::Image(img) = &paint.kind
&& let Some(tex) = image_texture(app, ui.ctx(), &img.image, f64::from(frame.width()) * ppp)
{
let [tw, th] = tex.size().map(|v| v.max(1) as f32);
let k = (frame.width() / tw).min(frame.height() / th);
let shown = Rect::from_center_size(frame.center(), vec2(tw * k, th * k));
ui.painter().image(tex.id(), shown, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
ui.painter().rect_stroke(frame, CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside);
ui.horizontal(|ui| {
ui.add_space(16.0);
if widgets::button(ui, tr("Upload from computer"), false, true).clicked() {
upload_image(app, target);
}
});
}
/// The row of paint kinds (solid, gradients, image, pattern) with `paint`'s kind selected.
fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &mut Paint) {
let kinds = [
@@ -563,6 +673,7 @@ fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &
(Icon::ViewGrid, tr("Pattern")),
];
let active = match &paint.kind {
_ if image_pending(ui.ctx(), target) => 5,
PaintKind::Solid { .. } => 0,
PaintKind::Gradient(g) => match g.kind {
GradientKind::Linear => 1,
@@ -577,22 +688,12 @@ fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &
ui.add_space(12.0);
if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {
let base = paint.preview_color();
let mut image_pending = false;
let choosing = i == 5;
set_image_pending(ui.ctx(), choosing.then_some(target).filter(|_| !matches!(paint.kind, PaintKind::Image(_))));
paint.kind = match i {
0 => PaintKind::Solid { color: base.with_alpha(1.0) },
5 => {
image_pending = true;
let target = target.clone();
app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
let Ok(bytes) = figcraft_engine::file_access::read_capped(&p, figcraft_doc::ImageData::MAX_BYTES) else { return };
let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
if let Some(mut paint) = current_paint(app, &target) {
paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
write(app, &target, &paint, false);
}
});
paint.kind.clone()
}
// Nothing changes until there is a file: the tab shows the placeholder.
5 => paint.kind.clone(),
6 => match pattern_sources(app, target).first() {
Some((id, _)) => Paint::pattern(id.0).kind,
None => {
@@ -610,7 +711,7 @@ fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &
}
}
};
if !image_pending {
if !choosing {
write(app, target, paint, false);
}
}
@@ -901,14 +1002,23 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
blend_row(ui, app, &target, &mut paint);
}
// Gradient stops.
let is_image = matches!(paint.kind, PaintKind::Image(_));
if is_image {
set_image_pending(ui.ctx(), None);
}
// Until a file is chosen the Image tab shows its placeholder, not the paint it will replace.
let placeholder = image_pending(ui.ctx(), &target);
let mut edit_color: Option<Color> = match &paint.kind {
PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),
PaintKind::Solid { color } if !placeholder => Some(color.with_alpha(color.a * paint.opacity)),
_ => None,
};
let sel_stop_id = stop_id();
if let PaintKind::Gradient(g) = paint.kind.clone() {
if let (PaintKind::Gradient(g), false) = (paint.kind.clone(), placeholder) {
edit_color = gradient_controls(app, ui, &target, &mut paint, g, &t, sel_stop_id);
}
if is_image || placeholder {
image_well(app, ui, &target, &paint, &t);
}
if let PaintKind::Image(img) = paint.kind.clone() {
image_controls(app, ui, &target, &mut paint, img, &t);
}
@@ -1019,6 +1129,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
ui.add_space(12.0);
});
if closed {
set_image_pending(ctx, None);
close(app);
} else if app.ui.picker.is_some() {
// Kept only while the picker is open: a body that closed it (the eyedropper) doesn't leave
@@ -1437,3 +1548,7 @@ mod tests {
assert_eq!(u.app.session.recent_colors, vec![c]);
}
}
#[cfg(test)]
#[path = "colorpicker_image_tests.rs"]
mod image_tests;
@@ -0,0 +1,286 @@
//! The colour picker's image well (#1682), as the help center describes it: choosing Image shows a
//! checkered placeholder and Upload from computer rather than a file dialog at once, and a file
//! dropped on the picker replaces the image, keeping the fill mode, crop and position.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use base64::Engine as _;
use egui::{Event, Pos2, RawInput};
use figcraft_color::{ImageScaleMode, PaintKind};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use super::{PickerTarget, set_image_pending, window_id};
use crate::layers_drag_tests::Ui;
use crate::live_tests::{flat, has_text, text_center};
use crate::services::{ContentsFuture, DialogFuture};
fn png(r: u8) -> Vec<u8> {
let image = image::RgbaImage::from_pixel(8, 4, image::Rgba([r, 0, 0, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
bytes.into_inner()
}
/// A dropped file whose contents arrive from memory, as in the browser (no file at its path).
#[derive(Debug)]
struct Dropped(std::path::PathBuf, Vec<u8>);
impl egui::DroppedFile for Dropped {
fn path(&self) -> &Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, String> {
Ok(self.1.clone())
}
}
/// A rectangle with an image fill in Fit mode, or a solid one.
fn layer(with_image: bool) -> (Ui, NodeId) {
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 80, "height": 80, "props": {"fill": "#00ff00"}})).unwrap();
let id = crate::present_tests::id(&r);
if with_image {
let data = base64::engine::general_purpose::STANDARD.encode(png(1));
s.execute("image.place", &json!({"data": data, "target": id, "mode": "fit"})).unwrap();
}
(Ui::new(s), id)
}
fn open_picker(u: &mut Ui, id: NodeId, index: usize) -> PickerTarget {
let target = PickerTarget::Paint { fills: true, index, ids: vec![id] };
u.app.ui.picker = Some(target.clone());
for _ in 0..4 {
u.frame(vec![]);
}
target
}
/// Drop a PNG with the pointer at `at` (`None`: the platform doesn't say), and let it go through.
fn drop_png(u: &mut Ui, at: Option<Pos2>, bytes: Vec<u8>) {
let events: Vec<Event> = at.map_or(vec![Event::PointerGone], |p| vec![Event::PointerMoved(p)]);
u.frame_raw(RawInput { events: events.clone(), ..Default::default() });
let file = Arc::new(Dropped(Path::new("figcraft-picker-drop").join("new.png"), bytes)) as egui::DroppedFileHandle;
u.frame_raw(RawInput { events, dropped_files: vec![file], ..Default::default() });
for _ in 0..6 {
u.frame(vec![]);
}
}
fn top_image(u: &Ui, id: NodeId, index: usize) -> figcraft_color::ImagePaint {
let paint = u.app.session.doc().unwrap().node(id).unwrap().fills[index].clone();
let PaintKind::Image(img) = paint.kind else { panic!("not an image: {paint:?}") };
img
}
fn layers(u: &Ui) -> usize {
u.app.session.doc().unwrap().node(u.app.session.page().unwrap()).unwrap().children.len()
}
#[test]
fn a_file_dropped_on_the_picker_replaces_the_image_and_keeps_its_settings() {
let (mut u, id) = layer(true);
let target = open_picker(&mut u, id, 1);
let before = top_image(&u, id, 1);
assert_eq!(before.scale_mode, ImageScaleMode::Fit);
assert!(has_text(&flat(&u.frame_output(vec![]).shapes), "Upload from computer"));
// Where the file is isn't known during the drag: the picker says it takes it.
let hovered = vec![egui::HoveredFile { path: Some("new.png".into()), mime: String::new() }];
let out = u.frame_raw(RawInput { events: vec![Event::PointerGone], hovered_files: hovered, ..Default::default() });
assert!(has_text(&flat(&out.shapes), "Drop to use this image"));
assert!(!has_text(&flat(&out.shapes), "Drop to import"));
let count = layers(&u);
drop_png(&mut u, None, png(2));
let after = top_image(&u, id, 1);
assert_ne!(after.image, before.image, "a new image");
assert_eq!(after.scale_mode, ImageScaleMode::Fit, "the fill mode stays");
assert_eq!(layers(&u), count, "no new layer");
assert_eq!(u.app.ui.picker, Some(target), "the picker stays open");
u.app.session.undo().unwrap();
assert_eq!(top_image(&u, id, 1).image, before.image, "one undo step");
}
#[test]
fn choosing_image_shows_the_placeholder_until_a_file_arrives() {
let (mut u, id) = layer(false);
let target = open_picker(&mut u, id, 0);
assert!(!has_text(&flat(&u.frame_output(vec![]).shapes), "Upload from computer"), "a solid paint has no well");
let fills = |u: &Ui| u.app.session.doc().unwrap().node(id).unwrap().fills.clone();
let before = fills(&u);
// The Image tab, as its click does: nothing changes yet, and no dialog opens.
set_image_pending(&u.ctx, Some(&target));
assert!(has_text(&flat(&u.frame_output(vec![]).shapes), "Upload from computer"));
assert_eq!(fills(&u), before);
// A drop fills the placeholder: an image filling the layer, and the placeholder is done.
drop_png(&mut u, None, png(3));
assert_eq!(top_image(&u, id, 0).scale_mode, ImageScaleMode::Fill);
u.frame(vec![]);
assert!(!super::image_pending(&u.ctx, &target));
}
#[test]
fn a_drop_goes_to_the_canvas_when_the_picker_shows_no_image_or_the_pointer_is_elsewhere() {
// A solid paint's picker takes nothing.
let (mut u, id) = layer(false);
open_picker(&mut u, id, 0);
let count = layers(&u);
drop_png(&mut u, None, png(4));
assert_eq!(layers(&u), count + 1, "imported to the canvas");
// Over the canvas, with the picker elsewhere: the canvas too.
let (mut u, id) = layer(true);
open_picker(&mut u, id, 1);
let before = top_image(&u, id, 1).image;
let count = layers(&u);
let picker = u.ctx.memory(|m| m.area_rect(window_id())).unwrap();
let canvas = u.app.canvas.rect;
let at = Pos2::new(canvas.min.x + 20.0, canvas.min.y + 20.0);
assert!(!picker.contains(at));
drop_png(&mut u, Some(at), png(5));
assert_eq!(layers(&u), count + 1);
assert_eq!(top_image(&u, id, 1).image, before);
}
/// What the platform's picker does with Upload from computer, counting the times it is asked.
struct Picker {
asked: Arc<AtomicUsize>,
/// The browser: a name and contents; the desktop: a path. `None`: cancelled.
file: Option<(PathBuf, Vec<u8>)>,
browser: bool,
}
impl crate::Services for Picker {
fn pick_open(&mut self, _title: &str, exts: &[&str]) -> DialogFuture {
assert!(!self.browser, "a platform that picks contents is never asked for a path");
assert!(exts.contains(&"png"), "{exts:?}");
self.asked.fetch_add(1, Ordering::SeqCst);
let path = self.file.as_ref().map(|f| f.0.clone());
Box::pin(std::future::ready(path))
}
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
if !self.browser {
return None;
}
assert!(exts.contains(&"png"), "{exts:?}");
self.asked.fetch_add(1, Ordering::SeqCst);
let file = self.file.as_ref().map(|(p, bytes)| Ok((p.to_string_lossy().into_owned(), bytes.clone())));
Some(Box::pin(std::future::ready(file)))
}
}
/// Put the picker in `u` that answers with `bytes` (`None`: cancelled), as the desktop or the
/// browser does; returns how many times it has been asked.
fn fake_picker(u: &mut Ui, bytes: Option<Vec<u8>>, browser: bool, dir: &Path) -> Arc<AtomicUsize> {
let asked = Arc::new(AtomicUsize::new(0));
let file = bytes.map(|b| {
let path = dir.join("picked.png");
std::fs::write(&path, &b).unwrap();
(path, b)
});
u.app.services = Box::new(Picker { asked: asked.clone(), file, browser });
asked
}
fn press(u: &mut Ui, at: Pos2) {
u.frame(vec![Event::PointerMoved(at)]);
u.frame(vec![Event::PointerMoved(at)]);
for pressed in [true, false] {
u.frame(vec![Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }]);
}
for _ in 0..4 {
u.frame(vec![]);
}
}
/// Click the picker's Image tab: the sixth of its 30 wide cells, under the title.
fn click_image_tab(u: &mut Ui) {
let picker = u.ctx.memory(|m| m.area_rect(window_id())).unwrap();
press(u, picker.min + egui::vec2(177.0, 52.0));
}
fn click_upload(u: &mut Ui) {
let at = text_center(&flat(&u.frame_output(vec![]).shapes), "Upload from computer");
press(u, at);
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("figcraft-upload-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// The Image tab opens nothing; its button asks the platform, and the file becomes the paint.
#[test]
fn upload_from_computer_fills_the_placeholder_on_the_desktop_and_in_the_browser() {
for browser in [false, true] {
let dir = temp_dir(&format!("placeholder-{browser}"));
let (mut u, id) = layer(false);
open_picker(&mut u, id, 0);
let asked = fake_picker(&mut u, Some(png(6)), browser, &dir);
click_image_tab(&mut u);
assert_eq!(asked.load(Ordering::SeqCst), 0, "the tab opens no dialog");
click_upload(&mut u);
assert_eq!(asked.load(Ordering::SeqCst), 1, "the button asks the platform once");
assert_eq!(top_image(&u, id, 0).scale_mode, ImageScaleMode::Fill, "browser: {browser}");
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Upload on an existing image replaces it and keeps its fill mode and crop, one undo step.
#[test]
fn upload_from_computer_replaces_an_image_keeping_its_fill_mode_and_crop() {
for browser in [false, true] {
let dir = temp_dir(&format!("replace-{browser}"));
let (mut u, id) = layer(true);
u.app.session.execute("image.crop", &json!({"ids": [id], "index": 1})).unwrap();
let target = PickerTarget::Paint { fills: true, index: 1, ids: vec![id] };
u.app.ui.picker = Some(target);
for _ in 0..4 {
u.frame(vec![]);
}
let before = top_image(&u, id, 1);
assert_eq!(before.scale_mode, ImageScaleMode::Crop);
let (asked, steps) = (fake_picker(&mut u, Some(png(7)), browser, &dir), u.app.session.undo_depth().0);
click_upload(&mut u);
assert_eq!(asked.load(Ordering::SeqCst), 1);
let after = top_image(&u, id, 1);
assert_ne!(after.image, before.image, "browser: {browser}");
assert_eq!((after.scale_mode, after.crop), (ImageScaleMode::Crop, before.crop));
assert_eq!(u.app.session.undo_depth().0, steps + 1, "one undo step");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Cancelling the picker changes nothing: no paint, no undo step, and the placeholder stays.
#[test]
fn cancelling_the_upload_changes_nothing() {
let dir = temp_dir("cancel");
for browser in [false, true] {
let (mut u, id) = layer(false);
open_picker(&mut u, id, 0);
let asked = fake_picker(&mut u, None, browser, &dir);
let fills = |u: &Ui| u.app.session.doc().unwrap().node(id).unwrap().fills.clone();
let (before, steps) = (fills(&u), u.app.session.undo_depth());
click_image_tab(&mut u);
click_upload(&mut u);
assert_eq!(asked.load(Ordering::SeqCst), 1);
assert_eq!(fills(&u), before, "browser: {browser}");
assert_eq!(u.app.session.undo_depth(), steps);
assert!(has_text(&flat(&u.frame_output(vec![]).shapes), "Upload from computer"), "the placeholder stays");
let (mut u, id) = layer(true);
open_picker(&mut u, id, 1);
fake_picker(&mut u, None, browser, &dir);
let (before, steps) = (top_image(&u, id, 1), u.app.session.undo_depth());
click_upload(&mut u);
assert_eq!(top_image(&u, id, 1), before);
assert_eq!(u.app.session.undo_depth(), steps);
}
let _ = std::fs::remove_dir_all(&dir);
}
+3
View File
@@ -263,6 +263,9 @@ Or in a project's `.mcp.json`:
tint, highlights, shadows; each -1..1). `image.adjust {ids?, target?: fill|stroke, index?,
preset?, filters?}` applies a built-in preset (`vivid`, `muted`, `warm`, `cool`, `blackAndWhite`,
`fade`, `reset`) or one saved in the file, with any `filters` values on top.
`image.replace {ids?, target?: fill|stroke, index?, data | path}` puts another image in a paint
(by default each layer's top image paint), keeping its fill mode, crop, position and adjustments;
a paint at `index` that isn't an image becomes one, filling the layer.
`image.savePreset {name, filters?}` saves the current (or given) adjustments in the file,
`image.deletePreset {name}` removes one and `image.presets` lists both kinds.
`image.crop {ids?, index?}` (one layer) switches its top visible image fill (or fill `index`) to
+2 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **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:
@@ -31,6 +31,7 @@ interaction feel not yet compared.
| Fields select their text on focus | done | Number and text values keep their vertical position when their editor gains focus (including typography spacing; `sidebar_field_text_stays_in_place_on_focus`). a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) |
| Colour picker models | done | Hex, RGB, HSL, HSB, LCH, OKLCH and CSS; LCH and OKLCH keep lightness and hue when a colour leaves sRGB (chroma comes down); the values typed stay while the colour is theirs (a hue typed on a grey, a chroma sRGB can't hold); CSS reads `lch()` and `oklch()` with any hue unit and `none` (`figcraft_color::{Lch, Oklch}`). Values are the stored channels read as sRGB |
| Colour picker placement and dismissal | done | Opens 8 pt beside the sidebar at the opening control, lifted to fit the screen; outside presses (including the sidebar), Escape and X close it. Reopening resets a dragged position; eyedropper sampling keeps it open. |
| Colour picker image well | partial | the Image tab shows a checkered placeholder and Upload from computer until there is a file (no dialog at once); an image paint shows its asset preview; a file dropped on the open picker replaces the image, keeping the fill mode, crop, position and adjustments (`image.replace`). Layers' fills and strokes only (text ranges, styles and selection colours keep Upload). The Fill and Stroke swatches take no drop: a dragged file's position isn't known, so a drop with none goes to the open picker |
| Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) |
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
@@ -175,6 +176,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | M1, #1682: as the help center describes the colour picker, choosing Image no longer opens a file dialog at once: the tab shows a checkered placeholder and Upload from computer until there is a file. An image paint shows its asset preview with the same button. A file dropped on the open picker replaces the image and keeps the fill mode, crop, position and adjustments; on the placeholder it becomes the paint's image, filling the layer. While a file is dragged the picker says it takes it instead of the window saying Drop to import. New command `image.replace {ids?, target?, index?, data | path}`, through the same size cap and decoder as `image.place`; one undo step. Dropped files in the browser arrive as `data`. A native window or a browser doesn't say where a dragged file is, so a drop with no known position goes to the open picker, and one known to be over the canvas imports as before. Left: the Fill and Stroke swatches in the Design panel don't take a drop, since their place can't be told during a drag; pasting into the picker isn't documented and isn't added. Tests: `colorpicker_image_tests`, `image_crop_tests::replacing_an_image_keeps_its_fill_mode_crop_and_adjustments`. Numbers unchanged |
| 2026-10-30 | minor | Gap 4: the keyboard's Menu key opens the context menu, as Shift+F10 does (at the focused layer row or below the canvas selection, with the keyboard in the menu). egui already reported the key; FigCraft's keymap had no name or binding for it. `view.contextMenu` now takes `Menu` too, and the Context menus row is done (`layers_keyboard_tests`). Stale rows corrected: smart-selection spacing has had reorder rings since the "M1: smart-selection drag reordering" landing (the row is still partial: irregular grids and several marked layers aren't covered), and gap 4 and the parity summary quoted shortcut counts from before the last bindings (now 128 bound, none missing, 57 undocumented pinned, as `shortcut_parity_tests` counts). |
| 2026-10-30 | minor | M4, gap 4: the eyedropper with Cmd/Ctrl+Shift held makes a colour style or variable of the colour it picks, as the reference app's eyedropper does, instead of filling the selection. It asks the UI for a Create from color window (`dialog.createFromColor`; the control channel never opens it). The window shows the colour and offers Style or Variable, a name (the next free "Color n") and, for a variable, a collection: the file's first, or a new one made with it in the same undo step. Create runs `style.create` or `variable.create`. Tests: `eyedropper_create_tests`, `sampled_color_tests`. |
| 2026-10-30 | minor | M1: sidebar number and text fields keep their text vertically centered when focused, including typography spacing. Editors use the full 24 pt field height instead of adding a 4 pt top inset to an editor already at least 24 pt tall. Regression: `field_tests::sidebar_field_text_stays_in_place_on_focus` compares painted text positions before and after focus. Readiness estimates unchanged. |