Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
980cc4fe44 | ||
|
|
6917984329 | ||
|
|
ba2fc64d89 | ||
|
|
db23e80ec4 | ||
|
|
da010c33a1 | ||
|
|
e47d542680 | ||
|
|
d8759492df |
No files matched your search
@@ -128,6 +128,20 @@ impl Selection {
|
||||
}
|
||||
self.target = Some(id);
|
||||
}
|
||||
/// Shift-clicking `id`'s target circle: it (a layer: its art) joins the selection, or leaves it
|
||||
/// when it is all selected already. Several objects are then selected, none targeted.
|
||||
pub fn toggle_target(&mut self, doc: &Document, id: NodeId) {
|
||||
let ids = match doc.node(id) {
|
||||
Some(n) if n.is_layer() => n.layer_art(true),
|
||||
_ => vec![id],
|
||||
};
|
||||
if ids.iter().all(|i| self.contains(*i)) {
|
||||
ids.iter().for_each(|i| self.remove(*i));
|
||||
} else {
|
||||
ids.into_iter().for_each(|i| self.add(i));
|
||||
}
|
||||
self.target = None;
|
||||
}
|
||||
/// What appearance and transparency edits act on: the targeted object, group or layer, else
|
||||
/// the selected objects.
|
||||
pub fn subjects(&self) -> &[NodeId] {
|
||||
|
||||
@@ -246,8 +246,9 @@ fn candidates(s: &Session, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
|
||||
|
||||
fn finish_same(s: &mut Session, cmd: &str, ids: Vec<NodeId>) -> Result<Value> {
|
||||
let n = ids.len();
|
||||
let from = s.doc()?.selection.objects.clone();
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({})));
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), json!({}), from));
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Target",
|
||||
[],
|
||||
None,
|
||||
"{id} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
"{id, add?: bool (Shift-click: the item joins the selection, or leaves it when all selected; nothing is targeted)} target a layer, group or object as clicking its target circle in the Layers panel does: a layer gets its visible, unlocked art (its sublayers' too) selected and becomes the current layer, and appearance.*, effect.*, transparency.* and the opacity-mask commands without `ids` then act on the layer itself; anything else is selected. Any other selection change ends the targeting (`document.inspect` → target) → {id, selected: [..]}",
|
||||
has_doc,
|
||||
target
|
||||
),
|
||||
@@ -362,7 +362,11 @@ fn target(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let layer = n.is_layer();
|
||||
let rows = st.layer_rows.clone();
|
||||
s.select(|d, sel| sel.set_target(d, id))?;
|
||||
if p.get("add").and_then(Value::as_bool).unwrap_or(false) {
|
||||
s.select(|d, sel| sel.toggle_target(d, id))?;
|
||||
} else {
|
||||
s.select(|d, sel| sel.set_target(d, id))?;
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
// Targeting doesn't change which rows are highlighted.
|
||||
st.layer_rows = rows;
|
||||
|
||||
@@ -216,8 +216,20 @@ fn none(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn reselect(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some((c, p)) = s.doc()?.last_selection_cmd.clone() else { return ok() };
|
||||
s.execute(&c, &p)
|
||||
let st = s.doc()?;
|
||||
let Some((c, p, from)) = st.last_selection_cmd.clone() else { return ok() };
|
||||
// After Deselect, a Same command takes its reference again from the objects that were selected
|
||||
// when it ran, those still there (#903).
|
||||
let from: Vec<NodeId> = from.into_iter().filter(|id| st.doc.node(*id).is_some()).collect();
|
||||
let restore = st.selection.objects.is_empty() && !from.is_empty();
|
||||
if restore {
|
||||
s.select(|_, sel| sel.set(from))?;
|
||||
}
|
||||
let r = s.execute(&c, &p);
|
||||
if restore && r.is_err() {
|
||||
s.select(|_, sel| sel.clear())?;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn inverse(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
@@ -401,8 +413,9 @@ fn select_where(s: &mut Session, cmd: &str, p: &Value, f: impl Fn(&Document, &No
|
||||
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
|
||||
visit(&st.doc, l, &f, &mut ids);
|
||||
}
|
||||
let from = st.selection.objects.clone();
|
||||
s.select(|_, sel| sel.set(ids.iter().copied()))?;
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone()));
|
||||
s.doc_mut()?.last_selection_cmd = Some((cmd.to_string(), p.clone(), from));
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
}
|
||||
|
||||
|
||||
@@ -126,8 +126,9 @@ pub struct DocState {
|
||||
pub undo_group: Option<UndoGroup>,
|
||||
/// For Object → Transform → Transform Again (⌘D).
|
||||
pub last_transform: Option<(Affine, bool)>,
|
||||
/// Selection saved by Select → Reselect.
|
||||
pub last_selection_cmd: Option<(String, Value)>,
|
||||
/// What Select → Reselect repeats: the last selection command, its params and the objects
|
||||
/// selected when it ran (a Same command's reference objects).
|
||||
pub last_selection_cmd: Option<(String, Value, Vec<NodeId>)>,
|
||||
/// Process-unique id of this open document (tab indices shift when tabs close).
|
||||
pub uid: u64,
|
||||
/// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas
|
||||
|
||||
@@ -149,6 +149,18 @@ fn select_same_fill() {
|
||||
let r = s.execute("select.same.fillColor", &json!({})).unwrap();
|
||||
assert_eq!(r["count"], 2);
|
||||
assert!(!s.doc().unwrap().selection.contains(a));
|
||||
// Reselect after Deselect repeats it from the same reference object (#903).
|
||||
let blue = s.doc().unwrap().selection.objects.clone();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("select.reselect", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, blue);
|
||||
// With its reference object gone, it fails as Select Same does, and selects nothing.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
for id in &blue {
|
||||
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
|
||||
}
|
||||
assert!(s.execute("select.reselect", &json!({})).is_err());
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+16
-10
@@ -32,7 +32,7 @@ use vectorcraft_geom::{Anchor, PathData, Point, Rect};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::{HandleSnap, PointSnap, Targets};
|
||||
use crate::guides::{HandleSnap, Leave, PointSnap, Targets};
|
||||
use crate::meshedit::MeshEdit;
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
@@ -428,12 +428,12 @@ impl Tool for DirectSelectionTool {
|
||||
self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection)));
|
||||
}
|
||||
let mut d = move_delta(start, p, ev.mods.shift);
|
||||
// The grabbed anchor snaps and the others follow it; Shift keeps the angle instead.
|
||||
// The grabbed anchor snaps and the others follow it. Shift keeps the move at its
|
||||
// angle, and the anchor slides along it onto what Smart Guides find there (#886).
|
||||
self.guides.clear();
|
||||
if !ev.mods.shift
|
||||
&& let Some(snap) = &self.anchor_snap
|
||||
{
|
||||
let (q, guides) = snap.snap(cx, grab + d);
|
||||
if let Some(snap) = &self.anchor_snap {
|
||||
let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true));
|
||||
let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref());
|
||||
(d, self.guides) = (q - grab, guides);
|
||||
}
|
||||
out.push(Action::Preview("path.moveAnchors".into(), json!({"dx": d.x, "dy": d.y})));
|
||||
@@ -1022,10 +1022,16 @@ mod tests {
|
||||
// In line with B's own bottom-left corner, which stays put.
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (303.0, 330.0), none);
|
||||
assert_eq!((v, labels), (json!({"dx": 0.0, "dy": 30.0}), vec!["align".to_string()]));
|
||||
// Shift keeps the move at 45° steps, unsnapped.
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), Mods { shift: true, ..none });
|
||||
let s = move_delta(Point::new(300.0, 300.0), Point::new(203.0, 199.0), true);
|
||||
assert_eq!((v, labels), (json!({"dx": s.x, "dy": s.y}), vec![]));
|
||||
// Shift keeps the move at 45° steps and still snaps along that line (#886): on the
|
||||
// diagonal from B's corner, onto A's bottom-right corner…
|
||||
let shift = Mods { shift: true, ..none };
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (203.0, 199.0), shift);
|
||||
let near = |v: &serde_json::Value, (x, y): (f64, f64)| (v["dx"].as_f64().unwrap() - x).hypot(v["dy"].as_f64().unwrap() - y) < 1e-9;
|
||||
assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}");
|
||||
// …and with nothing in reach, at the pointer's distance along its angle.
|
||||
let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift);
|
||||
let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true);
|
||||
assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}");
|
||||
// Two anchors: the one pressed on (B's top-right) lands on A's corner.
|
||||
let two = anchors_of(b, &[(0, 0), (0, 1)]);
|
||||
let c = cx(&d, &two, &p);
|
||||
|
||||
@@ -512,13 +512,16 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
|
||||
let line = ui.ctx().options(|o| o.input_options.line_scroll_speed);
|
||||
let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel;
|
||||
let alt_id = egui::Id::new("canvas-alt-wheel");
|
||||
let alt_before = ui.data(|d| d.get_temp(alt_id)).unwrap_or(false);
|
||||
let mut alt_turn = alt_before;
|
||||
let turn_id = egui::Id::new("canvas-wheel-turn");
|
||||
let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default();
|
||||
let mut turn = turn_before;
|
||||
let (pointer, m, space, (factor, scroll)) =
|
||||
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut alt_turn)));
|
||||
if alt_turn != alt_before {
|
||||
ui.data_mut(|d| d.insert_temp(alt_id, alt_turn));
|
||||
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), wheel(i, wheel_zooms, line, rect.height(), &mut turn)));
|
||||
if turn != turn_before {
|
||||
ui.data_mut(|d| d.insert_temp(turn_id, turn));
|
||||
}
|
||||
if turn.glide != 0.0 {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let v = *app.view().unwrap_or(&View::default());
|
||||
let xf = Xf::new(rect, &v);
|
||||
@@ -717,6 +720,18 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect:
|
||||
|
||||
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
|
||||
const WHEEL_ZOOM: f64 = 0.01;
|
||||
/// How fast a wheel notch's zoom glides in with Zoom with Mouse Wheel: the time constant (s) of
|
||||
/// the part still to come.
|
||||
const WHEEL_GLIDE: f64 = 0.05;
|
||||
|
||||
/// What the wheel did across frames ([`wheel`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct WheelTurn {
|
||||
/// The last wheel turn was an Alt-wheel one.
|
||||
alt: bool,
|
||||
/// The zoom (natural log) wheel notches still have to glide in.
|
||||
glide: f64,
|
||||
}
|
||||
/// Animated Zoom: how much one point of sideways drag zooms (100 points to the right double it).
|
||||
const SCRUB_ZOOM: f64 = std::f64::consts::LN_2 / 100.0;
|
||||
/// Animated Zoom: how far the pointer moves before a press is a drag, in points.
|
||||
@@ -754,19 +769,19 @@ fn marquee(p: &egui::Painter, r: egui::Rect) {
|
||||
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
|
||||
/// on the Mac) zoom. With General › Zoom with Mouse Wheel (`wheel_zooms`) the wheel and Alt-wheel
|
||||
/// zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
|
||||
/// per wheel line and page. `alt_turn`: the last wheel turn was an Alt-wheel one (kept by the
|
||||
/// caller across frames).
|
||||
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn: &mut bool) -> (f64, egui::Vec2) {
|
||||
/// per wheel line and page. `turn`: kept by the caller across frames. A mouse wheel's notches zoom
|
||||
/// in a glide over a few frames, as scrolling does (#888); a trackpad's fine steps at once.
|
||||
fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, turn: &mut WheelTurn) -> (f64, egui::Vec2) {
|
||||
// Fingers on a touch screen pan the content with them (a trackpad sends scrolls instead).
|
||||
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |t| t.translation_delta);
|
||||
if !wheel_zooms {
|
||||
// egui makes Cmd-wheel (and a pinch) its zoom and the rest a scroll it spreads over a few
|
||||
// frames: the rest of an Alt-wheel turn zooms too, however soon Alt is let go.
|
||||
if let Some(m) = i.events.iter().rev().find_map(|e| if let egui::Event::MouseWheel { modifiers, .. } = e { Some(modifiers) } else { None }) {
|
||||
*alt_turn = m.alt && !m.command;
|
||||
turn.alt = m.alt && !m.command;
|
||||
}
|
||||
let (zoom, scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
|
||||
return if *alt_turn && scroll != egui::Vec2::ZERO {
|
||||
return if turn.alt && scroll != egui::Vec2::ZERO {
|
||||
(zoom * (f64::from(scroll.x + scroll.y) * WHEEL_ZOOM).exp(), pan)
|
||||
} else {
|
||||
(zoom, scroll + pan)
|
||||
@@ -787,16 +802,23 @@ fn wheel(i: &egui::InputState, wheel_zooms: bool, line: f32, page: f32, alt_turn
|
||||
scroll.x += d.x + d.y;
|
||||
} else if modifiers.shift {
|
||||
scroll.y += d.x + d.y;
|
||||
} else {
|
||||
} else if *unit == egui::MouseWheelUnit::Point {
|
||||
zoom *= (f64::from(d.y) * WHEEL_ZOOM).exp();
|
||||
scroll.x += d.x;
|
||||
} else {
|
||||
turn.glide += f64::from(d.y) * WHEEL_ZOOM;
|
||||
scroll.x += d.x;
|
||||
}
|
||||
}
|
||||
egui::Event::Zoom(f) => zoom *= f64::from(*f),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
(zoom, scroll)
|
||||
// This frame's part of the glide: what is left shrinks by e every WHEEL_GLIDE seconds.
|
||||
let part = if turn.glide.abs() < 1e-3 { 1.0 } else { 1.0 - (-f64::from(i.stable_dt.min(0.1)) / WHEEL_GLIDE).exp() };
|
||||
let step = turn.glide * part;
|
||||
turn.glide -= step;
|
||||
(zoom * step.exp(), scroll)
|
||||
}
|
||||
|
||||
/// Enable Touch Gestures: this frame's finger contacts on the canvas ([`crate::touch`]). A second
|
||||
@@ -2868,6 +2890,43 @@ mod tests {
|
||||
assert!((zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0, "on: Cmd-wheel scrolls sideways ({zoom}, {scroll:?})");
|
||||
}
|
||||
|
||||
/// #888: with Zoom with Mouse Wheel, a wheel notch's zoom glides in over a few frames, as a
|
||||
/// scroll does, instead of jumping on the frame it arrives; it ends at the same zoom.
|
||||
#[test]
|
||||
fn wheel_notches_zoom_in_a_glide() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let run = |app: &mut VectorcraftApp| {
|
||||
let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
|
||||
app.raw_input_hook(&mut raw);
|
||||
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
run(&mut app);
|
||||
let at = app.canvas_rect.unwrap().center();
|
||||
let before = app.view().unwrap().zoom;
|
||||
crate::tests_synthetic::control(&mut app, &ctx, "ui.wheel", json!({"x": at.x, "y": at.y, "dy": 1}));
|
||||
// The first frame the zoom moves in (the event may land a frame later).
|
||||
let mut first = 1.0;
|
||||
for _ in 0..3 {
|
||||
run(&mut app);
|
||||
first = app.view().unwrap().zoom / before;
|
||||
if first != 1.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for _ in 0..60 {
|
||||
run(&mut app);
|
||||
}
|
||||
let done = app.view().unwrap().zoom / before;
|
||||
assert!(done > 1.01, "the notch zooms in ({done})");
|
||||
assert!(first > 1.0 && first < done, "the first frame takes only part of it ({first} of {done})");
|
||||
run(&mut app);
|
||||
assert_eq!(app.view().unwrap().zoom / before, done, "and it stops");
|
||||
}
|
||||
|
||||
/// Two fingers dragged together on a touch screen pan the canvas with them, under either
|
||||
/// Zoom with Mouse Wheel setting, and draw nothing (#449).
|
||||
#[test]
|
||||
|
||||
@@ -664,7 +664,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
|
||||
}
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
if tresp.clicked() {
|
||||
out.actions.push(("layer.target".into(), json!({"id": n.id.0})));
|
||||
out.actions.push(("layer.target".into(), json!({"id": n.id.0, "add": m.shift})));
|
||||
} else if sq_resp.clicked() {
|
||||
out.actions.push(("layer.selectAll".into(), json!({"id": n.id.0, "add": m.shift})));
|
||||
} else if resp.clicked() {
|
||||
@@ -1239,6 +1239,13 @@ mod tests {
|
||||
}
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.selection.target, st.selection.objects.clone()), (Some(a), vec![a]));
|
||||
// Shift-clicking another object's circle adds it to the selection (#901), and again takes
|
||||
// it out; nothing is targeted meanwhile.
|
||||
click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((st.selection.target, st.selection.objects.clone()), (None, vec![a, b]));
|
||||
click_with(&mut app, &ctx, c[1], egui::Modifiers::SHIFT);
|
||||
assert_eq!(app.session.active().unwrap().selection.objects, vec![a]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user