#2817: allow reopening manually drawn paths

This commit is contained in:
arnav-hiwarkar committed 2026-11-02 22:10:35 +05:30
1 parent 9d7baea5cc
commit c5bf611909
8 files changed
+367 -16

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ mod nav_more;
pub mod paint;
pub mod panels_cmds;
pub(crate) mod path_nulls;
mod paths;
pub(crate) mod paths;
mod project_items;
pub(crate) mod prop;
mod prop_groups;
+1 -1
View File
@@ -644,7 +644,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("mask.new", "New Mask from Points", [], None, "{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, mode?}", has_layers, new_mask),
cmd!("mask.addVertex", "Add Mask Vertex", [], None, "{layer?, mask, point: [x,y], in?, out?, index?}", has_layers, add_vertex),
cmd!("mask.setVertex", "Set Mask Vertex", [], None, "{layer?, mask, index, point?, in?, out?, merge?}", has_layers, set_vertex),
cmd!("mask.setClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{layer?, mask, closed?}", has_layers, set_closed),
cmd!("mask.setClosed", "Closed", [], None, "{layer?, mask, closed?}", has_layers, set_closed),
cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices),
cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices),
cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices),
+4
View File
@@ -12,6 +12,9 @@ use super::layer_menu::target_masks;
use super::{CommandSpec, b_p, bad, f_p, has_comp, has_layers, layer_mut, layer_p, merge_p, str_p};
use crate::{EngineError, Result, Session, VertexRef, cmd};
mod closed;
pub(crate) use closed::selected_paths_closed;
// ---------------------------------------------------------------- RotoBezier
/// Automatic (RotoBezier) tangents: each vertex's tangents follow the line through its
@@ -547,6 +550,7 @@ fn ungroup_shapes(s: &mut Session, p: &Value) -> Result<Value> {
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("path.toggleClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{}", closed::has_paths, closed::toggle_closed),
cmd!("path.rotoBezier", "RotoBezier", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?, value?}", has_layers, roto_bezier),
cmd!("path.convertToBezier", "Convert To Bezier Path", ["Layer", "Mask and Shape Path"], None, "{layer?, mask?}", has_layers, convert_to_bezier),
cmd!("path.groupShapes", "Group Shapes", ["Layer"], Some("Cmd+G"), "{layer?, items?: [uid]}", has_shape_items, group_shapes),
+125
View File
@@ -0,0 +1,125 @@
//! The selection-aware Closed menu action. Explicit edits retain `mask.setClosed`'s API.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{GroupKind, Layer, LayerId, PropGroup, Uid};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::super::{bad, layer_mut};
use crate::{EngineError, Result, Session, VertexRef};
const MAX_DEPTH: usize = 128;
struct Target {
layer: LayerId,
route: Vec<usize>,
closed: bool,
}
struct Selection {
vertices: Vec<VertexRef>,
props: Vec<(LayerId, Uid)>,
time: Tick,
}
/// A route uses checked child access and never recurses through an input-controlled tree.
fn group_at_mut<'a>(mut group: &'a mut PropGroup, route: &[usize]) -> Option<&'a mut PropGroup> {
for &index in route {
group = group.children.get_mut(index)?.as_group_mut()?;
}
Some(group)
}
fn targets(s: &Session) -> Result<Vec<Target>> {
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
// Layer navigation and insertion can retain point/property selections on other layers.
let selection = Selection {
vertices: s.state.selected_vertices.iter().filter(|v| s.state.selected_layers.contains(&v.layer)).copied().collect(),
props: s.state.selected_props.iter().filter(|(lid, _)| s.state.selected_layers.contains(lid)).copied().collect(),
time: s.time(),
};
let mut targets = Vec::new();
for layer in comp.layers.iter() {
let selected_layer = if !selection.vertices.is_empty() {
selection.vertices.iter().any(|v| v.layer == layer.id)
} else if !selection.props.is_empty() {
selection.props.iter().any(|(lid, _)| *lid == layer.id)
} else {
s.state.selected_layers.contains(&layer.id)
};
if !selected_layer || layer.switches.locked {
continue;
}
collect(&selection, layer, &layer.props, &mut Vec::new(), selection.props.is_empty(), &mut targets)?;
}
if targets.is_empty() {
return Err(bad("path.toggleClosed", "select an unlocked mask or Bezier shape path"));
}
Ok(targets)
}
fn collect(selection: &Selection, layer: &Layer, group: &PropGroup, route: &mut Vec<usize>, inherited: bool, targets: &mut Vec<Target>) -> Result<()> {
if route.len() > MAX_DEPTH {
return Err(bad("path.toggleClosed", "path group nesting exceeds 128 levels"));
}
if matches!(group.kind, GroupKind::Mask { locked: true, .. }) {
return Ok(());
}
let selected = inherited || selection.props.contains(&(layer.id, group.uid));
if super::super::mask::is_path_group(group)
&& let Some(prop) = group.get("path")
&& let KV::Path(path) = prop.value_at(layer.layer_time(selection.time))
{
let selected = if selection.vertices.is_empty() {
selected || selection.props.contains(&(layer.id, prop.uid))
} else {
selection.vertices.iter().any(|v| v.layer == layer.id && v.mask == group.uid && v.index < path.vertices.len())
};
if selected {
targets.push(Target { layer: layer.id, route: route.clone(), closed: path.closed });
}
return Ok(());
}
for (index, child) in group.children.iter().enumerate() {
let Some(child) = child.as_group() else { continue };
route.push(index);
collect(selection, layer, child, route, selected, targets)?;
route.pop();
}
Ok(())
}
pub(crate) fn selected_paths_closed(s: &Session) -> Option<bool> {
targets(s).ok().map(|targets| targets.iter().all(|target| target.closed))
}
pub(super) fn has_paths(s: &Session) -> std::result::Result<(), String> {
targets(s).map(|_| ()).map_err(|error| error.to_string())
}
pub(super) fn toggle_closed(s: &mut Session, _: &Value) -> Result<Value> {
let targets = targets(s)?;
let closed = !targets.iter().all(|target| target.closed);
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
let time = s.time();
s.edit("Closed", None, |project, _| {
for target in &targets {
let layer = layer_mut(project, cid, target.layer)?;
let time = layer.layer_time(time);
let group = group_at_mut(&mut layer.props, &target.route).ok_or_else(|| bad("path.toggleClosed", "the selected path no longer exists"))?;
let roto = super::is_roto(group);
let prop = group.get_mut("path").ok_or_else(|| bad("path.toggleClosed", "the selected group has no path"))?;
let KV::Path(mut path) = prop.value_at(time) else { return Err(bad("path.toggleClosed", "the selected value is not a path")) };
if path.closed == closed {
continue;
}
path.closed = closed;
if roto {
super::roto_smooth(&mut path);
}
prop.set_value_at(time, KV::Path(path));
}
Ok(())
})?;
Ok(json!({"paths": targets.len(), "closed": closed}))
}
+2 -1
View File
@@ -314,6 +314,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
let layer = s.state.selected_layers.first().and_then(|l| comp.layer(*l));
let pstr = |k: &str| params.get(k).and_then(Value::as_str);
match command {
"path.toggleClosed" => crate::cmd::paths::selected_paths_closed(s),
"layer.setBlendMode" => {
let want = effectcraft_color::BlendMode::from_name(pstr("mode")?)?;
Some(layer?.blend_mode == want)
@@ -820,7 +821,7 @@ Layer
Hide Locked Masks | layer.mask.hideLocked
Mask and Shape Path
RotoBezier | path.rotoBezier
Closed | mask.setClosed
Closed | path.toggleClosed
Convert To Bezier Path | path.convertToBezier
Set First Vertex | path.setFirstVertex
Free Transform Points | path.freeTransform
+38
View File
@@ -140,6 +140,44 @@ fn checked_reflects_layer_state() {
assert_eq!(checked(&s, "file.save", &serde_json::json!({})), None);
}
#[test]
fn closed_menu_opens_and_recloses_a_manual_shape() {
use serde_json::json;
let mut s = crate::Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
let r = s
.execute(
"shape.newPath",
json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true,
"inTangents": [[0, -12], [-8, 0], [0, -6]], "outTangents": [[12, 0], [0, 8], [-6, 0]]}),
)
.unwrap();
let lid = effectcraft_project::LayerId(r["layer"].as_u64().unwrap());
let uid = r["path"].as_u64().unwrap();
let path =
|s: &crate::Session| s.active_comp().unwrap().layer(lid).unwrap().props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
let original = path(&s);
for mac in [true, false] {
let (_, entry) =
platform_entries(mac).into_iter().find(|(p, e)| p.iter().map(String::as_str).eq(["Layer", "Mask and Shape Path"]) && e.label == "Closed").unwrap();
assert!(s.is_enabled(&entry.command));
assert_eq!(checked(&s, &entry.command, &entry.params), Some(true));
s.execute(&entry.command, entry.params.clone()).unwrap();
let mut opened = original.clone();
opened.closed = false;
assert_eq!(path(&s), opened);
assert!(!effectcraft_path::to_kurbo(&path(&s)).elements().contains(&effectcraft_path::PathEl::ClosePath));
assert_eq!(checked(&s, &entry.command, &entry.params), Some(false));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(path(&s), original);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(path(&s), opened);
s.execute(&entry.command, entry.params).unwrap();
assert_eq!(path(&s), original);
assert!(effectcraft_path::to_kurbo(&path(&s)).elements().contains(&effectcraft_path::PathEl::ClosePath));
}
}
/// `cargo test -p effectcraft-engine dump_specs -- --ignored --nocapture`: every command as TSV.
#[test]
#[ignore]
+179
View File
@@ -286,6 +286,185 @@ fn shape_path(s: &Session, lid: u64, uid: u64) -> effectcraft_keyframe::ShapePat
l.props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone()
}
#[test]
fn toggle_closed_respects_path_and_vertex_selection() {
let mut s = comp();
let a = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
let lid = a["layer"].as_u64().unwrap();
let a = a["path"].as_u64().unwrap();
let b = s.execute("shape.newPath", json!({"layer": lid, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap()["path"].as_u64().unwrap();
let leaf = layer(&s, lid).props.find_group(a).unwrap().get("path").unwrap().uid;
s.state.selected_vertices.clear();
s.state.selected_props = vec![(LayerId(lid), leaf)];
s.execute("path.toggleClosed", json!({})).unwrap();
assert!(!shape_path(&s, lid, a).closed);
assert!(shape_path(&s, lid, b).closed, "an unselected sibling stays closed");
s.state.selected_props = vec![(LayerId(lid), b)];
s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(lid), mask: a, index: 1 }];
s.execute("path.toggleClosed", json!({})).unwrap();
assert!(shape_path(&s, lid, a).closed, "vertices take precedence over property selection");
assert!(shape_path(&s, lid, b).closed);
s.state.selected_vertices.clear();
s.state.selected_props = vec![(LayerId(lid), a), (LayerId(lid), leaf), (LayerId(lid), b)];
let r = s.execute("path.toggleClosed", json!({})).unwrap();
assert_eq!(r["paths"], json!(2));
assert!(!shape_path(&s, lid, a).closed);
assert!(!shape_path(&s, lid, b).closed);
s.execute("edit.undo", json!({})).unwrap();
assert!(shape_path(&s, lid, a).closed);
assert!(shape_path(&s, lid, b).closed, "all paths undo in one step");
s.execute("mask.setClosed", json!({"layer": lid, "mask": a, "closed": false})).unwrap();
s.execute("path.toggleClosed", json!({})).unwrap();
assert!(shape_path(&s, lid, a).closed);
assert!(shape_path(&s, lid, b).closed, "a mixed selection closes all paths");
s.state.selected_props.clear();
s.execute("path.toggleClosed", json!({})).unwrap();
assert!(!shape_path(&s, lid, a).closed);
assert!(!shape_path(&s, lid, b).closed, "layer selection addresses all editable paths");
}
#[test]
fn toggle_closed_ignores_selections_retained_on_other_layers() {
let mut s = comp();
let a = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
let (a_layer, a_path) = (a["layer"].as_u64().unwrap(), a["path"].as_u64().unwrap());
let b_layer = s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap();
assert!(s.state.selected_vertices.iter().any(|v| v.layer == LayerId(a_layer)), "insertion retains old vertices");
assert!(!s.is_enabled("path.toggleClosed"));
assert!(s.execute("path.toggleClosed", json!({})).is_err());
assert!(shape_path(&s, a_layer, a_path).closed);
let b_path =
s.execute("shape.newPath", json!({"layer": b_layer, "vertices": [[0, 0], [50, 0], [50, 50]], "closed": true})).unwrap()["path"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": b_layer, "prop": b_path})).unwrap();
s.execute("layer.selectNext", json!({})).unwrap();
assert_eq!(s.state.selected_layers, vec![LayerId(a_layer)]);
assert!(s.state.selected_vertices.iter().any(|v| v.layer == LayerId(b_layer)), "navigation retains old vertices");
assert!(s.state.selected_props.contains(&(LayerId(b_layer), b_path)));
assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(true));
s.execute("path.toggleClosed", json!({})).unwrap();
assert!(!shape_path(&s, a_layer, a_path).closed);
assert!(shape_path(&s, b_layer, b_path).closed, "the deselected layer stays unchanged");
s.execute("layer.selectPrevious", json!({})).unwrap();
assert_eq!(s.state.selected_layers, vec![LayerId(b_layer)]);
s.execute("prop.select", json!({"layer": b_layer, "path": "transform/position"})).unwrap();
assert!(!s.is_enabled("path.toggleClosed"), "a non-path property does not select sibling paths");
s.execute("layer.selectNext", json!({})).unwrap();
s.execute("path.toggleClosed", json!({})).unwrap();
assert!(shape_path(&s, a_layer, a_path).closed, "a property retained on another layer cannot block this layer");
assert!(shape_path(&s, b_layer, b_path).closed);
}
#[test]
fn toggle_closed_rejects_locked_stale_and_nonpath_selection() {
let mut s = comp();
let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
let (lid, uid) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap());
let before = s.project.clone();
s.state.selected_vertices.clear();
s.state.selected_props = vec![(LayerId(lid), u64::MAX)];
assert!(!s.is_enabled("path.toggleClosed"));
assert!(s.execute("path.toggleClosed", json!({})).is_err());
assert!(s.project.same_content(&before));
s.state.selected_props.clear();
s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(lid), mask: uid, index: usize::MAX }];
assert!(!s.is_enabled("path.toggleClosed"));
assert!(s.execute("path.toggleClosed", json!({})).is_err());
s.state.selected_vertices.clear();
let cid = s.active_comp_id().unwrap();
std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap().switches.locked = true;
assert!(!s.is_enabled("path.toggleClosed"));
assert!(s.execute("path.toggleClosed", json!({})).is_err());
assert!(shape_path(&s, lid, uid).closed);
s.execute("layer.newShape", json!({"kind": "rect"})).unwrap();
s.state.selected_props.clear();
assert!(!s.is_enabled("path.toggleClosed"));
assert!(s.execute("path.toggleClosed", json!({})).is_err());
}
#[test]
fn toggle_closed_handles_masks_and_preserves_explicit_set_closed() {
let mut s = comp();
let lid = s.execute("layer.newSolid", json!({})).unwrap()["layer"].as_u64().unwrap();
let uid = s.execute("mask.new", json!({"layer": lid, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap()["mask"].as_u64().unwrap();
let path = |s: &Session| layer(s, lid).props.find_group(uid).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
let original = path(&s);
s.state.selected_props = vec![(LayerId(lid), uid)];
s.execute("path.toggleClosed", json!({})).unwrap();
assert!(!path(&s).closed);
s.execute("mask.setClosed", json!({"layer": lid, "mask": uid})).unwrap();
assert_eq!(path(&s), original, "explicit API still defaults to closed");
s.execute("mask.setClosed", json!({"layer": lid, "mask": uid, "closed": false})).unwrap();
assert!(!path(&s).closed);
s.execute("path.toggleClosed", json!({})).unwrap();
assert_eq!(path(&s), original);
let cid = s.active_comp_id().unwrap();
let g = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap().props.find_group_mut(uid).unwrap();
if let effectcraft_project::GroupKind::Mask { locked, .. } = &mut g.kind {
*locked = true;
}
assert!(!s.is_enabled("path.toggleClosed"));
assert!(s.execute("path.toggleClosed", json!({})).is_err());
assert_eq!(path(&s), original);
}
#[test]
fn toggle_closed_edits_layer_time_and_roundtrips() {
use effectcraft_keyframe::Keyframe;
let mut s = comp();
let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
let (lid, uid) = (r["layer"].as_u64().unwrap(), r["path"].as_u64().unwrap());
let cid = s.active_comp_id().unwrap();
let original = shape_path(&s, lid, uid);
let second = Tick::from_seconds_f64(1.0);
let layer = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(LayerId(lid)).unwrap();
layer.start_time = second;
layer.stretch = 200.0;
let prop = layer.props.find_group_mut(uid).unwrap().get_mut("path").unwrap();
prop.keys = vec![Keyframe::new(Tick::ZERO, KV::Path(original.clone())), Keyframe::new(second, KV::Path(original.clone()))];
let keys = prop.keys.clone();
s.execute("time.set", json!({"time": 3.0})).unwrap();
s.execute("path.toggleClosed", json!({})).unwrap();
let prop = s.project.comp(cid).unwrap().layer(LayerId(lid)).unwrap().props.find_group(uid).unwrap().get("path").unwrap();
assert_eq!(prop.keys.len(), 2);
assert_eq!(prop.keys[0], keys[0], "the other keyframe is untouched");
let mut opened = original;
opened.closed = false;
assert_eq!(prop.value_at(second), KV::Path(opened.clone()));
assert!(prop.value_at(Tick::from_seconds_f64(0.5)).as_path().unwrap().closed, "the earlier segment keeps its closedness");
let bytes = serde_json::to_vec(&*s.project).unwrap();
let restored: effectcraft_project::Project = serde_json::from_slice(&bytes).unwrap();
let prop = restored.comp(cid).unwrap().layer(LayerId(lid)).unwrap().props.find_group(uid).unwrap().get("path").unwrap();
assert_eq!(prop.value_at(second), KV::Path(opened));
s.execute("time.set", json!({"time": 2.0})).unwrap();
assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(true));
s.execute("time.set", json!({"time": 3.0})).unwrap();
assert_eq!(crate::menus::checked(&s, "path.toggleClosed", &json!({})), Some(false));
}
#[test]
fn toggle_closed_rejects_excessive_group_nesting_without_an_edit() {
use effectcraft_project::build::{self, Ids};
let mut s = comp();
let r = s.execute("shape.newPath", json!({"vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
let lid = LayerId(r["layer"].as_u64().unwrap());
let cid = s.active_comp_id().unwrap();
let project = std::sync::Arc::make_mut(&mut s.project);
let mut next = project.next_id;
let contents = project.comp_mut(cid).unwrap().layer_mut(lid).unwrap().props.sub_mut("contents").unwrap();
let node = contents.children.first_mut().unwrap();
let mut nested = node.as_group().unwrap().clone();
for _ in 0..130 {
nested = build::shape_group(&mut Ids(&mut next), "Nested", vec![nested]);
}
*node = effectcraft_project::Node::Group(nested);
project.next_id = next;
let before = s.project.clone();
assert!(!s.is_enabled("path.toggleClosed"));
assert!(s.execute("path.toggleClosed", json!({})).is_err());
assert!(s.project.same_content(&before));
}
#[test]
fn shape_pen_builds_shape_group_and_renders() {
let mut s = comp();
+17 -13
View File
@@ -699,7 +699,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "隐藏锁定的蒙版"),
("", "Mask and Shape Path", "蒙版和形状路径"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "已关闭"),
("path.toggleClosed", "Closed", "已关闭"),
("path.convertToBezier", "Convert To Bezier Path", "转换为贝塞尔曲线路径"),
("path.setFirstVertex", "Set First Vertex", "设置第一个顶点"),
("path.freeTransform", "Free Transform Points", "自由变换点"),
@@ -1333,7 +1333,7 @@ const SWEDISH: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "Dölj låsta masker"),
("", "Mask and Shape Path", "Mask- och formbana"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "Sluten"),
("path.toggleClosed", "Closed", "Sluten"),
("path.convertToBezier", "Convert To Bezier Path", "Konvertera till Bezierbana"),
("path.setFirstVertex", "Set First Vertex", "Ange första hörnpunkt"),
("path.freeTransform", "Free Transform Points", "Fri transformering av punkter"),
@@ -1973,7 +1973,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "隱藏鎖定的遮色片"),
("", "Mask and Shape Path", "遮色片和形狀路徑"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "封閉"),
("path.toggleClosed", "Closed", "封閉"),
("path.convertToBezier", "Convert To Bezier Path", "轉換為貝茲路徑"),
("path.setFirstVertex", "Set First Vertex", "設定第一個頂點"),
("path.freeTransform", "Free Transform Points", "自由變換點"),
@@ -2608,7 +2608,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "ロックしたマスクを非表示"),
("", "Mask and Shape Path", "マスクとシェイプのパス"),
("path.rotoBezier", "RotoBezier", "ロトベジェ"),
("mask.setClosed", "Closed", "閉じたパス"),
("path.toggleClosed", "Closed", "閉じたパス"),
("path.convertToBezier", "Convert To Bezier Path", "ベジェパスに変換"),
("path.setFirstVertex", "Set First Vertex", "最初の頂点を設定"),
("path.freeTransform", "Free Transform Points", "ポイントを自由変形"),
@@ -3246,7 +3246,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "Приховати заблоковані маски"),
("", "Mask and Shape Path", "Контур маски та фігури"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "Замкнений"),
("path.toggleClosed", "Closed", "Замкнений"),
("path.convertToBezier", "Convert To Bezier Path", "Перетворити на контур Безьє"),
("path.setFirstVertex", "Set First Vertex", "Задати першу вершину"),
("path.freeTransform", "Free Transform Points", "Вільне трансформування точок"),
@@ -3885,7 +3885,7 @@ const SPANISH: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "Ocultar máscaras bloqueadas"),
("", "Mask and Shape Path", "Trazado de máscara y de forma"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "Cerrado"),
("path.toggleClosed", "Closed", "Cerrado"),
("path.convertToBezier", "Convert To Bezier Path", "Convertir en trazado Bézier"),
("path.setFirstVertex", "Set First Vertex", "Establecer primer vértice"),
("path.freeTransform", "Free Transform Points", "Transformar puntos libremente"),
@@ -4522,7 +4522,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "Ocultar máscaras bloqueadas"),
("", "Mask and Shape Path", "Caminho da máscara e da forma"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "Fechado"),
("path.toggleClosed", "Closed", "Fechado"),
("path.convertToBezier", "Convert To Bezier Path", "Converter em caminho Bézier"),
("path.setFirstVertex", "Set First Vertex", "Definir primeiro vértice"),
("path.freeTransform", "Free Transform Points", "Pontos de transformação livre"),
@@ -5166,7 +5166,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "Скрыть заблокированные маски"),
("", "Mask and Shape Path", "Контур маски и фигуры"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "Замкнутый"),
("path.toggleClosed", "Closed", "Замкнутый"),
("path.convertToBezier", "Convert To Bezier Path", "Преобразовать в кривую Безье"),
("path.setFirstVertex", "Set First Vertex", "Задать первую вершину"),
("path.freeTransform", "Free Transform Points", "Свободное трансформирование точек"),
@@ -5803,7 +5803,7 @@ const FRENCH: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "Masquer les masques verrouillés"),
("", "Mask and Shape Path", "Trajectoire de masque et de forme"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "Fermé"),
("path.toggleClosed", "Closed", "Fermé"),
("path.convertToBezier", "Convert To Bezier Path", "Convertir en trajectoire Bézier"),
("path.setFirstVertex", "Set First Vertex", "Définir le premier sommet"),
("path.freeTransform", "Free Transform Points", "Transformation libre des points"),
@@ -6440,7 +6440,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "Gesperrte Masken ausblenden"),
("", "Mask and Shape Path", "Masken- und Formpfad"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "Geschlossen"),
("path.toggleClosed", "Closed", "Geschlossen"),
("path.convertToBezier", "Convert To Bezier Path", "In Bézier-Pfad konvertieren"),
("path.setFirstVertex", "Set First Vertex", "Ersten Scheitelpunkt festlegen"),
("path.freeTransform", "Free Transform Points", "Punkte frei transformieren"),
@@ -7076,7 +7076,7 @@ const POLISH: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "Ukryj zablokowane maski"),
("", "Mask and Shape Path", "Ścieżka maski i kształtu"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "Zamknięta"),
("path.toggleClosed", "Closed", "Zamknięta"),
("path.convertToBezier", "Convert To Bezier Path", "Konwertuj na ścieżkę Béziera"),
("path.setFirstVertex", "Set First Vertex", "Ustaw pierwszy wierzchołek"),
("path.freeTransform", "Free Transform Points", "Swobodne przekształcanie punktów"),
@@ -7712,7 +7712,7 @@ const CZECH: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "Skrýt zamknuté masky"),
("", "Mask and Shape Path", "Cesta masky a tvaru"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "Uzavřená"),
("path.toggleClosed", "Closed", "Uzavřená"),
("path.convertToBezier", "Convert To Bezier Path", "Převést na Bézierovu cestu"),
("path.setFirstVertex", "Set First Vertex", "Nastavit první vrchol"),
("path.freeTransform", "Free Transform Points", "Volně transformovat body"),
@@ -8345,7 +8345,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
("layer.mask.hideLocked", "Hide Locked Masks", "ჩაკეტილი ნიღბების დამალვა"),
("", "Mask and Shape Path", "ნიღაბი და ფიგურის ტრაექტორია"),
("path.rotoBezier", "RotoBezier", "RotoBezier"),
("mask.setClosed", "Closed", "შეკრული"),
("path.toggleClosed", "Closed", "შეკრული"),
("path.convertToBezier", "Convert To Bezier Path", "ბეზიეს ტრაექტორიად გარდაქმნა"),
("path.setFirstVertex", "Set First Vertex", "პირველი წვეროს დაყენება"),
("path.freeTransform", "Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"),
@@ -9215,12 +9215,16 @@ mod tests {
alt_shortcuts: Vec::new(),
};
let new_comp = menu_entry("comp.new", "New Composition...");
let closed_path = menu_entry("path.toggleClosed", "Closed");
let undo = menu_entry("edit.undo", "Undo");
for code in CATALOG_CODES {
app.session.execute("prefs.set", json!({"key":"general.language", "value":code})).unwrap();
let drawn = entry(&app, &new_comp, new_comp.label.clone());
assert_eq!(drawn, label(&app, "comp.new", "New Composition..."), "{code}");
assert_ne!(drawn, new_comp.label, "{code}: the menu entry is drawn in English");
let closed_label = entry(&app, &closed_path, closed_path.label.clone());
assert_eq!(closed_label, label(&app, "path.toggleClosed", "Closed"), "{code}");
assert_ne!(closed_label, closed_path.label, "{code}: Closed is drawn in English");
}
app.session.execute("prefs.set", json!({"key":"general.language", "value":"ru"})).unwrap();
assert_eq!(entry(&app, &undo, "Can't Undo".into()), "Нечего отменять");