mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
* M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464) * M1: crop mode shows the image dimmed outside the layer and heavier crop marks (#1464) * M1: the handle overlay draws gradients and crops in their own helpers (#1464)
This commit is contained in:
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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) |
|
||||
|
||||
+15
-1
@@ -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)]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Item = &'static MenuItem> {
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
vec![
|
||||
@@ -34,6 +34,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
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<Value> = 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<Value> {
|
||||
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<usize> {
|
||||
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
|
||||
}
|
||||
|
||||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
let cmd = "image.savePreset";
|
||||
let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
|
||||
|
||||
@@ -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<Paint> {
|
||||
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<u8> {
|
||||
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::<Vec<_>>();
|
||||
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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<Entry>(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<Pos2>| {
|
||||
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<Zones> {
|
||||
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<Drag> = 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<NodeId>) -> 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");
|
||||
}
|
||||
@@ -66,6 +66,7 @@ icons! {
|
||||
PaintBucket,
|
||||
Lasso,
|
||||
Cut,
|
||||
Crop,
|
||||
Boolean,
|
||||
Page,
|
||||
Check,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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<present::Pane>,
|
||||
pub picker: Option<colorpicker::PickerTarget>,
|
||||
/// 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<popover::Popover>,
|
||||
/// 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -462,6 +462,10 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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 |
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user