mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 22:36:51 -06:00
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:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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}")))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -730,5 +730,7 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
doc.take_dirty();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod frame_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user