From 1147dce3950d757c79b4e6f595ed47f2449b21b3 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 03:19:00 +0700 Subject: [PATCH] M1: frame presets panel for the Frame tool; presets land beside existing frames; swap orientation (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - With the Frame tool active the Design tab lists frame presets by category (Phone open by default, others collapsible) with their sizes; clicking one adds the frame, selects and shows it, and returns to the Move tool. - `frame.create {preset}` without a position places the frame 100 to the right of the page's right-most top-level layer (top-aligned), or centred in the view on an empty page. - New `frame.swapOrientation {ids?}` (portrait ⇄ landscape) with a button next to the frame type in the Design panel header. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/create.rs | 37 +++++++++++++++- crates/engine/src/frame_tests.rs | 30 +++++++++++++ crates/engine/src/lib.rs | 2 + crates/ui-egui/src/frame_presets.rs | 68 +++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/right.rs | 11 +++++ 7 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/frame_tests.rs create mode 100644 crates/ui-egui/src/frame_presets.rs diff --git a/ROADMAP.md b/ROADMAP.md index 88a2ff15d..ad8307e5f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,7 +131,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 | | Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 | | Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 | -| Frames, groups, sections, masks | 5 | 55% | section tooling, mask types (luminance/outline), frame presets UI | 6–10 | +| Frames, groups, sections, masks | 5 | 60% | section tooling, mask types (luminance/outline) | 5–8 | | Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 | | Text | 8 | 55% | range fills in the Fill section, right-to-left/bidi, text style picker depth, axis hover preview | 10–16 | | Components & variants | 9 | 55% | nested overrides depth, libraries, slots, descriptions | 12–18 | diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 874abcea4..49fd31aad 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -60,7 +60,7 @@ pub fn specs() -> Vec { spec!( "frame.create", "Create frame", - "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?}", + "{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?} (a preset without x/y lands to the right of the page's frames)", true, frame_create ), @@ -83,6 +83,15 @@ pub fn specs() -> Vec { true, place_image ), + spec!("frame.swapOrientation", "Swap orientation", "{ids?} (portrait ⇄ landscape: width and height trade places)", true, |s, p| { + let ids = req_targets("frame.swapOrientation", s, p)?; + let doc = s.doc()?; + let sizes: Vec<(NodeId, f64, f64)> = ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, n.width, n.height))).collect(); + for (i, w, h) in sizes { + super::arrange::resize_cmd(s, &json!({"ids": [i], "width": h, "height": w}))?; + } + ok() + }), spec!("frame.presets", "List frame presets", "{}", false, |_, _| { Ok(Value::Array(FRAME_PRESETS.iter().map(|(c, n, w, h)| json!({"category": c, "name": n, "width": w, "height": h})).collect())) }), @@ -263,6 +272,14 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { o.entry("height").or_insert(json!(h)); o.entry("name").or_insert(json!(pname)); } + // No position on the page: beside the right-most top-level layer, else in the view. + if num(p, "x").is_none() && num(p, "y").is_none() && id(p, "parent").is_none() { + let (x, y) = free_spot(s, *w, *h)?; + if let Some(o) = q.as_object_mut() { + o.insert("x".into(), json!(x)); + o.insert("y".into(), json!(y)); + } + } } if let Some(o) = q.as_object_mut() { o.insert("type".into(), json!("frame")); @@ -270,6 +287,24 @@ fn frame_create(s: &mut Session, p: &Value) -> Result { create(s, &q) } +/// Where a new top-level frame of `w`×`h` goes: 100 to the right of the right-most top-level +/// layer (top-aligned with it), or centred in the view on an empty page. +fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { + let ds = s.ds()?; + let doc = &ds.doc; + let right_most = doc + .node(ds.page) + .map(|pg| pg.children.iter().filter_map(|c| doc.world_bounds(*c)).collect::>()) + .unwrap_or_default() + .into_iter() + .filter(|b| b.x1.is_finite() && b.y0.is_finite()) + .max_by(|a, b| a.x1.total_cmp(&b.x1)); + Ok(match right_most { + Some(b) => ((b.x1 + 100.0).round(), b.y0.round()), + None => ((ds.view.cx - w / 2.0).round(), (ds.view.cy - h / 2.0).round()), + }) +} + #[cfg(not(target_arch = "wasm32"))] fn read_path(path: &str) -> Result> { std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) diff --git a/crates/engine/src/frame_tests.rs b/crates/engine/src/frame_tests.rs new file mode 100644 index 000000000..bf0c4e84b --- /dev/null +++ b/crates/engine/src/frame_tests.rs @@ -0,0 +1,30 @@ +//! Frame presets: placement beside existing frames and orientation swaps. + +use serde_json::json; + +use crate::Session; +use crate::params::id_of; + +#[test] +fn presets_land_beside_existing_frames_and_swap_orientation() { + let mut s = Session::new(); + let a = id_of(&s.execute("frame.create", &json!({"preset": "iPhone 16"})).unwrap()["id"]).unwrap(); + let ba = s.doc().unwrap().world_bounds(a).unwrap(); + assert_eq!((ba.width(), ba.height()), (393.0, 852.0)); + let view = s.ds().unwrap().view.clone(); + assert!((ba.center().x - view.cx).abs() <= 1.0 && (ba.center().y - view.cy).abs() <= 1.0, "first one in the view"); + let b = id_of(&s.execute("frame.create", &json!({"preset": "desktop"})).unwrap()["id"]).unwrap(); + let bb = s.doc().unwrap().world_bounds(b).unwrap(); + assert_eq!((bb.x0, bb.y0), (ba.x1 + 100.0, ba.y0), "to the right, top aligned"); + assert_eq!(s.doc().unwrap().node(b).unwrap().name, "Desktop"); + // Explicit positions still win. + let c = id_of(&s.execute("frame.create", &json!({"preset": "A4", "x": 5, "y": 6})).unwrap()["id"]).unwrap(); + assert_eq!(s.doc().unwrap().world_bounds(c).unwrap().x0, 5.0); + assert!(s.execute("frame.create", &json!({"preset": "Nope"})).is_err()); + // Swap orientation, one undo step. + s.execute("frame.swapOrientation", &json!({"ids": [a]})).unwrap(); + let n = s.doc().unwrap().node(a).unwrap().clone(); + assert_eq!((n.width, n.height), (852.0, 393.0)); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(a).unwrap().width, 393.0); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eb8a449f7..e628a5f7c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -730,5 +730,7 @@ pub fn normalize(doc: &mut Document, before: &Document) { doc.take_dirty(); } +#[cfg(test)] +mod frame_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/frame_presets.rs new file mode 100644 index 000000000..eb9244194 --- /dev/null +++ b/crates/ui-egui/src/frame_presets.rs @@ -0,0 +1,68 @@ +//! The Frame tool's panel: frame presets by category. Clicking one adds a frame of that size next +//! to the page's frames and returns to the Move tool. + +use egui::{Align2, CornerRadius, Id, Sense, Ui, pos2, vec2}; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, regular, semibold}; +use crate::widgets::{self, PAD}; + +pub fn panel(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + widgets::section_header(ui, "Frame", &[], false); + let w = ui.available_width(); + let mut cats: Vec<&str> = Vec::new(); + for (c, ..) in figcraft_engine::cmd::create::FRAME_PRESETS { + if !cats.contains(c) { + cats.push(c); + } + } + let mut chosen = None; + for cat in cats { + let open_id = Id::new(("preset_cat", cat)); + // The first category starts open. + let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(cat == "Phone"); + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(8.0, 2.0)), CornerRadius::same(5), t.hover); + } + icons::paint( + ui.painter(), + egui::Rect::from_center_size(pos2(r.min.x + PAD + 4.0, r.center().y), vec2(10.0, 10.0)), + if open { Icon::Chevron } else { Icon::ChevronRight }, + t.icon_secondary, + ); + ui.painter().text(pos2(r.min.x + PAD + 16.0, r.center().y), Align2::LEFT_CENTER, cat, semibold(11.0), t.text); + if resp.clicked() { + ui.data_mut(|d| d.insert_temp(open_id, !open)); + } + if !open { + continue; + } + for (_, name, pw, ph) in figcraft_engine::cmd::create::FRAME_PRESETS.iter().filter(|(c, ..)| *c == cat) { + let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(8.0, 1.0)), CornerRadius::same(5), t.hover); + } + ui.painter().text(pos2(r.min.x + PAD + 16.0, r.center().y), Align2::LEFT_CENTER, *name, regular(11.0), t.text); + ui.painter().text( + pos2(r.max.x - PAD, r.center().y), + Align2::RIGHT_CENTER, + format!("{}×{}", crate::canvas::fmt(*pw), crate::canvas::fmt(*ph)), + regular(11.0), + t.text_secondary, + ); + if resp.clicked() { + chosen = Some(*name); + } + } + } + if let Some(name) = chosen { + app.run("frame.create", json!({"preset": name})); + app.run("tool.set", json!({"tool": "move"})); + // Show the new frame. + app.run("view.zoomSelection", json!({})); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dfb99caf2..37382f9d9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod component_panel; pub mod control; pub mod credits; pub mod dialogs; +pub mod frame_presets; pub mod guides; pub mod handles; pub mod home; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 8a958101e..b6d0adf85 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -145,6 +145,8 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { return; } match app.ui.right_tab { + // The Frame tool shows the frame presets. + RightTab::Design if app.session.tool.tool == figcraft_engine::ToolId::Frame => crate::frame_presets::panel(app, ui), RightTab::Design => design(app, ui), RightTab::Prototype => crate::proto_panel::prototype(app, ui), RightTab::Inspect => inspect(app, ui), @@ -319,6 +321,15 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { Icon::Chevron, t.icon_secondary, ); + let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0)); + let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).on_hover_text("Swap orientation"); + if sresp.hovered() { + ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), sr.shrink(5.0), Icon::Rotate, t.icon); + if sresp.clicked() { + app.run("frame.swapOrientation", json!({"ids": sel})); + } let ids: Vec = sel.to_vec(); egui::Popup::menu(&resp).width(260.0).show(|ui| { egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {