Merge main into #628

This commit is contained in:
bflatastic
2026-10-17 13:27:46 +07:00
20 changed files with 256 additions and 735 deletions
-1
View File
@@ -520,7 +520,6 @@ fn load_with(
if loaded.doc.name.is_empty() {
loaded.doc.name = file_stem(name_hint);
}
super::styles::apply_paint_bindings_on_load(&mut loaded.doc);
Ok((loaded, false))
}
}
+58 -4
View File
@@ -26,10 +26,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"object.convertToFrame",
"Convert to frame",
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)",
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)",
true,
|s, p| convert(s, p, "object.convertToFrame", true)
),
spec!(
"object.convertToSection",
"Convert to section",
"{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)",
true,
convert_to_section
),
spec!(
"object.wrapInSection",
"Wrap in new section",
@@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
match (&n.kind, to_frame) {
// A frame can't hold sections.
(NodeKind::Section { .. }, true)
if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) =>
{
return refuse("holds sections, which can't go inside a frame: convert those first");
}
(NodeKind::Section { .. }, true) => todo.push(i),
(NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"),
(NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"),
(NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => {
@@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
}
for i in &todo {
if to_frame {
// The group's box as it is; nothing painted or clipped, so the look stays.
// The box as it is and nothing clipped, so the look stays: a group has nothing painted, a
// section keeps its fill.
let n = doc.get_mut(*i)?;
if matches!(n.kind, NodeKind::Group) {
n.fills.clear();
n.strokes.clear();
}
n.kind = NodeKind::Frame;
n.clip = false;
n.fills.clear();
n.strokes.clear();
continue;
}
let n = doc.get_mut(*i)?;
@@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
Ok(json!({"ids": todo}))
}
/// Frames on the page or in a section become sections in place (sections only sit there).
fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "object.convertToSection";
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let mut todo = Vec::new();
for i in ids {
let n = doc.get(i)?;
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }));
match &n.kind {
NodeKind::Section { .. } => {}
NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"),
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"),
NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"),
NodeKind::Frame => todo.push(i),
_ => return refuse("is not a frame"),
}
}
for i in &todo {
let n = doc.get_mut(*i)?;
n.kind = NodeKind::Section { contents_hidden: false };
(n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None);
n.grids.clear();
n.guides.clear();
n.styles.grid = None;
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
let kids = n.children.clone();
for k in kids {
// Out of the flow there is nothing to fill or to be absolute against.
doc.get_mut(k)?.child_layout.absolute = false;
}
figcraft_layout::coherent_sizing(doc, *i);
}
Ok(json!({"ids": todo}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.ungroup", s, p)?;
let doc = s.doc_mut()?;
+49 -3
View File
@@ -1,5 +1,5 @@
//! Frames and groups converted into each other in place: the layers stay where they are on the
//! page, what only a frame has goes with it, what can't convert is refused, one undo step.
//! Frames, groups and sections converted into each other in place: the layers stay where they are on
//! the page, what only a frame has goes with it, what can't convert is refused, one undo step.
use serde_json::{Value, json};
@@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() {
#[test]
fn junk_parameters_are_refused_not_fatal() {
let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])];
for cmd in ["object.convertToGroup", "object.convertToFrame"] {
for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] {
for j in &junk {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50})));
@@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() {
}
}
}
#[test]
fn a_frame_on_the_page_becomes_a_section_and_back() {
let mut s = Session::new();
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}})));
let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}}));
let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)];
assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false }));
assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill");
assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section");
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves");
// And back: a frame that keeps the fill and doesn't clip, so the look stays.
assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f]));
let n = s.doc().unwrap().node(f).unwrap().clone();
assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip);
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before);
// Each conversion is one undo step.
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Section { .. }));
ex(&mut s, "edit.undo", json!({}));
assert!(matches!(kind(&s, f), NodeKind::Frame));
assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back");
}
#[test]
fn only_frames_on_the_page_or_in_a_section_become_sections() {
let mut s = Session::new();
let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300})));
let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100})));
let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string();
assert!(err.contains("inside a frame"), "{err}");
let r = rect(&mut s, outer, 150.0, 150.0);
assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame");
// A frame inside a section converts; a section holding a section can't become a frame.
let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400})));
let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100})));
ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]}));
assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. }));
let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string();
assert!(err.contains("holds sections"), "{err}");
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
}
+4 -1
View File
@@ -53,7 +53,10 @@ fn initial_color_bindings_settle_in_constructor_and_loaders() {
let native = figcraft_format::save_bytes(&stale, None).unwrap();
let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap();
let loaded_id = assert_dark_bound_color(&loaded, name_transform);
// A native file settles when it opens as a document, not in the loader.
let opened = Session::with_document(loaded);
let loaded = opened.doc().unwrap();
let loaded_id = assert_dark_bound_color(loaded, name_transform);
assert_eq!(loaded_id, id);
assert_eq!(loaded.node(id).unwrap().source, stale.first_page());
assert!(loaded.node(id).unwrap().overrides.contains("sentinel"));
+2 -7
View File
@@ -64,14 +64,9 @@ impl Reader<'_> {
}
}
/// The network in blob `b`; `None` when it's truncated, out of range or not finite.
#[allow(dead_code, reason = "for readers that don't need the count of styled segments")]
pub(super) fn decode(b: &[u8]) -> Option<Network> {
decode_styled(b).map(|(net, _)| net)
}
/// The network in blob `b` and how many of its segments have a style id of their own (per-segment
/// stroke overrides, which the document model can't hold); `None` as for `decode`.
/// stroke overrides, which the document model can't hold); `None` when it's truncated, out of
/// range or not finite.
pub(super) fn decode_styled(b: &[u8]) -> Option<(Network, usize)> {
let mut r = Reader { b, pos: 0 };
let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize);
+6 -6
View File
@@ -355,9 +355,9 @@ fn unreadable_conditions_skip_the_conditional() {
#[test]
fn damaged_network_blobs_never_panic() {
let good = square_blob();
assert!(network::decode(&good).is_some());
assert!(network::decode_styled(&good).is_some());
for cut in 0..good.len() {
assert!(network::decode(&good[..cut]).is_none(), "{cut}");
assert!(network::decode_styled(&good[..cut]).is_none(), "{cut}");
}
let mut r = Lcg(5);
for _ in 0..5000 {
@@ -366,7 +366,7 @@ fn damaged_network_blobs_never_panic() {
let i = (r.next() as usize) % m.len();
m[i] ^= 1 << (r.next() % 8);
}
if let Some(net) = network::decode(&m) {
if let Some((net, _)) = network::decode_styled(&m) {
assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len()));
assert!(net.regions.iter().flat_map(|r| &r.loops).flatten().all(|i| *i < net.segments.len()));
assert!(net.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite()));
@@ -375,13 +375,13 @@ fn damaged_network_blobs_never_panic() {
// Counts larger than the blob, and indices out of range.
let mut huge = vec![0xff; 12];
huge.extend_from_slice(&[0; 64]);
assert!(network::decode(&huge).is_none());
assert!(network::decode_styled(&huge).is_none());
let mut bad = arrow_blob();
bad[12 + 24 + 4..12 + 24 + 8].copy_from_slice(&7u32.to_le_bytes());
assert!(network::decode(&bad).is_none());
assert!(network::decode_styled(&bad).is_none());
for _ in 0..2000 {
let junk: Vec<u8> = (0..(r.next() % 120)).map(|_| r.next() as u8).collect();
let _ = network::decode(&junk);
let _ = network::decode_styled(&junk);
}
}
+2
View File
@@ -201,6 +201,8 @@ name = "English"
"Code or invite link" = "Code or invite link"
"Color profile: {name}" = "Color profile: {name}"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards"
"Convert to frame" = "Convert to frame"
"Convert to section" = "Convert to section"
"CSS custom properties…" = "CSS custom properties…"
"Can edit" = "Can edit"
"Can't read this library: {e}" = "Can't read this library: {e}"
+3
View File
@@ -302,6 +302,8 @@ status = "draft — needs native review"
"Copied as SVG" = "Copiado como SVG"
"Copied as text" = "Copiado como texto"
"Copied {value}" = "Copiado: {value}"
"Convert to frame" = "Convertir en marco"
"Convert to section" = "Convertir en sección"
"Copy" = "Copiar"
"Copy as" = "Copiar como"
"Copy as CSS" = "Copiar como CSS"
@@ -1839,6 +1841,7 @@ status = "draft — needs native review"
"object.batchRename" = "Cambiar nombre en lote"
"object.convertToFrame" = "Convertir en marco"
"object.convertToGroup" = "Convertir en grupo"
"object.convertToSection" = "Convertir en sección"
"object.flipH" = "Voltear horizontalmente"
"object.flipV" = "Voltear verticalmente"
"object.frameSelection" = "Enmarcar selección"
+1 -1
View File
@@ -84,7 +84,7 @@ impl AboutTab {
}
/// The About window's width, the same on every tab (the Contributors table needs the room).
pub const ABOUT_WIDTH: f32 = 640.0;
pub const ABOUT_WIDTH: f32 = 660.0;
/// The About window's tab row, drawn like the Design / Prototype tabs of the right panel.
/// A row of tabs (the About window's, the shortcuts panel's): the current one highlighted.
+21
View File
@@ -467,6 +467,22 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
.collect();
(!secs.is_empty()).then(|| if secs.iter().all(|h| *h) { tr("Show section contents") } else { tr("Hide section contents") })
});
// Frames on the page (or in a section) convert to sections, and sections to frames.
let convert = app.session.doc().ok().and_then(|doc| {
let nodes: Vec<_> = ids.iter().filter_map(|i| doc.node(*i)).collect();
let on_board = |n: &figcraft_doc::Node| {
n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, figcraft_doc::NodeKind::Section { .. }))
};
if nodes.is_empty() {
None
} else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Section { .. })) {
Some((tr("Convert to frame"), "object.convertToFrame"))
} else if nodes.iter().all(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame) && on_board(n)) {
Some((tr("Convert to section"), "object.convertToSection"))
} else {
None
}
});
let mut entries: Vec<(&str, &str)> = if has_sel {
vec![
(tr("Copy"), "edit.copy"),
@@ -519,6 +535,11 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
{
entries.insert(at + 1, (label, "section.toggleContents"));
}
if let Some(entry) = convert
&& let Some(at) = entries.iter().position(|e| e.1 == "object.wrapInSection")
{
entries.insert(at + 1, entry);
}
// A right-clicked ruler guide (selected, with no layers) can be removed.
if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) {
entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]);