M1: frame presets panel for the Frame tool; presets land beside existing frames; swap orientation (#12)

- 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 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 03:19:00 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c6df8302b1
commit 1147dce395
7 changed files with 149 additions and 2 deletions
+1 -1
View File
@@ -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 |
+36 -1
View File
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<Value> {
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<Value> {
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::<Vec<_>>())
.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<Vec<u8>> {
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
+30
View File
@@ -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);
}
+2
View File
@@ -730,5 +730,7 @@ pub fn normalize(doc: &mut Document, before: &Document) {
doc.take_dirty();
}
#[cfg(test)]
mod frame_tests;
#[cfg(test)]
mod tests;
+68
View File
@@ -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!({}));
}
}
+1
View File
@@ -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;
+11
View File
@@ -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<NodeId> = sel.to_vec();
egui::Popup::menu(&resp).width(260.0).show(|ui| {
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {