mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 13:52:34 +00:00
Merge upstream/main into fix/scrub-speed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
e87b46b4e3
58 files changed
+1146
-130
No files matched your search
@@ -72,6 +72,52 @@ fn fill_on_a_child_turns_the_hugging_parent_fixed() {
|
||||
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_resized_to_the_full_available_space_becomes_fill() {
|
||||
// Documented: "Child objects of an auto layout frame will also be set to Fill container if
|
||||
// they are manually resized to the full available space of the parent frame."
|
||||
let mut s = Session::new();
|
||||
let f = al_frame(&mut s, json!({"padding": 10, "gap": 10})); // 200 × 100, fixed
|
||||
let a = rect(&mut s, f, 50.0, 20.0, "#FF0000");
|
||||
// Along the flow, alone in the frame: its width less the padding (200 - 20).
|
||||
s.execute("transform.resize", &json!({"ids": [a], "width": 180})).unwrap();
|
||||
assert_eq!(sizing(&s, a), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, a), (180.0, 20.0));
|
||||
s.undo().unwrap();
|
||||
assert_eq!(sizing(&s, a), (Sizing::Fixed, Sizing::Fixed), "undone as part of the edit");
|
||||
// Across the flow, beside a sibling too: the frame's height less its padding.
|
||||
let b = rect(&mut s, f, 50.0, 20.0, "#0000FF");
|
||||
set(&mut s, b, json!({"height": 80}));
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fill));
|
||||
assert_eq!(size(&s, b), (50.0, 80.0));
|
||||
// Short of it: fixed, as before.
|
||||
set(&mut s, b, json!({"height": 70}));
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
|
||||
// Along the flow beside a sibling, what is left is not "the full available space": a width
|
||||
// that happens to match it (180 - 10 - 50) stays fixed.
|
||||
s.execute("transform.resize", &json!({"ids": [b], "width": 120})).unwrap();
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
|
||||
// A hugging frame has no full space to offer: the child stays fixed.
|
||||
let g = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug", "padding": 10}));
|
||||
let c = rect(&mut s, g, 50.0, 20.0, "#00FF00");
|
||||
set(&mut s, c, json!({"width": 180}));
|
||||
assert_eq!(sizing(&s, c), (Sizing::Fixed, Sizing::Fixed));
|
||||
// An inside stroke the layout includes takes room like the padding: the full space is what
|
||||
// layout leaves the child (200 - 2 × 10 - 2 × 4), not the frame less its padding alone.
|
||||
let h = al_frame(&mut s, json!({"padding": 10, "strokesIncluded": true, "stroke": "#000000", "strokeWeight": 4, "strokeAlign": "inside"}));
|
||||
let d = rect(&mut s, h, 50.0, 20.0, "#FF0000");
|
||||
set(&mut s, d, json!({"width": 180}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed), "wider than the room the stroke leaves");
|
||||
assert_eq!(size(&s, d), (180.0, 20.0), "and kept as typed");
|
||||
set(&mut s, d, json!({"width": 172}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, d), (172.0, 20.0));
|
||||
// Excluded from the layout, the stroke takes no room: the frame less its padding again.
|
||||
set(&mut s, h, json!({"strokesIncluded": false}));
|
||||
set(&mut s, d, json!({"width": 172}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hug_and_fill_only_where_they_apply() {
|
||||
// Documented: Hug applies to auto layout frames (and text); Fill to children of auto layout,
|
||||
@@ -232,6 +278,20 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
|
||||
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
|
||||
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
// The axis is judged by the size the path is given, not the one before it: the frame's full
|
||||
// height (100) given with a path fills; a smaller height given with the next path is fixed,
|
||||
// though the layer filled the frame until then.
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fill);
|
||||
assert!((size(&s, v).1 - 100.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
set(&mut s, v, json!({"height": 40, "path": "M0 0 L20 5"}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
|
||||
assert!((size(&s, v).1 - 40.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
// Rotated, the layer's local size isn't the room layout gives it: a height matching the
|
||||
// frame's stays fixed rather than have Fill squash the path.
|
||||
set(&mut s, v, json!({"rotation": 90}));
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
|
||||
}
|
||||
|
||||
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
|
||||
|
||||
@@ -303,9 +303,21 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's
|
||||
/// start and Enter on an empty item move it a level out (at the top: out of the list). Returns
|
||||
/// whether the key was taken.
|
||||
/// What a list key does to the selected items.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ListMove {
|
||||
/// A level deeper (Tab).
|
||||
Deeper,
|
||||
/// A level out; at the top, out of the list (Shift+Tab, Enter on an empty item).
|
||||
Out,
|
||||
/// Out of the list at the level it had: "Use Backspace or Delete at the beginning of a list
|
||||
/// item to delete the counter, but keep the same level of indentation."
|
||||
Unlist,
|
||||
}
|
||||
|
||||
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab and Enter on an empty
|
||||
/// item move it a level out (at the top: out of the list); Backspace and Delete at an item's start
|
||||
/// take its marker and keep its level. Returns whether the key was taken.
|
||||
fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
|
||||
let t = text_of(s, te.id)?;
|
||||
@@ -315,19 +327,24 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
|
||||
let item = t.para_list(t.para_of(a)).list != ListStyle::None;
|
||||
let (pa, pb) = t.para_span(a, b);
|
||||
let any_item = (pa..=pb).any(|p| t.para_list(p).list != ListStyle::None);
|
||||
let deeper = match key {
|
||||
"Tab" if any_item => !shift,
|
||||
"Backspace" if item && at_start => false,
|
||||
"Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => false,
|
||||
let mv = match key {
|
||||
"Tab" if any_item && !shift => ListMove::Deeper,
|
||||
"Tab" if any_item => ListMove::Out,
|
||||
"Backspace" | "Delete" if item && at_start => ListMove::Unlist,
|
||||
"Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => ListMove::Out,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
edit(s, |t, _| {
|
||||
t.set_para_lists(a, b, |pl| match pl.list {
|
||||
ListStyle::None => {}
|
||||
_ if deeper => pl.indent = pl.indent.saturating_add(1),
|
||||
_ if pl.indent > 0 => pl.indent -= 1,
|
||||
_ => pl.list = ListStyle::None,
|
||||
})
|
||||
if mv == ListMove::Unlist {
|
||||
t.unlist_keeping_levels(a, b);
|
||||
} else {
|
||||
t.set_para_lists(a, b, |pl| match (pl.list, mv) {
|
||||
(ListStyle::None, _) | (_, ListMove::Unlist) => {}
|
||||
(_, ListMove::Deeper) => pl.indent = pl.indent.saturating_add(1),
|
||||
(_, ListMove::Out) if pl.indent > 0 => pl.indent -= 1,
|
||||
(_, ListMove::Out) => pl.list = ListStyle::None,
|
||||
});
|
||||
}
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -411,7 +428,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
|
||||
let t = text_of(s, te.id)?;
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let n = chars.len();
|
||||
if matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? {
|
||||
if matches!(key, "Tab" | "Backspace" | "Delete" | "Enter") && !word && !line && list_key(s, key, shift)? {
|
||||
return Ok(());
|
||||
}
|
||||
match key {
|
||||
|
||||
@@ -242,7 +242,7 @@ fn paragraph_and_feature_commands_on_nodes() {
|
||||
s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap();
|
||||
let t = data(&s, id);
|
||||
assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2));
|
||||
assert_eq!(t.auto_resize, TextAutoResize::Truncate);
|
||||
assert_eq!(t.auto_resize, TextAutoResize::WidthAndHeight, "max lines keep an auto-width text auto width");
|
||||
assert_eq!(t.style.decoration, TextDecoration::Strikethrough);
|
||||
assert_eq!(t.style.case, TextCase::SmallCaps);
|
||||
assert_eq!(t.list, ListStyle::Bulleted);
|
||||
|
||||
@@ -22,6 +22,29 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-click on an edge handle of the one selected layer auto layout sizes: Hug contents
|
||||
/// on that axis for an auto layout frame, Fill container with Alt for a child of one (a
|
||||
/// vertical edge sizes the width, a horizontal one the height). Whether it applied: on any
|
||||
/// other layer, or off the edges, the double-click goes on as before.
|
||||
fn edge_double_click(&mut self, doc: &Document, sel: &[NodeId], ev: &PointerEvent) -> Result<bool> {
|
||||
let [id] = sel[..] else { return Ok(false) };
|
||||
let Some(super::Handle::Edge(edge)) = super::handles::handle_at(doc, sel, ev.p(), ev.zoom) else { return Ok(false) };
|
||||
let Some(n) = doc.node(id) else { return Ok(false) };
|
||||
let applies = if ev.alt {
|
||||
!n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())
|
||||
} else {
|
||||
n.layout.is_some()
|
||||
};
|
||||
if !applies {
|
||||
return Ok(false);
|
||||
}
|
||||
// Edges run top, right, bottom, left: the odd ones are vertical and size the width.
|
||||
let mut props = serde_json::Map::new();
|
||||
props.insert(if edge % 2 == 1 { "sizingH" } else { "sizingV" }.to_string(), json!(if ev.alt { "fill" } else { "hug" }));
|
||||
self.execute("node.set", &json!({"id": id, "props": props}))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn double_click(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let ds = self.ds()?;
|
||||
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
|
||||
@@ -61,6 +84,14 @@ impl Session {
|
||||
self.tool.overlay.marquee = None;
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
// An edge of an auto layout object's bounding box: Hug contents on that axis, with Alt
|
||||
// Fill container ("Tip: Double-click the vertical or horizontal edge of an auto layout
|
||||
// object's bounding box to set it to Hug contents." and "Hold ⌥ Option or Alt and
|
||||
// double-click the vertical or horizontal edge of an auto layout object's bounding box to
|
||||
// set it to Fill container.", Guide to auto layout).
|
||||
if edits && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd) && self.edge_double_click(&doc, &sel, ev)? {
|
||||
return Ok(());
|
||||
}
|
||||
let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0);
|
||||
// A double-click anywhere on the one selected shape edits its points, like Enter: also
|
||||
// inside a hollow (unfilled) closed shape, where the hit test finds nothing under the
|
||||
|
||||
@@ -569,12 +569,6 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = doc.get_mut(i)?;
|
||||
let after = n.transform * Point::new(w * ax, h * ay);
|
||||
n.transform = Affine::translate(before - after) * n.transform;
|
||||
if num(p, "width").is_some() {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
}
|
||||
if num(p, "height").is_some() {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
if num(p, "height").is_some() {
|
||||
t.fix_size(num(p, "width").is_some(), true);
|
||||
@@ -582,6 +576,13 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.auto_resize = TextAutoResize::Height;
|
||||
}
|
||||
}
|
||||
// A side given is fixed, or fills its auto layout parent when it is the full space there.
|
||||
if num(p, "width").is_some() {
|
||||
super::props::fix_axis(doc, i, true)?;
|
||||
}
|
||||
if num(p, "height").is_some() {
|
||||
super::props::fix_axis(doc, i, false)?;
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
|
||||
@@ -437,17 +437,55 @@ fn layout_keys_beside_none(cmd: &str, props: &Map<String, Value>) -> Result<()>
|
||||
}
|
||||
|
||||
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
|
||||
/// into W or H.
|
||||
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
|
||||
/// into W or H; sized to the full space its auto layout parent has for it on that axis, the layer
|
||||
/// fills instead: "Child objects of an auto layout frame will also be set to Fill container if
|
||||
/// they are manually resized to the full available space of the parent frame" (Guide to auto
|
||||
/// layout).
|
||||
pub(crate) fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
|
||||
let sizing = if fills_available_space(doc, id, is_w) { Sizing::Fill } else { Sizing::Fixed };
|
||||
let n = doc.get_mut(id)?;
|
||||
if is_w {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
n.child_layout.sizing_h = sizing;
|
||||
} else {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
n.child_layout.sizing_v = sizing;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `id`'s width (`is_w`) or height is the full space its auto layout parent has for it on
|
||||
/// that axis: the frame's size less its padding and an inside stroke included in the layout (the
|
||||
/// insets layout itself takes). Across the flow that is every child's room;
|
||||
/// along the flow only a lone child has it (beside siblings what is left depends on the gaps, the
|
||||
/// alignment and overflow, so a size that happens to match it is not "the full available
|
||||
/// space"). Never in a hugging or wrapping frame or a grid, or for a layer that ignores the layout.
|
||||
fn fills_available_space(doc: &Document, id: NodeId, is_w: bool) -> bool {
|
||||
let Some(n) = doc.node(id) else { return false };
|
||||
let Some(parent) = n.parent.and_then(|p| doc.node(p)) else { return false };
|
||||
let Some(l) = &parent.layout else { return false };
|
||||
if n.child_layout.absolute || l.wrap || l.mode == LayoutMode::Grid {
|
||||
return false;
|
||||
}
|
||||
// A rotated layer's local size isn't the size layout gives it room for (its rotated bounds
|
||||
// are): it stays Fixed rather than have Fill squash its geometry.
|
||||
let [_, b, c, ..] = n.transform.as_coeffs();
|
||||
if b.abs() > 1e-9 || c.abs() > 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let hugs = if is_w { parent.child_layout.sizing_h } else { parent.child_layout.sizing_v } == Sizing::Hug;
|
||||
if hugs {
|
||||
return false;
|
||||
}
|
||||
if (l.mode == LayoutMode::Horizontal) == is_w
|
||||
&& parent.children.iter().filter(|c| **c != id).filter_map(|c| doc.node(*c)).any(|c| c.visible && !c.child_layout.absolute)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (pt, pr, pb, pl) = figcraft_layout::insets(parent, l);
|
||||
let available = if is_w { parent.width - pl - pr } else { parent.height - pt - pb };
|
||||
let size = if is_w { n.width } else { n.height };
|
||||
available > 0.0 && (size - available).abs() < 0.01
|
||||
}
|
||||
|
||||
/// The `geometry` properties of `apply`; `false` when `k` is not one of them.
|
||||
fn apply_geometry(
|
||||
cmd: &str,
|
||||
@@ -459,10 +497,17 @@ fn apply_geometry(
|
||||
path_offset: &mut (f64, f64),
|
||||
) -> Result<bool> {
|
||||
match k {
|
||||
"path" => *path_offset = set_path(cmd, doc, id, v, props)?,
|
||||
// A new path takes the size asked for as its scale (`set_path`); the size still fixes
|
||||
// its auto layout axis.
|
||||
"width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
|
||||
"path" => {
|
||||
*path_offset = set_path(cmd, doc, id, v, props)?;
|
||||
// A size given beside the path is its scale (`set_path`), and still fixes or fills
|
||||
// its auto layout axis, judged once the path has given the layer that size.
|
||||
for (long, short, is_w) in [("width", "w", true), ("height", "h", false)] {
|
||||
if props.contains_key(long) || props.contains_key(short) {
|
||||
fix_axis(doc, id, is_w)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
"width" | "height" | "w" | "h" if props.contains_key("path") => {}
|
||||
"x" | "y" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
|
||||
|
||||
@@ -319,13 +319,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"text.maxLines",
|
||||
"Max lines",
|
||||
"{lines (0 = no limit; truncates: auto-height text hugs up to that many lines, other text becomes a truncating box), ids?}",
|
||||
"{lines (0 = no limit; truncates: auto-height and auto-width text hug up to that many lines, fixed-size text becomes a truncating box), ids?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
|
||||
let r = para(s, p, |t| {
|
||||
t.max_lines = v;
|
||||
if v > 0 && t.auto_resize != TextAutoResize::Height {
|
||||
if v > 0 && !matches!(t.auto_resize, TextAutoResize::Height | TextAutoResize::WidthAndHeight) {
|
||||
t.auto_resize = TextAutoResize::Truncate;
|
||||
}
|
||||
})?;
|
||||
@@ -521,6 +521,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
textedit::end(s)?;
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"text.openLink",
|
||||
"Open link",
|
||||
"{} while editing: the link at the caret (the selection's start) goes to the UI to open, as Alt+Enter does (\"press Option Return / Alt Enter to activate a focused link\"); only a web or mail link opens → {url} ({url: null}: no link there)",
|
||||
false,
|
||||
|s, _| {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Err(bad("text.openLink", "no text is being edited")) };
|
||||
let doc = s.doc()?;
|
||||
let t = doc.node(te.id).and_then(|n| n.kind.text()).ok_or_else(|| bad("text.openLink", format!("{} is not text", te.id)))?;
|
||||
let n = t.char_count();
|
||||
let at = te.caret.min(te.anchor);
|
||||
// The caret sits between two chars: the link of the one after it, else before it.
|
||||
let link = (at < n)
|
||||
.then(|| t.style_at(at).link.clone())
|
||||
.flatten()
|
||||
.or_else(|| (at > 0 && at <= n).then(|| t.style_at(at - 1).link.clone()).flatten());
|
||||
match link {
|
||||
Some(url) if crate::doc_urls::openable(&url) => {
|
||||
s.request_ui("open.url", json!({"url": url}));
|
||||
Ok(json!({"url": url}))
|
||||
}
|
||||
Some(url) => Err(bad("text.openLink", format!("`{url}` is not a web or mail link"))),
|
||||
None => Ok(json!({"url": Value::Null})),
|
||||
}
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.fonts",
|
||||
"List fonts",
|
||||
|
||||
@@ -321,6 +321,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")),
|
||||
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")),
|
||||
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
|
||||
spec!(
|
||||
"view.minimize",
|
||||
"Minimize the interface",
|
||||
"{on?} collapses the navigation bar and both sidebars and keeps the toolbar (Shift+Cmd/Ctrl+\\, the Minimize button by the file name); the right sidebar shows while something is selected",
|
||||
false,
|
||||
|s, p| flag(s, p, |v| &mut v.ui_minimized)
|
||||
),
|
||||
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
|
||||
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
|
||||
ok()
|
||||
|
||||
@@ -128,3 +128,77 @@ fn a_press_drags_only_past_the_threshold_in_screen_pixels() {
|
||||
assert_eq!(origin(&s, r).2 != 200.0, resizes, "resize by {by}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-click with Alt held throughout, as the canvas sends it.
|
||||
fn alt_double_click(s: &mut Session, x: f64, y: f64, zoom: f64) {
|
||||
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
|
||||
let mut ev = PointerEvent::at(kind, x, y, zoom);
|
||||
ev.alt = true;
|
||||
s.pointer(&ev).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// "Tip: Double-click the vertical or horizontal edge of an auto layout object's bounding box to
|
||||
/// set it to Hug contents." and "Hold ⌥ Option or Alt and double-click the vertical or horizontal
|
||||
/// edge of an auto layout object's bounding box to set it to Fill container." (Guide to auto
|
||||
/// layout): a vertical edge sizes the width, a horizontal one the height; a layer auto layout
|
||||
/// doesn't size keeps the double-click's old meaning.
|
||||
#[test]
|
||||
fn a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills() {
|
||||
use figcraft_doc::Sizing;
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0}}));
|
||||
let c =
|
||||
id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"fill": "#FF0000"}}))["id"])
|
||||
.unwrap();
|
||||
let sizing = |s: &Session, id: NodeId| {
|
||||
let cl = &s.doc().unwrap().node(id).unwrap().child_layout;
|
||||
(cl.sizing_h, cl.sizing_v)
|
||||
};
|
||||
let size = |s: &Session, id: NodeId| {
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
(n.width, n.height)
|
||||
};
|
||||
ex(&mut s, "select.set", json!({"ids": [f]}));
|
||||
assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)));
|
||||
// The frame's right edge: Hug contents horizontally, and the frame shrinks onto its child.
|
||||
double_click(&mut s, 400.0, 50.0, 0.0, 1.0);
|
||||
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Fixed));
|
||||
assert_eq!(size(&s, f), (100.0, 100.0));
|
||||
assert_eq!(s.selection(), [f], "still selected, not entered");
|
||||
s.undo().unwrap();
|
||||
assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)), "one undo step");
|
||||
// Its bottom edge: Hug contents vertically.
|
||||
double_click(&mut s, 200.0, 100.0, 0.0, 1.0);
|
||||
assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Hug));
|
||||
assert_eq!(size(&s, f), (400.0, 40.0));
|
||||
// Alt on the child's right edge: Fill container horizontally, and the child takes the width.
|
||||
ex(&mut s, "select.set", json!({"ids": [c]}));
|
||||
alt_double_click(&mut s, 100.0, 20.0, 1.0);
|
||||
assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, c), (400.0, 40.0));
|
||||
// Without Alt the child, no auto layout frame itself, keeps the old meaning (a frame's
|
||||
// double-click: nothing to enter here), and a plain rectangle's edge still edits its points.
|
||||
double_click(&mut s, 400.0, 20.0, 0.0, 1.0);
|
||||
assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed));
|
||||
// A text child of the frame: Alt+double-click on its edge sets Fill like any child's, and a
|
||||
// plain double-click on its edge still enters the text.
|
||||
let x = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": x, "props": {"textAutoResize": "none", "width": 60, "height": 20}}));
|
||||
ex(&mut s, "select.set", json!({"ids": [x]}));
|
||||
let b = s.doc().unwrap().world_bounds(x).unwrap();
|
||||
alt_double_click(&mut s, b.x1, (b.y0 + b.y1) / 2.0, 1.0);
|
||||
assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed), "a text child fills too");
|
||||
let b = s.doc().unwrap().world_bounds(x).unwrap();
|
||||
// On the edge handle, a pixel in (the hit test's reach): the text, not the frame edge it shares.
|
||||
double_click(&mut s, b.x1 - 1.0, (b.y0 + b.y1) / 2.0, 0.0, 1.0);
|
||||
assert!(s.ds().unwrap().text_edit.as_ref().is_some_and(|te| te.id == x), "a text's edge still enters it without Alt");
|
||||
assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed));
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 100, "height": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
double_click(&mut s, 99.0, 350.0, 0.0, 1.0);
|
||||
assert_eq!(sizing(&s, r), (Sizing::Fixed, Sizing::Fixed));
|
||||
assert!(s.ds().unwrap().vector_edit.is_some(), "a shape's double-click still edits its points");
|
||||
}
|
||||
@@ -225,7 +225,7 @@ pub static KEYS: &[Binding] = &[
|
||||
k(View, "view.prototype", &["Alt+9"]).label("Prototype panel"),
|
||||
k(View, "view.designPrototype", &["Shift+E"]),
|
||||
k(View, "view.devMode", &["Shift+D"]).label("Dev Mode on/off"),
|
||||
k(View, "view.leftSidebar", &["Shift+Cmd+\\"]),
|
||||
k(View, "view.minimize", &["Shift+Cmd+\\"]),
|
||||
k(View, "view.nextRegion", &["F6"]).win(&["F6", "Ctrl+F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(),
|
||||
k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(),
|
||||
k(View, "view.contextMenu", &["Shift+F10", "Menu"]).label("Context menu of the selection (the focused layer row or the canvas)"),
|
||||
@@ -247,6 +247,7 @@ pub static KEYS: &[Binding] = &[
|
||||
// Text (on selected text layers, and on the selected characters while editing)
|
||||
k(Text, "text.bold", &["Cmd+B"]).both(),
|
||||
k(Text, "text.italic", &["Cmd+I"]).both(),
|
||||
k(Text, "text.openLink", &["Alt+Enter"]).both(),
|
||||
k(Text, "text.underline", &["Cmd+U"]).both(),
|
||||
k(Text, "text.strikethrough", &["Shift+Cmd+X"]).both(),
|
||||
k(Text, "text.link", &["Shift+Cmd+U", "Cmd+K"]).text(),
|
||||
@@ -370,6 +371,8 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
|
||||
(Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"),
|
||||
(Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"),
|
||||
(Transform, "Suspend snapping while dragging", "Hold ⌃ or ⌘", "Hold Ctrl"),
|
||||
(Arrange, "Set hug contents", "Double-click an edge of an auto layout frame", "Double-click an edge of an auto layout frame"),
|
||||
(Arrange, "Set fill container", "⌥ double-click an edge of an auto layout child", "Alt+double-click an edge of an auto layout child"),
|
||||
(Prototype, "Presenting: next frame", "→, Space or N", "→, Space or N"),
|
||||
(Prototype, "Presenting: previous frame", "←", "←"),
|
||||
(Prototype, "Presenting: restart", "R", "R"),
|
||||
|
||||
@@ -565,6 +565,8 @@ mod text_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod text_fill_tests;
|
||||
#[cfg(test)]
|
||||
mod text_link_tests;
|
||||
#[cfg(test)]
|
||||
mod text_metrics_local_tests;
|
||||
#[cfg(test)]
|
||||
mod text_motion_tests;
|
||||
|
||||
@@ -145,7 +145,7 @@ static ROWS: &[Row] = &[
|
||||
doc(View, "Outline mode", &["Shift+Cmd+O"], &[], Bound("view.outlines"), ""),
|
||||
doc(View, "Pixel preview", &["Ctrl+P"], &["Ctrl+Alt+P"], Bound("view.pixelPreview"), ""),
|
||||
doc(View, "Others' cursors in a live session", &["Alt+Cmd+\\"], &["Ctrl+Alt+\\"], Bound("view.multiplayerCursors"), "new command"),
|
||||
doc(View, "Hide the side panels (minimize the interface)", &["Shift+Cmd+\\"], &[], Bound("view.leftSidebar"), ""),
|
||||
doc(View, "Minimize the interface: the navigation bar and both sidebars", &["Shift+Cmd+\\"], &[], Bound("view.minimize"), "the toolbar stays; the right sidebar shows while something is selected"),
|
||||
doc(View, "Layers panel", &["Alt+1"], &["Alt+1", "Ctrl+1"], Bound("view.layers"), "one article writes Ctrl+1 for Windows, another Alt+1; both bound there"),
|
||||
doc(View, "Interface scale: make larger", &["Alt+Shift+Cmd+="], &[], Bound("pref.uiScaleUp"), "Accessibility at Figma, Adjust the scale of the Figma UI"),
|
||||
doc(View, "Interface scale: make smaller", &["Alt+Shift+Cmd+-"], &[], Bound("pref.uiScaleDown"), "Accessibility at Figma"),
|
||||
@@ -181,6 +181,7 @@ static ROWS: &[Row] = &[
|
||||
pin(Zoom, "view.nextFrame", &["N", "End"], &[]),
|
||||
// Text
|
||||
doc(Text, "Bold", &["Cmd+B"], &[], Bound("text.bold"), ""),
|
||||
doc(Text, "Activate a focused link", &["Alt+Enter"], &[], Bound("text.openLink"), "while editing: the link at the caret; web and mail links only"),
|
||||
doc(Text, "Italic", &["Cmd+I"], &[], Bound("text.italic"), ""),
|
||||
doc(Text, "Underline", &["Cmd+U"], &[], Bound("text.underline"), ""),
|
||||
doc(Text, "Strikethrough", &["Shift+Cmd+X"], &[], Bound("text.strikethrough"), ""),
|
||||
@@ -412,7 +413,7 @@ fn counts_match_the_docs() {
|
||||
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
|
||||
let pinned = ROWS.len() - documented.len();
|
||||
// docs/ui-parity.md quotes these.
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (133, 132, 1, 0, 2, 57));
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (134, 133, 1, 0, 2, 57));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -365,12 +365,80 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 1}})).unwrap();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"height": 30}})).unwrap();
|
||||
assert_eq!((resize_mode(&s, t), size(&s, t).1), (TextAutoResize::Truncate, 30.0));
|
||||
// Other text still becomes a truncating box with max lines.
|
||||
// Fixed-size text still becomes a truncating box with max lines.
|
||||
let fixed = text(&mut s, "Lorem", json!({"textAutoResize": "none", "width": 100, "height": 100}));
|
||||
s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap();
|
||||
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
|
||||
// "Max lines is available only if truncate text is enabled, and: Text resizing is set to
|
||||
// auto height or auto width" (Explore text properties): an auto-width text with max lines
|
||||
// stays auto width, growing with its longest line and hugging up to that many lines.
|
||||
use figcraft_doc::TextAutoResize;
|
||||
// The synthetic font: every character 8 px wide at 16 px, so the widths are the same
|
||||
// everywhere and never depend on what the system has installed.
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "one\ntwo\nthree", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
|
||||
assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight);
|
||||
assert_eq!(size(&s, t), (40.0, 72.0), "three lines tall, as wide as 'three'");
|
||||
s.execute("text.maxLines", &json!({"ids": [t], "lines": 2})).unwrap();
|
||||
assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight, "max lines keep auto width");
|
||||
assert_eq!(size(&s, t), (32.0, 48.0), "two lines tall, as wide as the widest line it shows with its ellipsis (two…), not the hidden third");
|
||||
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["maxLines"], json!(2));
|
||||
// Still auto width: a longer line widens the box.
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 3})).unwrap();
|
||||
s.execute("text.edit.insert", &json!({"text": " and a longer line"})).unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
assert_eq!(size(&s, t), (168.0, 48.0), "the box grew with its longest line (21 characters)");
|
||||
assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight);
|
||||
// No limit: all of its lines again.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap();
|
||||
assert_eq!(size(&s, t).1, 72.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_and_subscript_are_synthesised_without_glyphs_for_them() {
|
||||
// "If it isn't provided, Figma draws a synthesized version by using the font's standard
|
||||
// version of the character, shrinking it, and positioning it accordingly" (Explore text
|
||||
// properties). The synthetic font has no sups or subs feature and no OS/2 metrics, so the
|
||||
// usual 0.65 em at 0.35 em up (superscript) or 0.14 em down (subscript) apply.
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "x2", json!({"fontFamily": font, "fontSize": 40}));
|
||||
let glyphs = |s: &Session| -> Vec<(f64, f64, f64)> {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
l.glyphs.iter().map(|g| (g.size, g.y, g.advance)).collect()
|
||||
};
|
||||
let plain = glyphs(&s);
|
||||
assert_eq!(plain.len(), 2);
|
||||
assert_eq!((plain[0].0, plain[0].2), (40.0, 20.0));
|
||||
assert_eq!(size(&s, t).0, 40.0);
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": true}})).unwrap();
|
||||
let sup = glyphs(&s);
|
||||
for (g, p) in sup.iter().zip(&plain) {
|
||||
assert!((g.0 - 26.0).abs() < 1e-6 && (g.2 - 13.0).abs() < 1e-6, "shrunk to 0.65 em, the advance with it: {g:?}");
|
||||
assert!((p.1 - g.1 - 14.0).abs() < 1e-6, "raised 0.35 em: {g:?} against {p:?}");
|
||||
}
|
||||
assert!((size(&s, t).0 - 26.0).abs() < 1e-6, "the auto-width box hugs the smaller glyphs: {:?}", size(&s, t));
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": true}})).unwrap();
|
||||
let sub = glyphs(&s);
|
||||
for (g, p) in sub.iter().zip(&plain) {
|
||||
assert!((g.0 - 26.0).abs() < 1e-6 && (g.1 - p.1 - 5.6).abs() < 1e-6, "shrunk, and lowered 0.14 em: {g:?} against {p:?}");
|
||||
}
|
||||
// Capitalised, the same: the synthesis follows the characters shaping saw.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textCase": "upper"}})).unwrap();
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"subs": null, "sups": true}})).unwrap();
|
||||
for (g, p) in glyphs(&s).iter().zip(&plain) {
|
||||
assert!((g.0 - 26.0).abs() < 1e-6 && (p.1 - g.1 - 14.0).abs() < 1e-6, "upper case, superscript: {g:?} against {p:?}");
|
||||
}
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textCase": "original"}})).unwrap();
|
||||
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": false}})).unwrap();
|
||||
assert_eq!(glyphs(&s), plain, "off again");
|
||||
}
|
||||
|
||||
/// A synthetic monospaced TrueType font (outline-less glyphs, every printable ASCII character 500 of
|
||||
/// 1000 units wide), loaded into the font database so text measures the same on every machine.
|
||||
fn test_mono_font() -> &'static str {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Alt+Enter while editing text activates the link at the caret ("When editing text, press
|
||||
//! ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma): `text.openLink`
|
||||
//! hands a web or mail link at the caret to the UI, refuses other schemes, and does nothing where
|
||||
//! there is no link.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
#[test]
|
||||
fn the_link_at_the_caret_goes_to_the_ui_and_only_a_web_or_mail_link() {
|
||||
let mut s = Session::new();
|
||||
let t = id_of(&s.execute("text.create", &json!({"text": "See the docs now", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "https://example.com/docs"})).unwrap();
|
||||
assert!(s.execute("text.openLink", &json!({})).is_err(), "nothing to activate while no text is edited");
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 6})).unwrap();
|
||||
let r = s.execute("text.openLink", &json!({})).unwrap();
|
||||
assert_eq!(r["url"], "https://example.com/docs");
|
||||
let req = s.ui_requests.iter().find(|r| r.kind == "open.url").expect("a UI request to open it");
|
||||
assert_eq!(req.data["url"], "https://example.com/docs");
|
||||
assert!(s.ds().unwrap().text_edit.is_some(), "still editing");
|
||||
// Right after the link's last char the caret still counts as on it; before its start it doesn't.
|
||||
s.execute("text.edit.caret", &json!({"index": 12})).unwrap();
|
||||
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs");
|
||||
s.execute("text.edit.caret", &json!({"index": 2})).unwrap();
|
||||
assert!(s.execute("text.openLink", &json!({})).unwrap()["url"].is_null(), "no link there");
|
||||
// A selection: its start decides.
|
||||
s.execute("text.edit.caret", &json!({"index": 14})).unwrap();
|
||||
s.execute("text.edit.caret", &json!({"index": 8, "extend": true})).unwrap();
|
||||
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs");
|
||||
// Mail links open; anything that isn't a web or mail link is refused and nothing is requested.
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "mailto:hello@example.com"})).unwrap();
|
||||
s.execute("text.edit.caret", &json!({"index": 6})).unwrap();
|
||||
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "mailto:hello@example.com");
|
||||
let before = s.ui_requests.len();
|
||||
for bad in ["javascript:alert(1)", "file:///etc/passwd", "example.com/docs"] {
|
||||
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": bad})).unwrap();
|
||||
assert!(s.execute("text.openLink", &json!({})).is_err(), "{bad} is refused");
|
||||
}
|
||||
assert_eq!(s.ui_requests.len(), before, "nothing requested for a refused link");
|
||||
}
|
||||
@@ -129,6 +129,28 @@ fn list_keys_at_list_items() {
|
||||
"silent: Backspace at an item's start removes its bullet",
|
||||
),
|
||||
("a\nbc", vec![(B, 0), (B, 0)], 4, "Backspace", false, "a\nb", vec![(B, 0), (B, 0)], "Backspace inside an item deletes text"),
|
||||
(
|
||||
"a\nb",
|
||||
vec![(B, 0), (B, 1)],
|
||||
2,
|
||||
"Backspace",
|
||||
false,
|
||||
"a\nb",
|
||||
vec![(B, 0), (P, 1)],
|
||||
"Backspace at a nested item's start removes its bullet and keeps its level",
|
||||
),
|
||||
("a\nb", vec![(N, 0), (N, 2)], 2, "Delete", false, "a\nb", vec![(N, 0), (P, 2)], "Delete at an item's start does the same"),
|
||||
("a\nbc", vec![(B, 0), (B, 1)], 3, "Delete", false, "a\nb", vec![(B, 0), (B, 1)], "Delete inside an item deletes text"),
|
||||
(
|
||||
"a\nb",
|
||||
vec![(B, 0), (P, 1)],
|
||||
2,
|
||||
"Tab",
|
||||
false,
|
||||
"a\n\tb",
|
||||
vec![(B, 0), (P, 1)],
|
||||
"a plain paragraph at a level isn't an item: Tab types a tab",
|
||||
),
|
||||
("a\nb", vec![(P, 0), (P, 0)], 3, "Tab", false, "a\nb\t", vec![(P, 0), (P, 0)], "outside a list Tab types a tab"),
|
||||
("a", vec![(B, 8)], 1, "Tab", false, "a", vec![(B, 8)], "silent: nesting stops at 8 levels"),
|
||||
];
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct ViewState {
|
||||
pub snap_objects: bool,
|
||||
pub snap_pixel: bool,
|
||||
pub ui_hidden: bool,
|
||||
/// Minimized interface (Shift+Cmd/Ctrl+\): the navigation bar and both sidebars are collapsed
|
||||
/// and the toolbar stays; the right sidebar shows while something is selected.
|
||||
pub ui_minimized: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
@@ -67,6 +70,7 @@ impl Default for ViewState {
|
||||
snap_objects: true,
|
||||
snap_pixel: true,
|
||||
ui_hidden: false,
|
||||
ui_minimized: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user