mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
Merge pull request #4639 from storytold/feat/mesh-multi-points
Gradient Mesh: select several mesh points; move, color and opacity reach them all (#4207, #4208, #4170)
This commit is contained in:
@@ -7,7 +7,7 @@ use super::envelope::{
|
||||
env_edit_contents, env_expand, env_info, env_make_mesh, env_make_top, env_make_warp, env_options, env_release, env_reset_mesh, env_reset_warp,
|
||||
env_set_mesh_point,
|
||||
};
|
||||
use super::mesh::{mesh_add_line, mesh_create, mesh_delete_point, mesh_expand, mesh_move_point, mesh_set_color};
|
||||
use super::mesh::{mesh_add_line, mesh_create, mesh_delete_point, mesh_expand, mesh_move_point, mesh_move_points, mesh_set_color};
|
||||
use super::{CommandSpec, always, cmd, has_doc, has_multi, has_selection};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -245,7 +245,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Mesh Point Color",
|
||||
[],
|
||||
None,
|
||||
"{id, index, color, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour of one mesh point",
|
||||
"{id, index | points: [index…] | items: [{id, index | points}…], color?, keepModel?: bool (as paint.setFill), opacity?: 0..1} colour and/or opacity of gradient mesh points (one undo step; an index out of range changes nothing)",
|
||||
has_doc,
|
||||
mesh_set_color
|
||||
),
|
||||
@@ -258,6 +258,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
mesh_move_point
|
||||
),
|
||||
cmd!(
|
||||
"object.mesh.movePoints",
|
||||
"Move Mesh Points",
|
||||
[],
|
||||
None,
|
||||
"{id, points: [index…] | items: [{id, index | points}…], dx, dy} move points of gradient meshes or mesh envelopes by (dx, dy), their handles with them (one undo step; an index out of range changes nothing)",
|
||||
has_doc,
|
||||
mesh_move_points
|
||||
),
|
||||
cmd!(
|
||||
"object.mesh.addLine",
|
||||
"Add Mesh Line",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::common::{fix_ids, ids_json, is_mesh, replace_with, roots_param, selected_of};
|
||||
use super::envelope::{outline_of, rows_cols};
|
||||
use super::{
|
||||
Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeKind, Paint, Point, Result, Session, Value, bad,
|
||||
Color, Document, EngineError, EnvelopeKind, GradientMesh, MeshAppearance, Node, NodeId, NodeKind, Paint, Point, Result, Session, Value, bad,
|
||||
check_numbers, color_value, f64_or, f64_req, finite_numbers, gradient_sampler, id_param, json, live, ok, str_param,
|
||||
};
|
||||
|
||||
@@ -106,25 +108,86 @@ fn image_mesh(d: &Document, im: &vectorcraft_doc::ImageObject, rows: u32, cols:
|
||||
}
|
||||
|
||||
/// Run `f` on the gradient mesh `p.id` (one undo step `label`); with `envelopes`, a mesh
|
||||
/// envelope's grid too (its points and handles are written back: editing fills in the handles).
|
||||
/// envelope's grid too (see [`edit_grid`]).
|
||||
fn with_mesh<T>(s: &mut Session, p: &Value, cmd: &str, label: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let c = cmd.to_string();
|
||||
s.edit(label, move |d, _| match d.node_mut(id).map(|n| &mut n.kind) {
|
||||
s.edit(label, move |d, _| edit_grid(d, id, cmd, envelopes, f))
|
||||
}
|
||||
|
||||
/// Run `f` on the gradient mesh `id` of `d`; with `envelopes`, a mesh envelope's grid too (its
|
||||
/// points and handles are written back: editing fills in the handles).
|
||||
fn edit_grid<T>(d: &mut Document, id: NodeId, cmd: &str, envelopes: bool, f: impl FnOnce(&mut GradientMesh) -> Result<T>) -> Result<T> {
|
||||
match d.node_mut(id).map(|n| &mut n.kind) {
|
||||
Some(NodeKind::Mesh(m)) => f(m),
|
||||
Some(NodeKind::Envelope { kind, .. }) if envelopes && matches!(**kind, EnvelopeKind::Mesh { .. }) => {
|
||||
let EnvelopeKind::Mesh { rows, cols, points, handles } = &mut **kind else {
|
||||
return Err(bad(&c, "not a gradient mesh or mesh envelope"));
|
||||
return Err(bad(cmd, "not a gradient mesh or mesh envelope"));
|
||||
};
|
||||
let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(&c, "the envelope's mesh is malformed"))?;
|
||||
let mut m = live::envelope_grid(*rows, *cols, points, handles).ok_or_else(|| bad(cmd, "the envelope's mesh is malformed"))?;
|
||||
let r = f(&mut m)?;
|
||||
(*rows, *cols) = (m.rows, m.cols);
|
||||
*points = m.points.iter().map(|q| q.p).collect();
|
||||
*handles = m.points.iter().map(|q| q.handles).collect();
|
||||
Ok(r)
|
||||
}
|
||||
Some(_) => Err(bad(&c, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })),
|
||||
Some(_) => Err(bad(cmd, if envelopes { "not a gradient mesh or mesh envelope" } else { "not a gradient mesh" })),
|
||||
None => Err(EngineError::NoNode(id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most mesh points one command names, over all its meshes.
|
||||
const MAX_POINTS: usize = 1 << 20;
|
||||
|
||||
/// The mesh points `p` names: `{id, index}`, `{id, points: [index…]}` or `{items: [{id, index |
|
||||
/// points}…]}`. Each mesh comes once, with its points in order and without repeats.
|
||||
fn point_items(p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
|
||||
let one = |q: &Value| -> Result<(NodeId, BTreeSet<usize>)> {
|
||||
let id = id_param(q, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let points = match q.get("points") {
|
||||
None | Some(Value::Null) => BTreeSet::from([index_param(q, "index", cmd)?]),
|
||||
Some(Value::Array(v)) if v.len() <= MAX_POINTS => v
|
||||
.iter()
|
||||
.map(|i| index_of(i.as_f64().ok_or_else(|| bad(cmd, "`points` must be point indexes"))?, "points", cmd))
|
||||
.collect::<Result<_>>()?,
|
||||
Some(_) => return Err(bad(cmd, "`points` must be a list of point indexes")),
|
||||
};
|
||||
Ok((id, points))
|
||||
};
|
||||
let items: Vec<(NodeId, BTreeSet<usize>)> = match p.get("items") {
|
||||
None | Some(Value::Null) => vec![one(p)?],
|
||||
Some(Value::Array(v)) if v.len() <= MAX_POINTS => v.iter().map(one).collect::<Result<_>>()?,
|
||||
Some(_) => return Err(bad(cmd, "`items` must be a list of {id, points}")),
|
||||
};
|
||||
let mut merged: BTreeMap<NodeId, BTreeSet<usize>> = BTreeMap::new();
|
||||
for (id, points) in items {
|
||||
merged.entry(id).or_default().extend(points);
|
||||
}
|
||||
if merged.values().map(BTreeSet::len).sum::<usize>() > MAX_POINTS {
|
||||
return Err(bad(cmd, format!("at most {MAX_POINTS} points")));
|
||||
}
|
||||
Ok(merged.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Run `f` on each mesh point `items` names, as one undo step `label` (an index out of range
|
||||
/// fails it, changing nothing); with `envelopes`, on mesh envelopes' points too.
|
||||
fn with_points(
|
||||
s: &mut Session,
|
||||
items: &[(NodeId, BTreeSet<usize>)],
|
||||
cmd: &str,
|
||||
label: &str,
|
||||
envelopes: bool,
|
||||
mut f: impl FnMut(&mut live::MeshPoint) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
s.edit(label, |d, _| {
|
||||
for (id, points) in items {
|
||||
edit_grid(d, *id, cmd, envelopes, |m| {
|
||||
for i in points {
|
||||
f(m.points.get_mut(*i).ok_or_else(|| bad(cmd, "index out of range"))?)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -140,7 +203,11 @@ pub(super) fn handle_param(p: &Value, cmd: &str) -> Result<Option<usize>> {
|
||||
}
|
||||
|
||||
pub(super) fn index_param(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
let i = f64_req(p, key, cmd)?;
|
||||
index_of(f64_req(p, key, cmd)?, key, cmd)
|
||||
}
|
||||
|
||||
/// `i`, the value of `key`, as an index: a whole number ≥ 0.
|
||||
fn index_of(i: f64, key: &str, cmd: &str) -> Result<usize> {
|
||||
if i < 0.0 {
|
||||
return Err(bad(cmd, "index must be ≥ 0"));
|
||||
}
|
||||
@@ -156,14 +223,13 @@ pub(super) fn mesh_set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.setPointColor";
|
||||
check_numbers(p, &["opacity"], C)?;
|
||||
crate::cmd::paint::check_keep_model(p, C)?;
|
||||
let index = index_param(p, "index", C)?;
|
||||
let items = point_items(p, C)?;
|
||||
let color = p.get("color").and_then(color_value).map(|c| crate::cmd::paint::new_color(s, p, c));
|
||||
let opacity = p.get("opacity").and_then(Value::as_f64);
|
||||
if color.is_none() && opacity.is_none() {
|
||||
return Err(bad(C, "missing `color` or `opacity`"));
|
||||
}
|
||||
with_mesh(s, p, C, "Mesh Point Color", false, |m| {
|
||||
let pt = m.points.get_mut(index).ok_or_else(|| bad(C, "index out of range"))?;
|
||||
with_points(s, &items, C, "Mesh Point Color", false, |pt| {
|
||||
if let Some(c) = color {
|
||||
pt.color = c;
|
||||
}
|
||||
@@ -191,6 +257,18 @@ pub(super) fn mesh_move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn mesh_move_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.movePoints";
|
||||
let items = point_items(p, C)?;
|
||||
let (dx, dy) = (f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
// `Session::edit` refuses a move past the canvas's coordinate range.
|
||||
with_points(s, &items, C, "Move Mesh Points", true, |pt| {
|
||||
pt.p = Point::new(pt.p.x + dx, pt.p.y + dy);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn mesh_add_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.mesh.addLine";
|
||||
let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::collections::HashSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::{Document, Knockout, Node, NodeId, OpacityMask, Selection};
|
||||
use vectorcraft_doc::{Document, Knockout, Node, NodeId, NodeKind, OpacityMask, Selection};
|
||||
|
||||
use super::maskedit::{begin, finish, mask_group, mask_parts, show_editing};
|
||||
use super::pathops::{Beside, separate};
|
||||
@@ -368,12 +368,19 @@ impl TransparencyInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// The opacity of the focused gradient mesh `point` (see `transparency.set`) instead of its
|
||||
/// object's.
|
||||
fn with_mesh_point(mut self, point: Option<(NodeId, usize, f32)>) -> Self {
|
||||
if let Some((_, _, o)) = point {
|
||||
self.opacity = Some((o as f64 * 10_000.0).round() / 100.0);
|
||||
}
|
||||
/// The opacity of the picked gradient mesh `points` (see `transparency.set`) instead of their
|
||||
/// objects': `None` where they differ.
|
||||
fn with_mesh_points(mut self, d: &Document, points: Option<Vec<(NodeId, Vec<usize>)>>) -> Self {
|
||||
let Some(points) = points else { return self };
|
||||
let mut all = points.iter().flat_map(|(id, v)| {
|
||||
let m = match d.node(*id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Mesh(m)) => Some(m),
|
||||
_ => None,
|
||||
};
|
||||
v.iter().filter_map(move |i| m?.points.get(*i)).map(|q| (q.opacity as f64 * 10_000.0).round() as i64)
|
||||
});
|
||||
let first = all.next();
|
||||
self.opacity = first.filter(|f| all.all(|o| o == *f)).map(|o| o as f64 / 100.0);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -404,8 +411,8 @@ impl Session {
|
||||
pub fn transparency_info(&self) -> TransparencyInfo {
|
||||
match (self.active(), transparency_targets(self, &Value::Null)) {
|
||||
(Some(st), Ok(ids)) => {
|
||||
let point = super::paint::focused_mesh_point_of(self, &ids);
|
||||
TransparencyInfo::of(&st.doc, ids).with_mesh_point(point)
|
||||
let points = super::paint::picked_mesh_points_of(self, &ids);
|
||||
TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points)
|
||||
}
|
||||
_ => TransparencyInfo::default(),
|
||||
}
|
||||
@@ -415,8 +422,8 @@ impl Session {
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let ids = transparency_targets(s, p)?;
|
||||
let point = super::paint::focused_mesh_point_of(s, &ids);
|
||||
let i = TransparencyInfo::of(&st.doc, ids).with_mesh_point(point);
|
||||
let points = super::paint::picked_mesh_points_of(s, &ids);
|
||||
let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points);
|
||||
Ok(json!({
|
||||
"ids": i.ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"opacity": i.opacity,
|
||||
|
||||
@@ -462,9 +462,9 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
let selected = item.targets(s, p)?;
|
||||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||||
let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint };
|
||||
// The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill
|
||||
// recolours just that point of its mesh.
|
||||
let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None };
|
||||
// The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill
|
||||
// recolours just those points of their meshes.
|
||||
let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] };
|
||||
// Without `item`, unless the Appearance panel's active fill item takes the paint.
|
||||
let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top {
|
||||
colorized_images(&s.doc()?.doc, &ids, &paint).map_err(|e| bad(cmd, e))?
|
||||
@@ -486,11 +486,14 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool)
|
||||
}
|
||||
if index.is_none()
|
||||
&& let Paint::Solid { color, .. } = &paint
|
||||
&& let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id)
|
||||
&& let Some((_, points)) = mesh_points.iter().find(|(id, _)| *id == n.id)
|
||||
&& let NodeKind::Mesh(m) = &mut n.kind
|
||||
&& let Some(q) = m.points.get_mut(i)
|
||||
{
|
||||
q.color = *color;
|
||||
for i in points {
|
||||
if let Some(q) = m.points.get_mut(*i) {
|
||||
q.color = *color;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if index.is_none()
|
||||
@@ -689,20 +692,31 @@ fn black_or_white(c: &Color) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct
|
||||
/// Selection's point last clicked).
|
||||
fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {
|
||||
/// The points the active tool has picked (its `meshPoints` option: the Mesh tool's or Direct
|
||||
/// Selection's) on the gradient meshes among `ids`, in range, each mesh with its points.
|
||||
fn picked_mesh_points(s: &Session, ids: &[NodeId]) -> Vec<(NodeId, Vec<usize>)> {
|
||||
let o = s.tool_options();
|
||||
let pt = o.get("meshPoint")?;
|
||||
Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?))
|
||||
let (Some(picked), Ok(st)) = (o.get("meshPoints").and_then(Value::as_array), s.doc()) else { return vec![] };
|
||||
let mut out: Vec<(NodeId, Vec<usize>)> = vec![];
|
||||
for pt in picked {
|
||||
let (Some(id), Some(index)) = (pt.get("id").and_then(Value::as_u64).map(NodeId), pt.get("index").and_then(Value::as_u64)) else { continue };
|
||||
let Ok(index) = usize::try_from(index) else { continue };
|
||||
if !ids.contains(&id) || !matches!(st.doc.node(id).map(|n| &n.kind), Some(NodeKind::Mesh(m)) if index < m.points.len()) {
|
||||
continue;
|
||||
}
|
||||
match out.iter_mut().find(|(m, _)| *m == id) {
|
||||
Some((_, v)) => v.push(index),
|
||||
None => out.push((id, vec![index])),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The focused mesh point (see [`focused_mesh_point`]) with its opacity, when `ids` is just the
|
||||
/// gradient mesh it belongs to.
|
||||
pub(crate) fn focused_mesh_point_of(s: &Session, ids: &[NodeId]) -> Option<(NodeId, usize, f32)> {
|
||||
let (id, index) = focused_mesh_point(s).filter(|(id, _)| ids == [*id])?;
|
||||
let NodeKind::Mesh(m) = &s.doc().ok()?.doc.node(id)?.kind else { return None };
|
||||
Some((id, index, m.points.get(index)?.opacity))
|
||||
/// The picked gradient mesh points (see [`picked_mesh_points`]) when each of `ids` is a gradient
|
||||
/// mesh with points picked: the Transparency panel's opacity is theirs then.
|
||||
pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option<Vec<(NodeId, Vec<usize>)>> {
|
||||
let v = picked_mesh_points(s, ids);
|
||||
(!ids.is_empty() && ids.iter().all(|id| v.iter().any(|(m, _)| m == id))).then_some(v)
|
||||
}
|
||||
|
||||
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
|
||||
@@ -848,15 +862,16 @@ fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q.remove("item");
|
||||
q.remove("id");
|
||||
q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
|
||||
// The gradient mesh point last clicked with the Mesh tool or Direct Selection takes the
|
||||
// opacity itself, so only the mesh around it fades (#4206).
|
||||
// The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity
|
||||
// themselves, so only the mesh around them fades (#4206, #4170, #4208).
|
||||
if matches!(item, ItemTarget::Top)
|
||||
&& let Some(o) = q.get("opacity").and_then(Value::as_f64)
|
||||
&& let Some((id, index, _)) = focused_mesh_point_of(s, &ids)
|
||||
&& let Some(points) = picked_mesh_points_of(s, &ids)
|
||||
{
|
||||
q.remove("opacity");
|
||||
let opacity = f64::from(super::opacitymask::percent(o));
|
||||
s.execute("object.mesh.setPointColor", &json!({"id": id.0, "index": index, "opacity": opacity}))?;
|
||||
let items: Vec<Value> = points.iter().map(|(id, v)| json!({"id": id.0, "points": v})).collect();
|
||||
s.execute("object.mesh.setPointColor", &json!({"items": items, "opacity": opacity}))?;
|
||||
if q.keys().all(|k| k == "ids") {
|
||||
return ok();
|
||||
}
|
||||
|
||||
@@ -233,6 +233,81 @@ fn a_clicked_mesh_point_takes_the_opacity() {
|
||||
assert!(mesh_opacities(&s, m).iter().all(|a| *a == 1.0));
|
||||
}
|
||||
|
||||
/// Mesh points picked together (Shift-clicks with the Mesh tool, a Direct Selection marquee) all
|
||||
/// take a solid fill colour and the Transparency panel's opacity, and only they do; the panel
|
||||
/// shows their opacity, blank when it differs; a drag of one moves them all as one undo step
|
||||
/// (#4207, #4208, #4170).
|
||||
#[test]
|
||||
fn picked_mesh_points_take_the_fill_opacity_and_drag_together() {
|
||||
let mut s = session();
|
||||
let m = rect(&mut s, json!("#ff0000"));
|
||||
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [m.0]})).unwrap();
|
||||
let v = ViewInfo::default();
|
||||
let point = |s: &Session, i: usize| {
|
||||
let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") };
|
||||
g.points[i].p
|
||||
};
|
||||
let pointer = |s: &mut Session, kind: PointerKind, q: vectorcraft_geom::Point, shift: bool| {
|
||||
let mut ev = PointerEvent::new(kind, q.x, q.y);
|
||||
ev.mods.shift = shift;
|
||||
s.pointer(&ev, v).unwrap();
|
||||
};
|
||||
let click = |s: &mut Session, i: usize, shift: bool| {
|
||||
let q = point(s, i);
|
||||
pointer(s, PointerKind::Down, q, shift);
|
||||
pointer(s, PointerKind::Up, q, shift);
|
||||
};
|
||||
let check = |s: &Session, picked: &[usize], hex: &str, opacity: f32, what: &str| {
|
||||
let (c, o) = (mesh_colors(s, m), mesh_opacities(s, m));
|
||||
for i in 0..c.len() {
|
||||
let on = picked.contains(&i);
|
||||
assert_eq!(c[i].to_hex() == hex, on, "{what}: point {i} colour {c:?}");
|
||||
assert_eq!((o[i] - opacity).abs() < 1e-6, on, "{what}: point {i} opacity {o:?}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.node(m).unwrap().opacity, 1.0, "{what}: the object stays opaque");
|
||||
};
|
||||
// The Mesh tool: a click, then Shift-clicks add points (and a second one takes one away).
|
||||
s.select_tool("mesh", v).unwrap();
|
||||
click(&mut s, 1, false);
|
||||
for i in [4, 8, 8] {
|
||||
click(&mut s, i, true);
|
||||
}
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("transparency.set", &json!({"opacity": 40})).unwrap();
|
||||
check(&s, &[1, 4], "#0000ff", 0.4, "mesh tool");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step for both points");
|
||||
assert_eq!(s.transparency_info().opacity, Some(40.0), "the panel shows the points' opacity");
|
||||
s.execute("object.mesh.setPointColor", &json!({"id": m.0, "index": 4, "opacity": 0.9})).unwrap();
|
||||
assert_eq!(s.transparency_info().opacity, None, "mixed: blank");
|
||||
// Direct Selection: a marquee over the top-left patch picks its four points.
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
let corner = point(&s, 0);
|
||||
pointer(&mut s, PointerKind::Down, corner - vectorcraft_geom::Vec2::new(8.0, 8.0), false);
|
||||
let far = point(&s, 4) + vectorcraft_geom::Vec2::new(5.0, 5.0);
|
||||
pointer(&mut s, PointerKind::Drag, far, false);
|
||||
pointer(&mut s, PointerKind::Up, far, false);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [m], "the marquee selects the mesh");
|
||||
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("transparency.set", &json!({"opacity": 20})).unwrap();
|
||||
check(&s, &[0, 1, 3, 4], "#00ff00", 0.2, "marquee");
|
||||
// A drag of one picked point moves all four by the same offset, in one step.
|
||||
let before: Vec<_> = (0..9).map(|i| point(&s, i)).collect();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let d = vectorcraft_geom::Vec2::new(4.0, 6.0);
|
||||
pointer(&mut s, PointerKind::Down, before[4], false);
|
||||
pointer(&mut s, PointerKind::Drag, before[4] + d, false);
|
||||
pointer(&mut s, PointerKind::Up, before[4] + d, false);
|
||||
for (i, was) in before.iter().enumerate() {
|
||||
let want = if [0, 1, 3, 4].contains(&i) { *was + d } else { *was };
|
||||
assert!(point(&s, i).distance(want) < 1e-9, "point {i}: {:?} ≠ {want:?}", point(&s, i));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((0..9).map(|i| point(&s, i)).collect::<Vec<_>>(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_moves_a_mesh_to_the_blended_colour() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -851,6 +851,63 @@ fn mesh_point_colour_move_and_undo() {
|
||||
assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": 99, "color": "#000000"})).is_err());
|
||||
}
|
||||
|
||||
/// `object.mesh.movePoints` moves just the points it names (their handles with them), over one or
|
||||
/// several meshes, as one undo step; `object.mesh.setPointColor` colours and fades several points
|
||||
/// alike. A bad index, a non-number or a move past the coordinate range fails and changes nothing
|
||||
/// (#4207, #4208).
|
||||
#[test]
|
||||
fn mesh_points_move_and_colour_together() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 200.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a, b]);
|
||||
s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap();
|
||||
let (ma, mb) = (mesh_of(&s, a), mesh_of(&s, b));
|
||||
let n = undo_len(&s);
|
||||
s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [0, 4, 4], "dx": 10, "dy": -5})).unwrap();
|
||||
let m = mesh_of(&s, a);
|
||||
for (i, (q, was)) in m.points.iter().zip(&ma.points).enumerate() {
|
||||
let want = if i == 0 || i == 4 { Point::new(was.p.x + 10.0, was.p.y - 5.0) } else { was.p };
|
||||
assert_eq!((q.p, q.handles), (want, was.handles), "point {i}: moved once if named, its handles with it");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
s.execute("object.mesh.movePoints", &json!({"items": [{"id": a.0, "points": [1]}, {"id": b.0, "index": 2}], "dx": 1, "dy": 1})).unwrap();
|
||||
assert_eq!(mesh_of(&s, a).points[1].p, Point::new(ma.points[1].p.x + 1.0, ma.points[1].p.y + 1.0));
|
||||
assert_eq!(mesh_of(&s, b).points[2].p, Point::new(mb.points[2].p.x + 1.0, mb.points[2].p.y + 1.0));
|
||||
assert_eq!(undo_len(&s), n + 2, "both meshes in one step");
|
||||
// A move that would take a point past the coordinate range fails.
|
||||
assert!(s.execute("object.mesh.movePoints", &json!({"id": a.0, "points": [0], "dx": 1e308, "dy": 0})).is_err());
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
for _ in 0..2 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert_eq!((mesh_of(&s, a), mesh_of(&s, b)), (ma.clone(), mb.clone()));
|
||||
// Several points take one colour and opacity, in one step.
|
||||
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [2, 6], "color": "#0000ff", "opacity": 0.25})).unwrap();
|
||||
for (i, q) in mesh_of(&s, a).points.iter().enumerate() {
|
||||
let named = i == 2 || i == 6;
|
||||
assert_eq!((q.color.to_hex() == "#0000ff", q.opacity == 0.25), (named, named), "point {i}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
let n = undo_len(&s);
|
||||
let before = (mesh_of(&s, a), mesh_of(&s, b));
|
||||
for p in [
|
||||
json!({"id": a.0, "points": [0, 99], "dx": 1, "dy": 1}),
|
||||
json!({"items": [{"id": a.0, "points": [0]}, {"id": b.0, "points": [99]}], "dx": 1, "dy": 1}),
|
||||
json!({"id": a.0, "points": [-1], "dx": 1, "dy": 1}),
|
||||
json!({"id": a.0, "points": [1.5], "dx": 1, "dy": 1}),
|
||||
json!({"id": a.0, "points": ["1"], "dx": 1, "dy": 1}),
|
||||
json!({"id": a.0, "points": 1, "dx": 1, "dy": 1}),
|
||||
json!({"id": a.0, "points": [0], "dx": "1", "dy": 1}),
|
||||
json!({"items": 3, "dx": 1, "dy": 1}),
|
||||
json!({"points": [0], "dx": 1, "dy": 1}),
|
||||
] {
|
||||
assert!(s.execute("object.mesh.movePoints", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(s.execute("object.mesh.setPointColor", &json!({"id": a.0, "points": [0, 9], "color": "#000000"})).is_err());
|
||||
assert_eq!(((mesh_of(&s, a), mesh_of(&s, b)), undo_len(&s)), (before, n), "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_add_line_and_delete_point() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -282,6 +282,20 @@ impl ToolContext<'_> {
|
||||
pub fn star_radii_label(&self, outer: f64, inner: f64) -> String {
|
||||
format!("R1: {}\nR2: {}", self.len(outer), self.len(inner))
|
||||
}
|
||||
/// The move an arrow key nudges by: General › Keyboard Increment (ten times with Shift),
|
||||
/// along General › Constrain Angle's axes; `None` for other keys.
|
||||
pub fn arrow_nudge(&self, key: crate::ToolKey, mods: crate::Mods) -> Option<Vec2> {
|
||||
use crate::ToolKey;
|
||||
let step = self.keyboard_increment * if mods.shift { 10.0 } else { 1.0 };
|
||||
let d = match key {
|
||||
ToolKey::Left => Point::new(-step, 0.0),
|
||||
ToolKey::Right => Point::new(step, 0.0),
|
||||
ToolKey::Up => Point::new(0.0, -step),
|
||||
ToolKey::Down => Point::new(0.0, step),
|
||||
_ => return None,
|
||||
};
|
||||
Some(vectorcraft_geom::constrain_axes(self.constrain_angle).map_or(d, |axes| axes * d).to_vec2())
|
||||
}
|
||||
/// A move label: `dX: …` over `dY: …`.
|
||||
pub fn offset_label(&self, dx: f64, dy: f64) -> String {
|
||||
format!("dX: {}\ndY: {}", self.len(dx), self.len(dy))
|
||||
|
||||
+64
-20
@@ -25,8 +25,11 @@
|
||||
//! pointer ([`release_guide_at`]).
|
||||
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
|
||||
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient
|
||||
//! mesh's point last clicked). Dragging a corner or an edge of area
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]): Shift-clicks and the marquee pick several,
|
||||
//! a click on a gradient mesh's line picks its two ends and one inside a patch the four points
|
||||
//! around it, a drag of picked points moves them all, the arrow keys nudge them, and a solid fill
|
||||
//! colour or the Transparency panel's opacity goes to a gradient mesh's picked points. Dragging a
|
||||
//! corner or an edge of area
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size. A click
|
||||
//! on a frame anchor or a marquee selects frame anchors (Shift toggles them). While the selection
|
||||
//! holds only area type with selected frame anchors, those show filled, a drag of one of them moves
|
||||
@@ -49,7 +52,7 @@ use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2};
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::{HandleSnap, Leave, PointSnap, Targets};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::meshedit::{MeshEdit, grid_of};
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
use crate::select::{MoveSnap, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at};
|
||||
@@ -280,20 +283,17 @@ impl DirectSelectionTool {
|
||||
if !matches!(self.state, State::Idle) {
|
||||
return None;
|
||||
}
|
||||
let step = cx.keyboard_increment * if mods.shift { 10.0 } else { 1.0 };
|
||||
let d = match key {
|
||||
ToolKey::Left => Point::new(-step, 0.0),
|
||||
ToolKey::Right => Point::new(step, 0.0),
|
||||
ToolKey::Up => Point::new(0.0, -step),
|
||||
ToolKey::Down => Point::new(0.0, step),
|
||||
_ => return None,
|
||||
};
|
||||
let d = cx.arrow_nudge(key, mods)?;
|
||||
let items = self.frame_anchors(cx);
|
||||
if items.is_empty() {
|
||||
(!items.is_empty()).then_some((items, d))
|
||||
}
|
||||
|
||||
/// An arrow key's nudge of the picked points of the selected meshes.
|
||||
fn mesh_nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Option<Vec<Action>> {
|
||||
if !matches!(self.state, State::Idle) {
|
||||
return None;
|
||||
}
|
||||
let d = vectorcraft_geom::constrain_axes(cx.constrain_angle).map_or(d, |axes| axes * d);
|
||||
Some((items, d.to_vec2()))
|
||||
self.mesh.nudge(cx, key, mods, &|id| cx.selection.contains(id))
|
||||
}
|
||||
|
||||
/// Is `key` the E that Group Selection takes while a marquee is being dragged? Direct
|
||||
@@ -583,6 +583,9 @@ impl Tool for DirectSelectionTool {
|
||||
/// entirely inside it, whole, and E pressed again makes it select what it selects by default.
|
||||
/// The arrow keys nudge the selected frame anchors of area type.
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if let Some(out) = self.mesh_nudge(cx, key, mods) {
|
||||
return out;
|
||||
}
|
||||
if let Some((items, d)) = self.frame_nudge(cx, key, mods) {
|
||||
return vec![Action::Exec("text.reshapeArea".into(), reshape_params(&items, d)), Action::Notify(FRAME_PICKED.into())];
|
||||
}
|
||||
@@ -609,7 +612,7 @@ impl Tool for DirectSelectionTool {
|
||||
if frame { vec![Action::Cancel, Action::Notify(FRAME_PICKED.into())] } else { vec![Action::Cancel] }
|
||||
}
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.claims_letter(key) || self.frame_nudge(cx, key, Mods::default()).is_some()
|
||||
self.claims_letter(key) || self.mesh_nudge(cx, key, Mods::default()).is_some() || self.frame_nudge(cx, key, Mods::default()).is_some()
|
||||
}
|
||||
fn notify(&mut self, cx: &ToolContext, what: &str) {
|
||||
if what == FRAME_PICKED {
|
||||
@@ -656,9 +659,12 @@ impl Tool for DirectSelectionTool {
|
||||
self.snapped = false;
|
||||
if let Some(g) = self.mesh.hit(cx, &cx.selection.objects, p) {
|
||||
self.state = State::Mesh;
|
||||
return self.mesh.press(cx, g);
|
||||
return self.mesh.press(cx, &cx.selection.objects, g, p, ev.mods.shift);
|
||||
}
|
||||
// Shift keeps the picked mesh points: it adds to the selection.
|
||||
if !ev.mods.shift {
|
||||
self.mesh.unfocus();
|
||||
}
|
||||
self.mesh.unfocus();
|
||||
if let Some((id, si, ai, out)) = hit_handle(cx, p, point) {
|
||||
self.state = State::Handle { id, si, ai, out };
|
||||
self.handle_snap = HandleSnap::default();
|
||||
@@ -772,7 +778,7 @@ impl Tool for DirectSelectionTool {
|
||||
(PointerKind::Drag, State::Mesh) => self.mesh.drag_to(p).unwrap_or_default(),
|
||||
(PointerKind::Up, State::Mesh) => {
|
||||
self.state = State::Idle;
|
||||
self.mesh.release().unwrap_or_default()
|
||||
self.mesh.release(cx).unwrap_or_default()
|
||||
}
|
||||
(PointerKind::Drag, State::Marquee { start, toggle, enclosed, .. }) => {
|
||||
self.state = State::Marquee { start, cur: p, toggle, enclosed };
|
||||
@@ -820,7 +826,7 @@ impl Tool for DirectSelectionTool {
|
||||
State::Corner(c) => c.overlays(cx),
|
||||
_ => {
|
||||
let mut out = self.spine_overlays(cx);
|
||||
out.extend(self.mesh.overlays(cx));
|
||||
out.extend(self.mesh.overlays(cx, &|id| cx.selection.contains(id)));
|
||||
if !self.group {
|
||||
out.extend(frame_overlays(cx, &self.frame_anchors(cx)));
|
||||
out.extend(pathtype::overlays(cx));
|
||||
@@ -1131,6 +1137,24 @@ impl DirectSelectionTool {
|
||||
h.ancestry.push(part);
|
||||
h.leaf = part;
|
||||
}
|
||||
// On a gradient mesh a click picks the point, the mesh line's two ends or the four points
|
||||
// around the patch under it (Shift toggles them), and selects the mesh; a drag moves the
|
||||
// picked points.
|
||||
if !ev.mods.alt
|
||||
&& let Some(NodeKind::Mesh(m)) = cx.doc.node(h.leaf).map(|n| &n.kind)
|
||||
&& let Some(points) = crate::meshedit::part_points(m, p, cx.point_tol(), tol)
|
||||
{
|
||||
let id = h.leaf;
|
||||
let mut out = match (ev.mods.shift, cx.selection.contains(id)) {
|
||||
(true, false) => vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))],
|
||||
(false, false) => vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))],
|
||||
_ => vec![],
|
||||
};
|
||||
self.frame.clear();
|
||||
self.state = State::Mesh;
|
||||
out.extend(self.mesh.press_points(cx, &cx.selection.objects, id, &points, p, ev.mods.shift));
|
||||
return out;
|
||||
}
|
||||
// A segment pick keeps its endpoints available for movement without selecting
|
||||
// the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks
|
||||
// the whole path (and Alt-drag copies it), as with Group Selection.
|
||||
@@ -1344,6 +1368,26 @@ impl DirectSelectionTool {
|
||||
});
|
||||
framed.retain(|(id, _)| editable(*id));
|
||||
}
|
||||
// Direct Selection: the mesh points inside the rect are picked (with Shift, toggled), and
|
||||
// their meshes selected whole; with Shift a mesh is toggled only when it gains its first
|
||||
// picked point or loses its last one.
|
||||
let mut meshed: Vec<NodeId> = vec![];
|
||||
if !self.group {
|
||||
let mut inside: Vec<(NodeId, usize)> = vec![];
|
||||
walk_pickable(cx.doc, cx.isolation, |n| {
|
||||
if editable(n.id)
|
||||
&& let Some(grid) = grid_of(n)
|
||||
{
|
||||
inside.extend(grid.points.iter().enumerate().filter(|(_, q)| r.contains(q.p)).map(|(i, _)| (n.id, i)));
|
||||
}
|
||||
});
|
||||
for (id, any) in self.mesh.marquee(&inside, toggle) {
|
||||
meshed.push(id);
|
||||
if !toggle || cx.selection.contains(id) != any {
|
||||
sel.push((id, vec![]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut marked: BTreeMap<NodeId, BTreeSet<AnchorRef>> =
|
||||
if toggle { self.frame_anchors(cx).into_iter().map(|(id, v)| (id, v.into_iter().collect())).collect() } else { BTreeMap::new() };
|
||||
for (id, inside) in &framed {
|
||||
@@ -1363,7 +1407,7 @@ impl DirectSelectionTool {
|
||||
// the marquee touches them, as with the Selection tool (#927).
|
||||
for id in vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true) {
|
||||
let whole = cx.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }));
|
||||
if whole && !sel.iter().any(|(s, _)| *s == id) && !framed.iter().any(|(f, _)| *f == id) {
|
||||
if whole && !sel.iter().any(|(s, _)| *s == id) && !framed.iter().any(|(f, _)| *f == id) && !meshed.contains(&id) {
|
||||
sel.push((id, vec![]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
|
||||
//! new point takes the current fill color in its own model, unless Shift); drag a mesh point,
|
||||
//! or a handle of the point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]);
|
||||
//! Alt-click a mesh point → `object.mesh.deletePoint`. Clicking a gradient mesh's point selects
|
||||
//! the mesh, and the next solid fill color recolors just that point. Escape during a drag
|
||||
//! cancels it.
|
||||
//! Shift-click mesh points to pick several (Shift-click a picked one to drop it): a drag of one
|
||||
//! moves them all (`object.mesh.movePoints`) and the arrow keys nudge them. Alt-click a mesh
|
||||
//! point → `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the
|
||||
//! next solid fill color or Transparency panel opacity goes to just the picked points. Escape
|
||||
//! during a drag cancels it.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
@@ -190,6 +192,11 @@ impl MeshTool {
|
||||
fn grab_at(&self, cx: &ToolContext, p: Point) -> Option<crate::meshedit::MeshGrab> {
|
||||
self.edit.hit(cx, &Self::meshes(cx), p)
|
||||
}
|
||||
|
||||
/// An arrow key's nudge of the picked points.
|
||||
fn nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Option<Vec<Action>> {
|
||||
self.edit.nudge(cx, key, mods, &|_| true)
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for MeshTool {
|
||||
@@ -200,18 +207,23 @@ impl Tool for MeshTool {
|
||||
let p = ev.pos;
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
if let Some(g) = self.grab_at(cx, p) {
|
||||
let meshes = Self::meshes(cx);
|
||||
if let Some(g) = self.edit.hit(cx, &meshes, p) {
|
||||
if ev.mods.alt && g.handle.is_none() {
|
||||
self.edit.unfocus();
|
||||
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))];
|
||||
}
|
||||
// A gradient mesh's point is picked for the next fill colour, so its mesh is
|
||||
// what gets selected.
|
||||
// what gets selected (with Shift, added to the selection).
|
||||
let mut out = vec![];
|
||||
if cx.selection.objects != [g.id] && cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]})));
|
||||
if cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {
|
||||
if ev.mods.shift && !cx.selection.contains(g.id) {
|
||||
out.push(Action::Exec("select.add".into(), json!({"ids": [g.id.0]})));
|
||||
} else if !ev.mods.shift && cx.selection.objects != [g.id] {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]})));
|
||||
}
|
||||
}
|
||||
out.extend(self.edit.press(cx, g));
|
||||
out.extend(self.edit.press(cx, &meshes, g, p, ev.mods.shift));
|
||||
return out;
|
||||
}
|
||||
self.edit.unfocus();
|
||||
@@ -242,7 +254,7 @@ impl Tool for MeshTool {
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Drag => self.edit.drag_to(p).unwrap_or_default(),
|
||||
PointerKind::Up => self.edit.release().unwrap_or_default(),
|
||||
PointerKind::Up => self.edit.release(cx).unwrap_or_default(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
@@ -256,7 +268,7 @@ impl Tool for MeshTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend(self.edit.overlays(cx));
|
||||
out.extend(self.edit.overlays(cx, &|_| true));
|
||||
out
|
||||
}
|
||||
fn cursor(&self, cx: &ToolContext, p: Point, mods: Mods) -> Cursor {
|
||||
@@ -273,16 +285,19 @@ impl Tool for MeshTool {
|
||||
self.edit.dragging()
|
||||
}
|
||||
/// Escape during a drag of a mesh point or handle cancels it: the point goes back and the
|
||||
/// release does nothing.
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
/// release does nothing. The arrow keys nudge the picked points.
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||
if key != ToolKey::Escape {
|
||||
return vec![];
|
||||
return self.nudge(cx, key, mods).unwrap_or_default();
|
||||
}
|
||||
self.edit.cancel().unwrap_or_default()
|
||||
}
|
||||
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
||||
fn claims_key(&self, cx: &ToolContext, key: ToolKey) -> bool {
|
||||
self.nudge(cx, key, Mods::default()).is_some()
|
||||
}
|
||||
fn deactivate(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||||
self.edit.unfocus();
|
||||
self.edit.release().unwrap_or_default()
|
||||
self.edit.release(cx).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+328
-34
@@ -1,16 +1,21 @@
|
||||
//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct
|
||||
//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a
|
||||
//! point focuses it: its handles show and can be dragged too, and on a gradient mesh a solid fill
|
||||
//! colour recolours it (the tool's `meshPoint` option).
|
||||
//! Picking and dragging mesh points and their handles on the canvas, shared by the Mesh tool and
|
||||
//! Direct Selection: gradient meshes and mesh envelopes alike. A click on a point picks it alone
|
||||
//! and focuses it: its handles show and can be dragged too. Shift-clicks add points to the picked
|
||||
//! ones or take them away, and Direct Selection's marquee and its clicks on a gradient mesh's line
|
||||
//! or patch pick points too ([`part_points`]). The picked points show filled; a drag of one of
|
||||
//! them moves them all (`object.mesh.movePoint` for one, `object.mesh.movePoints` for several)
|
||||
//! and the arrow keys nudge them. On a gradient mesh a solid fill colour recolours them and the
|
||||
//! Transparency panel's opacity fades them (the tool's `meshPoints` option).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
|
||||
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
use crate::{Action, Overlay, ToolContext};
|
||||
use crate::{Action, Mods, Overlay, ToolContext, ToolKey};
|
||||
|
||||
const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
|
||||
|
||||
@@ -27,6 +32,29 @@ pub fn grid_of(n: &Node) -> Option<Cow<'_, GradientMesh>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The points of gradient mesh `m` a Direct Selection click at `p` picks: the point within
|
||||
/// `point_tol`, else the two ends of the mesh line within `line_tol`, else the four points around
|
||||
/// the patch under `p`.
|
||||
pub fn part_points(m: &GradientMesh, p: Point, point_tol: f64, line_tol: f64) -> Option<Vec<usize>> {
|
||||
if let Some(i) = m.point_near(p, point_tol) {
|
||||
return Some(vec![i]);
|
||||
}
|
||||
// `lines` lists the rows' edges, then the columns' (and nothing for a malformed mesh).
|
||||
let (rows, cols) = (m.rows.max(1) as usize, m.cols.max(1) as usize);
|
||||
if let Some((si, ..)) = m.lines().nearest(p).filter(|n| n.4 <= line_tol) {
|
||||
let across = (rows + 1) * cols;
|
||||
return Some(if si < across {
|
||||
let (r, c) = (si / cols, si % cols);
|
||||
vec![m.idx(r, c), m.idx(r, c + 1)]
|
||||
} else {
|
||||
let (c, r) = ((si - across) / rows, (si - across) % rows);
|
||||
vec![m.idx(r, c), m.idx(r + 1, c)]
|
||||
});
|
||||
}
|
||||
let (r, c, ..) = m.locate(p)?;
|
||||
Some(vec![m.idx(r, c), m.idx(r, c + 1), m.idx(r + 1, c), m.idx(r + 1, c + 1)])
|
||||
}
|
||||
|
||||
/// What a press on a mesh grabs: the mesh, the point and (on the focused point) a handle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct MeshGrab {
|
||||
@@ -35,21 +63,46 @@ pub struct MeshGrab {
|
||||
pub handle: Option<usize>,
|
||||
}
|
||||
|
||||
/// Mesh points, each mesh with its points.
|
||||
type MeshPoints = Vec<(NodeId, Vec<usize>)>;
|
||||
|
||||
/// An in-progress mesh point or handle drag.
|
||||
struct ActiveDrag {
|
||||
grab: MeshGrab,
|
||||
/// Where the grabbed point or handle was when the drag began.
|
||||
/// Where the press was.
|
||||
start: Point,
|
||||
/// Where the grabbed point or handle was when the drag began (where the press was, on a mesh
|
||||
/// line or patch).
|
||||
origin: Point,
|
||||
/// The current drag position (preview target); set on the first `drag_to`.
|
||||
at: Option<Point>,
|
||||
/// The picked points, when the drag moves several of them by its offset; empty when it moves
|
||||
/// `grab` alone to the pointer.
|
||||
many: MeshPoints,
|
||||
}
|
||||
|
||||
/// Point and handle drags on meshes.
|
||||
/// The params of `object.mesh.movePoints` moving `items` by `d`.
|
||||
fn move_params(items: &[(NodeId, Vec<usize>)], d: Vec2) -> Value {
|
||||
match items {
|
||||
[(id, points)] => json!({"id": id.0, "points": points, "dx": d.x, "dy": d.y}),
|
||||
_ => {
|
||||
let items: Vec<Value> = items.iter().map(|(id, v)| json!({"id": id.0, "points": v})).collect();
|
||||
json!({"items": items, "dx": d.x, "dy": d.y})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh point picks and point and handle drags on meshes.
|
||||
#[derive(Default)]
|
||||
pub struct MeshEdit {
|
||||
/// The point last clicked: its handles show and can be dragged.
|
||||
focus: Option<(NodeId, usize)>,
|
||||
/// The picked points (the focused one among them).
|
||||
picked: BTreeSet<(NodeId, usize)>,
|
||||
drag: Option<ActiveDrag>,
|
||||
/// Pressed without Shift on points already picked among others: a click (no drag) picks
|
||||
/// these alone, as a drag moves every picked point.
|
||||
click: Option<(NodeId, Vec<usize>)>,
|
||||
}
|
||||
|
||||
impl MeshEdit {
|
||||
@@ -72,23 +125,84 @@ impl MeshEdit {
|
||||
})
|
||||
}
|
||||
|
||||
/// Start dragging `grab` (its point takes the focus).
|
||||
pub fn press(&mut self, cx: &ToolContext, grab: MeshGrab) -> Vec<Action> {
|
||||
self.focus = Some((grab.id, grab.index));
|
||||
let mut origin = Point::ZERO;
|
||||
if let Some(grid) = cx.doc.node(grab.id).and_then(grid_of)
|
||||
&& let Some(q) = grid.points.get(grab.index)
|
||||
{
|
||||
origin = if let Some(h) = grab.handle { q.p + q.handles[h] } else { q.p };
|
||||
/// The picked points that still apply: of editable meshes `shown` accepts, in range.
|
||||
fn live(&self, cx: &ToolContext, shown: &dyn Fn(NodeId) -> bool) -> MeshPoints {
|
||||
let editable = cx.doc.editable();
|
||||
let mut out: Vec<(NodeId, usize, Vec<usize>)> = vec![];
|
||||
for &(id, i) in &self.picked {
|
||||
if out.last().is_none_or(|(last, ..)| *last != id) {
|
||||
let len = if shown(id) && editable(id) { cx.doc.node(id).and_then(grid_of).map_or(0, |g| g.points.len()) } else { 0 };
|
||||
out.push((id, len, vec![]));
|
||||
}
|
||||
if let Some((_, len, v)) = out.last_mut()
|
||||
&& i < *len
|
||||
{
|
||||
v.push(i);
|
||||
}
|
||||
}
|
||||
self.drag = Some(ActiveDrag { grab, origin, at: None });
|
||||
vec![Action::Begin("Move Mesh Point".into())]
|
||||
out.into_iter().filter(|(.., v)| !v.is_empty()).map(|(id, _, v)| (id, v)).collect()
|
||||
}
|
||||
|
||||
/// A press at `p` on `grab`, among the meshes `ids`: a handle drag, or a point picked as
|
||||
/// [`Self::press_points`] picks it.
|
||||
pub fn press(&mut self, cx: &ToolContext, ids: &[NodeId], grab: MeshGrab, p: Point, shift: bool) -> Vec<Action> {
|
||||
let Some(h) = grab.handle else { return self.press_points(cx, ids, grab.id, &[grab.index], p, shift) };
|
||||
let Some(q) = cx.doc.node(grab.id).and_then(grid_of).and_then(|g| g.points.get(grab.index).cloned()) else { return vec![] };
|
||||
let origin = q.p + q.handles.get(h).copied().unwrap_or_default();
|
||||
self.start(ActiveDrag { grab, start: p, origin, at: None, many: vec![] })
|
||||
}
|
||||
|
||||
/// A press at `p` on the points `points` of mesh `id` (one point, a mesh line's ends or a
|
||||
/// patch's corners), among the meshes `ids`. Shift takes them away from the picked points when
|
||||
/// all of them are picked (no drag), else adds them; without it they are picked alone unless
|
||||
/// all of them already are. A drag then moves every picked point.
|
||||
pub fn press_points(&mut self, cx: &ToolContext, ids: &[NodeId], id: NodeId, points: &[usize], p: Point, shift: bool) -> Vec<Action> {
|
||||
let keys: Vec<(NodeId, usize)> = points.iter().map(|i| (id, *i)).collect();
|
||||
let all = keys.iter().all(|k| self.picked.contains(k));
|
||||
self.click = None;
|
||||
if shift && all {
|
||||
for k in &keys {
|
||||
self.picked.remove(k);
|
||||
}
|
||||
self.focus = self.focus.filter(|f| !keys.contains(f));
|
||||
return vec![];
|
||||
}
|
||||
if !shift && !all {
|
||||
self.picked.clear();
|
||||
}
|
||||
self.picked.extend(keys.iter().copied());
|
||||
self.focus = keys.first().copied().filter(|_| keys.len() == 1);
|
||||
let many = self.live(cx, &|m| m == id || ids.contains(&m));
|
||||
let count: usize = many.iter().map(|(_, v)| v.len()).sum();
|
||||
if count == 0 {
|
||||
return vec![];
|
||||
}
|
||||
if !shift && all && count > keys.len() {
|
||||
self.click = Some((id, points.to_vec()));
|
||||
}
|
||||
let grab = MeshGrab { id, index: points.first().copied().unwrap_or_default(), handle: None };
|
||||
// One point goes where the pointer takes it; a line or a patch moves by the drag's offset.
|
||||
let origin = match points {
|
||||
[i] => cx.doc.node(id).and_then(grid_of).and_then(|g| g.points.get(*i).map(|q| q.p)).unwrap_or(p),
|
||||
_ => p,
|
||||
};
|
||||
let many = if count == 1 && keys.len() == 1 { vec![] } else { many };
|
||||
self.start(ActiveDrag { grab, start: p, origin, at: None, many })
|
||||
}
|
||||
|
||||
fn start(&mut self, d: ActiveDrag) -> Vec<Action> {
|
||||
let label = if d.many.is_empty() { "Move Mesh Point" } else { "Move Mesh Points" };
|
||||
self.drag = Some(d);
|
||||
vec![Action::Begin(label.into())]
|
||||
}
|
||||
|
||||
/// The preview of a drag to `p` (`None` when nothing is being dragged).
|
||||
pub fn drag_to(&mut self, p: Point) -> Option<Vec<Action>> {
|
||||
let d = self.drag.as_mut()?;
|
||||
d.at = Some(p);
|
||||
if !d.many.is_empty() {
|
||||
return Some(vec![Action::Preview("object.mesh.movePoints".into(), move_params(&d.many, p - d.origin))]);
|
||||
}
|
||||
let g = d.grab;
|
||||
let mut params = json!({"id": g.id.0, "index": g.index, "x": p.x, "y": p.y});
|
||||
if let Some(h) = g.handle {
|
||||
@@ -97,13 +211,21 @@ impl MeshEdit {
|
||||
Some(vec![Action::Preview("object.mesh.movePoint".into(), params)])
|
||||
}
|
||||
|
||||
/// End a drag (`None` when nothing was being dragged).
|
||||
pub fn release(&mut self) -> Option<Vec<Action>> {
|
||||
self.drag.take().map(|_| vec![Action::Commit])
|
||||
/// End a drag (`None` when nothing was being dragged). A click on one of several picked
|
||||
/// points picks it alone.
|
||||
pub fn release(&mut self, cx: &ToolContext) -> Option<Vec<Action>> {
|
||||
let d = self.drag.take()?;
|
||||
if let Some((id, points)) = self.click.take()
|
||||
&& d.at.is_none_or(|a| a.distance(d.start) < cx.tol(3.0))
|
||||
{
|
||||
self.picked = points.into_iter().map(|i| (id, i)).collect();
|
||||
}
|
||||
Some(vec![Action::Commit])
|
||||
}
|
||||
|
||||
/// Cancel a drag: the point or handle goes back (`None` when nothing was being dragged).
|
||||
pub fn cancel(&mut self) -> Option<Vec<Action>> {
|
||||
self.click = None;
|
||||
self.drag.take().map(|_| vec![Action::Cancel])
|
||||
}
|
||||
|
||||
@@ -111,26 +233,69 @@ impl MeshEdit {
|
||||
self.drag.is_some()
|
||||
}
|
||||
|
||||
/// Forget the focused point.
|
||||
/// Forget the focused and the picked points.
|
||||
pub fn unfocus(&mut self) {
|
||||
self.focus = None;
|
||||
(self.focus, self.click) = (None, None);
|
||||
self.picked.clear();
|
||||
}
|
||||
|
||||
/// `{meshPoint: {id, index}|null}`: the focused point, which a solid fill colour recolours
|
||||
/// (`paint.setFill` on a gradient mesh).
|
||||
/// A marquee's picks: the points `inside` it are picked (with `toggle`, those picked are
|
||||
/// taken away and the others added). Returns each mesh with points inside and whether any of
|
||||
/// its points are picked now.
|
||||
pub fn marquee(&mut self, inside: &[(NodeId, usize)], toggle: bool) -> Vec<(NodeId, bool)> {
|
||||
if !toggle {
|
||||
self.picked.clear();
|
||||
}
|
||||
for k in inside {
|
||||
if !(toggle && self.picked.remove(k)) {
|
||||
self.picked.insert(*k);
|
||||
}
|
||||
}
|
||||
self.focus = self.picked.first().copied().filter(|_| self.picked.len() == 1);
|
||||
let mut meshes: Vec<NodeId> = inside.iter().map(|(id, _)| *id).collect();
|
||||
meshes.dedup();
|
||||
meshes.into_iter().map(|id| (id, self.picked.range((id, 0)..=(id, usize::MAX)).next().is_some())).collect()
|
||||
}
|
||||
|
||||
/// An arrow key's nudge of the picked points of the meshes `shown` accepts
|
||||
/// (`object.mesh.movePoints`); `None` during a drag, for other keys or without picked points.
|
||||
pub fn nudge(&self, cx: &ToolContext, key: ToolKey, mods: Mods, shown: &dyn Fn(NodeId) -> bool) -> Option<Vec<Action>> {
|
||||
if self.drag.is_some() {
|
||||
return None;
|
||||
}
|
||||
let d = cx.arrow_nudge(key, mods)?;
|
||||
let live = self.live(cx, shown);
|
||||
(!live.is_empty()).then(|| vec![Action::Exec("object.mesh.movePoints".into(), move_params(&live, d))])
|
||||
}
|
||||
|
||||
/// `{meshPoints: [{id, index}…]}`: the picked points, which a solid fill colour recolours
|
||||
/// (`paint.setFill`) and the Transparency panel's opacity fades (`transparency.set`) on a
|
||||
/// gradient mesh.
|
||||
pub fn options(&self) -> Value {
|
||||
json!({"meshPoint": self.focus.map(|(id, index)| json!({"id": id.0, "index": index}))})
|
||||
json!({"meshPoints": self.picked.iter().map(|(id, index)| json!({"id": id.0, "index": index})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
/// The focused point's handles (lines and ends).
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some((id, index)) = self.focus else { return vec![] };
|
||||
let Some(grid) = cx.doc.node(id).and_then(grid_of) else { return vec![] };
|
||||
let Some(q) = grid.points.get(index) else { return vec![] };
|
||||
let mut out = vec![Overlay::Anchor { p: q.p, color: FEEDBACK, filled: true, size: 5.0 }];
|
||||
for h in q.handles.iter().filter(|h| h.hypot() > 1e-6) {
|
||||
out.push(Overlay::Line { a: q.p, b: q.p + *h, color: FEEDBACK, dashed: false });
|
||||
out.push(Overlay::Handle { p: q.p + *h, color: FEEDBACK });
|
||||
/// The picked points of the meshes `shown` accepts, filled, and the focused point's handles
|
||||
/// (lines and ends).
|
||||
pub fn overlays(&self, cx: &ToolContext, shown: &dyn Fn(NodeId) -> bool) -> Vec<Overlay> {
|
||||
let mut out = vec![];
|
||||
for (id, points) in self.live(cx, shown) {
|
||||
let Some(grid) = cx.doc.node(id).and_then(grid_of) else { continue };
|
||||
out.extend(points.iter().filter_map(|i| grid.points.get(*i)).map(|q| Overlay::Anchor {
|
||||
p: q.p,
|
||||
color: FEEDBACK,
|
||||
filled: true,
|
||||
size: 5.0,
|
||||
}));
|
||||
}
|
||||
if let Some((id, index)) = self.focus.filter(|(id, _)| shown(*id))
|
||||
&& let Some(grid) = cx.doc.node(id).and_then(grid_of)
|
||||
&& let Some(q) = grid.points.get(index)
|
||||
{
|
||||
for h in q.handles.iter().filter(|h| h.hypot() > 1e-6) {
|
||||
out.push(Overlay::Line { a: q.p, b: q.p + *h, color: FEEDBACK, dashed: false });
|
||||
out.push(Overlay::Handle { p: q.p + *h, color: FEEDBACK });
|
||||
}
|
||||
}
|
||||
if cx.measurement_labels
|
||||
&& let Some(d) = &self.drag
|
||||
@@ -262,4 +427,133 @@ mod tests {
|
||||
assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want)));
|
||||
assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. })));
|
||||
}
|
||||
|
||||
/// A 2×2 gradient mesh over the 100..200 square, its points 50 apart.
|
||||
fn doc_with_mesh() -> (Document, NodeId) {
|
||||
let (mut d, a) = doc_with_rect();
|
||||
d.remove(a).unwrap();
|
||||
let id = d.alloc_id();
|
||||
let m = GradientMesh::from_surface(2, 2, &|u, v| Point::new(100.0 + 100.0 * u, 100.0 + 100.0 * v), &|_, _| vectorcraft_color::Color::WHITE);
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, Node::new(id, NodeKind::Mesh(m))).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// The picked points of `t`, as its `meshPoints` option lists them.
|
||||
fn picked(t: &dyn Tool) -> Vec<usize> {
|
||||
t.options()["meshPoints"].as_array().unwrap().iter().map(|p| p["index"].as_u64().unwrap() as usize).collect()
|
||||
}
|
||||
|
||||
fn shift(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
let mut e = PointerEvent::new(kind, x, y);
|
||||
e.mods.shift = true;
|
||||
e
|
||||
}
|
||||
|
||||
/// With the Mesh tool and Direct Selection, Shift-clicks pick several mesh points (and take a
|
||||
/// picked one away); they show filled, a drag of one moves them all as one move, the arrow keys
|
||||
/// nudge them, and a click on one of them picks it alone (#4207).
|
||||
#[test]
|
||||
fn shift_clicks_pick_mesh_points_that_drag_and_nudge_together() {
|
||||
let (d, m) = doc_with_mesh();
|
||||
let mut s = Selection::default();
|
||||
s.set([m]);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let at = |x: f64, y: f64| grid_of(d.node(m).unwrap()).unwrap().point_near(Point::new(x, y), 1.0).unwrap();
|
||||
let sorted = |mut v: Vec<usize>| {
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let (a, b, e) = (at(100.0, 100.0), at(200.0, 150.0), at(150.0, 100.0));
|
||||
let tools: [(&str, Box<dyn Tool>); 2] =
|
||||
[("mesh", crate::meshblend::create("mesh").unwrap()), ("directSelection", Box::new(crate::direct::DirectSelectionTool::new(false)))];
|
||||
for (what, mut t) in tools {
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)), vec![Action::Begin("Move Mesh Point".into())], "{what}");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 100.0));
|
||||
assert_eq!(t.pointer(&c, &shift(PointerKind::Down, 200.0, 150.0)), vec![Action::Begin("Move Mesh Points".into())], "{what}");
|
||||
t.pointer(&c, &shift(PointerKind::Up, 200.0, 150.0));
|
||||
assert_eq!(picked(&*t), sorted(vec![a, b]), "{what}");
|
||||
let filled = t.overlays(&c).iter().filter(|o| matches!(o, Overlay::Anchor { filled: true, .. })).count();
|
||||
assert_eq!(filled, 2, "{what}: the picked points show filled");
|
||||
// A drag of either moves both.
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)), vec![Action::Begin("Move Mesh Points".into())], "{what}");
|
||||
assert_eq!(
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 110.0, 95.0)),
|
||||
vec![Action::Preview("object.mesh.movePoints".into(), json!({"id": m.0, "points": sorted(vec![a, b]), "dx": 10.0, "dy": -5.0}))],
|
||||
"{what}"
|
||||
);
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 110.0, 95.0)), vec![Action::Commit], "{what}");
|
||||
assert_eq!(picked(&*t), sorted(vec![a, b]), "{what}: a drag keeps them picked");
|
||||
// Shift-clicking a picked point takes it away, without a drag.
|
||||
assert!(t.pointer(&c, &shift(PointerKind::Down, 200.0, 150.0)).is_empty(), "{what}");
|
||||
t.pointer(&c, &shift(PointerKind::Up, 200.0, 150.0));
|
||||
assert_eq!(picked(&*t), [a], "{what}");
|
||||
// The arrow keys nudge the picked points.
|
||||
assert!(t.claims_key(&c, ToolKey::Right), "{what}");
|
||||
assert_eq!(
|
||||
t.key(&c, ToolKey::Right, Mods::default()),
|
||||
vec![Action::Exec("object.mesh.movePoints".into(), json!({"id": m.0, "points": [a], "dx": 1.0, "dy": 0.0}))],
|
||||
"{what}"
|
||||
);
|
||||
// A click on one of several picked points picks it alone.
|
||||
t.pointer(&c, &shift(PointerKind::Down, 150.0, 100.0));
|
||||
t.pointer(&c, &shift(PointerKind::Up, 150.0, 100.0));
|
||||
assert_eq!(picked(&*t), sorted(vec![a, e]), "{what}");
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)), vec![Action::Commit], "{what}");
|
||||
assert_eq!(picked(&*t), [a], "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct Selection's marquee picks the mesh points inside it and selects their mesh (Shift
|
||||
/// toggles them); a click on a mesh line picks its two ends and one inside a patch the four
|
||||
/// points around it, selecting the mesh, and a drag from there moves them (#4207).
|
||||
#[test]
|
||||
fn direct_selection_picks_mesh_points_by_marquee_line_and_patch() {
|
||||
let (d, m) = doc_with_mesh();
|
||||
let mut s = Selection::default();
|
||||
s.set([m]);
|
||||
let p = paint();
|
||||
let c = cx(&d, &s, &p);
|
||||
let at = |x: f64, y: f64| grid_of(d.node(m).unwrap()).unwrap().point_near(Point::new(x, y), 1.0).unwrap();
|
||||
let set = |v: &[(f64, f64)]| {
|
||||
let mut v: Vec<usize> = v.iter().map(|(x, y)| at(*x, *y)).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let mut t = crate::direct::DirectSelectionTool::new(false);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 90.0, 90.0));
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 160.0, 120.0));
|
||||
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Up, 160.0, 120.0));
|
||||
assert!(
|
||||
matches!(out.first(), Some(Action::Exec(cmd, q)) if cmd == "select.anchorsMany" && q["items"] == json!([{"id": m.0, "anchors": []}])),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(picked(&t), set(&[(100.0, 100.0), (150.0, 100.0)]));
|
||||
// Shift: the picked points inside leave, the others join; the mesh stays selected.
|
||||
t.pointer(&c, &shift(PointerKind::Down, 90.0, 90.0));
|
||||
t.pointer(&c, &shift(PointerKind::Drag, 110.0, 160.0));
|
||||
let out = t.pointer(&c, &shift(PointerKind::Up, 110.0, 160.0));
|
||||
assert!(matches!(out.first(), Some(Action::Exec(cmd, q)) if cmd == "select.anchorsMany" && q["items"] == json!([])), "{out:?}");
|
||||
assert_eq!(picked(&t), set(&[(150.0, 100.0), (100.0, 150.0)]));
|
||||
// A mesh line: its two ends.
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 125.0, 150.0)), vec![Action::Begin("Move Mesh Points".into())]);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 125.0, 150.0));
|
||||
assert_eq!(picked(&t), set(&[(100.0, 150.0), (150.0, 150.0)]));
|
||||
// A patch: the four points around it, which a drag moves by its offset.
|
||||
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 175.0, 175.0)), vec![Action::Begin("Move Mesh Points".into())]);
|
||||
let four = set(&[(150.0, 150.0), (200.0, 150.0), (150.0, 200.0), (200.0, 200.0)]);
|
||||
assert_eq!(
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 185.0, 180.0)),
|
||||
vec![Action::Preview("object.mesh.movePoints".into(), json!({"id": m.0, "points": four, "dx": 10.0, "dy": 5.0}))]
|
||||
);
|
||||
t.pointer(&c, &PointerEvent::new(PointerKind::Up, 185.0, 180.0));
|
||||
// A mesh that isn't selected yet gets selected.
|
||||
let none = Selection::default();
|
||||
let c = cx(&d, &none, &p);
|
||||
let out = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 125.0, 125.0));
|
||||
assert_eq!(out, vec![Action::Exec("select.set".into(), json!({"ids": [m.0]})), Action::Begin("Move Mesh Points".into())]);
|
||||
assert_eq!(picked(&t), set(&[(100.0, 100.0), (150.0, 100.0), (100.0, 150.0), (150.0, 150.0)]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1860,6 +1860,7 @@ font-technology Variable Променливи
|
||||
Move in Perspective Преместване в перспектива
|
||||
Move Locked and Hidden Artwork with Artboard Преместване на заключени и скрити обекти с работната област
|
||||
Move Mesh Point Преместване на точка от мрежата
|
||||
Move Mesh Points Преместване на точки от мрежата
|
||||
Move Plane Преместване на равнина
|
||||
Move Plane to Match Object Преместване на равнината според обекта
|
||||
Move Rows Преместване на редове
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2419,6 +2419,7 @@
|
||||
Move Library Item to Group Mou l’element de la biblioteca al grup
|
||||
Move Locked and Hidden Artwork with Artboard Mou la il·lustració bloquejada i oculta amb el tauler
|
||||
Move Mesh Point Mou el punt de malla
|
||||
Move Mesh Points Mou els punts de malla
|
||||
Move Panel Mou el panell
|
||||
Move Plane Mou el pla
|
||||
Move Plane and Copy Objects Mou el pla i copia els objectes
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3314,6 +3314,7 @@ theme Appearance Motiv
|
||||
Make Envelope Vytvořit obálku
|
||||
Move Envelope Mesh Point Přesunout bod sítě obálky
|
||||
Move Mesh Point Přesunout bod sítě
|
||||
Move Mesh Points Přesunout body sítě
|
||||
Move Spine Point Přesunout bod páteře
|
||||
Reshape Spine Segment Přetvarovat segment páteře
|
||||
Reset with Mesh Obnovit sítí
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3072,6 +3072,7 @@ theme Appearance Udseende
|
||||
Make Envelope Opret konvolut
|
||||
Move Envelope Mesh Point Flyt konvolut-meshpunkt
|
||||
Move Mesh Point Flyt meshpunkt
|
||||
Move Mesh Points Flyt meshpunkter
|
||||
Move Spine Point Flyt rygradpunkt
|
||||
Reshape Spine Segment Omform rygradsegment
|
||||
Reset with Mesh Nulstil med mesh
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1915,6 +1915,7 @@ file-filter Templates Vorlagen
|
||||
Move in Perspective In Perspektive verschieben
|
||||
Move Locked and Hidden Artwork with Artboard Gesperrte und ausgeblendete Grafik mit Zeichenfläche verschieben
|
||||
Move Mesh Point Gitterpunkt verschieben
|
||||
Move Mesh Points Gitterpunkte verschieben
|
||||
Move Plane Perspektivebene verschieben
|
||||
Move Plane to Match Object Perspektivebene an Objekt ausrichten
|
||||
Move Rows Zeilen verschieben
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2675,6 +2675,7 @@ theme Appearance Εμφάνιση
|
||||
Move in Perspective Μετακίνηση σε προοπτική
|
||||
Move Locked and Hidden Artwork with Artboard Μετακίνηση κλειδωμένων και κρυφών σχεδίων μαζί με τον πίνακα σχεδίασης
|
||||
Move Mesh Point Μετακίνηση σημείου πλέγματος
|
||||
Move Mesh Points Μετακίνηση σημείων πλέγματος
|
||||
Move Plane Μετακίνηση επιπέδου προοπτικής
|
||||
Move Plane to Match Object Μετακίνηση επιπέδου προοπτικής ώστε να ταιριάζει στο αντικείμενο
|
||||
Move Rows Μετακίνηση γραμμών
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 164 and column 5.
|
@@ -1913,6 +1913,7 @@ font-technology Variable Variables
|
||||
Move in Perspective Mover en perspectiva
|
||||
Move Locked and Hidden Artwork with Artboard Mover ilustración bloqueada y oculta con la mesa de trabajo
|
||||
Move Mesh Point Mover punto de malla
|
||||
Move Mesh Points Mover puntos de malla
|
||||
Move Plane Mover plano
|
||||
Move Plane to Match Object Mover plano para que coincida con el objeto
|
||||
Move Rows Mover filas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3253,6 +3253,7 @@ theme Appearance Ulkoasu
|
||||
Make Envelope Tee kuori
|
||||
Move Envelope Mesh Point Siirrä kuoren verkkopistettä
|
||||
Move Mesh Point Siirrä verkkopistettä
|
||||
Move Mesh Points Siirrä verkkopisteitä
|
||||
Move Spine Point Siirrä selkärangan pistettä
|
||||
Reshape Spine Segment Muotoile selkärangan segmentti uudelleen
|
||||
Reset with Mesh Palauta verkolla
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1912,6 +1912,7 @@ font-technology Variable Variables
|
||||
Move in Perspective Déplacer en perspective
|
||||
Move Locked and Hidden Artwork with Artboard Déplacer les illustrations verrouillées et masquées avec le plan de travail
|
||||
Move Mesh Point Déplacer le point de filet
|
||||
Move Mesh Points Déplacer les points de filet
|
||||
Move Plane Déplacer le plan
|
||||
Move Plane to Match Object Déplacer le plan pour l’aligner sur l’objet
|
||||
Move Rows Déplacer les lignes
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2418,6 +2418,7 @@ file-filter Templates Sablonok
|
||||
Move Library Item to Group Könyvtárelem áthelyezése csoportba
|
||||
Move Locked and Hidden Artwork with Artboard Zárolt és rejtett grafika mozgatása a rajztáblával
|
||||
Move Mesh Point Hálópont áthelyezése
|
||||
Move Mesh Points Hálópontok áthelyezése
|
||||
Move Panel Panel áthelyezése
|
||||
Move Plane Sík áthelyezése
|
||||
Move Plane to Match Object Sík igazítása az objektumhoz
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3072,6 +3072,7 @@ theme Appearance Tampilan
|
||||
Make Envelope Buat envelope
|
||||
Move Envelope Mesh Point Pindahkan titik mesh envelope
|
||||
Move Mesh Point Pindahkan titik mesh
|
||||
Move Mesh Points Pindahkan titik-titik mesh
|
||||
Move Spine Point Pindahkan titik spine
|
||||
Reshape Spine Segment Bentuk ulang segmen spine
|
||||
Reset with Mesh Atur ulang dengan mesh
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2169 and column 5.
|
@@ -1940,6 +1940,7 @@ file-filter Templates Modelli
|
||||
Move in Perspective Sposta in prospettiva
|
||||
Move Locked and Hidden Artwork with Artboard Sposta grafica bloccata e nascosta con la tavola da disegno
|
||||
Move Mesh Point Sposta punto trama
|
||||
Move Mesh Points Sposta punti trama
|
||||
Move Plane Sposta piano
|
||||
Move Plane to Match Object Sposta piano in base all'oggetto
|
||||
Move Rows Sposta righe
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2082,6 +2082,7 @@ font-technology Variable バリアブル
|
||||
Move Guide ガイドを移動
|
||||
Move Locked and Hidden Artwork with Artboard ロックおよび非表示のアートワークをアートボードと一緒に移動
|
||||
Move Mesh Point メッシュポイントを移動
|
||||
Move Mesh Points メッシュポイントを移動
|
||||
Move Plane 面を移動
|
||||
Move Rows 行を移動
|
||||
Move Slices スライスを移動
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2885,6 +2885,7 @@ theme Appearance იერსახე
|
||||
Make Envelope გარსის შექმნა
|
||||
Move Envelope Mesh Point გარსის ბადის წერტილის გადაადგილება
|
||||
Move Mesh Point ბადის წერტილის გადაადგილება
|
||||
Move Mesh Points ბადის წერტილების გადაადგილება
|
||||
Move Spine Point ხერხემლის წერტილის გადაადგილება
|
||||
Reshape Spine Segment ხერხემლის სეგმენტის ფორმის შეცვლა
|
||||
Reset with Mesh ბადით ჩამოყრა
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -2077,6 +2077,7 @@ font-technology Variable 가변
|
||||
Move Guide 안내선 이동
|
||||
Move Locked and Hidden Artwork with Artboard 잠기거나 숨겨진 아트워크를 아트보드와 함께 이동
|
||||
Move Mesh Point 메시 포인트 이동
|
||||
Move Mesh Points 메시 포인트 이동
|
||||
Move Plane 평면 이동
|
||||
Move Rows 행 이동
|
||||
Move Slices 슬라이스 이동
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3086,6 +3086,7 @@ theme Appearance Vormgeving
|
||||
Make Envelope Omtrek maken
|
||||
Move Envelope Mesh Point Maaspunt van omtrek verplaatsen
|
||||
Move Mesh Point Maaspunt verplaatsen
|
||||
Move Mesh Points Maaspunten verplaatsen
|
||||
Move Spine Point Aspunt verplaatsen
|
||||
Reshape Spine Segment Segment van de as opnieuw vormgeven
|
||||
Reset with Mesh Opnieuw instellen met maas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3194,6 +3194,7 @@ theme Appearance Wygląd
|
||||
Make Envelope Utwórz kopertę
|
||||
Move Envelope Mesh Point Przesuń punkt siatki koperty
|
||||
Move Mesh Point Przesuń punkt siatki
|
||||
Move Mesh Points Przesuń punkty siatki
|
||||
Move Spine Point Przesuń punkt grzbietu
|
||||
Reshape Spine Segment Zmień kształt segmentu grzbietu
|
||||
Reset with Mesh Resetuj z siatką
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1597,6 +1597,7 @@
|
||||
Move Guide Mover Guia
|
||||
Move Locked and Hidden Artwork with Artboard Mover Arte Bloqueada e Oculta com a Mesa de Trabalho
|
||||
Move Mesh Point Mover Ponto de Malha
|
||||
Move Mesh Points Mover Pontos de Malha
|
||||
Move Plane Mover Plano
|
||||
Move Plane to Match Object Mover Plano para Corresponder ao Objeto
|
||||
Move Slices Mover Fatias
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1938,6 +1938,7 @@ file-filter Templates Шаблоны
|
||||
Move in Perspective Переместить в перспективе
|
||||
Move Locked and Hidden Artwork with Artboard Перемещать заблокированный и скрытый рисунок вместе с монтажной областью
|
||||
Move Mesh Point Переместить точку сетки
|
||||
Move Mesh Points Переместить точки сетки
|
||||
Move Plane Переместить плоскость
|
||||
Move Plane to Match Object Переместить плоскость по объекту
|
||||
Move Rows Переместить строки
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1860,6 +1860,7 @@ font-technology Variable Variabla
|
||||
Move in Perspective Flytta i perspektiv
|
||||
Move Locked and Hidden Artwork with Artboard Flytta låst och dold grafik med ritytan
|
||||
Move Mesh Point Flytta nätpunkt
|
||||
Move Mesh Points Flytta nätpunkter
|
||||
Move Plane Flytta plan
|
||||
Move Plane to Match Object Flytta plan så att det matchar objekt
|
||||
Move Rows Flytta rader
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1757,6 +1757,7 @@ font-technology Variable Değişken
|
||||
Move in Perspective Perspektifte Hareket Et
|
||||
Move Locked and Hidden Artwork with Artboard Çalışma Yüzeyi ile Kilitli ve Gizli Resimleri Taşıma
|
||||
Move Mesh Point Mesh Noktasını Taşı
|
||||
Move Mesh Points Mesh Noktalarını Taşı
|
||||
Move Plane Düzlemi Taşı
|
||||
Move Plane to Match Object Nesneyi Eşleştirecek Düzlemi Taşı
|
||||
Move Rows Satırları Taşı
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1934,6 +1934,7 @@ file-filter Templates Шаблони
|
||||
Move in Perspective Перемістити у перспективі
|
||||
Move Locked and Hidden Artwork with Artboard Переміщати заблокований та прихований малюнок разом із монтажною областю
|
||||
Move Mesh Point Перемістити точку сітки
|
||||
Move Mesh Points Перемістити точки сітки
|
||||
Move Plane Перемістити площину
|
||||
Move Plane to Match Object Перемістити площину об’єктом
|
||||
Move Rows Перемістити рядки
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 178 and column 5.
|
@@ -2227,6 +2227,7 @@ file-filter Templates Mẫu
|
||||
Move Library Item to Group Di chuyển mục thư viện vào nhóm
|
||||
Move Locked and Hidden Artwork with Artboard Di chuyển tác phẩm đã khóa và ẩn cùng bảng vẽ
|
||||
Move Mesh Point Di chuyển điểm lưới
|
||||
Move Mesh Points Di chuyển các điểm lưới
|
||||
Move Panel Di chuyển bảng
|
||||
Move Plane Di chuyển mặt phẳng
|
||||
Move Plane to Match Object Di chuyển mặt phẳng khớp với đối tượng
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 200 and column 5.
|
@@ -1811,6 +1811,7 @@ file-filter Templates 模板
|
||||
Move in Perspective 在透视中移动
|
||||
Move Locked and Hidden Artwork with Artboard 随画板移动锁定和隐藏图稿
|
||||
Move Mesh Point 移动网格点
|
||||
Move Mesh Points 移动网格点
|
||||
Move Plane 移动平面
|
||||
Move Plane to Match Object 移动平面以符合对象
|
||||
Move Slices 移动切片
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2729,6 +2729,7 @@ file-filter Templates 範本
|
||||
Move in Perspective 在透視中移動
|
||||
Move Locked and Hidden Artwork with Artboard 移動工作區域時一併移動鎖定與隱藏的圖稿
|
||||
Move Mesh Point 移動網格點
|
||||
Move Mesh Points 移動網格點
|
||||
Move Plane 移動平面
|
||||
Move Plane to Match Object 移動平面以符合物件
|
||||
Move Rows 移動列
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+24
-1
@@ -1249,7 +1249,7 @@ the Transparency panel's values, with `null` where the objects differ. Making a
|
||||
empty mask and enters mask editing: art drawn then becomes the mask, and the mask commands act on the masked object
|
||||
until `transparency.stopEditingOpacityMask`. While a mask is edited, `object.exitIsolation` (Esc, the isolation bar's
|
||||
back arrow, Exit Isolation Mode) leaves mask editing the same way, in one undo step. Saves and exports never include
|
||||
the editing layer.
|
||||
the editing layer. With gradient mesh points picked, `opacity` goes to those points (see Picking several mesh points).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"transparency.set","params":{"ids":[12],"opacity":40,"blend":"Multiply"}}}
|
||||
@@ -5599,6 +5599,29 @@ Envelope Options.
|
||||
{"name":"run_command","arguments":{"command":"object.mesh.movePoint","params":{"id":12,"index":4,"x":220,"y":140,"handle":0}}}
|
||||
```
|
||||
|
||||
## Picking several mesh points
|
||||
|
||||
The Mesh tool and Direct Selection pick mesh points on gradient meshes and mesh envelopes. A click picks one point
|
||||
(its handles show); Shift-click adds a point or takes a picked one away. Direct Selection's marquee picks the points
|
||||
inside it (Shift toggles them) and selects their meshes; its click on a gradient mesh's line picks the line's two
|
||||
ends, and a click inside a patch picks the four points around it. The picked points show filled. Dragging one of them
|
||||
moves them all as one undo step (`object.mesh.movePoints`), a click on one of several picked points picks it alone,
|
||||
and the arrow keys nudge them by the keyboard increment. The active tool lists them in its options
|
||||
(`tool.setOption {}` returns them as `meshPoints: [{id, index}…]`). On gradient meshes a solid fill colour (`paint.setFill`, from the
|
||||
Color panel, Swatches or the Eyedropper) recolours just the picked points, and when every target of
|
||||
`transparency.set` is a gradient mesh with points picked, its `opacity` fades those points instead of the objects
|
||||
(`transparency.info` then shows their opacity, `null` when it differs). Both are one undo step.
|
||||
|
||||
`object.mesh.movePoints {id, points: [index…], dx, dy}` (or `{items: [{id, points}…], dx, dy}` over several meshes)
|
||||
moves points by (dx, dy), their handles with them; `object.mesh.setPointColor` takes `points` or `items` the same
|
||||
way to colour or fade several points at once. An index out of range, an index that is not a whole number ≥ 0, or a
|
||||
move past the coordinate range fails and changes nothing.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.mesh.movePoints","params":{"id":12,"points":[0,4],"dx":10,"dy":-5}}}
|
||||
{"name":"run_command","arguments":{"command":"object.mesh.setPointColor","params":{"id":12,"points":[0,4],"color":"#3366ff","opacity":0.5}}}
|
||||
```
|
||||
|
||||
## Shortcut sets from other apps
|
||||
|
||||
`shortcutSet.read {path? | data? | dataBase64?, name?}` reads a shortcut set another app saved as text (a `.kys` file
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user