diff --git a/ROADMAP.md b/ROADMAP.md index b650f08e..8da4e1be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) | | 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged | | 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) | diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 6e61084d..42fb6a69 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -486,9 +486,23 @@ impl ImagePaint { { return c; } + self.fitted_crop(iw, ih, w, h, false) + } + + /// The crop transform that keeps the picture where its scale mode draws it: the crop in effect + /// in Crop mode, the Fit placement in Fit mode, else (Fill, Tile) the Fill placement. + pub fn placement_crop(&self, iw: f64, ih: f64, w: f64, h: f64) -> [f64; 6] { + match self.scale_mode { + ImageScaleMode::Crop => self.effective_crop(iw, ih, w, h), + mode => self.fitted_crop(iw, ih, w, h, mode == ImageScaleMode::Fit), + } + } + + /// The Fill placement (`fit`: the Fit placement) as a crop transform. + fn fitted_crop(&self, iw: f64, ih: f64, w: f64, h: f64, fit: bool) -> [f64; 6] { let (rw, rh) = self.rotated_size(iw.max(1.0), ih.max(1.0)); let (w, h) = (w.max(1e-6), h.max(1e-6)); - let s = (w / rw).max(h / rh); + let s = if fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; let (x0, y0) = ((w - rw * s) / 2.0, (h - rh * s) / 2.0); [w / (rw * s), 0.0, 0.0, h / (rh * s), -x0 / (rw * s), -y0 / (rh * s)] } diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index a192e338..e0f56a10 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -546,15 +546,8 @@ impl Session { } let Some(data) = doc.images.get(&img.image) else { continue }; let (iw, ih) = (f64::from(data.width), f64::from(data.height)); - let crop = if img.scale_mode == ImageScaleMode::Crop { - img.effective_crop(iw, ih, w, h) - } else { - let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); - let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; - [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] - }; // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. - let crop = (Affine::new(crop) * to_old).as_coeffs(); + let crop = (Affine::new(img.placement_crop(iw, ih, w, h)) * to_old).as_coeffs(); if !crop.iter().all(|c| c.is_finite()) { continue; } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 5180b340..62236b8e 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -151,6 +151,8 @@ pub static MENU: &[MenuItem] = &[ m!("Object", "Use as mask", "object.mask"), m!("Object", "Release mask", "object.releaseMask"), m!("Object", "Set as thumbnail", "object.setThumbnail"), + // FigCraft's own (in `OWN`): the way into crop mode that the Design panel also offers. + m!("Object", "Crop image", "image.crop"), m!("Object", SEP, ""), m!("Object", "Bring to front", "arrange.front"), m!("Object", "Bring forward", "arrange.forward"), @@ -290,7 +292,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"]; +const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs index c82e30d1..cefd723b 100644 --- a/crates/engine/src/cmd/image.rs +++ b/crates/engine/src/cmd/image.rs @@ -1,12 +1,12 @@ -//! Image paints: adjustment presets, built in and saved in the file. +//! Image paints: adjustment presets, built in and saved in the file, and Crop image. -use figcraft_color::{ImageFilters, PaintKind}; -use figcraft_doc::{Document, ImagePreset}; +use figcraft_color::{ImageFilters, ImageScaleMode, PaintKind}; +use figcraft_doc::{Document, ImagePreset, Node}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets}; -use crate::{Result, Session, bad, spec}; +use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ @@ -34,6 +34,13 @@ pub fn specs() -> Vec { } ok() }), + spec!( + "image.crop", + "Crop image", + "{ids? (one layer), index? (an image fill; default its top visible one)} → {id, index}: the image fill switches to Crop, keeping the picture where it was, and the canvas enters crop mode on it", + true, + crop + ), spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| { let built_in: Vec = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect(); Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets})) @@ -135,6 +142,41 @@ fn adjust(s: &mut Session, p: &Value) -> Result { Ok(json!({"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 { + n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_))) +} + +fn crop(s: &mut Session, p: &Value) -> Result { + let cmd = "image.crop"; + let ids = req_targets(cmd, s, p)?; + // A selection with nothing to crop is refused as unavailable, not as bad input. + let refuse = |why: &str| EngineError::Disabled(cmd.into(), why.into()); + let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) }; + let doc = s.doc()?; + let n = doc.get(id)?; + let index = match get(p, "index") { + None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?, + Some(_) => req_index(cmd, p, "index")?, + }; + let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?; + let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) }; + // An image already in Crop mode only enters crop mode: no edit, no undo step. + if img.scale_mode != ImageScaleMode::Crop { + let data = doc.images.get(&img.image).ok_or_else(|| bad(cmd, "the image's data is missing"))?; + img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height)); + img.scale_mode = ImageScaleMode::Crop; + let n = s.doc_mut()?.get_mut(id)?; + if let Some(f) = n.fills.get_mut(index) { + *f = paint; + } + // Like any paint edit, the layer leaves its paint style. + n.styles.fill = None; + } + s.request_ui(cmd, json!({"id": id, "index": index})); + Ok(json!({"id": id, "index": index})) +} + fn save_preset(s: &mut Session, p: &Value) -> Result { let cmd = "image.savePreset"; let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); diff --git a/crates/engine/src/image_crop_tests.rs b/crates/engine/src/image_crop_tests.rs new file mode 100644 index 00000000..c7403dfb --- /dev/null +++ b/crates/engine/src/image_crop_tests.rs @@ -0,0 +1,110 @@ +//! `image.crop` (Object ▸ Crop image, the right-click menu and the image fill row): the layer's +//! top visible image fill (or the one `index` names) switches to Crop with the picture left where +//! its old mode drew it, as one undo step, and the canvas is asked to enter crop mode. A layer +//! without an image fill, or several layers, are refused with a reason and nothing changes. + +use base64::Engine as _; +use figcraft_color::{ImageScaleMode, Paint, PaintKind}; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// A 200×100 synthetic image layer (every pixel differs), resized to 200×150 so Fill and Fit +/// place the picture differently. +fn image_layer(s: &mut Session) -> NodeId { + let image = image::RgbaImage::from_fn(200, 100, |x, y| image::Rgba([x as u8, y as u8, 120, 255])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes.into_inner()); + let id = id_of(&ex(s, "file.import", json!({"data": data, "name": "crop.png", "x": 0, "y": 0}))["id"]).unwrap(); + ex(s, "node.set", json!({"ids": [id], "props": {"height": 150}})); + id +} + +fn fills(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().node(id).unwrap().fills.clone() +} + +fn set_mode(s: &mut Session, id: NodeId, mode: ImageScaleMode) { + let mut f = fills(s, id); + let PaintKind::Image(img) = &mut f[0].kind else { panic!("image fill") }; + img.scale_mode = mode; + ex(s, "node.set", json!({"ids": [id], "props": {"fills": f}})); +} + +fn pixels(s: &Session, id: NodeId) -> Vec { + figcraft_render::render_node(s.doc().unwrap(), id, 1.0).unwrap().data().to_vec() +} + +fn undo_steps(s: &Session) -> usize { + s.ds().unwrap().history.undo.len() +} + +#[test] +fn crop_image_switches_to_crop_keeping_the_picture_as_one_undo_step() { + for mode in [ImageScaleMode::Fill, ImageScaleMode::Fit, ImageScaleMode::Crop] { + let mut s = Session::new(); + let layer = image_layer(&mut s); + set_mode(&mut s, layer, mode); + let (before, doc_before, steps) = (pixels(&s, layer), s.doc().unwrap().clone(), undo_steps(&s)); + s.ui_requests.clear(); + let r = ex(&mut s, "image.crop", json!({})); + assert_eq!(r, json!({"id": layer, "index": 0}), "{mode:?}"); + let PaintKind::Image(img) = &fills(&s, layer)[0].kind else { panic!("image fill") }; + assert_eq!(img.scale_mode, ImageScaleMode::Crop, "{mode:?}"); + assert_eq!(pixels(&s, layer), before, "{mode:?}: the picture stays where it was"); + let asked: Vec<_> = s.ui_requests.iter().filter(|u| u.kind == "image.crop").map(|u| u.data.clone()).collect(); + assert_eq!(asked, vec![json!({"id": layer, "index": 0})], "{mode:?}: the canvas enters crop mode"); + if mode == ImageScaleMode::Crop { + assert_eq!(undo_steps(&s), steps, "already cropped: nothing to undo"); + continue; + } + assert_eq!(undo_steps(&s), steps + 1, "{mode:?}: one undo step"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap(), &doc_before, "{mode:?}: undo restores the fill"); + } +} + +#[test] +fn crop_image_takes_the_top_visible_image_fill_or_the_one_named() { + let mut s = Session::new(); + let layer = image_layer(&mut s); + let mut f = fills(&s, layer); + let image = f[0].clone(); + let mut hidden = image.clone(); + hidden.visible = false; + // Bottom to top: image, image, solid, hidden image. + f = vec![image.clone(), image, Paint::solid(figcraft_color::Color::BLACK), hidden]; + ex(&mut s, "node.set", json!({"ids": [layer], "props": {"fills": f}})); + assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer]}))["index"], 1, "the top visible image fill"); + let modes = + |s: &Session| fills(s, layer).iter().map(|p| if let PaintKind::Image(i) = &p.kind { Some(i.scale_mode) } else { None }).collect::>(); + assert_eq!(modes(&s), [Some(ImageScaleMode::Fill), Some(ImageScaleMode::Crop), None, Some(ImageScaleMode::Fill)]); + assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer], "index": 0}))["index"], 0, "an image fill by index"); + assert_eq!(modes(&s)[0], Some(ImageScaleMode::Crop)); + let err = s.execute("image.crop", &json!({"ids": [layer], "index": 2})).unwrap_err().to_string(); + assert!(err.contains("not an image"), "{err}"); + let err = s.execute("image.crop", &json!({"ids": [layer], "index": 9})).unwrap_err().to_string(); + assert!(err.contains("no fill at that index"), "{err}"); +} + +#[test] +fn crop_image_is_refused_without_one_image_layer() { + let mut s = Session::new(); + let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 300, "y": 0, "width": 50, "height": 50}))["id"]).unwrap(); + let layer = image_layer(&mut s); + let doc = s.doc().unwrap().clone(); + s.ui_requests.clear(); + for (ids, why) in [(json!([rect]), "no image fill"), (json!([rect, layer]), "select one layer")] { + let err = s.execute("image.crop", &json!({"ids": ids})).unwrap_err(); + assert!(matches!(&err, crate::EngineError::Disabled(..)) && err.to_string().contains(why), "{err}"); + } + assert_eq!(s.doc().unwrap(), &doc, "nothing changes"); + assert!(s.ui_requests.is_empty(), "no crop mode"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 998bae24..d36f1905 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -307,6 +307,8 @@ mod huge_stroke_tests; #[cfg(test)] mod id_params_behaviour_tests; #[cfg(test)] +mod image_crop_tests; +#[cfg(test)] mod images_left_in_file_tests; #[cfg(test)] mod import_export_tests; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 8e556151..8707e3e9 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -368,6 +368,7 @@ name = "English" "Create style from the selected text" = "Create style from the selected text" "Created" = "Created" "Crop" = "Crop" +"Crop image" = "Crop image" "Crop marks" = "Crop marks" "Ctrl+arrows" = "Ctrl+arrows" "Ctrl+click" = "Ctrl+click" diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 4ccb8939..dc34885a 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -226,6 +226,16 @@ impl FigApp { "layers.collapseAll" => layers::collapse_all(self), "contextMenu" => self.ui.key_menu = true, "selectArea" => self.start_key_box(), + "image.crop" => { + // Crop mode takes the canvas: an open colour picker would edit the same paint. + self.ui.picker = None; + self.ui.crop = r + .data + .get("id") + .and_then(figcraft_engine::params::id_of) + .zip(r.data.get("index").and_then(Value::as_u64)) + .map(|(id, i)| (id, i as usize)); + } "canvas.renameLabel" => { self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true }); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index faf10528..a7e41c34 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -173,7 +173,8 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers sy: f64::from(pos.y), shift: m.shift, alt: m.alt, - cmd: m.command, + // Crop mode: resizing the layer crops it, as Cmd/Ctrl-resizing does. + cmd: m.command || app.ui.crop.is_some(), ctrl: m.ctrl, zoom, pressure: app.canvas.pressure, diff --git a/crates/ui-egui/src/canvas/handles.rs b/crates/ui-egui/src/canvas/handles.rs index d29d59ee..137e2c92 100644 --- a/crates/ui-egui/src/canvas/handles.rs +++ b/crates/ui-egui/src/canvas/handles.rs @@ -40,22 +40,27 @@ enum Drag { /// The Crop-mode image being edited: (node, fills, index, paint, image rect in the unit box). type UnitRect = (f64, f64, f64, f64); +/// The paint open in the colour picker, else the image fill in crop mode (while its layer is the +/// selection). fn crop_target(app: &FigApp) -> Option<(NodeId, bool, usize, ImagePaint, UnitRect)> { - let Some(PickerTarget::Paint { fills, index, ids }) = &app.ui.picker else { return None }; - if ids.len() != 1 { - return None; - } - let id = *ids.first()?; + let (id, fills, index) = match (&app.ui.picker, app.ui.crop) { + (Some(PickerTarget::Paint { fills, index, ids }), _) => match ids.as_slice() { + [id] => (*id, *fills, *index), + _ => return None, + }, + (None, Some((id, index))) if app.session.selection() == [id] => (id, true, index), + _ => return None, + }; let doc = app.session.doc().ok()?; let n = doc.node(id)?; - let list = if *fills { &n.fills } else { &n.strokes }; - let PaintKind::Image(img) = &list.get(*index)?.kind else { return None }; + let list = if fills { &n.fills } else { &n.strokes }; + let PaintKind::Image(img) = &list.get(index)?.kind else { return None }; if img.scale_mode != ImageScaleMode::Crop { return None; } let data = doc.images.get(&img.image)?; let crop = img.effective_crop(f64::from(data.width), f64::from(data.height), n.width, n.height); - Some((id, *fills, *index, img.clone(), ImagePaint::crop_rect(crop))) + Some((id, fills, index, img.clone(), ImagePaint::crop_rect(crop))) } fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Pos2; 4]> { @@ -67,6 +72,114 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po ]) } +/// How opaque the image shows outside the layer in crop mode (premultiplied white tint). +const GHOST_ALPHA: u8 = 115; +/// The longest side of the crop overlay's texture: the dimmed part needs no more. +const GHOST_MAX_SIDE: usize = 2048; + +/// The image paint's texture for the crop overlay and the image's own size: uploaded once (from +/// the renderer's decoded copy, at most [`GHOST_MAX_SIDE`] a side) and kept while the same image, +/// adjustments and colour profile are cropped. +fn ghost_texture(ctx: &egui::Context, doc: &figcraft_doc::Document, img: &ImagePaint) -> Option<(egui::TextureId, f64, f64)> { + type Entry = std::sync::Arc<(String, figcraft_color::ImageFilters, figcraft_color::ColorProfile, egui::TextureHandle, f64, f64)>; + let id = Id::unique("figcraft_crop_ghost"); + if let Some(e) = ctx.data(|d| d.get_temp::(id)) + && e.0 == img.image + && e.1 == img.filters + && e.2 == doc.color_profile + { + return Some((e.3.id(), e.4, e.5)); + } + let pm = figcraft_render::images::get(&img.image, doc.images.get(&img.image), &img.filters, doc.color_profile)?; + let (w, h) = (pm.width() as usize, pm.height() as usize); + let step = w.max(h).div_ceil(GHOST_MAX_SIDE).max(1); + let (tw, th) = (w.div_ceil(step), h.div_ceil(step)); + let data = pm.data(); + let mut px = Vec::with_capacity(tw * th * 4); + for y in (0..h).step_by(step) { + for x in (0..w).step_by(step) { + let i = (y * w + x) * 4; + px.extend_from_slice(data.get(i..i + 4)?); + } + } + let tex = ctx.load_texture("crop-ghost", egui::ColorImage::from_rgba_premultiplied([tw, th], &px), egui::TextureOptions::LINEAR); + let tid = tex.id(); + let e: Entry = std::sync::Arc::new((img.image.clone(), img.filters, doc.color_profile, tex, w as f64, h as f64)); + ctx.data_mut(|d| d.insert_temp(id, e)); + Some((tid, w as f64, h as f64)) +} + +/// The image outside the layer's box, dimmed, so cropping shows what it cuts away. `r` is the +/// image's rectangle in the layer's unit box: the bands of it outside 0..1 are drawn. +fn crop_ghost(app: &FigApp, painter: &egui::Painter, id: NodeId, img: &ImagePaint, r: UnitRect) { + // Only an axis-aligned crop has a rectangle in the unit box (as the crop handles assume). + if img.crop.is_some_and(|m| m[1] != 0.0 || m[2] != 0.0) { + return; + } + let Ok(doc) = app.session.doc() else { return }; + let Some(n) = doc.node(id) else { return }; + let Some((tex, iw, ih)) = ghost_texture(painter.ctx(), doc, img) else { return }; + let (w, h) = (n.width.max(1e-9), n.height.max(1e-9)); + // Unit box → image pixels → texture coordinates. + let t = figcraft_render::paint::image_transform(img, iw, ih, w, h); + let to_image = figcraft_geom::Affine::new([t.sx, t.ky, t.kx, t.sy, t.tx, t.ty].map(f64::from)).inverse(); + if !to_image.as_coeffs().iter().all(|c| c.is_finite()) { + return; + } + let uv = |u: f64, v: f64| { + let p = to_image * Point::new(u * w, v * h); + pos2((p.x / iw) as f32, (p.y / ih) as f32) + }; + let (x0, y0, x1, y1) = (r.0.min(r.2), r.1.min(r.3), r.0.max(r.2), r.1.max(r.3)); + let (cy0, cy1) = (y0.max(0.0), y1.min(1.0)); + let bands = [(x0, y0, x1, y1.min(0.0)), (x0, y0.max(1.0), x1, y1), (x0, cy0, x1.min(0.0), cy1), (x0.max(1.0), cy0, x1, cy1)]; + let tint = Color32::from_white_alpha(GHOST_ALPHA); + let mut mesh = egui::Mesh::with_texture(tex); + for (bx0, by0, bx1, by1) in bands.into_iter().filter(|b| b.2 - b.0 > 1e-9 && b.3 - b.1 > 1e-9) { + let base = mesh.vertices.len() as u32; + for (u, v) in [(bx0, by0), (bx1, by0), (bx1, by1), (bx0, by1)] { + let Some(pos) = unit_to_screen(app, id, (u, v)) else { return }; + mesh.vertices.push(egui::epaint::Vertex { pos, uv: uv(u, v), color: tint }); + } + mesh.indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]); + } + if !mesh.indices.is_empty() { + painter.add(Shape::mesh(mesh)); + } +} + +/// Crop mode's marks on the layer's box, heavier than the selection's square handles (hidden in +/// crop mode): L-shaped brackets at the corners and short bars across the middle of each edge, +/// in the selection colour. +fn crop_marks(app: &FigApp, painter: &egui::Painter, id: NodeId) { + let Some(b) = crop_corners(app, id, (0.0, 0.0, 1.0, 1.0)) else { return }; + const WIDTH: f32 = 5.0; + let (halo, mark) = (Stroke::new(WIDTH + 2.0, Color32::from_black_alpha(50)), Stroke::new(WIDTH, crate::theme::canvas_accent(painter.ctx()))); + let draw = |pts: Vec| { + painter.add(Shape::line(pts.clone(), halo)); + painter.add(Shape::line(pts, mark)); + }; + for i in 0..4 { + let (Some(p), Some(prev), Some(next)) = (b.get(i), b.get((i + 3) % 4), b.get((i + 1) % 4)) else { continue }; + let (to_prev, to_next) = ((*prev - *p).normalized(), (*next - *p).normalized()); + // Inside the box, flush with its edges: along one edge is inward from the other. + let at = *p + (to_prev + to_next) * (WIDTH / 2.0); + let leg = |dir: egui::Vec2, len: f32| at + dir * 22.0_f32.min(len / 3.0); + draw(vec![leg(to_prev, (*prev - *p).length()), at, leg(to_next, (*next - *p).length())]); + let len = (*next - *p).length(); + let mid = *p + (*next - *p) * 0.5 + to_prev * (WIDTH / 2.0); + let half = to_next * 12.0_f32.min(len / 6.0); + draw(vec![mid - half, mid + half]); + } +} + +/// Whether screen point `pos` is on a resize handle (a corner or an edge) of the selection's box. +fn on_resize_handle(app: &FigApp, pos: Pos2) -> bool { + let Ok(ds) = app.session.ds() else { return false }; + let handle = figcraft_engine::tools::handle_at(&ds.doc, &ds.selection, app.to_page(pos), ds.view.zoom); + matches!(handle, Some(figcraft_engine::tools::Handle::Corner(_) | figcraft_engine::tools::Handle::Edge(_))) +} + /// Stable egui id for handle dragging. fn drag_id() -> Id { Id::unique("figcraft_handle_drag") @@ -275,40 +388,67 @@ fn al_zones(app: &FigApp) -> Option { Some(Zones { id, bands, gaps, tracks }) } +/// The gradient's line, stops and end handles on the canvas. +fn draw_gradient(app: &FigApp, painter: &egui::Painter, id: NodeId, g: &figcraft_color::Gradient) { + let (Some(a), Some(b), Some(c)) = (unit_to_screen(app, id, g.from), unit_to_screen(app, id, g.to), unit_to_screen(app, id, g.width)) else { + return; + }; + painter.line_segment([a, b], Stroke::new(2.0, Color32::WHITE)); + painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120))); + if g.kind != figcraft_color::GradientKind::Linear { + painter.add(Shape::dashed_line(&[a, c], Stroke::new(1.0, Color32::WHITE), 4.0, 3.0)); + } + 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::doc_c32(painter.ctx(), 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, crate::theme::canvas_accent(painter.ctx())), + StrokeKind::Outside, + ); + } + } + for (i, p) in [a, b, c].iter().enumerate() { + if i == 2 && g.kind == figcraft_color::GradientKind::Linear { + continue; + } + painter.circle_filled(*p, 5.0, Color32::WHITE); + painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); + } +} + +/// The image's crop outline and corner handles, its dimmed outside and, in crop mode, the marks. +fn draw_crop(app: &FigApp, painter: &egui::Painter, id: NodeId, img: &ImagePaint, r: UnitRect) { + let Some(c) = crop_corners(app, id, r) else { return }; + crop_ghost(app, painter, id, img, r); + let outline = vec![c[0], c[1], c[2], c[3], c[0]]; + painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE))); + painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())))); + for p in c { + let hr = Rect::from_center_size(p, vec2(8.0, 8.0)); + painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE); + painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside); + } + if app.ui.crop.is_some() { + crop_marks(app, painter, id); + } +} + /// Draw the handles. -pub fn draw(app: &FigApp, painter: &egui::Painter) { - let hover = painter.ctx().input(|i| i.pointer.hover_pos()); +pub fn draw(app: &mut FigApp, painter: &egui::Painter) { + // Crop mode ends when a colour picker opens, or when its layer leaves the selection or its fill + // is no longer a Crop image (an undo). + if app.ui.crop.is_some() && (app.ui.picker.is_some() || crop_target(app).is_none()) { + app.ui.crop = None; + } + let app = &*app; if let Some((id, _, _, g)) = gradient_target(app) { - let (Some(a), Some(b), Some(c)) = (unit_to_screen(app, id, g.from), unit_to_screen(app, id, g.to), unit_to_screen(app, id, g.width)) else { - return; - }; - painter.line_segment([a, b], Stroke::new(2.0, Color32::WHITE)); - painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120))); - if g.kind != figcraft_color::GradientKind::Linear { - painter.add(Shape::dashed_line(&[a, c], Stroke::new(1.0, Color32::WHITE), 4.0, 3.0)); - } - 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::doc_c32(painter.ctx(), 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, crate::theme::canvas_accent(painter.ctx())), - StrokeKind::Outside, - ); - } - } - for (i, p) in [a, b, c].iter().enumerate() { - if i == 2 && g.kind == figcraft_color::GradientKind::Linear { - continue; - } - painter.circle_filled(*p, 5.0, Color32::WHITE); - painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); - } + draw_gradient(app, painter, id, &g); return; } if let Some((id, _, e)) = blur_target(app, painter.ctx()) { @@ -322,18 +462,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.circle_stroke(b, 5.0, Stroke::new(1.5, Color32::WHITE)); return; } - if let Some((id, _, _, _, r)) = crop_target(app) { - let Some(c) = crop_corners(app, id, r) else { return }; - let outline = vec![c[0], c[1], c[2], c[3], c[0]]; - painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE))); - painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())))); - for p in c { - let hr = Rect::from_center_size(p, vec2(8.0, 8.0)); - painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE); - painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside); - } + if let Some((id, _, _, img, r)) = crop_target(app) { + draw_crop(app, painter, id, &img, r); return; } + let hover = painter.ctx().input(|i| i.pointer.hover_pos()); let tool_move = app.session.tool.tool == figcraft_engine::ToolId::Move && !app.session.tool.busy(); let dragging: Option = painter.ctx().data(|d| d.get_temp(drag_id())); if !tool_move && dragging.is_none() { @@ -452,6 +585,10 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { } } if let Some((id, fills, index, _, rect)) = crop_target(app) { + // Crop mode: the layer's own resize handles crop it (the pointer events carry Cmd/Ctrl). + if app.ui.crop.is_some() && on_resize_handle(app, pos) { + return false; + } let Some(c) = crop_corners(app, id, rect) else { return false }; let start = screen_to_unit(app, id, pos).unwrap_or((0.0, 0.0)); let corner = c.iter().position(|p| (*p - pos).length() <= 8.0); @@ -459,7 +596,13 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { let which = match corner { Some(i) => i, None if inside => 4, - None => return false, + None => { + // A click outside the image ends crop mode and goes on to the tools. + if pressed { + app.ui.crop = None; + } + return false; + } }; ctx.set_cursor_icon(if which == 4 { egui::CursorIcon::Move diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 15af030e..2d4d82d5 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -272,16 +272,22 @@ fn selection_overlay( let measuring = app.session.tool.overlay.measure_box.is_some(); // Locked layers (selected in the layers panel) can't be resized, nor anything in Dev // Mode: no handles either. - let handles = - if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)) { - 0 - } else if is_line { - 2 - } else if tiny { - 0 - } else { - 4 - }; + // Crop mode draws its own marks on the box instead (`handles::crop_marks`). + let cropping = app.ui.crop.is_some(); + let handles = if cropping + || measuring + || app.session.tool.dev_mode + || connector.is_some() + || sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)) + { + 0 + } else if is_line { + 2 + } else if tiny { + 0 + } else { + 4 + }; size_label = transform_box( app, painter, @@ -296,7 +302,7 @@ fn selection_overlay( painter.circle_filled(c, 5.0, Color32::WHITE); painter.circle_stroke(c, 5.0, stroke(col, 1.5)); } - if !measuring { + if !measuring && !cropping { arc_overlay(app, painter, col); shape_overlay(app, painter, col); } diff --git a/crates/ui-egui/src/crop_image_tests.rs b/crates/ui-egui/src/crop_image_tests.rs new file mode 100644 index 00000000..7883e9fe --- /dev/null +++ b/crates/ui-egui/src/crop_image_tests.rs @@ -0,0 +1,130 @@ +//! Crop image on the canvas: the right-click menu offers it for one image layer (and not for a +//! layer without an image fill), it enters crop mode with the image's handles, dragging the image +//! moves its crop as one undo step, and Escape, Enter or a click outside the image finishes it +//! without deselecting. + +use base64::Engine as _; +use egui::{Event, Key, Modifiers, Pos2, vec2}; +use figcraft_color::{ImageScaleMode, PaintKind}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; +use crate::present_tests::id; + +/// A 200×100 image layer at (0, 0) and a plain rectangle beside it; the image is selected. +fn setup() -> (Ui, NodeId, NodeId) { + let mut s = Session::new(); + let image = image::RgbaImage::from_fn(40, 20, |x, y| image::Rgba([(x * 6) as u8, (y * 12) as u8, 120, 255])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(png.into_inner()); + let rect = id(&s.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 80, "height": 80})).unwrap()); + let layer = id(&s.execute("image.place", &json!({"data": data, "x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + s.execute("select.set", &json!({"ids": [layer]})).unwrap(); + (Ui::new(s), layer, rect) +} + +fn key(u: &mut Ui, key: Key) { + let modifiers = Modifiers::NONE; + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn mode(u: &Ui, layer: NodeId) -> (ImageScaleMode, Option<[f64; 6]>) { + match &u.app.session.doc().unwrap().node(layer).unwrap().fills[0].kind { + PaintKind::Image(img) => (img.scale_mode, img.crop), + _ => panic!("image fill"), + } +} + +fn menu_has_crop(u: &mut Ui, ids: Vec) -> bool { + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), ids)); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + u.app.ui.context_menu = None; + has_text(&shapes, "Crop image") +} + +#[test] +fn the_context_menu_enters_crop_mode_and_escape_or_enter_finishes_it() { + let (mut u, layer, rect) = setup(); + u.app.run("select.set", json!({"ids": [rect]})); + assert!(!menu_has_crop(&mut u, vec![rect]), "a layer without an image fill has nothing to crop"); + u.app.run("select.set", json!({"ids": [layer]})); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![layer])); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + let at = text_center(&shapes, "Crop image"); + u.button(at, true); + u.button(at, false); + assert_eq!(u.app.ui.crop, Some((layer, 0)), "crop mode on the image fill"); + assert_eq!(mode(&u, layer).0, ImageScaleMode::Crop); + for k in [Key::Escape, Key::Enter] { + u.frame(vec![]); + key(&mut u, k); + assert_eq!(u.app.ui.crop, None, "{k:?} finishes crop mode"); + assert_eq!(u.app.session.selection(), vec![layer], "{k:?} keeps the selection"); + crate::menus::invoke(&mut u.app, "image.crop"); + assert_eq!(u.app.ui.crop, Some((layer, 0)), "Object ▸ Crop image enters it again"); + } +} + +#[test] +fn dragging_the_image_in_crop_mode_moves_its_crop_and_a_click_outside_finishes() { + let (mut u, layer, _) = setup(); + u.app.run("image.crop", json!({})); + u.frame(vec![]); + let steps = u.app.session.ds().unwrap().history.undo.len(); + let (_, before) = mode(&u, layer); + let from = u.app.to_screen(100.0, 50.0); + u.drag_to(from, from + vec2(30.0, 0.0)); + u.button(from + vec2(30.0, 0.0), false); + let (m, after) = mode(&u, layer); + assert_eq!(m, ImageScaleMode::Crop); + assert_ne!(after, before, "the drag moved the image inside the layer"); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps + 1, "one undo step"); + assert_eq!(u.app.ui.crop, Some((layer, 0)), "still cropping"); + // A click away from the image ends crop mode. + let away = u.app.to_screen(250.0, 300.0); + u.button(away, true); + u.button(away, false); + assert_eq!(u.app.ui.crop, None); +} + +#[test] +fn in_crop_mode_the_layers_edges_crop_without_moving_the_picture() { + let (mut u, layer, _) = setup(); + u.app.run("image.crop", json!({})); + u.frame(vec![]); + let (_, Some(before)) = mode(&u, layer) else { panic!("a crop") }; + let (from, to) = (u.app.to_screen(200.0, 50.0), u.app.to_screen(150.0, 50.0)); + u.drag_to(from, to); + u.button(to, false); + let n = u.app.session.doc().unwrap().node(layer).unwrap().clone(); + assert!((n.width - 150.0).abs() < 1e-6 && n.transform.as_coeffs()[4].abs() < 1e-6, "the right edge moved in: {} wide", n.width); + let (m, Some(after)) = mode(&u, layer) else { panic!("a crop") }; + assert_eq!(m, ImageScaleMode::Crop); + // The unit box shrank to 3/4 of its width over the same pixels: x scale 3/4, same offset. + assert!((after[0] - before[0] * 0.75).abs() < 1e-9 && (after[4] - before[4]).abs() < 1e-9, "{before:?} -> {after:?}"); + assert_eq!(u.app.ui.crop, Some((layer, 0)), "still cropping"); + // The cut-away part shows dimmed: one band right of the layer, textured from the image. + let ghost = |u: &mut Ui| { + flat(&u.frame_output(vec![]).shapes).into_iter().find_map(|s| match s { + egui::Shape::Mesh(m) if m.vertices.iter().all(|v| v.color == egui::Color32::from_white_alpha(115)) => Some(m), + _ => None, + }) + }; + let m = ghost(&mut u).expect("the dimmed image outside the layer"); + assert_ne!(m.texture_id, egui::TextureId::default()); + let (edge, end) = (u.app.to_screen(150.0, 0.0).x, u.app.to_screen(200.0, 0.0).x); + assert_eq!(m.vertices.len(), 4, "one band"); + assert!(m.vertices.iter().all(|v| (v.pos.x - edge).abs() < 0.5 || (v.pos.x - end).abs() < 0.5), "from the layer's edge to the image's"); + assert!(m.vertices.iter().all(|v| v.uv.x >= 0.74 && v.uv.x <= 1.0 + 1e-4), "the image's right quarter"); + key(&mut u, Key::Escape); + assert!(ghost(&mut u).is_none(), "gone with crop mode"); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 219fe85a..eeacb275 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -66,6 +66,7 @@ icons! { PaintBucket, Lasso, Cut, + Crop, Boolean, Page, Check, diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index a96314cf..7eedfdd1 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -326,6 +326,11 @@ fn draw(p: &P, icon: Icon) { p.line(6.0, 10.0, 11.5, 2.5); p.line(10.0, 10.0, 4.5, 2.5); } + Crop => { + // Two right angles overlapping into a frame, each running past the other's corner. + p.poly(&[(4.5, 1.5), (4.5, 11.5), (14.5, 11.5)], false); + p.poly(&[(1.5, 4.5), (11.5, 4.5), (11.5, 14.5)], false); + } Rectangle => p.rect(2.5, 2.5, 13.5, 13.5, 1.0), Line => p.line(2.5, 13.5, 13.5, 2.5), Arrow => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e77ec34d..b72455ec 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -67,6 +67,8 @@ mod context_menu_tests; #[cfg(test)] mod corner_individual_tests; #[cfg(test)] +mod crop_image_tests; +#[cfg(test)] mod cursors_tests; #[cfg(test)] mod design_paste_tests; @@ -410,6 +412,9 @@ pub struct UiState { /// The prototype preview pane floating over the canvas, when open. pub preview: Option, pub picker: Option, + /// Crop mode (`image.crop`): the layer and the index of the image fill whose crop handles + /// show on the canvas until Enter, Escape or a click outside the image. + pub crop: Option<(NodeId, usize)>, /// The colour picker's popover while it is open (its place survives a drag). pub picker_popover: Option, /// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls): @@ -593,6 +598,7 @@ impl Default for UiState { present_view: present::PresentView::default(), preview: None, picker: None, + crop: None, picker_popover: None, picker_button: None, context_menu: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 34e6e69c..76f220f4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -476,6 +476,10 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .ok() .filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection())) .map(|_| i18n::command("component.combineVariants", "Combine as variants")); + // One layer with an image fill can be cropped (judged on the selection, as above). + let crop = app.session.doc().ok().is_some_and(|doc| { + matches!(app.session.selection().as_slice(), [id] if doc.node(*id).and_then(figcraft_engine::cmd::image::top_image_fill).is_some()) + }); let copy_link = i18n::command("edit.copyLink", "Copy link to selection"); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ @@ -536,6 +540,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "component.combineVariants")); } + if crop && let Some(at) = entries.iter().position(|e| e.1 == "object.mask") { + entries.insert(at + 1, (tr("Crop image"), "image.crop")); + } // An instance's actions reach its main component: replace "Create component" with // "Go to main component" (as in Figma's context menu). if app diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 4d7c5a31..a24fca55 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -509,6 +509,15 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, } _ => { child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text); + // One layer's image fill: crop it on the canvas. + if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) { + let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0)); + let cresp = ui.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image")); + icons::paint(ui.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary }); + if cresp.clicked() { + app.run("image.crop", json!({"ids": ids, "index": i})); + } + } } } let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 5a0933e4..c34c587d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -462,6 +462,10 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if key_box(app, key, m) { return true; } + // Enter or Escape finishes crop mode (the crop is already applied). + if matches!(key, Key::Enter | Key::Escape) && m.is_none() && app.ui.crop.take().is_some() { + return true; + } // 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; diff --git a/docs/mcp.md b/docs/mcp.md index be9c392b..5ddc9964 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -244,6 +244,9 @@ Or in a project's `.mcp.json`: `fade`, `reset`) or one saved in the file, with any `filters` values on top. `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 + Crop, keeping the picture where it was, and returns `{id, index}`; the app also enters crop mode + on the canvas. The crop itself is the paint's `crop` transform (`fill.set`). - Text props (`fontFamily`, `fontStyle`, `fontSize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `textFills`, `fontVariations`…) apply to the whole layer, or to chars `start`..`end` when given (`text.style {id, start, end, …}` does the same). Styled ranges come back as diff --git a/docs/roadmap.md b/docs/roadmap.md index 180de4ab..cc24f859 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-27 | minor | Bounded attached MCP replies (M8, #1113): `figcraft-cli mcp --attach` read each reply from the app's control port with no size limit, so a process listening on that port could make it buffer until memory ran out. Replies are now read up to 512 MiB (an app window capture is at most 256 MiB as base64); reading stops at the first byte past that, the connection is closed and the call is reported like any other lost reply, and the next call reconnects. Regression tests in the MCP backend tests. Numbers unchanged | | 2026-10-27 | minor | Undo kept after a failed batch (M8, #1410): a batch that undid an earlier edit and then failed restored the document but lost that edit's undo step; a failed group now restores the undo entries as well as the redo entries, so the earlier edit can still be undone and redone. Regression tests in the batch rollback suite. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 599ff48b..1503b8e5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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-29 · **Change:** minor (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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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: @@ -92,7 +92,7 @@ interaction feel not yet compared. | Comment (C), Shift+C to show/hide | done | local only | | Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | -| Image crop handles | done | | +| Image crop handles; Crop image | done | Object ▸ Crop image, the right-click menu and the crop button on an image fill row (`image.crop`) switch the image to Crop where it already shows and enter crop mode: the whole image shows, dimmed outside the layer; the layer's box carries L-shaped corner brackets and edge bars in the selection colour (in place of the square handles), and its edges and corners crop it with the picture held still (as Cmd/Ctrl-resizing does anywhere), the image drags to move and its corners scale it (Shift: freely); Enter, Escape or a click outside finishes (`image_crop_tests`, `crop_image_tests`). Not compared side by side | ## Canvas @@ -174,6 +174,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Crop image (#1464): Object ▸ Crop image, the right-click menu and a crop button on the image fill row enter crop mode on the canvas (`image.crop`; the image dimmed outside the layer, corner brackets and edge bars on its box; Enter, Escape or a click outside finishes). The row stays done | | 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged | | 2026-10-27 | minor | The Design panel's sections sit on one grid (#858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows, 11 pt semibold section titles over 10 pt captions, 4 pt between the two buttons at a row's end; the panel's frame no longer carries a stroke (it counts as margin), so an icon's box ends 8 pt from the window edge. Checked in the app on Windows over the control channel | | 2026-10-25 | minor | Context menus: the Menu key is no longer an egui limitation at 0.37 (egui-winit maps the native Menu/ContextMenu key to egui's `Key::Menu`), but FigCraft normalizes no binding for `Menu`, so it does not open a context menu yet; Shift+F10 still does, with the same behaviour. The row moves from done to partial. Numbers unchanged |