Merge fix/872 into #813

This commit is contained in:
bflatastic
2026-10-20 03:26:53 +07:00
63 changed files with 2842 additions and 170 deletions
+3
View File
@@ -64,3 +64,6 @@ sync_github_name = true
[people.MortenHusted]
sync_github_name = true
[people.AganzeFelicite]
sync_github_name = true
+28 -10
View File
@@ -949,21 +949,39 @@ impl Document {
self.styles.iter().find(|s| s.id == id || s.name == id)
}
/// The effective variable modes for a node (walking up its ancestors).
/// The effective variable modes for a node: the ones it sets over [`Self::inherited_modes`].
pub fn modes_for(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
let mut out = BTreeMap::new();
let mut chain = vec![id];
chain.extend(self.ancestors(id));
for c in chain.into_iter().rev() {
if let Some(n) = self.node(c) {
for (k, v) in &n.var_modes {
out.insert(k.clone(), v.clone());
}
}
let mut out = self.inherited_modes(id);
if let Some(n) = self.node(id) {
out.extend(n.var_modes.iter().map(|(k, v)| (k.clone(), v.clone())));
}
out
}
/// The modes a node is on where it sets none (Auto): its ancestors', and for an instance the
/// ones its main component sets, which are closer than any container's.
pub fn inherited_modes(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
let mut out = BTreeMap::new();
for c in self.ancestors(id).into_iter().rev() {
if let Some(n) = self.node(c) {
out.extend(self.main_modes(n).chain(&n.var_modes).map(|(k, v)| (k.clone(), v.clone())));
}
}
if let Some(n) = self.node(id) {
out.extend(self.main_modes(n).map(|(k, v)| (k.clone(), v.clone())));
}
out
}
/// The modes an instance's main component sets (none for other layers).
fn main_modes<'a>(&'a self, n: &Node) -> impl Iterator<Item = (&'a CollectionId, &'a ModeId)> {
let main = match n.kind {
NodeKind::Instance { component, .. } => self.node(component),
_ => None,
};
main.into_iter().flat_map(|m| &m.var_modes)
}
/// Check structural invariants (used by tests and after loading untrusted files).
pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new();
+48
View File
@@ -55,3 +55,51 @@ fn reduce_motion_is_a_preference_that_persists() {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.reduce_motion);
}
#[test]
fn interface_size_is_relative_to_the_system_and_persists() {
let mut s = Session::new();
assert_eq!(s.prefs.ui_scale, 1.0, "the system's own size by default");
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.0);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25);
assert_eq!(s.prefs.ui_scale, 1.25);
// Reading changes nothing; "auto" (older scripts) is the system's own size.
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], 1.0);
// Nothing to undo: the document didn't change.
assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false));
// Saved with the other preferences and read back.
s.prefs.ui_scale = 0.9;
let v = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(v["ui_scale"], 0.9);
let back: crate::Prefs = serde_json::from_value(v).unwrap();
assert_eq!(back.ui_scale, 0.9);
}
#[test]
fn interface_size_stops_at_the_ends_and_junk_never_changes_it() {
let mut s = Session::new();
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX);
// The size is a factor (1.25), not a percentage: 125 is far past the end and stops there.
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX);
s.prefs.ui_scale = 1.5;
for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] {
assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}");
assert_eq!(s.prefs.ui_scale, 1.5, "{junk}");
}
}
#[test]
fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_size_and_keeps_the_rest() {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert_eq!(old.ui_scale, 1.0);
for bad in [json!("big"), json!(null), json!([2]), json!(true)] {
let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (1.0, "dark"), "{bad}");
}
// A number out of range is clamped rather than refused.
let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark"));
}
+49
View File
@@ -27,6 +27,13 @@ pub fn specs() -> Vec<CommandSpec> {
spacing
),
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
spec!(
"arrange.reorder",
"Reorder selection",
"{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)",
true,
reorder
),
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
@@ -556,6 +563,48 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// Reorder visual slots without rebuilding or reparenting layers.
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "arrange.reorder";
let ids = req_targets(cmd, s, p)?;
let source = req_id(cmd, p, "source")?;
let target = req_id(cmd, p, "target")?;
let ds = s.ds()?;
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
let layout =
crate::selection_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?;
let positions = layout
.positions(source, target, boolean(p, "swap") == Some(true))
.ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?;
// Validate every delta before editing; absolute targets always come from the gesture's
// snapshot so crossing several slots and returning to the start does not accumulate edits.
let doc = s.doc()?;
let mut moves = Vec::new();
for (id, at) in positions {
let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?;
let (dx, dy) = (at.x - now.x0, at.y - now.y0);
if !dx.is_finite() || !dy.is_finite() {
return Err(bad(cmd, "non-finite reorder result"));
}
if dx.abs() > 1e-9 || dy.abs() > 1e-9 {
let node = doc.get(id)?;
let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform;
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
return Err(bad(cmd, "non-finite local transform"));
}
moves.push((id, dx, dy));
}
}
if !moves.is_empty() {
let doc = s.doc_mut()?;
for (id, dx, dy) in moves {
move_world(doc, id, dx, dy)?;
}
}
ok()
}
/// Set the distances between smart-selection tracks without rebuilding/reparenting the layers.
fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("arrange.spacing", s, p)?;
+14
View File
@@ -472,6 +472,7 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
// Own layers lose their link (slot content stays as it is); nested instances stay
// instances of their own mains.
let nested = unlink_layers(doc, i, Some(i));
keep_main_modes(doc, i)?;
let n = doc.get_mut(i)?;
n.kind = NodeKind::Frame;
n.source = None;
@@ -491,6 +492,19 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ids": out}))
}
/// Make the variable modes instance `i` takes from its main its own (it's being detached).
fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> {
let modes = match doc.get(i)?.kind {
NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(),
_ => return Ok(()),
};
let own = &mut doc.get_mut(i)?.var_modes;
for (c, m) in modes {
own.entry(c).or_insert(m);
}
Ok(())
}
fn reset_instance(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.resetInstance";
let ids = req_targets(cmd, s, p)?;
+22 -1
View File
@@ -515,6 +515,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
s.clipboard.source_doc = uid;
s.clipboard.copied_components = nodes
.iter()
.filter(|(_, sub)| {
cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
})
.map(|(root, _)| *root)
.collect();
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
@@ -593,7 +600,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
let (nodes, images, bounds) = copied(&doc, &tops);
let props = s.clipboard.props.take();
// No open file has id 0. So paste treats this copy as a copy from a different file.
s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None };
s.clipboard =
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
@@ -623,6 +631,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
}
// From another file: its instances follow the mains this file can have.
let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc));
let copy_local = s.ds()?.uid == clip.source_doc;
let doc = s.doc_mut()?;
for (k, v) in &clip.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
@@ -636,6 +645,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
let mut out = Vec::new();
let mut all = BTreeMap::new();
for (root, nodes) in &clip.nodes {
if copy_local
&& clip.copied_components.contains(root)
&& let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. }))
&& doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
{
let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?;
doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform;
out.push(instance);
continue;
}
let mut map = BTreeMap::new();
for n in nodes {
map.insert(n.id, doc.alloc_id());
@@ -661,10 +680,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
let sel = s.selection();
let doc = s.doc()?;
let copy_local = s.ds()?.uid == s.clipboard.source_doc;
let target = id(p, "parent").or_else(|| {
sel.first()
.copied()
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
});
let parent = target.unwrap_or(page);
let at = match (num(p, "x"), num(p, "y")) {
+29
View File
@@ -149,6 +149,35 @@ mod tests {
assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err());
}
/// Each side key writes its own padding slot and leaves the other three alone.
#[test]
fn each_side_padding_sets_its_side_alone() {
let mut s = Session::new();
let (f, _, _) = al_frame(&mut s);
// One side at a time: the other three stay 0 ([top, right, bottom, left]).
for (key, v, want) in [
("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]),
("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]),
("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]),
("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]),
] {
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}}));
ex(&mut s, "node.set", json!({"id": f, "props": {key: v}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}");
}
// The [t, r, b, l] array and the axis pairs agree with the four sides.
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]);
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]);
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]);
// Negatives clamp to 0; junk is refused.
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0);
assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err());
}
#[test]
fn baseline_alignment_lines_up_text() {
let mut s = Session::new();
+35
View File
@@ -481,5 +481,40 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
spec!(
"text.rescanFonts",
"Rescan installed fonts",
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
true,
|s, _| {
let rescan = figcraft_text::rescan_system_fonts();
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
Ok(json!({"added": rescan.added, "refitted": refitted}))
}
),
spec!(
"text.loadFont",
"Load font file",
"{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}",
true,
|s, p| {
let cmd = "text.loadFont";
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
} else if let Some(path) = string(p, "path") {
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
} else {
return Err(bad(cmd, "pass `path` or `data` (base64)"));
};
let families = figcraft_text::load_font_bytes(bytes);
if families.is_empty() {
return Err(bad(cmd, "not a font file"));
}
let refitted = crate::fonts::refit_open(s, None, Some(&families));
Ok(json!({"families": families, "refitted": refitted}))
}
),
]
}
+17
View File
@@ -370,6 +370,23 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!(
"pref.uiScale",
"Interface size",
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
false,
|s, p| {
match get(p, "scale") {
None => {}
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0,
Some(_) => {
let scale = req_num("pref.uiScale", p, "scale")?;
s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?;
}
}
Ok(json!({"scale": s.prefs.ui_scale}))
}
),
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
+2
View File
@@ -285,6 +285,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
root.dev_resources.clear();
// The main's interactions play on its instances in prototypes; the instance's own start empty.
root.interactions.clear();
// So do the variable modes the main sets (see `Document::inherited_modes`).
root.var_modes.clear();
doc.insert(root, parent, index)?;
sync_instance(doc, id);
Ok(id)
@@ -82,6 +82,118 @@ fn text(n: &Node) -> &TextData {
n.kind.text().unwrap()
}
#[test]
fn copying_a_main_component_pastes_a_linked_instance() {
for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, "select.set", json!({"ids": [k.card]}));
ex(&mut s, "edit.copy", json!({}));
if command != "edit.paste" {
ex(&mut s, "select.set", json!({"ids": [k.inst]}));
}
let before = undo_len(&s);
let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), k.card, "{command}");
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
assert_eq!(node(&s, pasted).source, Some(k.card));
assert_eq!(undo_len(&s), before + 1);
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(pasted).is_none());
ex(&mut s, "edit.redo", json!({}));
assert_eq!(component_of(&s, pasted), k.card);
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated");
healthy(&s);
}
}
#[test]
fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() {
let mut s = Session::new();
let (set, _, hover) = button_set(&mut s);
ex(&mut s, "select.set", json!({"ids": [hover]}));
ex(&mut s, "edit.copy", json!({}));
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), hover);
assert_eq!(node(&s, pasted).name, node(&s, set).name);
let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") };
assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover");
assert_eq!(props["Show dot"], true);
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
ex(&mut s, "select.set", json!({"ids": [set]}));
ex(&mut s, "edit.copy", json!({}));
let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. }));
assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap()));
assert_eq!(kids(&s, copied_set).len(), 2);
for variant in kids(&s, copied_set) {
assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. }));
assert!(!kids(&s, set).contains(&variant));
}
healthy(&s);
}
#[test]
fn cut_components_and_components_copied_to_another_file_remain_components() {
for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, copy, json!({"ids": [k.card]}));
if undo_cut {
ex(&mut s, "edit.undo", json!({}));
}
if new_file {
ex(&mut s, "file.new", json!({}));
}
let page = s.page().unwrap();
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}");
assert_eq!(kids(&s, pasted).len(), 3);
healthy(&s);
}
}
#[test]
fn copying_a_component_does_not_link_to_an_id_reused_after_undo() {
let mut s = Session::new();
let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"})));
let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
ex(&mut s, "edit.copy", json!({"ids": [main]}));
s.undo().unwrap();
s.undo().unwrap();
let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"})));
ex(&mut s, "component.create", json!({"ids": [other]}));
assert_eq!(other, main, "undo makes the original id available again");
let page = s.page().unwrap();
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
assert_eq!(node(&s, pasted).name, "Copied main");
assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0));
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }));
healthy(&s);
}
#[test]
fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}}));
let original = s.doc().unwrap().world_transform(k.card);
let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000})));
ex(&mut s, "edit.copy", json!({"ids": [k.card]}));
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), k.card);
for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) {
assert!((actual - expected).abs() < 1e-9);
}
assert_eq!(node(&s, pasted).parent, Some(frame));
let before = s.doc().unwrap().clone();
assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err());
assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically");
healthy(&s);
}
#[test]
fn overridable_properties_stay_through_main_edits_and_reset() {
// (what, layer, group, override on the instance, the same property edited on the main, read,
+70 -10
View File
@@ -1,13 +1,15 @@
//! Fonts a document names that this machine doesn't have (#752): text layers, their styled
//! ranges, text styles and the string variables bound to a font family. Text in such a family
//! is drawn in a fallback font, so the user is told which ones are missing.
//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is
//! there, [`refit`] lets auto-sizing text measure its box in it again.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::{get, req_targets};
use crate::{Result, Session, spec};
/// A family the document names that no installed or built-in font provides.
@@ -79,20 +81,78 @@ fn string_values(vars: &Variables, id: &str) -> Vec<String> {
out
}
/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing
/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under
/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the
/// layers whose size changed.
pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
let want: Option<BTreeSet<String>> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect());
let scope: Option<BTreeSet<NodeId>> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect());
let ids: Vec<NodeId> = doc
.nodes()
.filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id)))
.filter(|n| {
let NodeKind::Text(t) = &n.kind else { return false };
let uses = |w: &BTreeSet<String>| {
w.contains(&t.style.font_family.trim().to_lowercase())
|| t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase()))
};
want.as_ref().is_none_or(uses)
&& figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
})
.map(|n| n.id)
.collect();
if !ids.is_empty() {
let before = doc.clone();
doc.mark_dirty(ids.iter().copied());
crate::normalize(doc, &before);
}
ids
}
/// [`refit`] in the open document, if there is one.
pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
match s.doc_mut() {
Ok(doc) => refit(doc, under, families),
Err(_) => Vec::new(),
}
}
/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the
/// whole document.
fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
let under = match get(p, "ids") {
None | Some(Value::Null) => None,
Some(_) => Some(req_targets("text.refit", s, p)?),
};
s.doc()?;
Ok(json!({"refitted": refit_open(s, under.as_deref(), None)}))
}
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
s.request_ui("dialog.missingFonts", json!({}));
Ok(json!({"families": families}))
}
/// The font commands: `text.missingFonts` and `text.refit`.
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
false,
missing_fonts
)]
vec![
spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
false,
missing_fonts
),
spec!(
"text.refit",
"Fit text to fonts",
"{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}",
true,
refit_text
),
]
}
#[cfg(test)]
+89
View File
@@ -124,3 +124,92 @@ fn needs_an_open_document() {
let mut s = Session::empty();
assert!(s.execute("text.missingFonts", &json!({})).is_err());
}
/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content.
fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 {
let doc = s.doc_mut().unwrap();
let n = doc.get_mut(id).unwrap();
let want = figcraft_text::auto_size(n).unwrap().0;
n.width = want + dw;
doc.take_dirty();
want
}
/// The width of layer `id`.
fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 {
s.doc().unwrap().node(id).unwrap().width
}
/// The ids a refit reports.
fn refitted(r: &Value) -> Vec<u64> {
r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs.
#[test]
fn refit_measures_stale_text_boxes_again() {
let mut s = Session::new();
let a = text(&mut s, "FigCraft Refit A");
let b = text(&mut s, "FigCraft Refit B");
let fixed = text(&mut s, "FigCraft Refit A");
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind {
t.auto_resize = figcraft_doc::TextAutoResize::None;
}
s.execute("select.none", &json!({})).unwrap();
let want_a = stale(&mut s, a, -30.0);
let want_b = stale(&mut s, b, 25.0);
s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0;
s.doc_mut().unwrap().take_dirty();
// Only the families asked for.
let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()]));
assert_eq!(only_a, vec![a]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, b), want_b + 25.0);
// Only the layers asked for, then everything; a fixed box keeps its size.
stale(&mut s, a, 12.0);
assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]);
assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b));
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, fixed), 7.0);
// Nothing stale: nothing changes.
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty());
assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err());
// An `ids` that isn't a list is refused, not taken as the whole document.
stale(&mut s, a, 9.0);
assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err());
assert_eq!(width(&s, a), want_a + 9.0);
assert!(Session::empty().execute("text.refit", &json!({})).is_err());
}
/// Text inside an instance is refitted too.
#[test]
fn refit_reaches_text_in_instances() {
let mut s = Session::new();
let t = text(&mut s, "FigCraft Refit C");
let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap();
let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap();
let copy = s.doc().unwrap().node(inst).unwrap().children[0];
let want = stale(&mut s, copy, -20.0);
let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap();
assert_eq!(refitted(&r), vec![copy.0]);
assert_eq!(width(&s, copy), want);
}
/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added.
#[test]
fn fonts_load_from_files_and_rescans_report_new_families() {
let mut s = Session::new();
assert!(s.execute("text.loadFont", &json!({})).is_err());
assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file");
assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err());
assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err());
if let Some(f) = figcraft_text::CRAFT_FONTS.first() {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(f.bytes);
let r = s.execute("text.loadFont", &json!({"data": data})).unwrap();
assert_eq!(r["families"].as_array().map(Vec::len), Some(1));
}
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
assert!(r["added"].is_array());
}
+128
View File
@@ -0,0 +1,128 @@
//! Variable modes on instances: an instance is on the modes its main component sets, under the
//! ones set on the instance itself, so switching variants or changing the main's mode reaches it.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::NodeId;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn fill(s: &Session, n: NodeId) -> String {
s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
fn set_mode(s: &mut Session, n: NodeId, mode: Value) {
run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode}));
}
/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B.
fn tone(s: &mut Session) {
run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]}));
run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}}));
}
/// A component filled from `c`, on `mode` when one is given.
fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId {
let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name})));
run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"}));
if let Some(m) = mode {
set_mode(s, f, json!(m));
}
id(run(s, "component.create", json!({"ids": [f]})))
}
/// A set "Kind" whose variants A and B each set their own Tone mode.
fn kinds(s: &mut Session) -> (NodeId, NodeId) {
tone(s);
let a = swatch(s, "Kind=A", Some("A"));
let b = swatch(s, "Kind=B", Some("B"));
let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]})));
(set, b)
}
fn instance(s: &mut Session, component: NodeId) -> NodeId {
id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0})))
}
fn switch(s: &mut Session, i: NodeId, kind: &str) {
run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}}));
}
#[test]
fn switching_variants_takes_the_new_variants_mode() {
let mut s = Session::new();
let (set, _) = kinds(&mut s);
let i = instance(&mut s, set);
assert_eq!(fill(&s, i), "FF0000");
switch(&mut s, i, "B");
assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B");
switch(&mut s, i, "A");
assert_eq!(fill(&s, i), "FF0000");
assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own");
}
#[test]
fn instances_follow_a_mode_their_main_sets_later() {
let mut s = Session::new();
tone(&mut s);
let main = swatch(&mut s, "Chip", None);
let i = instance(&mut s, main);
assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default");
set_mode(&mut s, main, json!("B"));
assert_eq!(fill(&s, i), "0000FF");
set_mode(&mut s, main, Value::Null);
assert_eq!(fill(&s, i), "FF0000", "the main back on Auto");
}
#[test]
fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() {
let mut s = Session::new();
let (set, _) = kinds(&mut s);
let i = instance(&mut s, set);
set_mode(&mut s, i, json!("B"));
assert_eq!(fill(&s, i), "0000FF");
for kind in ["B", "A"] {
switch(&mut s, i, kind);
assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}");
}
set_mode(&mut s, i, Value::Null);
assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode");
}
#[test]
fn a_main_mode_is_closer_than_the_frame_around_the_instance() {
let mut s = Session::new();
let (_, b) = kinds(&mut s);
let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100})));
set_mode(&mut s, frame, json!("A"));
let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame})));
assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A");
// Nested: an outer component on mode A holding an instance of B.
let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"})));
run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame}));
set_mode(&mut s, outer_frame, json!("A"));
let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]})));
let copy = instance(&mut s, outer);
let nested = s.doc().unwrap().node(copy).unwrap().children[0];
assert_eq!(fill(&s, nested), "0000FF");
}
#[test]
fn detaching_keeps_the_mode_it_took_from_its_main() {
let mut s = Session::new();
let (set, b) = kinds(&mut s);
let i = instance(&mut s, set);
switch(&mut s, i, "B");
run(&mut s, "component.detach", json!({"ids": [i]}));
set_mode(&mut s, b, json!("A"));
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
}
+55 -4
View File
@@ -49,6 +49,7 @@ pub mod proto_run;
pub mod rename;
pub mod rotation_origin;
pub mod selection_colors;
pub mod selection_reorder;
pub mod selection_spacing;
pub mod shape_handles;
pub mod shapebuilder;
@@ -603,6 +604,23 @@ impl DocState {
}
}
/// The smallest and largest interface size, relative to the system's display scale (1.0 is the
/// system's own size).
pub const UI_SCALE_MIN: f64 = 0.75;
pub const UI_SCALE_MAX: f64 = 3.0;
/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`.
pub fn clamp_ui_scale(scale: f64) -> Option<f64> {
scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX))
}
/// Reads the saved interface size. Anything but a number reads as 1 (the system's size) and a
/// number out of range is clamped, so a settings file edited by hand never costs the other
/// preferences.
fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0))
}
/// App-wide preferences that commands read (nudge amounts, behaviours).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -630,6 +648,11 @@ pub struct Prefs {
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
/// too). Prototype transitions still play.
pub reduce_motion: bool,
/// How large the whole interface is drawn, relative to the system's display scale: 1.0 is the
/// system's own size, 1.25 a quarter larger (kept between [`UI_SCALE_MIN`] and
/// [`UI_SCALE_MAX`]). The canvas zoom is separate.
#[serde(deserialize_with = "read_ui_scale")]
pub ui_scale: f64,
/// Files that have a path are saved by themselves a few seconds after they change (the app's
/// timer; off: only File ▸ Save writes them).
pub auto_save: bool,
@@ -700,6 +723,7 @@ impl Default for Prefs {
language: "system".into(),
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false,
ui_scale: 1.0,
auto_save: false,
start_on_home: false,
invite_links: None,
@@ -738,6 +762,8 @@ pub struct Clipboard {
/// The page-space bounds of the copied selection.
pub bounds: Option<figcraft_geom::Rect>,
pub source_doc: u64,
/// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused).
pub copied_components: std::collections::BTreeSet<NodeId>,
/// The document copied from (pastes into other files follow its mains) and its file path.
pub source: Option<Arc<Document>>,
pub source_path: Option<String>,
@@ -1172,6 +1198,13 @@ impl Session {
}
ds.clean_selection();
ds.revision += 1;
let (uid, doc) = (ds.uid, ds.doc.clone());
if self.clipboard.source_doc == uid {
self.clipboard.copied_components.retain(|id| {
doc.node(*id)
.is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. }))
});
}
Ok(true)
}
@@ -1466,21 +1499,35 @@ fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool {
}
/// The layers whose variable bindings an edit may have changed: every bound layer when a variable
/// changed, else the touched layers and everything below those that moved, are new or chose
/// another variable mode (modes are inherited).
/// changed, else the touched layers and everything below those that moved, are new, chose
/// another variable mode or became an instance of another main (modes are inherited, an
/// instance's from its main too), and the instances of a main that chose another mode.
fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet<NodeId>) -> Option<BTreeSet<NodeId>> {
if doc.variables != before.variables {
return None;
}
let main = |n: &Node| match n.kind {
figcraft_doc::NodeKind::Instance { component, .. } => Some(component),
_ => None,
};
let mut out = BTreeSet::new();
for &id in touched {
let Some(n) = doc.node(id) else { continue };
let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent);
if inherited {
let Some(b) = before.node(id) else {
out.extend(doc.subtree(id));
continue;
};
let modes = b.var_modes != n.var_modes;
if modes || b.parent != n.parent || main(b) != main(n) {
out.extend(doc.subtree(id));
} else {
out.insert(id);
}
if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) {
for i in components::instances_of(doc, id) {
out.extend(doc.subtree(i));
}
}
}
Some(out)
}
@@ -1658,6 +1705,8 @@ mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
#[cfg(test)]
mod instance_modes_tests;
#[cfg(test)]
mod instance_resizing_tests;
#[cfg(test)]
mod interaction_rollback_tests;
@@ -1844,5 +1893,7 @@ mod view_behaviour_tests;
#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
mod selection_reorder_tests;
#[cfg(test)]
mod selection_spacing_tests;
+106
View File
@@ -0,0 +1,106 @@
//! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns
//! and complete top-left-aligned grids have unambiguous insertion slots.
//! Marking several items and irregular grids are out of scope.
use figcraft_doc::{Document, NodeId};
use figcraft_geom::{Point, Rect};
use crate::selection_spacing::selection_spacing;
#[derive(Clone, Debug)]
pub struct ReorderLayout {
/// Visual order, independent of selection order and layer stacking.
pub slots: Vec<(NodeId, Rect)>,
columns: usize,
rows: usize,
gap_x: f64,
gap_y: f64,
}
impl ReorderLayout {
pub fn new(doc: &Document, ids: &[NodeId]) -> Option<Self> {
let layout = selection_spacing(doc, ids);
let h = layout.horizontal.as_ref();
let v = layout.vertical.as_ref();
let gap = |axis: Option<&crate::selection_spacing::Axis>| match axis {
Some(a) => a.value.filter(|v| *v >= 0.0),
None => Some(0.0),
};
let (gap_x, gap_y) = (gap(h)?, gap(v)?);
let columns = h.map_or(1, |a| a.tracks.len());
let rows = v.map_or(1, |a| a.tracks.len());
if ids.len() < 2 || columns.checked_mul(rows)? != ids.len() {
return None;
}
let mut cells = std::collections::BTreeMap::new();
for id in ids {
let col = match h {
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
None => 0,
};
let row = match v {
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
None => 0,
};
let b = doc.world_bounds(*id)?;
if columns > 1 && rows > 1 {
let x = h?.tracks.get(col)?.bounds.x0;
let y = v?.tracks.get(row)?.bounds.y0;
if (b.x0 - x).abs() > 0.01 || (b.y0 - y).abs() > 0.01 {
return None;
}
}
if cells.insert((row, col), (*id, b)).is_some() {
return None;
}
}
Some(Self { slots: cells.into_values().collect(), columns, rows, gap_x, gap_y })
}
/// Insertion shifts intervening items; swapping exchanges just two entries. Sizes and
/// original gaps are retained, so unequal-sized layers reflow instead of overlapping.
pub fn positions(&self, source: NodeId, target: NodeId, swap: bool) -> Option<Vec<(NodeId, Point)>> {
let from = self.slots.iter().position(|(id, _)| *id == source)?;
let to = self.slots.iter().position(|(id, _)| *id == target)?;
if from == to {
return Some(self.slots.iter().map(|(id, b)| (*id, Point::new(b.x0, b.y0))).collect());
}
let mut order = self.slots.clone();
if swap {
order.swap(from, to);
} else {
let item = order.remove(from);
order.insert(to, item);
}
let mut widths = vec![0.0_f64; self.columns];
let mut heights = vec![0.0_f64; self.rows];
for (i, (_, b)) in order.iter().enumerate() {
let w = widths.get_mut(i % self.columns)?;
*w = w.max(b.width());
let h = heights.get_mut(i / self.columns)?;
*h = h.max(b.height());
}
let x0 = self.slots.iter().map(|(_, b)| b.x0).fold(f64::INFINITY, f64::min);
let y0 = self.slots.iter().map(|(_, b)| b.y0).fold(f64::INFINITY, f64::min);
let starts = |sizes: &[f64], mut at: f64, gap: f64| {
sizes
.iter()
.map(|size| {
let start = at;
at += size + gap;
start
})
.collect::<Vec<_>>()
};
let xs = starts(&widths, x0, self.gap_x);
let ys = starts(&heights, y0, self.gap_y);
order
.iter()
.enumerate()
.map(|(i, (id, b))| {
let x = if self.columns == 1 { b.x0 } else { *xs.get(i % self.columns)? };
let y = if self.rows == 1 { b.y0 } else { *ys.get(i / self.columns)? };
Some((*id, Point::new(x, y)))
})
.collect()
}
}
@@ -0,0 +1,152 @@
use crate::{Session, selection_reorder::ReorderLayout};
use figcraft_doc::NodeId;
use serde_json::json;
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap())
}
fn xy(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0)
}
#[test]
fn insertion_reflows_unequal_sizes_with_original_gaps_and_hierarchy() {
for vertical in [false, true] {
let mut s = Session::new();
let mut ids = vec![];
for (at, size) in [(0., 40.), (50., 60.), (120., 20.)] {
ids.push(rect(
&mut s,
if vertical { 0. } else { at },
if vertical { at } else { 0. },
if vertical { 30. } else { size },
if vertical { size } else { 30. },
None,
));
}
let before = s.doc().unwrap().clone();
// Explicit selection order must not determine visual order.
s.execute("arrange.reorder", &json!({"ids":[ids[2],ids[0],ids[1]],"source":ids[0],"target":ids[2]})).unwrap();
for (id, at) in [(ids[1], 0.), (ids[2], 70.), (ids[0], 100.)] {
assert_eq!(xy(&s, id), if vertical { (0., at) } else { (at, 0.) });
let old = before.node(id).unwrap();
let new = s.doc().unwrap().node(id).unwrap();
assert_eq!((new.width, new.height, new.parent), (old.width, old.height, old.parent));
}
assert_eq!(s.doc().unwrap().node(s.ds().unwrap().page).unwrap().children, before.node(s.ds().unwrap().page).unwrap().children);
s.undo().unwrap();
assert_eq!(s.doc().unwrap(), &before);
s.redo().unwrap();
assert_eq!(xy(&s, ids[0]), if vertical { (0., 100.) } else { (100., 0.) });
}
}
#[test]
fn grids_insert_across_rows_and_swap_only_the_two_entries() {
let mut s = Session::new();
let mut ids = vec![];
for row in 0..2 {
for col in 0..3 {
ids.push(rect(&mut s, col as f64 * 70., row as f64 * 60., 50., 40., None));
}
}
let before = s.doc().unwrap().clone();
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4]})).unwrap();
assert_eq!(xy(&s, ids[0]), (70., 60.));
assert_eq!(xy(&s, ids[3]), (140., 0.));
assert_eq!(xy(&s, ids[5]), (140., 60.));
s.undo().unwrap();
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4],"swap":true})).unwrap();
assert_eq!(xy(&s, ids[0]), (70., 60.));
assert_eq!(xy(&s, ids[4]), (0., 0.));
for id in [ids[1], ids[2], ids[3], ids[5]] {
assert_eq!(s.doc().unwrap().node(id), before.node(id));
}
}
#[test]
fn grid_reflow_keeps_gaps_with_different_sizes() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 40., 30., None);
let b = rect(&mut s, 50., 0., 60., 30., None);
let c = rect(&mut s, 0., 50., 40., 50., None);
let d = rect(&mut s, 50., 50., 60., 50., None);
s.execute("arrange.reorder", &json!({"ids":[a,b,c,d],"source":a,"target":d})).unwrap();
assert_eq!(xy(&s, b), (0., 0.));
assert_eq!(xy(&s, c), (70., 0.));
assert_eq!(xy(&s, d), (0., 70.));
assert_eq!(xy(&s, a), (70., 70.));
}
#[test]
fn previews_are_absolute_and_returning_to_start_is_a_noop() {
let mut s = Session::new();
let ids: Vec<_> = (0..3).map(|i| rect(&mut s, i as f64 * 70., 0., 50., 40., None)).collect();
s.execute("select.set", &json!({"ids":ids})).unwrap();
let before = s.doc().unwrap().clone();
let history = s.undo_depth().0;
s.begin_interaction("Reorder selection").unwrap();
for target in [ids[2], ids[1], ids[0]] {
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth().0, history);
s.begin_interaction("Reorder selection").unwrap();
for target in [ids[1], ids[2]] {
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(s.undo_depth().0, history + 1);
assert_eq!(s.selection(), ids);
s.undo().unwrap();
assert_eq!(s.doc().unwrap(), &before);
}
#[test]
fn invalid_selections_and_targets_do_not_edit_the_document() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 50., 40., None);
let b = rect(&mut s, 70., 0., 50., 40., None);
let c = rect(&mut s, 160., 0., 50., 40., None);
for args in [
json!({"ids":[a,b,c],"source":a,"target":b}), // Mixed gaps.
json!({"ids":[a,b],"source":c,"target":b}),
json!({"ids":[a,b],"source":a,"target":999999}),
json!({"ids":[a,a,b],"source":a,"target":b}),
json!({"ids":[a,b],"source":[],"target":b}),
] {
let before = s.doc().unwrap().clone();
assert!(s.execute("arrange.reorder", &args).is_err());
assert_eq!(s.doc().unwrap(), &before);
}
for props in [json!({"locked":true}), json!({"visible":false})] {
s.execute("node.set", &json!({"id":b,"props":props})).unwrap();
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b]).is_none());
s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap();
}
let d = rect(&mut s, 0., 70., 50., 40., None);
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b, d]).is_none(), "an incomplete grid has no slots");
let frame =
NodeId(s.execute("frame.create", &json!({"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap());
let x = rect(&mut s, 0., 0., 50., 40., Some(frame));
let y = rect(&mut s, 70., 0., 50., 40., Some(frame));
assert!(ReorderLayout::new(s.doc().unwrap(), &[x, y]).is_none());
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, x]).is_none());
}
#[test]
fn reordering_children_of_a_rotated_parent_uses_page_coordinates() {
let mut s = Session::new();
let frame = NodeId(
s.execute("frame.create", &json!({"x":200,"y":200,"width":300,"height":100,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(),
);
let a = rect(&mut s, 0., 0., 50., 40., Some(frame));
let b = rect(&mut s, 70., 0., 50., 40., Some(frame));
let (pa, pb) = (xy(&s, a), xy(&s, b));
s.execute("arrange.reorder", &json!({"ids":[a,b],"source":a,"target":b})).unwrap();
let (qa, qb) = (xy(&s, a), xy(&s, b));
assert!((qa.0 - pb.0).abs() < 1e-7 && (qa.1 - pb.1).abs() < 1e-7);
assert!((qb.0 - pa.0).abs() < 1e-7 && (qb.1 - pa.1).abs() < 1e-7);
}
+6
View File
@@ -376,6 +376,7 @@ fn pointer_draw_move_resize() {
assert_eq!(s.selection(), vec![r]);
}
/// No command panics on junk parameters.
#[test]
fn every_command_survives_junk_params() {
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
@@ -418,6 +419,9 @@ fn every_command_survives_junk_params() {
// Selection color slots: malformed indexes, ranges and paints.
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
// Smart-selection reorder targets and modes as junk.
json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}),
json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}),
// Rotation origins and placing with a tool as junk.
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
@@ -434,6 +438,8 @@ fn every_command_survives_junk_params() {
| "file.placeImage"
| "image.place"
| "file.revert"
// Takes no params and walks the font folders: one walk per junk is slow.
| "text.rescanFonts"
) {
continue;
}
+3 -1
View File
@@ -782,6 +782,7 @@ mod tests {
assert!(r.is_null() || r["axes"].is_array());
}
/// No text command panics on junk parameters while a text is being edited.
#[test]
fn text_commands_survive_junk_while_editing() {
let junk = [
@@ -793,7 +794,8 @@ mod tests {
json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}),
json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}),
];
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) {
// `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow.
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") {
for j in &junk {
let (mut s, id) = setup("Hi there\nyou");
begin(&mut s, id, Some(2), false).unwrap();
+64 -18
View File
@@ -368,6 +368,9 @@ struct Mapper<'a> {
blobs: Vec<&'a [u8]>,
/// Component property definitions: id → (REST name "Name#id", type).
prop_defs: BTreeMap<String, (String, &'a str)>,
/// The copies of a set's definitions each variant keeps (no name or type of their own; the
/// variant's layers and its instances' assignments use them): copy id → the definition's id.
prop_parents: BTreeMap<String, String>,
/// File nodes by asset `key` and `version`: references can name a collection, variable or
/// style as the asset instead of the guid, and the file carries the resources to resolve
/// them (the collection node itself says the same `key`).
@@ -402,6 +405,7 @@ impl<'a> Mapper<'a> {
let mut nodes = BTreeMap::new();
let mut kids: BTreeMap<String, Vec<(&'a str, String)>> = BTreeMap::new();
let mut prop_defs = BTreeMap::new();
let mut prop_parents = BTreeMap::new();
let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default();
// The last change of a GUID wins; a REMOVED change deletes what came before it.
for n in changes {
@@ -423,10 +427,11 @@ impl<'a> Mapper<'a> {
kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone()));
}
for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() {
if let (Some(did), Some(name), Some(ty)) =
(d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str))
{
let Some(did) = d.get("id").and_then(guid) else { continue };
if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) {
prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty));
} else if let Some(parent) = d.get("parentPropDefId").and_then(guid) {
prop_parents.insert(did, parent);
}
}
}
@@ -450,6 +455,7 @@ impl<'a> Mapper<'a> {
kids,
blobs,
prop_defs,
prop_parents,
assets,
styles: Obj::new(),
components: Obj::new(),
@@ -640,7 +646,7 @@ impl<'a> Mapper<'a> {
targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid));
for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() {
if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) {
targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid));
targets.extend(assigned(assignment).and_then(prop_guid));
}
}
let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default();
@@ -651,7 +657,31 @@ impl<'a> Mapper<'a> {
}
fn is_instance_swap(&self, def: &Kv) -> Option<()> {
matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(())
matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(())
}
/// The definition a copy of it was made from, followed to the original: the values an instance
/// sets are kept under it, since an assignment and the layers that read it may name different
/// copies of one definition.
fn prop_root(&self, def: &str) -> String {
let mut def = def;
for _ in 0..=MAX_DEPTH {
let Some(parent) = self.prop_parents.get(def) else { break };
def = parent;
}
def.to_string()
}
/// The definition `def` names: its own, or the set's for a variant's copy of it.
fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> {
let mut def = def;
for _ in 0..=MAX_DEPTH {
if let Some(found) = self.prop_defs.get(def) {
return Some(found);
}
def = self.prop_parents.get(def)?;
}
None
}
/// The branches of the internal canvas `id` that hold the `required_mains` (with the frames
@@ -908,7 +938,7 @@ impl<'a> Mapper<'a> {
}
self.prop_refs(&mut o, &view, inst);
let slot_content = if own == "FRAME" {
prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned()))
prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned()))
} else {
None
};
@@ -1892,11 +1922,11 @@ impl<'a> Mapper<'a> {
// Expanded frames must follow their main frame's source link. A direct slot reference
// makes the engine resolve ownership against the instance, where the property is not defined.
if !(field == "slot" && inst.is_some())
&& let Some((name, _)) = self.prop_defs.get(&def)
&& let Some((name, _)) = self.prop_def(&def)
{
refs.insert(field.into(), json!(name));
}
let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue };
let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue };
match field {
"visible" => {
if let Some(b) = val.get("boolValue").and_then(Kv::bool) {
@@ -1911,7 +1941,7 @@ impl<'a> Mapper<'a> {
if own_text(v) != Some(t) {
o.remove("characterStyleOverrides");
o.remove("styleOverrideTable");
if let Some(tv) = val.get("textValue") {
if let Some(tv) = prop_text_data(val) {
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
char_styles(o, tv, size, &mut |w| self.warn(w));
}
@@ -1933,7 +1963,7 @@ impl<'a> Mapper<'a> {
// An instance swap property value picks the main.
if let Some(i) = outer
&& let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID")
&& let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid)
&& let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p))
{
sym = Some(g);
}
@@ -1945,12 +1975,12 @@ impl<'a> Mapper<'a> {
let assignments = v.list("componentPropAssignments");
let mut props = Obj::new();
for a in assignments {
let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue };
let val = a.get("value");
let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue };
let val = assigned(a);
let p = match ty {
"BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})),
"TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})),
"INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
"INSTANCE_SWAP" => val.and_then(prop_guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
_ => None,
};
if let Some(p) = p {
@@ -2027,11 +2057,11 @@ impl<'a> Mapper<'a> {
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
let mut slot_contents: BTreeMap<String, String> = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default();
for a in assignments {
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) {
all_props.entry(d).or_insert(val);
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) {
all_props.entry(self.prop_root(&d)).or_insert(val);
}
let Some(def) = a.get("defID").and_then(guid) else { continue };
if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
continue;
}
let var_value = a.get("varValue");
@@ -2052,7 +2082,7 @@ impl<'a> Mapper<'a> {
self.warn("slot content assignment target is missing or is not slot content");
continue;
}
slot_contents.entry(def).or_insert(target);
slot_contents.entry(self.prop_root(&def)).or_insert(target);
}
let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
symbols.push(sym.clone());
@@ -2777,7 +2807,23 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>, warn: &mut dyn FnMut(Str
/// The text of a component property value.
fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> {
v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str)
prop_text_data(v).and_then(|t| t.get("characters")).and_then(Kv::str)
}
/// The text data of a component property value: `textValue`, or `textDataValue` in the variable form.
fn prop_text_data<'k, 's>(v: &'k Kv<'s>) -> Option<&'k Kv<'s>> {
v.get("textValue").or_else(|| v.get("textDataValue"))
}
/// The main an instance swap value names: `guidValue`, or `symbolIdValue` in the variable form.
fn prop_guid(v: &Kv) -> Option<String> {
v.get("guidValue").and_then(guid).or_else(|| v.get("symbolIdValue")?.get("guid").and_then(guid))
}
/// The value a component property assignment sets: `value`, or the variable form `varValue` holds
/// when `value` is empty, as current files write many booleans and instance swaps.
fn assigned<'k, 's>(assignment: &'k Kv<'s>) -> Option<&'k Kv<'s>> {
assignment.get("value").filter(|v| !matches!(v, Kv::Object(fields) if fields.is_empty())).or_else(|| assignment.get("varValue")?.get("value"))
}
/// The property a layer's `field` is bound to (`parameterConsumptionMap`).
+2
View File
@@ -26,6 +26,8 @@ pub mod kiwi;
mod map;
mod network;
#[cfg(test)]
mod prop_value_tests;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod source_identity_tests;
+188
View File
@@ -0,0 +1,188 @@
//! Component properties as current `.fig` files write them, on synthetic messages: the copy of a
//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own), and
//! assignments that carry their value only in the variable form (`varValue`).
use figcraft_doc::NodeKind;
use serde_json::Value;
use super::kiwi::Kv;
use super::map::to_rest;
fn g(local: u32) -> Kv<'static> {
Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))])
}
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn s(text: &str) -> Kv<'static> {
Kv::Str(text.into())
}
fn node(local: u32, kind: &'static str, name: &str, parent: Option<u32>, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", s(name))];
if let Some(parent) = parent {
all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s("!"))])));
}
all.append(&mut fields);
o(all)
}
/// A layer's `field` bound to property definition `def`.
fn bound(field: &'static str, def: u32) -> (&'static str, Kv<'static>) {
let entry = o(vec![
("variableField", Kv::Enum(field)),
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", Kv::Enum("PROP_REF"))])),
]);
("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![entry]))]))
}
fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> Kv<'static> {
o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)])
}
/// An assignment of `def` that leaves `value` empty and says it in `varValue` only.
fn variable_assignment(def: u32, data_type: &'static str, value: Kv<'static>) -> Kv<'static> {
o(vec![
("defID", g(def)),
("value", o(vec![])),
("varValue", o(vec![("value", value), ("dataType", Kv::Enum(data_type)), ("resolvedDataType", Kv::Enum(data_type))])),
])
}
fn message(nodes: Vec<Kv<'static>>) -> Kv<'static> {
let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])];
all.extend(nodes);
o(vec![("nodeChanges", Kv::Array(all))])
}
fn by_id<'v>(v: &'v Value, id: &str) -> &'v Value {
fn find<'v>(v: &'v Value, id: &str) -> Option<&'v Value> {
if v.get("id").and_then(Value::as_str) == Some(id) {
return Some(v);
}
v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| find(v, id))
}
find(v, id).unwrap_or_else(|| panic!("no layer {id}"))
}
fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap<String, Value> {
let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Instance").unwrap();
let NodeKind::Instance { props, .. } = &instance.kind else { panic!("not an instance") };
props
}
/// A "Field" set with a BOOL definition (9:70), a variant whose "Hint text" reads its copy (9:71),
/// and an instance of the variant that sets the property false through definition `assigned`.
fn field_set(assigned: u32) -> Kv<'static> {
message(vec![
node(
9,
"FRAME",
"Field",
Some(2),
vec![
("isStateGroup", Kv::Bool(true)),
("componentPropDefs", Kv::Array(vec![definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))]))])),
],
),
node(
10,
"SYMBOL",
"State=Default",
Some(9),
vec![("componentPropDefs", Kv::Array(vec![o(vec![("id", g(71)), ("parentPropDefId", g(70))])]))],
),
node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 71)]),
node(
20,
"INSTANCE",
"Instance",
Some(2),
vec![
("symbolData", o(vec![("symbolID", g(10))])),
("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(assigned)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])),
],
),
])
}
#[test]
fn variant_layers_bind_to_the_set_properties_their_copies_name() {
let msg = field_set(71);
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN");
assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70");
assert_eq!(by_id(&rest["document"], "9:20")["componentProperties"]["Hint#9:70"]["value"], false);
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
let hint = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Hint text" && n.source.is_none()).unwrap();
assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint"));
assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false)));
}
/// An assignment reaches layers that read another copy of its definition: here the instance sets
/// the set's own definition while the variant's layer reads the variant's copy.
#[test]
fn assignments_reach_layers_that_read_another_copy_of_their_definition() {
let (rest, _, _) = to_rest(&field_set(70), "Synthetic", crate::rest::MAX_NODES);
let instance = by_id(&rest["document"], "9:20");
assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false);
assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false);
}
#[test]
fn property_values_written_only_as_variables_are_applied() {
let msg = message(vec![
node(
10,
"SYMBOL",
"Field",
Some(2),
vec![(
"componentPropDefs",
Kv::Array(vec![
definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))])),
definition(71, "Label", "TEXT", o(vec![("textValue", o(vec![("characters", s("Label"))]))])),
definition(72, "Icon", "INSTANCE_SWAP", o(vec![("guidValue", g(40))])),
]),
)],
),
node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 70)]),
node(12, "TEXT", "Label", Some(10), vec![("textData", o(vec![("characters", s("Label"))])), bound("TEXT_DATA", 71)]),
node(13, "INSTANCE", "Icon", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), bound("OVERRIDDEN_SYMBOL_ID", 72)]),
node(40, "SYMBOL", "Mail", Some(2), vec![]),
node(41, "SYMBOL", "Card", Some(2), vec![]),
node(
20,
"INSTANCE",
"Instance",
Some(2),
vec![
("symbolData", o(vec![("symbolID", g(10))])),
(
"componentPropAssignments",
Kv::Array(vec![
variable_assignment(70, "BOOLEAN", o(vec![("boolValue", Kv::Bool(false))])),
variable_assignment(71, "TEXT_DATA", o(vec![("textDataValue", o(vec![("characters", s("Name on card"))]))])),
variable_assignment(72, "SYMBOL_ID", o(vec![("symbolIdValue", o(vec![("guid", g(41))]))])),
]),
),
],
),
]);
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
let instance = by_id(&rest["document"], "9:20");
assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false);
assert_eq!(instance["componentProperties"]["Label#9:71"]["value"], "Name on card");
assert_eq!(instance["componentProperties"]["Icon#9:72"]["value"], "9:41");
assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false);
assert_eq!(by_id(instance, "I9:20;9:12")["characters"], "Name on card");
assert_eq!(by_id(instance, "I9:20;9:13")["componentId"], "9:41");
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
let props = instance_props(&doc);
assert_eq!(props.get("Hint"), Some(&Value::Bool(false)));
assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into())));
}
+99 -1
View File
@@ -444,9 +444,22 @@ pub struct FontDb {
families: BTreeMap<String, (String, Vec<usize>)>,
/// System faces known by name but not loaded (lower-case family → entries).
pending: BTreeMap<String, (String, Vec<Pending>)>,
/// System faces read from disk (file, face index), so a rescan only offers new ones.
loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>,
/// Font files added as bytes (hash and length → families), so loading one again adds nothing.
loaded_bytes: std::collections::HashMap<(u64, usize), Vec<String>>,
system_scanned: bool,
}
/// What a later walk of the font folders found ([`rescan_system_fonts`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Rescan {
/// Families that were unknown before.
pub added: Vec<String>,
/// Every family that gained faces: the new ones and those with styles installed since.
pub changed: Vec<String>,
}
static DB: OnceLock<RwLock<FontDb>> = OnceLock::new();
/// Set by [`scan_system_fonts_on_demand`].
static ON_DEMAND: AtomicBool = AtomicBool::new(false);
@@ -580,6 +593,7 @@ impl FontDb {
};
if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) {
self.add(face);
self.loaded_files.insert((e.path, e.index));
}
}
}
@@ -670,6 +684,35 @@ impl FontDb {
self.faces.len()
}
/// Offer the families of a later walk of the font folders: new families become pending, a
/// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets
/// the files it hasn't read (styles installed since) as pending, read when it is next used.
fn merge_rescan(&mut self, found: BTreeMap<String, (String, Vec<Pending>)>) -> Rescan {
let mut out = Rescan::default();
for (k, (display, entries)) in found {
if self.families.contains_key(&k) {
let waiting = self.pending.get(&k).map(|(_, v)| v.clone()).unwrap_or_default();
let new: Vec<Pending> = entries
.into_iter()
.filter(|e| {
!self.loaded_files.contains(&(e.path.clone(), e.index)) && !waiting.iter().any(|w| w.path == e.path && w.index == e.index)
})
.collect();
if !new.is_empty() {
out.changed.push(display.clone());
self.pending.entry(k).or_insert_with(|| (display, Vec::new())).1.extend(new);
}
continue;
}
if !self.pending.contains_key(&k) {
out.added.push(display.clone());
}
out.changed.push(display.clone());
self.pending.insert(k, (display, entries));
}
out
}
/// Index the system font directories (desktop only): family names are read now, faces loaded
/// when first used. Safe to call more than once.
pub fn scan_system(&mut self) {
@@ -776,13 +819,19 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
// system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed
// buffer, so only those parts are ever touched.
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 256 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
if meta.len() > MAX_FONT_FILE {
continue;
}
let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue };
for (index, family, style) in faces_in(&bytes) {
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style });
}
}
}
/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB).
pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024;
/// The parts of a font file the scan reads: headers, table directories and `name` tables, at their
/// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan).
fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
@@ -887,6 +936,55 @@ pub fn scan_system_fonts() {
});
}
/// Walk the system font folders again, for fonts installed while the app runs (the first scan is
/// once per process): the families that were unknown before, and every family that gained faces.
/// Before the first scan this is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS`.
pub fn rescan_system_fonts() -> Rescan {
#[cfg(not(target_arch = "wasm32"))]
if !system_fonts_opted_out() {
if SCANNED.get().is_none() {
let before: std::collections::BTreeSet<String> =
raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect();
scan_system_fonts();
let added: Vec<String> = family_names().into_iter().filter(|f| !before.contains(f)).collect();
return Rescan { changed: added.clone(), added };
}
let mut found: BTreeMap<String, (String, Vec<Pending>)> = BTreeMap::new();
for dir in system_font_dirs() {
scan_dir(&dir, 0, &mut found);
}
let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found);
if !rescan.changed.is_empty() {
crate::clear_layout_cache();
}
return rescan;
}
Rescan::default()
}
/// Load font file bytes (every face of a collection) into the global db, for a font that isn't
/// installed. Returns the families of its faces (none: not a font file); the same file again
/// returns them without adding its faces twice.
pub fn load_font_bytes(bytes: Vec<u8>) -> Vec<String> {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
let key = (h.finish(), bytes.len());
let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(families) = db.loaded_bytes.get(&key) {
return families.clone();
}
let faces = db.add_bytes(bytes);
let mut families: Vec<String> = faces.iter().map(|f| f.family.clone()).collect();
families.dedup();
if !families.is_empty() {
db.loaded_bytes.insert(key, families.clone());
drop(db);
crate::clear_layout_cache();
}
families
}
/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts.
#[cfg(not(target_arch = "wasm32"))]
fn system_fonts_opted_out() -> bool {
+46
View File
@@ -217,3 +217,49 @@ fn user_font_dirs_preserve_non_utf8_paths() {
assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts");
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
}
/// A rescan offers new families and the styles installed since, and never a file twice.
#[test]
fn a_rescan_offers_fonts_installed_since() {
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() };
let mut db = pending(&["Regular"]);
let found = |fams: &[(&str, &[&str])]| -> BTreeMap<String, (String, Vec<Pending>)> {
fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect()
};
// A new family is added; a pending one takes the walk's files (a new style); both changed.
let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])]));
assert_eq!(r.added, ["Brand Sans"]);
assert_eq!(r.changed, ["Brand Sans", "CJK UI"]);
assert!(db.has_family("brand sans"));
assert_eq!(left(&db), ["Regular", "Bold"]);
// A loaded family keeps its faces and gets only the files it hasn't read, once.
db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new()));
db.pending.remove("brand sans");
db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0));
let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])]));
assert_eq!(same, Rescan::default());
assert!(!db.pending.contains_key("brand sans"));
let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])]));
assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()]));
let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::<Vec<_>>()).unwrap_or_default();
assert_eq!(waiting(&db), ["Black"]);
assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default());
assert_eq!(waiting(&db), ["Black"]);
}
/// Font file bytes add their faces once; junk adds nothing.
#[test]
fn font_bytes_load_into_the_db() {
assert!(load_font_bytes(b"not a font".to_vec()).is_empty());
assert!(load_font_bytes(Vec::new()).is_empty());
// With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added,
// and loading the same file again returns it without adding its faces twice.
if let Some(f) = CRAFT_FONTS.first() {
let families = load_font_bytes(f.bytes.to_vec());
assert_eq!(families.len(), 1);
assert!(families.iter().all(|f| has_family(f)));
let faces = db().read().unwrap().face_count();
assert_eq!(load_font_bytes(f.bytes.to_vec()), families);
assert_eq!(db().read().unwrap().face_count(), faces);
}
}
+47 -23
View File
@@ -82,6 +82,9 @@ pub struct Line {
pub x: f64,
/// The line ends a paragraph (hard break or end of text).
pub para_end: bool,
/// The line ends with a line break char: a newline, or a line separator (U+2028) that breaks
/// the line inside its paragraph.
pub break_after: bool,
/// The paragraph runs right to left (its first strong character is right-to-left).
pub rtl: bool,
}
@@ -157,6 +160,15 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 {
if v.is_finite() { v } else { auto }
}
/// The line separator (U+2028): a line break inside a paragraph, as `.fig` files store a soft
/// break (imported text keeps it; typed text has newlines only).
const LINE_SEPARATOR: char = '\u{2028}';
/// A char that ends its line: a newline (which also ends the paragraph) or a line separator.
fn is_line_break(c: char) -> bool {
c == '\n' || c == LINE_SEPARATOR
}
/// Opening punctuation, which hangs outside a line's start.
fn opens(c: char) -> bool {
matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}')
@@ -332,7 +344,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
// Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only
// for a text with emoji, or with characters nothing above covers.
let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c));
let covered = |c: char| is_line_break(c) || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c));
let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() };
fallbacks.extend(emoji.iter().cloned());
drop(dbw);
@@ -370,7 +382,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
{
return f.clone();
}
if c == '\n' || primary.has_char(c) {
if is_line_break(c) || primary.has_char(c) {
return primary.clone();
}
let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
@@ -382,13 +394,15 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let mut seg_end = seg_start + 1;
while seg_end < *end
&& chars.get(seg_end).is_some_and(|c| {
*c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id)
!is_line_break(*c)
&& !is_line_break(first)
&& (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id)
})
&& level_at(seg_end) == level
{
seg_end += 1;
}
if first == '\n' {
if is_line_break(first) {
items.push(Item {
face: face.clone(),
coords: no_coords(),
@@ -401,7 +415,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
char_start: seg_start,
char_end: seg_start + 1,
style: st_idx,
ch: '\n',
ch: first,
level,
});
} else {
@@ -465,6 +479,17 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
para += 1;
continue;
}
// A line separator (a soft break) breaks the line inside its paragraph: no
// paragraph spacing and no new list marker after it.
if it.ch == LINE_SEPARATOR {
lines.push((line_start, i + 1, false, para));
i += 1;
line_start = i;
x = 0.0;
last_break = None;
para_first_line = false;
continue;
}
let line_indent = line_indent_of(para, para_first_line, it.char_start);
let w = it.adv;
// Hanging punctuation takes no room inside the box.
@@ -491,7 +516,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
i += 1;
}
if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) {
let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch));
if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) {
lines.push((line_start, items.len(), true, para));
}
@@ -546,7 +572,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let baseline = y + (lh - (asc + desc)) / 2.0 + asc;
// Trailing spaces don't count for width/alignment.
let mut vis_end = *e;
while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') {
while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) {
vis_end -= 1;
}
// Letter spacing separates glyphs: none after the line's last visible one.
@@ -563,7 +589,9 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
// Justify: extra space per gap.
let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count();
let justify_extra = match (text.align_h, wrap) {
(TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0),
(TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => {
((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0)
}
_ => 0.0,
};
// Where the visible text ends once justified.
@@ -574,7 +602,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
.get(vis_end..*e)
.unwrap_or(&[])
.iter()
.filter(|it| it.ch != '\n')
.filter(|it| !is_line_break(it.ch))
.map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 })
.sum();
let mut pen = if rtl_line { -trailing } else { line_indent - hang_l };
@@ -649,7 +677,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
(0..line_items.len()).collect()
};
for it in order.iter().filter_map(|k| line_items.get(*k)) {
if it.ch == '\n' {
if is_line_break(it.ch) {
continue;
}
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
@@ -687,6 +715,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
width: width + line_indent - hang_l - hang_r,
x: 0.0,
para_end: *para_end,
break_after: break_after(*e),
rtl: rtl_line,
});
y += lh;
@@ -1142,16 +1171,12 @@ impl TextLayout {
pub fn caret(&self, index: usize) -> (f64, usize) {
for (li, line) in self.lines.iter().enumerate() {
let last_line = li + 1 == self.lines.len();
// A caret at a line's end belongs to the next line, whether the line wraps or ends with a
// line break (before the break it is still on this line: past its glyphs).
let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end));
// A caret at the end of a soft-wrapped line belongs to the next line.
if !(in_line || index == line.char_end && line.para_end && !last_line) {
if !in_line {
continue;
}
if index == line.char_end && line.para_end && !last_line {
// Before the newline: end of this line.
let x = self.line_end_x(line);
return (x, li);
}
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
for g in gl {
if g.char_end == g.char_start {
@@ -1179,8 +1204,8 @@ impl TextLayout {
if !line.rtl && !gl.iter().any(|g| g.rtl) {
return None;
}
// The caret can't sit after a paragraph's newline on its own line.
let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end };
// The caret can't sit after a line's break on its own line.
let end = if line.break_after && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end };
let next = (line.char_start..=end.max(line.char_start))
.filter(|i| *i != index)
.map(|i| (i, self.caret(i).0))
@@ -1247,7 +1272,7 @@ impl TextLayout {
}
}
let last_line = li + 1 == self.lines.len();
let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end };
let end = if line.break_after && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end };
// Right of a right-to-left line is its start.
if line.rtl { line.char_start } else { end }
}
@@ -1274,12 +1299,11 @@ impl TextLayout {
out.extend(Self::mixed_selection(gl, a, b, line));
continue;
}
// From the line's own start: `caret(char_start)` after a hard break is the end of the
// line above.
// From the line's own start.
let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 };
let x1 = if b >= line.char_end {
// The last line has no paragraph break to show, so it ends at its glyphs.
self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
self.line_end_x(line) + if line.break_after && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
} else {
self.caret(b).0
};
+5 -2
View File
@@ -16,8 +16,9 @@ mod onpath;
pub use breaks::grapheme_step;
pub use color::{ColorBitmap, ColorInk, ColorLayer};
pub use fontdb::{
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family,
installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,
has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand,
system_fonts_scanned,
};
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
pub use onpath::OnPath;
@@ -150,6 +151,8 @@ mod behaviour_tests;
#[cfg(test)]
mod cache_tests;
#[cfg(test)]
mod line_separator_tests;
#[cfg(test)]
mod list_tests;
#[cfg(test)]
mod metrics_tests;
+97
View File
@@ -0,0 +1,97 @@
//! A line separator (U+2028: a soft break in `.fig` files, kept on import) breaks the
//! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852).
use super::*;
use figcraft_doc::{LineHeight, ListStyle, TextAutoResize, TextData};
const EPS: f64 = 1e-6;
/// A 20 px text on 30 px lines in the default face, or None (skip) on a machine without fonts.
fn text(s: &str) -> Option<TextData> {
scan_system_fonts();
let f = db().write().unwrap().default_face()?;
let mut t = TextData::new(s);
(t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone());
t.style.font_size = 20.0;
t.style.line_height = LineHeight::Pixels(30.0);
t.auto_resize = TextAutoResize::WidthAndHeight;
Some(t)
}
/// The chars of each line.
fn line_texts(t: &TextData, l: &TextLayout) -> Vec<String> {
let chars: Vec<char> = t.characters.chars().collect();
l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::<String>()).collect()
}
#[test]
fn breaks_the_line_but_not_the_paragraph() {
let Some(mut t) = text("one\u{2028}two\nthree") else { return };
t.paragraph_spacing = 10.0;
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["one\u{2028}", "two\n", "three"]);
assert!(!l.lines[0].para_end && l.lines[0].break_after);
assert!(l.lines[1].para_end && l.lines[1].break_after);
assert!(!l.lines[2].break_after);
// Paragraph spacing after the newline only: 30, then 30 + 10.
assert!((l.lines[1].top - 30.0).abs() < EPS && (l.lines[2].top - 70.0).abs() < EPS, "{} {}", l.lines[1].top, l.lines[2].top);
// The separator draws nothing.
assert!(l.glyphs.iter().all(|g| !(g.char_start..g.char_end).contains(&3)));
}
#[test]
fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() {
// "Hi,⏎¶Season" in #852: an empty line between the two.
let Some(t) = text("Hi,\u{2028}\nSeason") else { return };
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]);
let Some(t) = text("end\u{2028}") else { return };
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["end\u{2028}", ""]);
assert!((l.height - 60.0).abs() < EPS, "{}", l.height);
}
#[test]
fn a_list_item_continues_after_it_without_a_new_marker() {
let Some(mut t) = text("item\u{2028}more\nnext") else { return };
t.list = ListStyle::Bulleted;
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["item\u{2028}", "more\n", "next"]);
// Markers are the glyphs that cover no char: one per paragraph.
assert_eq!(l.glyphs.iter().filter(|g| g.char_start == g.char_end).count(), 2);
// The line after the separator lines up with the item's text, past the bullet.
let text_x = |li: usize| l.glyphs.iter().find(|g| g.char_start == l.lines[li].char_start && g.char_end > g.char_start).map(|g| g.x);
let (first, cont) = (text_x(0).unwrap(), text_x(1).unwrap());
assert!(first > 0.0 && (first - cont).abs() < 1e-3, "{first} {cont}");
}
#[test]
fn carets_and_clicks_treat_it_like_a_newline() {
let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return };
let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None));
for i in 0..=5 {
assert_eq!(ls.caret(i), ln.caret(i), "caret before char {i}");
}
for line in &ln.lines {
for x in [-10.0, 5.0, line.width + 50.0] {
assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top);
}
}
// Before the separator: the end of the first line; after it: the start of the second.
assert_eq!(ls.caret(2).1, 0);
assert!(ls.caret(2).0 > 0.0);
assert_eq!(ls.caret(3), (0.0, 1));
// A click right of the first line lands before the separator, not after it.
assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2);
}
#[test]
fn a_selection_across_it_shows_the_break_like_a_newline() {
let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return };
let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None));
let (before, across) = (ls.selection_rects(0, 2), ls.selection_rects(0, 5));
assert_eq!((before.len(), across.len()), (1, 2));
// Reaching past the separator adds the 4 px break mark at the first line's end.
assert!((across[0].width() - before[0].width() - 4.0).abs() < EPS, "{} {}", across[0].width(), before[0].width());
assert_eq!(across, ln.selection_rects(0, 5));
}
+16
View File
@@ -481,3 +481,19 @@ fn selection_after_a_line_break_starts_at_the_line_start() {
// A break followed by text still doesn't highlight the next line's start.
assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none());
}
#[test]
fn the_caret_after_a_line_break_sits_on_the_next_line() {
if !have_fonts() {
return;
}
// Before the break: the end of its line; after it: the start of the next (after Enter, the
// caret shows on the new line, and Up/Down count from there).
let l = layout(&TextData::new("ab\ncd"), None, None);
assert_eq!(l.caret(2).1, 0);
assert!(l.caret(2).0 > 0.0);
assert_eq!(l.caret(3), (0.0, 1));
// A trailing break: the caret sits on the empty last line.
let l = layout(&TextData::new("ab\n"), None, None);
assert_eq!(l.caret(3), (0.0, 1));
}
+1
View File
@@ -930,6 +930,7 @@ status = "draft — needs native review"
"PDF print settings" = "Nastavení tisku PDF"
"Padding" = "Vnitřní odsazení"
"Page" = "Stránka"
"Page deleted" = "Stránka smazána"
"Pages" = "Stránky"
"Pages added to the document" = "Stránky přidány do dokumentu"
"Placed on the current page" = "Umístěno na aktuální stránku"
+1
View File
@@ -899,6 +899,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF-Druckeinstellungen"
"Padding" = "Innenabstand"
"Page" = "Seite"
"Page deleted" = "Seite gelöscht"
"Pages" = "Seiten"
"Pages added to the document" = "Seiten zum Dokument hinzugefügt"
"Placed on the current page" = "Auf der aktuellen Seite platziert"
+13
View File
@@ -172,6 +172,7 @@ name = "English"
"Both directions" = "Both directions"
"Bottom" = "Bottom"
"Bottom left radius" = "Bottom left radius"
"Bottom padding" = "Bottom padding"
"Bottom right radius" = "Bottom right radius"
"Branch" = "Branch"
"Branch archived" = "Branch archived"
@@ -633,6 +634,7 @@ name = "English"
"Included" = "Included"
"Included in layout" = "Included in layout"
"Individual corners" = "Individual corners"
"Individual padding" = "Individual padding"
"Individual strokes" = "Individual strokes"
"Ink" = "Ink"
"Inner shadow" = "Inner shadow"
@@ -654,6 +656,7 @@ name = "English"
"Interaction" = "Interaction"
"Interactions" = "Interactions"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
"Interface size" = "Interface size"
"Intersect" = "Intersect"
"Intersects" = "Intersects"
"Invalid color" = "Invalid color"
@@ -703,6 +706,7 @@ name = "English"
"Leaves" = "Leaves"
"Left" = "Left"
"Left & right" = "Left & right"
"Left padding" = "Left padding"
"Left side" = "Left side"
"Left side width" = "Left side width"
"Letter case" = "Letter case"
@@ -951,6 +955,7 @@ name = "English"
"PDF print settings" = "PDF print settings"
"Padding" = "Padding"
"Page" = "Page"
"Page deleted" = "Page deleted"
"Pages" = "Pages"
"Pages added to the document" = "Pages added to the document"
"Placed on the current page" = "Placed on the current page"
@@ -1062,6 +1067,7 @@ name = "English"
"Reduce motion" = "Reduce motion"
"Refraction" = "Refraction"
"Regular expression" = "Regular expression"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."
"Remove" = "Remove"
"Remove all" = "Remove all"
"Remove annotation" = "Remove annotation"
@@ -1100,6 +1106,7 @@ name = "English"
"Rename on the server…" = "Rename on the server…"
"Rename page" = "Rename page"
"Rename to" = "Rename to"
"Reorder selection" = "Reorder selection"
"Replace all" = "Replace all"
"Replace mixed export settings" = "Replace mixed export settings"
"Replace with" = "Replace with"
@@ -1135,6 +1142,7 @@ name = "English"
"Review and merge" = "Review and merge"
"Ribbon" = "Ribbon"
"Right" = "Right"
"Right padding" = "Right padding"
"Right side" = "Right side"
"Right side width" = "Right side width"
"Rotate 90°" = "Rotate 90°"
@@ -1185,6 +1193,7 @@ name = "English"
"Search results" = "Search results"
"Search fonts" = "Search fonts"
"Search layers" = "Search layers"
"Search pages" = "Search pages"
"Search shortcuts" = "Search shortcuts"
"Section" = "Section"
"Section color" = "Section color"
@@ -1373,6 +1382,7 @@ name = "English"
"The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one"
"The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}."
"The whole file" = "The whole file"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%."
"Theme" = "Theme"
"There's no destination for that action" = "There's no destination for that action"
"Thickness" = "Thickness"
@@ -1404,6 +1414,7 @@ name = "English"
"Top" = "Top"
"Top & bottom" = "Top & bottom"
"Top left radius" = "Top left radius"
"Top padding" = "Top padding"
"Top right radius" = "Top right radius"
"Transform" = "Transform"
"Triangle arrow" = "Triangle arrow"
@@ -1452,6 +1463,7 @@ name = "English"
"Use number keys for opacity" = "Use number keys for opacity"
"Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension."
"Use the layer's fill" = "Use the layer's fill"
"Use your browser's zoom to change the interface size." = "Use your browser's zoom to change the interface size."
"Used in {files} · {instances}" = "Used in {files} · {instances}"
"Username" = "Username"
"Value" = "Value"
@@ -1723,6 +1735,7 @@ name = "English"
"{n}m" = "{n}m"
"{n}w" = "{n}w"
"{page}, nothing selected" = "{page}, nothing selected"
"{percent}%" = "{percent}%"
"{profile} (current)" = "{profile} (current)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written."
"{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)."
+18
View File
@@ -175,6 +175,7 @@ status = "draft — needs native review"
"Both directions" = "Ambas direcciones"
"Bottom" = "Abajo"
"Bottom left radius" = "Radio inferior izquierdo"
"Bottom padding" = "Margen interno inferior"
"Bottom right radius" = "Radio inferior derecho"
"Branch" = "Rama"
"Branch archived" = "Rama archivada"
@@ -634,6 +635,7 @@ status = "draft — needs native review"
"Included" = "Incluidos"
"Included in layout" = "Incluidos en el diseño"
"Individual corners" = "Esquinas individuales"
"Individual padding" = "Margen interno individual"
"Individual strokes" = "Trazos individuales"
"Ink" = "Tinta"
"Inner shadow" = "Sombra interior"
@@ -655,6 +657,7 @@ status = "draft — needs native review"
"Interaction" = "Interacción"
"Interactions" = "Interacciones"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
"Interface size" = "Tamaño de la interfaz"
"Intersect" = "Intersecar"
"Intersects" = "Intersecciones"
"Invalid color" = "Color no válido"
@@ -704,6 +707,7 @@ status = "draft — needs native review"
"Leaves" = "Hojas"
"Left" = "Izquierda"
"Left & right" = "Izquierda y derecha"
"Left padding" = "Margen interno izquierdo"
"Left side" = "Lado izquierdo"
"Left side width" = "Ancho del lado izquierdo"
"Letter case" = "Mayúsculas y minúsculas"
@@ -952,6 +956,7 @@ status = "draft — needs native review"
"PDF print settings" = "Ajustes de impresión PDF"
"Padding" = "Margen interno"
"Page" = "Página"
"Page deleted" = "Página eliminada"
"Pages" = "Páginas"
"Pages added to the document" = "Páginas añadidas al documento"
"Placed on the current page" = "Colocado en la página actual"
@@ -1063,6 +1068,7 @@ status = "draft — needs native review"
"Reduce motion" = "Reducir movimiento"
"Refraction" = "Refracción"
"Regular expression" = "Expresión regular"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativo al escalado de pantalla del sistema (100% es el tamaño del propio sistema). El zoom del documento es independiente."
"Remove" = "Quitar"
"Remove all" = "Quitar todo"
"Remove annotation" = "Quitar anotación"
@@ -1101,6 +1107,7 @@ status = "draft — needs native review"
"Rename on the server…" = "Cambiar el nombre en el servidor…"
"Rename page" = "Cambiar nombre de página"
"Rename to" = "Nuevo nombre"
"Reorder selection" = "Reordenar selección"
"Replace all" = "Reemplazar todo"
"Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos"
"Replace with" = "Reemplazar con"
@@ -1136,6 +1143,7 @@ status = "draft — needs native review"
"Review and merge" = "Revisar y fusionar"
"Ribbon" = "Cinta"
"Right" = "Derecha"
"Right padding" = "Margen interno derecho"
"Right side" = "Lado derecho"
"Right side width" = "Ancho del lado derecho"
"Rotate 90°" = "Girar 90°"
@@ -1186,6 +1194,7 @@ status = "draft — needs native review"
"Search results" = "Resultados de búsqueda"
"Search fonts" = "Buscar fuentes"
"Search layers" = "Buscar capas"
"Search pages" = "Buscar páginas"
"Search shortcuts" = "Buscar atajos"
"Section" = "Sección"
"Section color" = "Color de sección"
@@ -1374,6 +1383,7 @@ status = "draft — needs native review"
"The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno"
"The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}."
"The whole file" = "El archivo completo"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La ventana es demasiado pequeña para {size}%, así que la interfaz se dibuja al {drawn}%. Agranda la ventana para usar {size}%."
"Theme" = "Tema"
"There's no destination for that action" = "No hay destino para esa acción"
"Thickness" = "Grosor"
@@ -1405,6 +1415,7 @@ status = "draft — needs native review"
"Top" = "Arriba"
"Top & bottom" = "Arriba y abajo"
"Top left radius" = "Radio superior izquierdo"
"Top padding" = "Margen interno superior"
"Top right radius" = "Radio superior derecho"
"Transform" = "Transformar"
"Triangle arrow" = "Flecha triangular"
@@ -1453,6 +1464,7 @@ status = "draft — needs native review"
"Use number keys for opacity" = "Usar teclas numéricas para la opacidad"
"Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig."
"Use the layer's fill" = "Usar el relleno de la capa"
"Use your browser's zoom to change the interface size." = "Usa el zoom del navegador para cambiar el tamaño de la interfaz."
"Used in {files} · {instances}" = "Usado en {files} · {instances}"
"Username" = "Usuario"
"Value" = "Valor"
@@ -1724,6 +1736,7 @@ status = "draft — needs native review"
"{n}m" = "{n} min"
"{n}w" = "{n} sem"
"{page}, nothing selected" = "{page}, nada seleccionado"
"{percent}%" = "{percent}%"
"{profile} (current)" = "{profile} (actual)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual."
"{server} can't say what became of your files (it is older than this app)." = "{server} no puede decir qué fue de tus archivos (es anterior a esta aplicación)."
@@ -1766,6 +1779,7 @@ status = "draft — needs native review"
"arrange.front" = "Traer al frente"
"arrange.packH" = "Agrupar en horizontal"
"arrange.packV" = "Agrupar en vertical"
"arrange.reorder" = "Reordenar selección"
"arrange.roundToPixels" = "Redondear a píxeles"
"arrange.spacing" = "Espaciado"
"arrange.tidy" = "Ordenar"
@@ -2037,6 +2051,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Renombrar capas duplicadas"
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
"pref.startOnHome" = "Empezar en Inicio"
"pref.uiScale" = "Tamaño de la interfaz"
"pref.userName" = "Tu nombre en los comentarios"
"proto.action.add" = "Añadir acción de prototipo"
"proto.action.move" = "Reordenar acción de prototipo"
@@ -2142,6 +2157,7 @@ status = "draft — needs native review"
"text.listIndent" = "Aumentar sangría de la lista"
"text.listOutdent" = "Reducir sangría de la lista"
"text.listStyle" = "Estilo de lista"
"text.loadFont" = "Cargar archivo de fuente"
"text.maxLines" = "Máximo de líneas"
"text.missingFonts" = "Fuentes que faltan"
"text.numberList" = "Lista numerada"
@@ -2150,7 +2166,9 @@ status = "draft — needs native review"
"text.paragraphSpacing" = "Espacio entre párrafos"
"text.pathOptions" = "Opciones de texto en trazado"
"text.preview" = "Previsualizar cambio de texto"
"text.refit" = "Ajustar texto a las fuentes"
"text.removeFromPath" = "Quitar texto del trazado"
"text.rescanFonts" = "Volver a buscar fuentes instaladas"
"text.set" = "Establecer texto"
"text.setFeatures" = "Funciones OpenType"
"text.setVariations" = "Ejes de fuente variable"
+4
View File
@@ -953,6 +953,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF-tulostusasetukset"
"Padding" = "Sisämarginaali"
"Page" = "Sivu"
"Page deleted" = "Sivu poistettu"
"Pages" = "Sivut"
"Pages added to the document" = "Sivut lisätty asiakirjaan"
"Pages: {pages}" = "Sivut: {pages}"
@@ -2144,6 +2145,7 @@ status = "draft — needs native review"
"text.listIndent" = "Sisennä luettelo"
"text.listOutdent" = "Vähennä luettelon sisennystä"
"text.listStyle" = "Luettelotyyli"
"text.loadFont" = "Lataa fonttitiedosto"
"text.maxLines" = "Rivien enimmäismäärä"
"text.missingFonts" = "Puuttuvat fontit"
"text.numberList" = "Numeroitu luettelo"
@@ -2152,7 +2154,9 @@ status = "draft — needs native review"
"text.paragraphSpacing" = "Kappaleväli"
"text.pathOptions" = "Tekstipolun asetukset"
"text.preview" = "Esikatsele tekstimuutosta"
"text.refit" = "Sovita teksti fontteihin"
"text.removeFromPath" = "Poista teksti polulta"
"text.rescanFonts" = "Etsi asennetut fontit uudelleen"
"text.set" = "Aseta teksti"
"text.setFeatures" = "OpenType-ominaisuudet"
"text.setVariations" = "Muuttuvan fontin akselit"
+7
View File
@@ -653,6 +653,7 @@ status = "draft — needs native review"
"Interaction" = "Interaction"
"Interactions" = "Interactions"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft."
"Interface size" = "Taille de l’interface"
"Intersect" = "Intersection"
"Intersects" = "Intersections"
"Invalid color" = "Couleur non valide"
@@ -948,6 +949,7 @@ status = "draft — needs native review"
"PDF print settings" = "Réglages d’impression PDF"
"Padding" = "Marge intérieure"
"Page" = "Page"
"Page deleted" = "Page supprimée"
"Pages" = "Pages"
"Pages added to the document" = "Pages ajoutées au document"
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil"
@@ -1059,6 +1061,7 @@ status = "draft — needs native review"
"Reduce motion" = "Réduire les animations"
"Refraction" = "Réfraction"
"Regular expression" = "Expression régulière"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative à la mise à l’échelle d’affichage de votre système (100 % est la taille du système). Le zoom du document est distinct."
"Remove" = "Retirer"
"Remove all" = "Tout retirer"
"Remove annotation" = "Retirer l’annotation"
@@ -1368,6 +1371,7 @@ status = "draft — needs native review"
"The selection uses several text styles: click to apply one" = "La sélection utilise plusieurs styles de texte : cliquez pour en appliquer un"
"The server deletes this file if nobody opens it before {date}." = "Le serveur supprime ce fichier si personne ne l’ouvre avant le {date}."
"The whole file" = "Le fichier entier"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La fenêtre est trop petite pour {size} %, l’interface est donc affichée à {drawn} %. Agrandissez la fenêtre pour utiliser {size} %."
"Theme" = "Thème"
"There's no destination for that action" = "Cette action n’a pas de destination"
"Thickness" = "Épaisseur"
@@ -1445,6 +1449,7 @@ status = "draft — needs native review"
"Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité"
"Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig."
"Use the layer's fill" = "Utiliser le remplissage du calque"
"Use your browser's zoom to change the interface size." = "Utilisez le zoom de votre navigateur pour modifier la taille de l’interface."
"Used in {files} · {instances}" = "Utilisé dans {files} · {instances}"
"Username" = "Nom d’utilisateur"
"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode"
@@ -1713,6 +1718,7 @@ status = "draft — needs native review"
"{n}m" = "{n} min"
"{n}w" = "{n} sem."
"{page}, nothing selected" = "{page}, aucune sélection"
"{percent}%" = "{percent} %"
"{profile} (current)" = "{profile} (actuel)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} donne sa valeur ; {property ? \"a\" : \"b\"} choisit un texte. Tout autre {…} reste tel quel."
"{server} can't say what became of your files (it is older than this app)." = "{server} ne peut pas dire ce que sont devenus vos fichiers (il est plus ancien que cette application)."
@@ -2026,6 +2032,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Renommer les calques dupliqués"
"pref.showDimensions" = "Afficher les dimensions sur les objets"
"pref.startOnHome" = "Démarrer sur l’Accueil"
"pref.uiScale" = "Taille de l’interface"
"pref.userName" = "Votre nom dans les commentaires"
"proto.action.add" = "Ajouter une action de prototype"
"proto.action.move" = "Réordonner l’action de prototype"
+1
View File
@@ -934,6 +934,7 @@ status = "draft — needs native review"
"PDF print settings" = "Pengaturan cetak PDF"
"Padding" = "Padding"
"Page" = "Halaman"
"Page deleted" = "Halaman dihapus"
"Pages" = "Halaman"
"Pages added to the document" = "Halaman ditambahkan ke dokumen"
"Placed on the current page" = "Ditempatkan di halaman saat ini"
+1
View File
@@ -939,6 +939,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF 印刷設定"
"Padding" = "パディング"
"Page" = "ページ"
"Page deleted" = "ページは削除済み"
"Pages" = "ページ"
"Pages added to the document" = "ページをドキュメントに追加した"
"Placed on the current page" = "現在のページに配置した"
+1
View File
@@ -893,6 +893,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF 인쇄 설정"
"Padding" = "패딩"
"Page" = "페이지"
"Page deleted" = "페이지가 삭제됨"
"Pages" = "페이지"
"Pages added to the document" = "문서에 페이지 추가됨"
"Placed on the current page" = "현재 페이지에 배치됨"
+1
View File
@@ -953,6 +953,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF-afdrukinstellingen"
"Padding" = "Padding"
"Page" = "Pagina"
"Page deleted" = "Pagina verwijderd"
"Pages" = "Pagina's"
"Pages added to the document" = "Pagina's toegevoegd aan het document"
"Pages: {pages}" = "Pagina's: {pages}"
+1
View File
@@ -892,6 +892,7 @@ status = "draft — needs native review"
"PDF print settings" = "Configurações de impressão em PDF"
"Padding" = "Espaçamento interno"
"Page" = "Página"
"Page deleted" = "Página excluída"
"Pages" = "Páginas"
"Pages added to the document" = "Páginas adicionadas ao documento"
"Placed on the current page" = "Colocado na página atual"
+1
View File
@@ -933,6 +933,7 @@ name = "Русский"
"PDF print settings" = "Настройки печати PDF"
"Padding" = "Внутренние отступы"
"Page" = "Страница"
"Page deleted" = "Страница удалена"
"Pages" = "Страницы"
"Pages added to the document" = "Страницы добавлены в документ"
"Placed on the current page" = "Размещено на текущей странице"
+1
View File
@@ -943,6 +943,7 @@ name = "Svenska"
"PDF print settings" = "PDF-utskriftsinställningar"
"Padding" = "Utfyllnad"
"Page" = "Sida"
"Page deleted" = "Sidan har raderats"
"Pages" = "Sidor"
"Pages added to the document" = "Sidor tillagda i dokumentet"
"Placed on the current page" = "Placerat på den aktuella sidan"
+1
View File
@@ -934,6 +934,7 @@ name = "Türkçe"
"PDF print settings" = "PDF baskı ayarları"
"Padding" = "İç boşluk"
"Page" = "Sayfa"
"Page deleted" = "Sayfa silindi"
"Pages" = "Sayfalar"
"Pages added to the document" = "Sayfalar belgeye eklendi"
"Placed on the current page" = "Geçerli sayfaya yerleştirildi"
+1
View File
@@ -894,6 +894,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF 打印设置"
"Padding" = "内边距"
"Page" = "页面"
"Page deleted" = "页面已删除"
"Pages" = "页面"
"Pages added to the document" = "页面已添加到文档"
"Placed on the current page" = "已放置在当前页面"
+3
View File
@@ -491,6 +491,8 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods:
/// then doesn't show.
fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
if crate::proto_canvas::input(app, ui, resp) {
// A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
app.ui.pages_focus = None;
return true;
}
let ctx = ui.ctx().clone();
@@ -541,6 +543,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
// A press on the canvas gives the keyboard back to it (arrows nudge again).
if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) {
app.ui.layers_focus = false;
app.ui.pages_focus = None;
app.ui.key_box = None;
}
// Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans.
+47 -1
View File
@@ -142,8 +142,12 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) {
}
/// Escape closes the topmost simple dialog (one without menus of its own), before the canvas's
/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too.
/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. A menu
/// open over the dialog (Preferences ▸ Interface size) takes the first Escape and closes alone.
pub fn keys(app: &mut FigApp, ctx: &Context) {
if egui::Popup::is_any_open(ctx) {
return;
}
let u = &mut app.ui;
let open = u.color_profile.is_some()
|| u.menu_input.is_some()
@@ -275,6 +279,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
}
});
ui.add_space(8.0);
interface_size(app, ui, prefs.ui_scale);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Theme"));
for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] {
@@ -475,6 +481,46 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value {
p
}
/// Preferences ▸ Interface size: a size relative to the system's display scale (100 % is the
/// system's own), as a drop-down that takes effect at once. The browser has its own zoom, so there
/// the row only says so.
fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) {
let t = Tokens::get(ui.ctx());
if cfg!(target_arch = "wasm32") {
ui.label(tr("Interface size"));
crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary);
return;
}
let percent = |size: f64| trf("{percent}%", &[("percent", &crate::ui_scale::percent(size as f32))]);
let shown = percent(chosen);
ui.horizontal(|ui| {
ui.label(tr("Interface size"));
let resp = crate::widgets::dropdown(ui, &shown, 120.0, None);
crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp));
crate::right::menu_popup(&resp).show(|ui| {
for size in crate::ui_scale::SIZES {
if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") {
app.run("pref.uiScale", json!({"scale": size}));
ui.close();
}
}
});
});
crate::widgets::label(
ui,
tr("Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."),
t.text_secondary,
);
// Said in words, not only by the menu's value: the window is too small for the size chosen.
if app.ui.ui_scale.limited {
let note = trf(
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.",
&[("size", &crate::ui_scale::percent(chosen as f32)), ("drawn", &crate::ui_scale::percent(app.ui.ui_scale.zoom))],
);
crate::widgets::label(ui, &note, t.text_secondary);
}
}
/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers
/// start, a preview of the first new names, then one `object.batchRename`.
fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) {
+39 -4
View File
@@ -85,6 +85,8 @@ icons! {
CornerRadius,
Angle,
IndependentCorners,
/// Individual padding: a frame with one inward tick per side, each set alone.
IndependentPadding,
LinkOn,
LinkOff,
LayoutH,
@@ -139,6 +141,11 @@ icons! {
Fixed,
PaddingH,
PaddingV,
/// Individual padding sides: a frame with the controlled side filled.
PaddingTop,
PaddingRight,
PaddingBottom,
PaddingLeft,
Gap,
Reset,
Detach,
@@ -713,6 +720,14 @@ fn draw(p: &P, icon: Icon) {
p.corner(x, y, dx, dy, 4.0, 2.0);
}
}
IndependentPadding => {
// A frame outline with one inward tick per side: each side's padding, set alone.
p.rect(2.5, 2.5, 13.5, 13.5, 2.0);
p.line(8.0, 2.5, 8.0, 5.0);
p.line(13.5, 8.0, 11.0, 8.0);
p.line(8.0, 13.5, 8.0, 11.0);
p.line(2.5, 8.0, 5.0, 8.0);
}
LinkOn | Link => {
p.path(&capsule((4.0, 12.0), (6.5, 9.5), 2.4), true);
p.path(&capsule((9.5, 6.5), (12.0, 4.0), 2.4), true);
@@ -930,12 +945,32 @@ fn draw(p: &P, icon: Icon) {
p.line(3.0, 7.5, 13.0, 7.5);
}
PaddingH => {
p.rect(2.5, 3.5, 13.5, 12.5, 1.0);
p.fill_rect(5.0, 6.0, 11.0, 10.0, 0.0);
// Horizontal padding: bars down the padded left and right sides in their frame.
p.rect(2.5, 2.5, 13.5, 13.5, 1.0);
p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0);
p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0);
}
PaddingV => {
p.rect(3.5, 2.5, 12.5, 13.5, 1.0);
p.fill_rect(6.0, 5.0, 10.0, 11.0, 0.0);
// Vertical padding: bars across the padded top and bottom in their frame.
p.rect(2.5, 2.5, 13.5, 13.5, 1.0);
p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0);
p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0);
}
PaddingTop => {
p.rect(2.5, 2.5, 13.5, 13.5, 1.0);
p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0);
}
PaddingRight => {
p.rect(2.5, 2.5, 13.5, 13.5, 1.0);
p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0);
}
PaddingBottom => {
p.rect(2.5, 2.5, 13.5, 13.5, 1.0);
p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0);
}
PaddingLeft => {
p.rect(2.5, 2.5, 13.5, 13.5, 1.0);
p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0);
}
Online => {
p.circle(8.0, 8.0, 5.5);
+56 -18
View File
@@ -184,13 +184,18 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
app.ui.pages_open = !app.ui.pages_open;
}
let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0));
let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(crate::shortcuts::tip(tr("Find"), "edit.findReplace"));
if sresp.hovered() {
let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages"));
if sresp.hovered() || app.ui.page_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon);
let search_id = Id::new("page_search_field");
if sresp.clicked() {
app.ui.find = Some((String::new(), String::new()));
app.ui.page_search = !app.ui.page_search;
app.ui.page_filter.clear();
if app.ui.page_search {
app.ui.pages_open = true;
}
}
let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0));
let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page"));
@@ -199,15 +204,22 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
}
icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon);
if aresp.clicked() {
app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid);
app.run("page.add", json!({}));
}
if !app.ui.pages_open {
return;
}
if app.ui.page_search {
search_field(ui, &t, w, search_id, tr("Search pages"), &mut app.ui.page_filter, &mut app.ui.page_search, sresp.clicked());
}
let Ok(ds) = app.session.ds() else { return };
let pages: Vec<(NodeId, String, bool)> =
ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect();
let filter = app.ui.page_filter.trim().to_lowercase();
let filtering = !filter.is_empty();
let cur = ds.page;
let uid = ds.uid;
let sel = ds.selection.clone();
let mut select = None;
let mut rename_done: Option<(NodeId, String)> = None;
@@ -221,8 +233,14 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
let max_h = pages_list_height(app.ui.pages_height, ui.available_height());
let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| {
for (k, (id, name, divider)) in pages.iter().cloned().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag());
reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() });
if filtering && (divider || !name.to_lowercase().contains(&filter)) {
continue;
}
let sense = if filtering { Sense::click() } else { Sense::click_and_drag() };
let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense);
if !filtering {
reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() });
}
a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur);
let r = r.shrink2(vec2(8.0, 0.0));
a11y::ring_around(ui, r, &resp);
@@ -257,6 +275,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
}
if resp.clicked() && !divider {
select = Some(id);
app.ui.pages_focus = Some(uid);
app.ui.layers_focus = false;
}
if resp.double_clicked() {
app.ui.rename_page = Some((id, name.clone()));
@@ -274,7 +294,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
ui.close();
}
if ui.button(tr("Delete page")).clicked() {
app.run("page.delete", json!({"id": id}));
delete_page(app, id);
ui.close();
}
});
@@ -311,6 +331,20 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y));
}
/// The Pages list has the keyboard: a page row (or the add-page button) of the open file was
/// clicked last, and the list is still on screen.
pub(crate) fn pages_have_keyboard(app: &FigApp) -> bool {
let Ok(ds) = app.session.ds() else { return false };
app.ui.pages_focus == Some(ds.uid) && app.ui.pages_open && !app.ui.left_collapsed && !ds.view.ui_hidden && app.ui.left_tab == LeftTab::File
}
/// Delete `page` (no confirmation: Undo brings it back) and say so.
pub(crate) fn delete_page(app: &mut FigApp, page: NodeId) {
if app.run("page.delete", json!({"id": page})).is_some() {
app.toast(tr("Page deleted"));
}
}
/// The pages list's height in a panel with `avail` below the Pages header: the height asked for
/// (by default 40% of it), at least three rows, and leaving the layers their header and three rows.
pub(crate) fn pages_list_height(asked: Option<f32>, avail: f32) -> f32 {
@@ -623,8 +657,10 @@ fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut BTre
}
}
/// The search row under the Layers header (live filter; Escape closes it).
fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) {
/// The search row under the Pages or Layers header: a live filter `text`, taking the keyboard when
/// `focus` (just opened). Escape in it clears it and closes the row (`open`), and goes no further
/// (the canvas keeps its selection).
fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut String, open: &mut bool, focus: bool) {
let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover());
let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0));
ui.painter().rect_filled(field, CornerRadius::same(5), t.field);
@@ -632,21 +668,25 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) {
let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect));
let r = child.add(
egui::TextEdit::singleline(&mut app.ui.layer_filter)
egui::TextEdit::singleline(text)
.id(id)
.frame(egui::Frame::NONE)
.font(regular(11.0))
.hint_text(tr("Find layers"))
.hint_text(hint)
.vertical_align(egui::Align::Center)
.desired_width(te_rect.width()),
);
crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter);
// After the field: the click that opened it ends elsewhere, which would take the focus back.
if focus {
r.request_focus();
}
crate::widgets::select_all_on_focus(ui, &r, text);
if r.has_focus() {
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, ACCENT), egui::StrokeKind::Inside);
}
if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.layer_search = false;
app.ui.layer_filter.clear();
if (r.has_focus() || r.lost_focus()) && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
*open = false;
text.clear();
}
}
@@ -665,12 +705,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
if sresp.clicked() {
app.ui.layer_search = !app.ui.layer_search;
app.ui.layer_filter.clear();
if app.ui.layer_search {
ui.memory_mut(|m| m.request_focus(search_id));
}
}
if app.ui.layer_search {
search_field(app, ui, &t, w, search_id);
search_field(ui, &t, w, search_id, tr("Find layers"), &mut app.ui.layer_filter, &mut app.ui.layer_search, sresp.clicked());
}
let Ok(ds) = app.session.ds() else { return };
let doc = ds.doc.clone();
@@ -1064,6 +1101,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
}
if let Some((id, m)) = clicked {
app.ui.layers_focus = true;
app.ui.pages_focus = None;
let order: Vec<NodeId> = rows.iter().map(|r| r.id).collect();
let pos = |x: NodeId| order.iter().position(|o| *o == x);
let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos);
@@ -199,6 +199,81 @@ fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 {
panic!("no row for page {page:?}");
}
#[test]
fn pages_search_button_opens_the_filter_and_escape_clears_it() {
let s = Session::new();
let _ = s.page().unwrap();
let mut u = Ui::new(s);
// The search icon in the Pages header opens the page-name field, not Find and Replace.
click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE);
assert!(u.app.ui.page_search, "the Pages search is open");
assert!(u.app.ui.find.is_none(), "page search does not open Find and Replace");
u.frame(vec![Event::Text("COMPON".into())]);
assert_eq!(u.app.ui.page_filter, "COMPON", "typing can start immediately");
u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert!(!u.app.ui.page_search, "Escape closes page search");
assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter");
}
#[test]
fn pages_search_leaves_escape_to_the_canvas_when_the_field_is_unfocused() {
let mut s = Session::new();
let r = rect(&mut s, "r", 0.0, None);
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.page_search = true;
u.app.ui.page_filter = "COMPON".into();
u.frame(vec![]);
u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert!(u.app.session.selection().is_empty(), "Escape deselects on the canvas");
assert!(u.app.ui.page_search, "the page search stays open");
assert_eq!(u.app.ui.page_filter, "COMPON", "and keeps its filter");
}
#[test]
fn escape_in_the_pages_search_does_not_reach_the_canvas() {
let mut s = Session::new();
let r = rect(&mut s, "r", 0.0, None);
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE);
assert!(u.app.ui.page_search);
u.frame(vec![Event::Text("x".into())]);
u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
assert!(!u.app.ui.page_search, "Escape closes the page search");
assert_eq!(u.app.session.selection(), vec![r], "and leaves the selection alone");
}
#[test]
fn pages_search_filters_page_names_case_insensitively() {
let mut s = Session::new();
let _overview = s.page().unwrap();
let components = id(s.execute("page.add", &json!({"name": "Components"})).unwrap());
let library = id(s.execute("page.add", &json!({"name": "Library"})).unwrap());
s.execute("page.select", &json!({"id": library})).unwrap();
let mut u = Ui::new(s);
u.app.ui.page_search = true;
u.app.ui.page_filter = "COMPON".into();
let shapes = u.frame_output(vec![]).shapes;
let text = crate::live_tests::texts(&crate::live_tests::flat(&shapes));
assert!(text.iter().any(|(label, _, _)| label == "Components"), "the matching page remains visible");
assert!(!text.iter().any(|(label, _, _)| label == "Library"), "nonmatching pages are filtered out");
// Search leaves only Components; the active page starts as Library.
for y in (150..400).step_by(4) {
let p = Pos2::new(150.0, y as f32);
u.button(p, true);
u.button(p, false);
if u.app.session.page().unwrap() == components {
break;
}
}
assert_eq!(u.app.session.page().unwrap(), components, "matching is case-insensitive and opens the named page");
}
#[test]
fn pages_drag_to_reorder_and_dividers_open_nothing() {
let mut s = Session::new();
+124 -2
View File
@@ -1,8 +1,8 @@
//! Arrow keys in the layers panel once a row has been clicked: up/down through the rows (Shift
//! extends), left/right to collapse, expand, and go to the parent or first child; a press on the
//! canvas gives arrows back to nudging.
//! canvas gives arrows back to nudging. Delete after a click in the Pages list deletes the page.
use egui::{Event, Key, Modifiers, pos2};
use egui::{Event, Key, Modifiers, Pos2, pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
@@ -107,3 +107,125 @@ fn a_canvas_press_gives_arrows_back_to_nudging() {
let doc = u.app.session.doc().unwrap();
assert_eq!(doc.node(c).unwrap().transform.translation().x, 201.0);
}
/// The middle of `page`'s row in the Pages list, found by clicking down the list until it opens;
/// then back to `back`.
fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 {
for y in (90..400).step_by(4) {
let p = pos2(150.0, y as f32);
u.button(p, true);
u.button(p, false);
if u.app.session.page().unwrap() == page {
u.app.session.execute("page.select", &json!({"id": back})).unwrap();
u.frame(vec![]);
// Rows are 32 high: aim at the middle, past the hit tolerance at its edge.
return p + vec2(0.0, 12.0);
}
}
panic!("no row for page {page:?}");
}
fn pages(u: &Ui) -> usize {
u.app.session.doc().unwrap().pages.len()
}
#[test]
fn delete_after_clicking_a_page_deletes_it_without_asking() {
// #859: Delete on a page picked in the Pages list said "nothing is selected".
let (mut u, ..) = setup();
let first = u.app.session.page().unwrap();
let second = id(u.app.session.execute("page.add", &json!({})).unwrap());
u.app.session.execute("page.select", &json!({"id": first})).unwrap();
u.frame(vec![]);
let row = page_row(&mut u, second, first);
u.button(row, true);
u.button(row, false);
assert_eq!(u.app.session.page().unwrap(), second);
key(&mut u, Key::Delete, false);
// Gone at once, no confirmation: a toast says so and Undo brings it back.
assert_eq!(pages(&u), 1);
assert!(!u.app.session.doc().unwrap().pages.contains(&second));
assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Page deleted"));
u.app.run("edit.undo", json!({}));
assert_eq!(pages(&u), 2);
// Backspace (the Mac's delete key) does the same.
let row = page_row(&mut u, second, first);
u.button(row, true);
u.button(row, false);
key(&mut u, Key::Backspace, false);
assert_eq!(pages(&u), 1);
// The last page stays.
key(&mut u, Key::Delete, false);
assert_eq!(pages(&u), 1);
}
#[test]
fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() {
let (mut u, a, ..) = setup();
let first = u.app.session.page().unwrap();
let second = id(u.app.session.execute("page.add", &json!({})).unwrap());
u.frame(vec![]);
let row = page_row(&mut u, first, second);
u.button(row, true);
u.button(row, false);
assert_eq!(u.app.session.page().unwrap(), first);
assert!(u.app.ui.pages_focus.is_some());
// Layers selected after the click (Select all, say): Delete deletes them, not the page.
u.app.run("select.set", json!({"ids": [a]}));
key(&mut u, Key::Delete, false);
assert_eq!(pages(&u), 2);
assert!(u.app.session.doc().unwrap().node(a).is_none());
// Nothing is selected now, but the list gave up the keyboard: a second Delete keeps the page.
assert!(u.app.ui.pages_focus.is_none());
key(&mut u, Key::Delete, false);
assert_eq!(pages(&u), 2, "a second Delete keeps the page");
// A press on the canvas gives Delete back to it.
u.button(row, true);
u.button(row, false);
assert!(u.app.ui.pages_focus.is_some());
let p = pos2(900.0, 700.0);
u.button(p, true);
u.button(p, false);
assert!(u.app.ui.pages_focus.is_none());
u.app.run("select.none", json!({}));
key(&mut u, Key::Delete, false);
assert_eq!(pages(&u), 2, "Delete with nothing selected on the canvas deletes no page");
}
#[test]
fn delete_deletes_no_page_once_the_pages_list_is_off_screen_or_another_file_is_open() {
let (mut u, ..) = setup();
let first = u.app.session.page().unwrap();
let second = id(u.app.session.execute("page.add", &json!({})).unwrap());
u.app.session.execute("page.select", &json!({"id": first})).unwrap();
u.frame(vec![]);
let row = page_row(&mut u, second, first);
let click = |u: &mut Ui| {
u.button(row, true);
u.button(row, false);
assert!(u.app.ui.pages_focus.is_some());
};
// The Assets tab, a collapsed Pages list, a collapsed left panel.
let hide: [fn(&mut Ui, bool); 3] = [
|u, on| u.app.ui.left_tab = if on { crate::LeftTab::Assets } else { crate::LeftTab::File },
|u, on| u.app.ui.pages_open = !on,
|u, on| u.app.ui.left_collapsed = on,
];
for (k, hide) in hide.iter().enumerate() {
click(&mut u);
hide(&mut u, true);
u.frame(vec![]);
key(&mut u, Key::Delete, false);
assert_eq!(pages(&u), 2, "case {k}: the list is off screen");
hide(&mut u, false);
u.frame(vec![]);
}
// Another file open: its pages are safe.
click(&mut u);
u.app.run("file.new", json!({}));
u.app.run("page.add", json!({}));
u.frame(vec![]);
assert_eq!(pages(&u), 2);
key(&mut u, Key::Delete, false);
assert_eq!(pages(&u), 2, "the other file keeps its pages");
}
+57 -26
View File
@@ -401,35 +401,66 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
}
}
/// The Spacing row: the H/V padding pair, or, with Individual padding on, two rows of one field
/// per side (like Individual corners).
fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
let col = widgets::col_width(ui.available_width());
let [pt, pr, pb, pl] = l.padding;
row(ui, 32.0, |ui| {
let e = crate::variable_ui::number_field(
app,
ui,
ids,
"paddingHorizontal",
Id::new("al_padh"),
Lead::Icon(Icon::PaddingH, tr("Horizontal padding")),
(pl == pr).then_some(pl),
"",
col,
);
app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0)));
let e = crate::variable_ui::number_field(
app,
ui,
ids,
"paddingVertical",
Id::new("al_padv"),
Lead::Icon(Icon::PaddingV, tr("Vertical padding")),
(pt == pb).then_some(pt),
"",
col,
);
app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0)));
});
let open = ui.data(|d| d.get_temp::<bool>(Id::new("indep_padding"))).unwrap_or(false);
let toggle = |ui: &mut Ui| {
if icons::button(ui, Icon::IndependentPadding, 24.0, open, tr("Individual padding")).clicked() {
ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), !open));
}
};
if open {
// `l` is the selection's common layout, so its sides are common too. `k` indexes
// [top, right, bottom, left].
let sides = [
("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3),
("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0),
("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1),
("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2),
];
for (r, pair) in sides.chunks(2).enumerate() {
row(ui, 32.0, |ui| {
for (key, name, icon, v, k) in pair {
let e = crate::variable_ui::number_field(app, ui, ids, key, Id::new(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col);
app.field(ids, key, e, |v| json!(v.max(0.0)));
}
if r == 0 {
toggle(ui);
}
});
}
} else {
row(ui, 32.0, |ui| {
let e = crate::variable_ui::number_field(
app,
ui,
ids,
"paddingHorizontal",
Id::new("al_padh"),
Lead::Icon(Icon::PaddingH, tr("Horizontal padding")),
(pl == pr).then_some(pl),
"",
col,
);
app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0)));
let e = crate::variable_ui::number_field(
app,
ui,
ids,
"paddingVertical",
Id::new("al_padv"),
Lead::Icon(Icon::PaddingV, tr("Vertical padding")),
(pt == pb).then_some(pt),
"",
col,
);
app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0)));
toggle(ui);
});
}
if l.mode != LayoutMode::Grid && l.wrap {
// Wrapped rows (horizontal) or columns (vertical): the gap between them.
let vertical = l.mode == LayoutMode::Vertical;
+31
View File
@@ -61,6 +61,7 @@ pub mod tabs;
pub mod theme;
pub mod toolbar;
pub mod typography;
pub mod ui_scale;
pub mod variable_ui;
pub mod variables_panel;
pub mod vector_panel;
@@ -156,6 +157,8 @@ mod online_tests;
#[cfg(test)]
mod open_issues_tests;
#[cfg(test)]
mod padding_individual_tests;
#[cfg(test)]
mod palette_tests;
#[cfg(test)]
mod panel_remove_tests;
@@ -204,6 +207,8 @@ mod text_enter_tests;
#[cfg(test)]
mod titlebar_tests;
#[cfg(test)]
mod ui_scale_tests;
#[cfg(test)]
mod variables_export_ui_tests;
#[cfg(test)]
mod variables_rename_tests;
@@ -483,6 +488,9 @@ pub struct UiState {
pub last_right_tab: RightTab,
pub left_tab: LeftTab,
pub pages_open: bool,
/// Pages panel search: open, and the case-insensitive name filter.
pub page_search: bool,
pub page_filter: String,
/// The height asked for the pages list (`None`: a share of the panel).
pub pages_height: Option<f32>,
pub expanded: BTreeSet<NodeId>,
@@ -511,6 +519,9 @@ pub struct UiState {
pub menu_input: Option<dialogs::MenuInput>,
/// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame.
pub rename_file: bool,
/// What the interface size drew last frame and whether the window held it back. Set by
/// [`FigApp::logic`] for the main window.
pub ui_scale: ui_scale::Plan,
pub shortcuts_panel: bool,
/// The Keyboard shortcuts panel's tab and search text.
pub shortcuts_tab: figcraft_engine::keymap::Group,
@@ -552,6 +563,10 @@ pub struct UiState {
/// The layers panel has the keyboard: arrow keys move through its rows instead of nudging
/// (set by a click on a row, cleared by a press on the canvas).
pub layers_focus: bool,
/// The file (its `uid`) whose pages list has the keyboard: Delete with no layers selected deletes
/// its current page (set by a click on a page row or the add-page button, cleared by a click on a
/// layer row or a press on the canvas).
pub pages_focus: Option<u64>,
pub layers_seen_sel: Vec<NodeId>,
pub dark: bool,
pub follow_system: bool,
@@ -647,6 +662,8 @@ impl Default for UiState {
last_right_tab: RightTab::Design,
left_tab: LeftTab::File,
pages_open: true,
page_search: false,
page_filter: String::new(),
pages_height: None,
expanded: BTreeSet::new(),
rename: None,
@@ -664,6 +681,7 @@ impl Default for UiState {
preferences: false,
menu_input: None,
rename_file: false,
ui_scale: ui_scale::plan(1.0, None, None),
shortcuts_panel: false,
shortcuts_tab: figcraft_engine::keymap::Group::Essential,
shortcuts_query: String::new(),
@@ -686,6 +704,7 @@ impl Default for UiState {
layer_filter: String::new(),
layer_anchor: None,
layers_focus: false,
pages_focus: None,
layers_seen_sel: Vec::new(),
dark: false,
follow_system: false,
@@ -1352,6 +1371,16 @@ impl FigApp {
theme::install_fonts(ctx, true, cjk);
}
}
// egui's zoom is for the whole app and multiplies each window's own system scale, so the
// size is the same relative to every screen; the main window, whose size can hold a size
// back, sets it. The browser keeps its own zoom.
if ctx.viewport_id() == egui::ViewportId::ROOT {
let window_px = ctx.content_rect().size() * ctx.pixels_per_point();
let size = if cfg!(target_arch = "wasm32") { 1.0 } else { self.session.prefs.ui_scale };
let plan = ui_scale::plan(size, ctx.native_pixels_per_point(), Some((window_px.x, window_px.y)));
ctx.set_zoom_factor(plan.zoom);
self.ui.ui_scale = plan;
}
let dark = match self.session.prefs.theme.as_str() {
"dark" => true,
// The active visuals were set by us last frame (possibly for a manual theme).
@@ -1770,6 +1799,8 @@ impl FigApp {
"importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect),
});
state["homeSearch"] = json!(self.ui.home_view.file_query);
// Set apart from the literal above, which is at the macro recursion limit.
state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited});
state["pendingOpens"] = json!(self.ui.pending_opens.len());
state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect));
state
+13
View File
@@ -119,6 +119,19 @@ pub fn invoke(app: &mut FigApp, id: &str) {
// Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab
// removes that interaction, not the layer (#767).
"edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {}
// After a click in the Pages list, while the list is on screen and its file open, with no
// layers selected, it deletes the current page (#859).
"edit.delete" if crate::layers::pages_have_keyboard(app) && app.session.selection().is_empty() => {
if let Ok(page) = app.session.page() {
crate::layers::delete_page(app, page);
}
}
// Otherwise the list gives up the keyboard: once Delete has taken layers selected since the
// click (Select all, say), a second Delete must not take the page.
"edit.delete" if app.ui.pages_focus.is_some() => {
app.ui.pages_focus = None;
app.run(id, json!({}));
}
"file.open" => app.open_dialog(),
"plugin.import" => crate::plugins_ui::import_dialog(app),
"file.import" => app.import_dialog(false),
@@ -0,0 +1,114 @@
//! Individual padding in the Design panel, driven headless: the four side fields only
//! appear once the toggle opens, and typing in one sets that side alone.
use egui::accesskit::Role;
use egui::{Event, Id, Key, Modifiers, Pos2, pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
/// An auto layout frame with uniform padding 10, selected.
fn scene() -> (Ui, NodeId) {
let mut s = Session::new();
let f =
NodeId(
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "props": {"layout": "horizontal", "padding": 10}}))
.unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("select.set", &json!({"ids": [f]})).unwrap();
(Ui::new(s), f)
}
/// The frame's padding as `[top, right, bottom, left]`.
fn padding(u: &Ui, f: NodeId) -> [f64; 4] {
u.app.session.doc().unwrap().node(f).unwrap().layout.clone().unwrap().padding
}
/// A full press-and-release at `p`, then settling one frame.
fn click(u: &mut Ui, p: Pos2) {
u.button(p, true);
u.button(p, false);
u.frame(vec![]);
}
/// A press-and-release of Enter, committing the focused number field.
fn enter(u: &mut Ui) {
u.frame(vec![
Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
]);
}
/// The middle of the toggle button named `label`, found in the AccessKit tree.
fn named_button(u: &mut Ui, label: &str) -> Pos2 {
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label);
let b = n.bounds().expect("bounds");
pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
}
/// The side fields stay hidden until the toggle opens, then typing in one sets
/// that side alone in a single undo step.
#[test]
fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() {
let (mut u, f) = scene();
assert_eq!(padding(&u, f), [10.0; 4]);
// Shut: no per-side fields.
assert!(u.ctx.read_response(Id::new(("padding", 0)).with("field")).is_none(), "top field hidden until toggled");
// Click the toggle: the four sides appear.
let t = named_button(&mut u, "Individual padding");
click(&mut u, t);
let top = u.ctx.read_response(Id::new(("padding", 0)).with("field")).expect("top field shows").rect.center();
assert!(u.ctx.read_response(Id::new(("padding", 3)).with("field")).is_some(), "left field shows");
// Typing in the top field sets only the top, in one undo step.
let history = u.app.session.undo_depth().0;
click(&mut u, top);
u.frame(vec![Event::Text("8".into())]);
enter(&mut u);
u.frame(vec![]);
assert_eq!(padding(&u, f), [8.0, 10.0, 10.0, 10.0]);
assert_eq!(u.app.session.undo_depth().0, history + 1);
u.app.run("edit.undo", json!({}));
assert_eq!(padding(&u, f), [10.0; 4]);
}
/// The middle of the shut pair field with accessible name `label`.
fn named_field(u: &mut Ui, label: &str) -> Pos2 {
u.ctx.enable_accesskit();
u.frame(vec![]);
let out = u.frame_output(vec![]);
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() != Role::Button && n.label() == Some(label)).expect(label);
let b = n.bounds().expect("bounds");
pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
}
/// The shut pair fields read Mixed when their sides disagree and edit their own axis: horizontal
/// touches left and right only, vertical touches top and bottom only.
#[test]
fn shut_pair_fields_edit_their_own_axis() {
let (mut u, f) = scene();
u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}}));
assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]);
let shown = crate::live_tests::texts(&crate::live_tests::flat(&u.frame_output(vec![]).shapes));
assert!(shown.iter().any(|(t, _, _)| t == "Mixed"), "sides that disagree read Mixed: {shown:?}");
let h = named_field(&mut u, "Horizontal padding");
click(&mut u, h);
u.frame(vec![Event::Text("20".into())]);
enter(&mut u);
u.frame(vec![]);
assert_eq!(padding(&u, f), [10.0, 20.0, 10.0, 20.0]);
let v = named_field(&mut u, "Vertical padding");
click(&mut u, v);
u.frame(vec![Event::Text("7".into())]);
enter(&mut u);
u.frame(vec![]);
assert_eq!(padding(&u, f), [7.0, 20.0, 7.0, 20.0]);
}
+20
View File
@@ -295,6 +295,26 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool {
mod tests {
use super::*;
/// A press on a connection handle takes the keyboard from the Pages list, as a press anywhere
/// else on the canvas does, so a later Delete can't take the page (#859).
#[test]
fn a_press_on_a_connection_handle_ends_the_pages_list_focus() {
let mut s = figcraft_engine::Session::new();
let a = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 150})).unwrap();
let a = crate::present_tests::id(&a);
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = crate::present_tests::Ui::new(s);
u.app.ui.right_tab = RightTab::Prototype;
u.run("view.zoomSelection", json!({}));
u.frames(2, Vec::new);
u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid);
let doc = u.app.session.doc().unwrap().clone();
let (_, at) = *handles(&u.app, &doc, &[a]).first().unwrap();
u.button(at, true);
assert!(u.app.ui.pages_focus.is_none());
u.button(at, false);
}
#[test]
fn curves_leave_the_facing_side() {
let a = Rect::from_min_size(pos2(0.0, 0.0), vec2(50.0, 20.0));
+23
View File
@@ -304,3 +304,26 @@ fn escape_closes_prototype_editor_without_deselecting_layer() {
u.key(pos2(100.0, 100.0), egui::Key::Escape);
assert!(u.app.session.selection().is_empty(), "selection cleared on third escape");
}
/// After a click in the Pages list, Delete that removes an interaction ends the list's hold on the
/// keyboard: with the layer deselected afterwards, Delete doesn't take the page (#859).
#[test]
fn deleting_an_interaction_ends_the_pages_list_focus() {
let (s, _a, _b, btn) = setup();
let mut u = Ui::new(s);
let first = u.app.session.page().unwrap();
u.run("page.add", json!({}));
u.run("page.select", json!({"id": first}));
// As after a click on the page's row (layers_keys_tests clicks the real row).
u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid);
u.app.ui.right_tab = crate::RightTab::Prototype;
u.run("select.set", json!({"ids": [btn]}));
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(1, Vec::new);
u.key(pos2(100.0, 100.0), egui::Key::Delete);
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0);
assert!(u.app.ui.pages_focus.is_none());
u.run("select.none", json!({}));
u.key(pos2(100.0, 100.0), egui::Key::Delete);
assert_eq!(u.app.session.doc().unwrap().pages.len(), 2, "the page stays");
}
+2
View File
@@ -53,6 +53,8 @@ pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> boo
return false;
}
open_editor(ctx, None);
// Delete went to the interaction, so a later one doesn't go to the Pages list.
app.ui.pages_focus = None;
let ids = targets_for(app, node);
app.run("proto.interaction.remove", json!({"ids": ids, "index": index}));
true
+2 -2
View File
@@ -961,8 +961,8 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
// The collection's name, then a wider mode picker ("Auto (Light)") across both field columns.
let col = widgets::col_width(ui.available_width());
let (name_w, pick_w) = ((col * 0.7).round(), (col * 1.3).round());
// Auto resolves from the parent of the first selected layer.
let parent_modes = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).map(|p| doc.modes_for(p)).unwrap_or_default();
// Auto resolves as for the first selected layer: from its parent, or an instance's main.
let parent_modes = sel.first().map(|i| doc.inherited_modes(*i)).unwrap_or_default();
let mut group = None;
for r in &rows {
let c = r.coll;
+155 -45
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use egui::{CursorIcon, Id, Pos2, Rect, Stroke, Ui, vec2};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::selection_reorder::ReorderLayout;
use figcraft_engine::selection_spacing::{Axis, SelectionSpacing, selection_spacing};
use serde_json::json;
@@ -20,6 +21,7 @@ struct LayoutCache {
ids: Vec<NodeId>,
frame: u64,
layout: Arc<SelectionSpacing>,
reorder: Option<Arc<ReorderLayout>>,
}
fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc<SelectionSpacing> {
@@ -41,25 +43,68 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc<SelectionSpa
// Panel, input and drawing share one calculation per frame. If a command changes the
// document during this frame, refresh next frame; unchanged snapshots also reuse it then.
let layout = Arc::new(selection_spacing(&ds.doc, ids));
let reorder = ReorderLayout::new(&ds.doc, ids).map(Arc::new);
ctx.data_mut(|d| {
d.insert_temp(key, LayoutCache { document: ds.uid, page: ds.page, doc: ds.doc.clone(), ids: ids.to_vec(), frame, layout: layout.clone() });
d.insert_temp(
key,
LayoutCache { document: ds.uid, page: ds.page, doc: ds.doc.clone(), ids: ids.to_vec(), frame, layout: layout.clone(), reorder },
);
});
layout
}
fn reorder_layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Option<Arc<ReorderLayout>> {
layout(app, ctx, ids);
ctx.data(|d| d.get_temp::<LayoutCache>(Id::new("selection_spacing_layout"))).and_then(|c| c.reorder)
}
fn drag_id() -> Id {
Id::new("selection_spacing_drag")
}
/// The reorder ring of a layer with world bounds `b`: its centre on screen and hit radius. The
/// radius is 8 px, at most a quarter of the layer's smaller side on screen, so most of a small
/// layer still drags the selection; a layer under 16 px on screen has no ring.
fn ring(app: &FigApp, b: figcraft_geom::Rect) -> Option<(Pos2, f32)> {
let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom);
let side = ((b.x1 - b.x0).min(b.y1 - b.y0) * zoom) as f32;
(side >= 16.0).then(|| (app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5), (side / 4.0).min(8.0)))
}
#[derive(Clone)]
enum Action {
Spacing { axis: Axis, value: f64, last_value: Option<f64> },
Reorder { layout: Arc<ReorderLayout>, source: NodeId, target: NodeId, swap: bool },
}
#[derive(Clone)]
struct Drag {
ids: Vec<NodeId>,
axis: Axis,
action: Action,
start: Pos2,
value: f64,
uid: u64,
page: NodeId,
moved: bool,
last_value: Option<f64>,
}
fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec<NodeId>, p: Pos2, action: Action) {
let label = match action {
Action::Spacing { .. } => tr("Spacing"),
Action::Reorder { .. } => tr("Reorder selection"),
};
if app.session.begin_interaction(label).is_ok()
&& let Ok(ds) = app.session.ds()
{
let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false };
app.ui.layers_focus = false;
app.ui.pages_focus = None;
app.ui.key_box = None;
ctx.memory_mut(|m| {
if let Some(f) = m.focused() {
m.surrender_focus(f);
}
});
ctx.data_mut(|d| d.insert_temp(drag_id(), drag));
}
}
pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) {
@@ -146,7 +191,7 @@ pub fn cancel(app: &mut FigApp, ctx: &egui::Context) -> bool {
/// Retire handles when another document, page, tool or editing context takes over.
pub fn sync(app: &mut FigApp, ctx: &egui::Context) {
if let Some(drag) = ctx.data(|d| d.get_temp::<Drag>(drag_id())) {
let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids);
let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some());
if !same || !available(app) {
cancel(app, ctx);
}
@@ -157,39 +202,77 @@ pub fn sync(app: &mut FigApp, ctx: &egui::Context) {
pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered: bool, space: bool) -> bool {
let dragging = ctx.data(|d| d.get_temp::<Drag>(drag_id()));
if let Some(mut drag) = dragging {
let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids);
let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some());
if !same {
cancel(app, ctx);
return true;
}
let down = ctx.input(|i| i.pointer.primary_down());
if let Some(p) = pos {
let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6);
let distance = if drag.axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x };
drag.moved |= distance.abs() >= 3.0;
if drag.moved {
let value = ctx
.input(|i| {
let quantum = if i.modifiers.alt {
0.1
} else if i.modifiers.shift {
10.0
} else {
1.0
};
((drag.value + f64::from(distance) / zoom) / quantum).round() * quantum
})
.clamp(-1e6, 1e6);
let key = if drag.axis.vertical { "vertical" } else { "horizontal" };
if drag.last_value != Some(value) {
match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) {
Ok(_) => drag.last_value = Some(value),
Err(e) => app.toast(e.to_string()),
match &mut drag.action {
Action::Spacing { axis, value: original, last_value } => {
let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6);
let distance = if axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x };
drag.moved |= distance.abs() >= 3.0;
if drag.moved {
let value = ctx
.input(|i| {
let quantum = if i.modifiers.alt {
0.1
} else if i.modifiers.shift {
10.0
} else {
1.0
};
((*original + f64::from(distance) / zoom) / quantum).round() * quantum
})
.clamp(-1e6, 1e6);
let key = if axis.vertical { "vertical" } else { "horizontal" };
if *last_value != Some(value) {
match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) {
Ok(_) => *last_value = Some(value),
Err(e) => app.toast(e.to_string()),
}
}
}
}
Action::Reorder { layout, source, target, swap } if app.canvas.rect.contains(p) => {
drag.moved |= p.distance(drag.start) >= 3.0;
// Hit original slots, never the moving preview: otherwise a stationary
// pointer could alternate between two permutations on successive frames.
let nearest = layout
.slots
.iter()
.min_by(|a, b| {
let center = |b: figcraft_geom::Rect| app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5);
center(a.1).distance_sq(p).total_cmp(&center(b.1).distance_sq(p))
})
.map(|(id, _)| *id)
.unwrap_or(*source);
let swapping = ctx.input(|i| i.modifiers.command);
if drag.moved && (nearest != *target || swapping != *swap) {
match app
.session
.execute("arrange.reorder", &json!({"ids":drag.ids,"source":source,"target":nearest,"swap":swapping,"fromGesture":true}))
{
Ok(_) => {
*target = nearest;
*swap = swapping;
}
Err(e) => app.toast(e.to_string()),
}
}
}
// A release over a panel or outside the window commits the last canvas
// preview; the release's sentinel coordinates must not select a new slot.
Action::Reorder { .. } => {}
}
}
ctx.set_cursor_icon(if drag.axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal });
ctx.set_cursor_icon(match &drag.action {
Action::Spacing { axis, .. } if axis.vertical => CursorIcon::ResizeVertical,
Action::Spacing { .. } => CursorIcon::ResizeHorizontal,
Action::Reorder { .. } => CursorIcon::Grabbing,
});
if !down {
let _ = app.session.commit_interaction();
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
@@ -198,11 +281,26 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered:
}
return true;
}
if !hovered || space || !available(app) || ctx.input(|i| i.modifiers.command) {
if !hovered || space || !available(app) {
return false;
}
let Some(p) = pos else { return false };
let ids = app.session.selection();
if let Some(reorder) = reorder_layout(app, ctx, &ids) {
for (id, b) in &reorder.slots {
if ring(app, *b).is_some_and(|(center, r)| center.distance(p) <= r) {
ctx.set_cursor_icon(CursorIcon::Grab);
if ctx.input(|i| i.pointer.primary_pressed()) {
let source = *id;
start_drag(app, ctx, ids, p, Action::Reorder { layout: reorder, source, target: source, swap: false });
}
return true;
}
}
}
if ctx.input(|i| i.modifiers.command) {
return false;
}
let layout = layout(app, ctx, &ids);
for axis in [layout.horizontal.as_ref(), layout.vertical.as_ref()].into_iter().flatten() {
for h in handles(app, axis) {
@@ -210,20 +308,8 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered:
continue;
}
ctx.set_cursor_icon(if axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal });
if ctx.input(|i| i.pointer.primary_pressed())
&& app.session.begin_interaction(tr("Spacing")).is_ok()
&& let Ok(ds) = app.session.ds()
{
let drag =
Drag { ids, axis: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value };
app.ui.layers_focus = false;
app.ui.key_box = None;
ctx.memory_mut(|m| {
if let Some(f) = m.focused() {
m.surrender_focus(f);
}
});
ctx.data_mut(|d| d.insert_temp(drag_id(), drag));
if ctx.input(|i| i.pointer.primary_pressed()) {
start_drag(app, ctx, ids, p, Action::Spacing { axis: axis.clone(), value: h.value, last_value: axis.value });
}
return true;
}
@@ -244,8 +330,32 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
if drag.is_none() && !hover.is_some_and(|p| rect.contains(p)) {
return;
}
let axes = if let Some(d) = &drag {
vec![d.axis.clone()]
let reorder = match drag.as_ref().map(|d| &d.action) {
Some(Action::Reorder { layout, .. }) => Some(layout.clone()),
Some(Action::Spacing { .. }) => None,
None => reorder_layout(app, ctx, &ds.selection),
};
if let Some(layout) = reorder {
for (id, _) in &layout.slots {
let Some((center, r)) = ds.doc.world_bounds(*id).and_then(|b| ring(app, b)) else { continue };
let hot = hover.is_some_and(|p| center.distance(p) <= r);
let active = matches!(drag.as_ref().map(|d| &d.action), Some(Action::Reorder { source, .. }) if source == id);
if active {
painter.circle_filled(center, 4.5, PINK);
} else {
painter.circle_stroke(center, 4.5, Stroke::new(if hot { 2.5 } else { 1.5 }, PINK));
}
}
}
if let Some(Drag { action: Action::Reorder { layout, target, .. }, moved: true, .. }) = &drag {
if let Some((_, b)) = layout.slots.iter().find(|(id, _)| id == target) {
let rect = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(3.0);
painter.rect_stroke(rect, 0, Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Outside);
}
return;
}
let axes = if let Some(Drag { action: Action::Spacing { axis, .. }, .. }) = &drag {
vec![axis.clone()]
} else {
let layout = layout(app, ctx, &ds.selection);
[layout.horizontal.clone(), layout.vertical.clone()].into_iter().flatten().collect()
@@ -205,3 +205,157 @@ fn stationary_gap_drag_does_not_change_the_document_again() {
u.button(p + vec2(20.2 * z, 0.), false);
assert_eq!(xy(&u, ids[1]), (200., 100.));
}
#[test]
fn reorder_ring_drag_inserts_across_grid_rows_as_one_undo_step() {
let (mut u, ids) = scene(true);
let before = u.app.session.undo_depth().0;
let p = u.app.to_screen(125., 130.);
let target = u.app.to_screen(205., 230.);
let output = u.frame_output(vec![Event::PointerMoved(p)]);
assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::Grab);
drag(&mut u, p, target - p, true);
assert_eq!(xy(&u, ids[0]), (180., 200.));
assert_eq!(xy(&u, ids[3]), (260., 100.));
assert_eq!(u.app.session.selection(), ids);
assert_eq!(u.app.session.undo_depth().0, before + 1);
u.app.run("edit.undo", json!({}));
assert_eq!(xy(&u, ids[0]), (100., 100.));
u.app.run("edit.redo", json!({}));
assert_eq!(xy(&u, ids[0]), (180., 200.));
}
#[test]
fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() {
for action in [0, 1, 2] {
let (mut u, ids) = scene(false);
let history = u.app.session.undo_depth().0;
let before = u.app.session.doc().unwrap().clone();
let p = u.app.to_screen(125., 130.);
let target = u.app.to_screen(285., 130.);
u.frame(vec![Event::PointerMoved(p)]);
if action == 0 {
u.button(p, true);
} else {
drag(&mut u, p, target - p, false);
assert_eq!(xy(&u, ids[0]), (260., 100.));
if action == 1 {
key(&mut u, Key::Escape);
} else {
u.frame(vec![Event::PointerMoved(p)]);
}
}
u.button(p, false);
u.frame(vec![]);
assert_eq!(u.app.session.doc().unwrap(), &before);
assert_eq!(u.app.session.undo_depth().0, history);
assert_eq!(u.app.session.selection(), ids);
assert!(u.app.session.ds().unwrap().interaction.is_none());
}
}
#[test]
fn command_drag_swaps_and_a_stationary_pointer_keeps_the_preview() {
let (mut u, ids) = scene(true);
let p = u.app.to_screen(125., 130.);
let target = u.app.to_screen(205., 230.);
// The modifier can already be down when the handle is pressed.
let m = Modifiers::COMMAND;
u.frame_with(vec![Event::PointerMoved(p)], m);
u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m);
u.frame_with(vec![Event::PointerMoved(target)], m);
assert_eq!(xy(&u, ids[0]), (180., 200.));
assert_eq!(xy(&u, ids[4]), (100., 100.));
assert_eq!(xy(&u, ids[1]), (180., 100.));
let snapshot = u.app.session.ds().unwrap().doc.clone();
for _ in 0..5 {
u.frame_with(vec![], m);
}
assert!(std::sync::Arc::ptr_eq(&snapshot, &u.app.session.ds().unwrap().doc));
// Changing modifiers without moving the pointer switches to insertion.
u.frame(vec![]);
assert_eq!(xy(&u, ids[1]), (100., 100.));
assert_eq!(xy(&u, ids[4]), (100., 200.));
u.button(target, false);
}
#[test]
fn switching_documents_during_reordering_rolls_back_its_owner() {
let (mut u, ids) = scene(false);
let before = u.app.session.doc().unwrap().clone();
let p = u.app.to_screen(125., 130.);
let target = u.app.to_screen(285., 130.);
drag(&mut u, p, target - p, false);
u.app.run("file.new", json!({}));
u.app.session.begin_interaction("Unrelated").unwrap();
u.frame(vec![]);
assert!(u.app.session.ds().unwrap().interaction.is_some());
u.app.session.active = 0;
assert_eq!(u.app.session.doc().unwrap(), &before);
assert_eq!(u.app.session.selection(), ids);
}
#[test]
fn reorder_release_outside_window_commits_and_undo_while_dragging_cancels() {
for undo in [false, true] {
let (mut u, ids) = scene(false);
let history = u.app.session.undo_depth().0;
let p = u.app.to_screen(125., 130.);
let target = u.app.to_screen(285., 130.);
drag(&mut u, p, target - p, false);
if undo {
let m = Modifiers::COMMAND;
u.frame_with(vec![Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: m }], m);
}
u.frame(vec![Event::PointerGone]);
u.button(Pos2::new(-1., -1.), false);
u.frame(vec![]);
assert_eq!(u.app.session.undo_depth().0, history + usize::from(!undo));
assert_eq!(xy(&u, ids[0]), if undo { (100., 100.) } else { (260., 100.) });
assert!(u.app.session.ds().unwrap().interaction.is_none());
}
}
#[test]
fn an_externally_finished_reorder_cannot_resume_on_later_pointer_motion() {
let (mut u, ids) = scene(false);
let p = u.app.to_screen(125., 130.);
let target = u.app.to_screen(285., 130.);
drag(&mut u, p, target - p, false);
u.app.session.commit_interaction().unwrap();
let history = u.app.session.undo_depth().0;
let committed = u.app.session.doc().unwrap().clone();
let middle = u.app.to_screen(205., 130.);
u.frame(vec![Event::PointerMoved(middle)]);
u.button(middle, false);
u.frame(vec![]);
assert_eq!(u.app.session.doc().unwrap(), &committed);
assert_eq!(u.app.session.undo_depth().0, history);
assert_eq!(xy(&u, ids[0]), (260., 100.));
}
#[test]
fn small_layers_on_screen_have_no_reorder_ring_and_drag_the_selection_from_their_middle() {
let mut u = Ui::new(Session::new());
let z = u.app.session.ds().unwrap().view.zoom;
// 12 px squares 36 px apart on screen: a regular row, too small for rings.
let ids: Vec<NodeId> = (0..3)
.map(|col| {
let p = json!({"x": (100. + f64::from(col) * 36.) / z, "y": 100. / z, "width": 12. / z, "height": 12. / z});
NodeId(u.app.session.execute("shape.rectangle", &p).unwrap()["id"].as_u64().unwrap())
})
.collect();
u.app.session.execute("select.set", &json!({"ids": ids})).unwrap();
let before: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect();
let p = u.app.to_screen(before[0].0 + 6. / z, before[0].1 + 6. / z);
let output = u.frame_output(vec![Event::PointerMoved(p)]);
assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Grab);
drag(&mut u, p, vec2(0., 60.), true);
let after: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect();
assert!(after[0].1 > before[0].1, "the selection moved: {before:?} -> {after:?}");
assert!(
before.iter().zip(&after).all(|(b, a)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1 - (after[0].1 - before[0].1)).abs() < 1e-6),
"all together, in order"
);
assert_eq!(u.app.session.selection(), ids);
}
+116
View File
@@ -0,0 +1,116 @@
//! The interface size (Preferences ▸ Interface size, `pref.uiScale`): how the saved choice becomes
//! egui's zoom factor.
//!
//! egui draws at `pixels_per_point = zoom factor × the system's scale`. The size is relative to the
//! system's scale, so it is the zoom itself: 100 % is the system's own size on every screen (native
//! on a Retina Mac too), 125 % a quarter larger. A size above 100 % is held back while the window is
//! too small to lay the interface out at it; the saved choice never changes.
/// The smallest window, in points, the interface is laid out for: the main window's minimum size.
pub const FIT_POINTS: (f32, f32) = (800.0, 500.0);
/// The zoom factors egui's own settings window allows.
const ZOOM_RANGE: (f32, f32) = (0.1, 10.0);
/// What to give egui for a saved choice on this screen and window.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Plan {
/// For `Context::set_zoom_factor`: the size drawn, relative to the system's scale.
pub zoom: f32,
/// Physical pixels per point once drawn (the zoom times the system's scale).
pub drawn: f32,
/// The window is too small for the size chosen, so a smaller one is drawn.
pub limited: bool,
}
/// The plan for `choice` (relative to the system's scale), the system's scale `native` and the
/// window's size in physical pixels `window_px`. An unknown, zero or not finite value reads as no
/// information: a size of 1, a system scale of 1 and a window of any size.
pub fn plan(choice: f64, native: Option<f32>, window_px: Option<(f32, f32)>) -> Plan {
let native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0);
let want = Some(choice as f32).filter(|c| c.is_finite() && *c > 0.0).unwrap_or(1.0);
// The largest zoom at which the window still holds the smallest layout.
let fit = window_px
.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0)
.map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1) / native);
// Never below 100 % (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further.
let zoom = match fit {
Some(fit) if want > fit => fit.max(want.min(1.0)),
_ => want,
}
.clamp(ZOOM_RANGE.0, ZOOM_RANGE.1);
Plan { zoom, drawn: zoom * native, limited: zoom < want - 0.005 }
}
/// A size as the percentage Preferences shows (1.25 → 125).
pub fn percent(scale: f32) -> u32 {
(scale * 100.0).round().clamp(0.0, 100_000.0) as u32
}
/// The sizes Preferences offers, relative to the system's scale (`pref.uiScale` takes up to 3).
pub const SIZES: [f64; 8] = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0];
#[cfg(test)]
mod tests {
use super::*;
const BIG: Option<(f32, f32)> = Some((20000.0, 12000.0));
#[test]
fn a_size_is_relative_to_the_systems_scale_on_every_screen() {
// Windows 100/125/150/175 %, Retina 2×, a bare X11 window manager's guessed 2×, a 3× screen.
for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] {
let p = plan(1.0, Some(native), BIG);
assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "100 % is the system's own size on {native}");
for size in SIZES {
let p = plan(size, Some(native), BIG);
assert!((p.zoom - size as f32).abs() < 1e-5, "{size} on {native}: zoom {}", p.zoom);
assert!((p.drawn - size as f32 * native).abs() < 1e-4, "{size} on {native}: drawn {}", p.drawn);
assert!(!p.limited, "{size} on {native}");
}
}
}
#[test]
fn a_missing_or_unusable_scale_reads_as_1_and_never_gives_egui_a_bad_zoom() {
for native in [None, Some(0.0), Some(-2.0), Some(f32::NAN), Some(f32::INFINITY)] {
for choice in [1.0, 1.5, f64::NAN, f64::INFINITY, -1.0, 0.0, 1e300] {
let p = plan(choice, native, Some((f32::NAN, 0.0)));
assert!(p.zoom.is_finite() && (ZOOM_RANGE.0..=ZOOM_RANGE.1).contains(&p.zoom), "{choice:?} {native:?}: {p:?}");
assert!(p.drawn.is_finite() && p.drawn > 0.0, "{choice:?} {native:?}: {p:?}");
}
}
// A choice that isn't a usable number is the system's own size.
assert_eq!(plan(f64::NAN, Some(2.0), BIG), plan(1.0, Some(2.0), BIG));
}
#[test]
fn a_small_window_holds_a_big_size_back_without_going_below_100() {
// The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels, which holds 100 % only.
let min = Some((1600.0, 1000.0));
let p = plan(1.5, Some(2.0), min);
assert_eq!((p.zoom, p.drawn, p.limited), (1.0, 2.0, true));
assert!(!plan(1.0, Some(2.0), min).limited);
// A larger window on a 1× screen: 1200 × 750 pixels hold 150 %.
let p = plan(2.0, Some(1.0), Some((1200.0, 750.0)));
assert_eq!((p.zoom, p.limited), (1.5, true));
// A window smaller than the minimum (a tiling window manager): never below 100 %, and never
// below a smaller size the person chose.
assert_eq!(plan(2.0, Some(1.0), Some((400.0, 250.0))).zoom, 1.0);
let p = plan(0.75, Some(1.0), Some((400.0, 250.0)));
assert_eq!((p.zoom, p.limited), (0.75, false));
}
#[test]
fn the_limit_does_not_depend_on_the_zoom_so_it_cannot_flicker() {
// The window's size in pixels is the same whatever zoom is drawn now, so the plan for it is too.
let px = Some((1700.0, 1100.0));
assert_eq!(plan(2.5, Some(2.0), px), plan(2.5, Some(2.0), px));
}
#[test]
fn percentages_round_like_the_menu_shows_them() {
assert_eq!([percent(0.75), percent(0.9), percent(1.1), percent(1.25), percent(2.0), percent(1.7999)], [75, 90, 110, 125, 200, 180]);
assert_eq!([percent(f32::NAN), percent(-1.0)], [0, 0]);
}
}
+211
View File
@@ -0,0 +1,211 @@
//! The interface size, driven through egui headless on screens of different system scales: 100 %
//! leaves the system's scale alone, another size is relative to it on every screen, and the
//! Preferences drop-down tells a screen reader its name and value.
//!
//! The test window's size is fixed in points, so it grows with the zoom here; the limit for a small
//! window is tested on its own in `ui_scale::tests`, and the sizes below stay inside it.
use egui::accesskit::Role;
use egui::{RawInput, ViewportId, ViewportInfo};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
/// One frame on a screen whose system scale is `native` (a real window reports it every frame).
fn frame_at(u: &mut Ui, native: f32) -> egui::FullOutput {
let mut raw = RawInput::default();
raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() });
u.frame_raw(raw)
}
/// Frames on that screen, enough for a new zoom to take effect.
fn settle(u: &mut Ui, native: f32) {
for _ in 0..4 {
frame_at(u, native);
}
}
const SCREENS: [f32; 5] = [1.0, 1.25, 1.5, 2.0, 3.0];
#[test]
fn by_default_the_interface_is_the_systems_own_size() {
for native in SCREENS {
let mut u = Ui::new(Session::new());
settle(&mut u, native);
assert_eq!(u.ctx.zoom_factor(), 1.0, "{native}");
assert_eq!(u.ctx.pixels_per_point(), native, "{native}");
}
}
#[test]
fn a_size_is_relative_to_the_systems_scale_on_every_screen_and_auto_is_100() {
for native in SCREENS {
let mut u = Ui::new(Session::new());
for size in [0.75_f64, 0.9, 1.25, 1.5] {
assert!(u.app.run("pref.uiScale", json!({"scale": size})).is_some());
settle(&mut u, native);
let drawn = u.ctx.pixels_per_point();
assert!((drawn - size as f32 * native).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}");
assert!((u.ctx.zoom_factor() - size as f32).abs() < 1e-5, "{size} on {native}");
assert!(!u.app.ui.ui_scale.limited, "{size} on {native}");
}
assert!(u.app.run("pref.uiScale", json!({"scale": "auto"})).is_some());
settle(&mut u, native);
assert_eq!((u.ctx.zoom_factor(), u.ctx.pixels_per_point()), (1.0, native), "{native}");
}
}
#[test]
fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() {
let mut u = Ui::new(Session::new());
u.app.ui.preferences = true;
u.ctx.enable_accesskit();
let combo = |u: &mut Ui| {
settle(u, 2.0);
let out = frame_at(u, 2.0);
let nodes: Vec<_> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect();
let n = nodes.iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Interface size")).expect("an Interface size menu");
(n.value().map(str::to_owned), n.is_expanded())
};
// 100 % is the system's own size, whatever it is (2× here).
assert_eq!(combo(&mut u), (Some("100%".to_owned()), Some(false)));
u.app.run("pref.uiScale", json!({"scale": 1.25}));
assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false)));
}
/// Every window size, system scale and interface size, drawn the way a real window is: the window is
/// a fixed number of physical pixels, and its size in points is that divided by the scale drawn now
/// (egui-winit does the same each frame), so a bigger size shrinks the window in points.
///
/// What a fixed size promises: whatever it draws at above 100 %, the window still holds the smallest
/// layout the interface is made for (`ui_scale::FIT_POINTS`), and the panels leave room for a canvas.
#[test]
fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window() {
use crate::ui_scale::{FIT_POINTS, SIZES};
use crate::{FigApp, NoServices};
use egui::{Pos2, Rect, vec2};
// Window sizes in points: the main window's minimum, common laptop and desktop screens up to 5K,
// and a short wide strip.
let windows: [(f32, f32); 10] = [
(800.0, 500.0),
(1024.0, 640.0),
(1280.0, 720.0),
(1366.0, 768.0),
(1440.0, 900.0),
(1920.0, 1080.0),
(2560.0, 1440.0),
(3840.0, 2160.0),
(5120.0, 2880.0),
(2560.0, 520.0),
];
for native in [1.0_f32, 1.25, 1.5, 1.75, 2.0, 3.0] {
for (w, h) in windows {
let px = vec2(w * native, h * native);
for want in SIZES {
let case = format!("{w}x{h} pt at {native}x, {want}");
let mut app = FigApp::new(Session::new(), Box::new(NoServices));
assert!(app.run("pref.uiScale", json!({"scale": want})).is_some(), "{case}");
let ctx = egui::Context::default();
let mut time = 0.0;
let mut seen = Vec::new();
for _ in 0..8 {
time += 1.0 / 60.0;
let ppp = ctx.pixels_per_point();
let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, px / ppp)), time: Some(time), ..Default::default() };
raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() });
let mut out = ctx.run_ui(raw, |ui| app.ui(ui));
out.textures_delta.clear();
seen.push((ctx.pixels_per_point(), ctx.content_rect().size()));
}
// It settles within a few frames and then stays put: no flicker between two sizes.
let (ppp, content) = seen[seen.len() - 1];
assert_eq!(seen[seen.len() - 2].0, ppp, "{case}: still changing");
assert!(ppp.is_finite() && ppp > 0.0 && content.x.is_finite() && content.y.is_finite(), "{case}: {ppp} {content:?}");
let plan = app.ui.ui_scale;
assert!((ppp - plan.drawn).abs() < 1e-3, "{case}: egui draws at {ppp}, the plan said {}", plan.drawn);
// The size drawn, relative to the system's scale.
let zoom = ppp / native;
let want = want as f32;
assert_eq!(plan.limited, zoom < want - 0.005, "{case}: limited flag");
// Above 100 % the window holds the smallest layout; at or below it there is nothing more to give up.
if zoom > 1.001 {
assert!(content.x >= FIT_POINTS.0 - 0.5 && content.y >= FIT_POINTS.1 - 0.5, "{case}: window is {content:?} points at {ppp}");
}
// It never draws bigger than asked, and only a window too small for it draws smaller.
assert!(zoom <= want + 1e-3, "{case}");
if !plan.limited {
assert!((zoom - want).abs() < 1e-3, "{case}");
}
let panels = &app.inspect()["panels"];
let side = panels["left"].as_f64().unwrap_or(0.0) + panels["right"].as_f64().unwrap_or(0.0);
assert!(side < f64::from(content.x) - 100.0, "{case}: the panels ({side} pt) leave no canvas in {content:?}");
}
}
}
}
/// The interface size is reachable and changeable without a pointer: Tab reaches the menu, Enter
/// opens it, the arrows and Tab move through its entries, Enter picks one and closes the menu, and
/// Escape closes it alone, without changing anything and without closing Preferences (a second
/// Escape does).
#[test]
fn the_interface_size_menu_works_from_the_keyboard() {
use crate::a11y::focused_name;
use egui::{Event, Key, Modifiers, Popup};
// A key press, then two quiet frames for the focus (and the name a screen reader reads) to settle.
let press = |u: &mut Ui, key: Key| {
u.frame(vec![
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
]);
u.frame(vec![]);
u.frame(vec![]);
};
let open = |u: &mut Ui| {
// The Preferences dialog is not modal for the keyboard: Tab crosses the main window first.
let mut tabs = 0;
while focused_name(&u.ctx) != "Interface size" {
assert!(tabs < 140, "Tab never reaches the Interface size menu (focus: {:?})", focused_name(&u.ctx));
press(u, Key::Tab);
tabs += 1;
}
press(u, Key::Enter);
assert!(Popup::is_any_open(&u.ctx), "Enter opens the menu");
};
let chosen = |u: &Ui| u.app.inspect()["uiScale"]["choice"].clone();
let mut u = Ui::new(Session::new());
u.app.ui.preferences = true;
u.ctx.enable_accesskit();
settle(&mut u, 1.0);
assert_eq!(chosen(&u), json!(1.0));
// Escape closes the menu and nothing else: the size is unchanged and Preferences stays open.
open(&mut u);
press(&mut u, Key::Escape);
u.frame(vec![]);
assert!(!Popup::is_any_open(&u.ctx));
assert!(u.app.ui.preferences, "the first Escape closes the menu only");
assert_eq!(chosen(&u), json!(1.0));
// The arrows enter the menu on its first size and go down through the sizes (75, 90, 100, 110,
// 125); Enter picks the one that has the keyboard and closes the menu.
open(&mut u);
press(&mut u, Key::ArrowDown);
assert_eq!(focused_name(&u.ctx), "75%");
for _ in 0..4 {
press(&mut u, Key::ArrowDown);
}
assert_eq!(focused_name(&u.ctx), "125%");
press(&mut u, Key::Enter);
assert_eq!(chosen(&u), json!(1.25));
assert!(!Popup::is_any_open(&u.ctx), "picking a size closes the menu");
assert!(u.app.ui.preferences, "picking a size leaves Preferences open");
// With no menu open, Escape closes Preferences as before.
press(&mut u, Key::Escape);
assert!(!u.app.ui.preferences);
}
+4 -1
View File
@@ -172,7 +172,10 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)).
`canvas` or `properties`), whether `keyboard` mode is on (F6 or Tab moved the focus since the last
pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a
screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or
the system's accessibility setting turns the interface's animations off. `live` is the live
the system's accessibility setting turns the interface's animations off. `uiScale` is the interface
size: the `choice` (relative to the system's display scale, `pref.uiScale`), the `zoom` drawn
(relative too), what that is in physical pixels per point (`drawn`) and whether the window is too
small for the choice (`limited`, so `zoom` is smaller). `live` is the live
session of the window's active file as the window shows it (sessions are per file: another tab
shows its own, or `status: "off"`): `session` (its id, null when the file isn't in one),
`sessions` (how many the app has running), `status`, `role`, the number of `peers`, who you `follow`, the
+11 -1
View File
@@ -234,7 +234,12 @@ Or in a project's `.mcp.json`:
`text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}`
lists a font's axes and named instances (named instances are also valid `fontStyle` values).
`text.missingFonts` lists the families the document names that aren't installed
(`{families: [{family, layers}]}`); their text draws in a fallback font.
(`{families: [{family, layers}]}`); their text draws in a fallback font. Fonts are indexed once
per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and
`text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`).
Text boxes keep the size they were measured at: both refit the open document's auto-sizing text
in the new families (`refitted: [ids]`), and `text.refit {ids?}` does it for any text, e.g. a
file saved while its fonts were missing.
OpenType features: `features {"liga": false, "tnum": true}`.
Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and
`textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline
@@ -421,6 +426,11 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
nested layers), Escape or a click drops it.
- `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's
accessibility setting turns it on too). Prototype transitions still play.
- `pref.uiScale {scale?}`: how large the whole interface is drawn, relative to the system's display
scale: 1 (the default) is the system's own size on every screen, 1.25 means 125 %, not 125; from
0.75 to 3 (outside the range it stops at the nearest end; `"auto"` is 1). Omit `scale` to read it.
The canvas zoom is separate. A window too small for a size above 100 % draws a smaller one until
it is larger; the choice is kept. In the browser the interface always follows the browser's zoom.
- `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change
(off by default; the desktop app's timer does it, so scripts still call `file.save`).
- `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file