Layers: only the primary button drags rows, guides, art, columns and appearances

A drag with the right or middle button on a Layers row could move or
copy the row, and the same drag on a guide row, the selected-art
square, the eye or lock column or a target circle moved the guide or
the art, swept visibility or locks, or transferred the appearance.
Each of these drags now starts only with the primary button, as the
Artboards panel's row drag does, and the grab cursor follows the
primary button too. A right-click still opens the row's menu.

The new test drags each of the six sources with the secondary and the
middle button, checks that no drag payload is held and that nothing in
the document or its undo steps changes, then drags the same path with
the primary button to show the drop target takes it.
This commit is contained in:
dotCooCoo
2026-11-12 17:39:54 -08:00
parent f7a03f7899
commit 8e5360b7b1
3 changed files with 135 additions and 23 deletions
+16 -11
View File
@@ -152,11 +152,12 @@ fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) {
out.actions.push(("guide.select".into(), json!({"indexes": [i], "toggle": m.shift || m.command})));
}
// Dragged: the selected guides when this is one of them, else this one, to another layer's row.
if editable && resp.drag_started() {
// Only the primary button drags a guide row.
if editable && resp.drag_started_by(egui::PointerButton::Primary) {
let guides = if selected { view.guides.clone() } else { vec![i] };
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Guides(guides));
}
if resp.dragged() {
if resp.dragged_by(egui::PointerButton::Primary) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
// Guides dropped here go to this guide's layer.
@@ -363,7 +364,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
}
let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.scope_id().with(("selsq", n.id.0)), Sense::click_and_drag());
// Dragging the selected-art square carries the selection to another row.
if (is_sel || child_sel) && sq_resp.drag_started() {
if (is_sel || child_sel) && sq_resp.drag_started_by(egui::PointerButton::Primary) {
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Art);
}
let m = ui.input(|i| i.modifiers);
@@ -396,8 +397,9 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple })));
});
});
// Dragging rows: the highlighted rows when this is one of them, else this row.
if resp.drag_started() && view.column.is_none() {
// Dragging rows: the highlighted rows when this is one of them, else this row. Only the primary
// button drags; the secondary button opens the row menu, and a drag with it moves nothing.
if resp.drag_started_by(egui::PointerButton::Primary) && view.column.is_none() {
let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] };
egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Rows(ids));
let grab = resp.interact_pointer_pos().map_or(vec2(h, h / 2.0), |p| p - r.min);
@@ -406,7 +408,7 @@ pub(super) fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
d.insert_temp(key("grabbed"), n.id);
});
}
if resp.dragged() {
if resp.dragged_by(egui::PointerButton::Primary) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
drop_target(ui, view, n, r, x - 16.0, open, &resp, out);
@@ -489,10 +491,10 @@ fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out
} else if lr.clicked() {
out.actions.push(("layer.setProps".into(), json!({"ids": [n.id.0], "locked": !n.locked})));
}
// Dragging down a column gives every row it passes this row's new state.
if er.drag_started() {
// Dragging down a column with the primary button gives every row it passes this row's new state.
if er.drag_started_by(egui::PointerButton::Primary) {
out.column = Some(Some(ColumnDrag { eye: true, value: !n.visible, ids: vec![n.id.0] }));
} else if lr.drag_started() {
} else if lr.drag_started_by(egui::PointerButton::Primary) {
out.column = Some(Some(ColumnDrag { eye: false, value: !n.locked, ids: vec![n.id.0] }));
} else if let Some(c) = out.column.clone().unwrap_or_else(|| view.column.clone())
&& !c.ids.contains(&n.id.0)
@@ -567,7 +569,10 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open:
/// copies it) or the trash.
fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, actions: &mut Vec<(String, Value)>, t: &Tokens) -> egui::Response {
let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), ui.scope_id().with(("target", n.id.0)), Sense::click_and_drag());
widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id));
// Only the primary button carries the appearance away.
if resp.drag_started_by(egui::PointerButton::Primary) {
egui::DragAndDrop::set_payload(ui.ctx(), PanelDrag::Appearance(n.id));
}
let held = resp.dnd_hover_payload::<PanelDrag>().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id));
if let Some(d) = resp.dnd_release_payload::<PanelDrag>()
&& let PanelDrag::Appearance(source) = *d
@@ -584,7 +589,7 @@ fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, a
if has_styled_target(n) {
ui.painter().circle_filled(c, 3.2, t.icon);
}
if resp.dragged() {
if resp.dragged_by(egui::PointerButton::Primary) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
resp
+118 -12
View File
@@ -94,22 +94,30 @@ fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>
}
fn button(pos: egui::Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
button_by(pos, pressed, egui::PointerButton::Primary)
}
/// `pointer` pressed (or released) at `pos`.
fn button_by(pos: egui::Pos2, pressed: bool, pointer: egui::PointerButton) -> egui::Event {
egui::Event::PointerButton { pos, button: pointer, pressed, modifiers: Default::default() }
}
/// Drag from `a` to `b` over a few frames (Alt held when `alt`).
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, alt: bool) {
let steps = [
vec![egui::Event::PointerMoved(a)],
vec![button(a, true)],
vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))],
vec![egui::Event::PointerMoved(b)],
vec![button(b, false)],
vec![],
];
for e in steps {
frame(app, ctx, e, alt);
}
drag_by(app, ctx, a, b, alt, egui::PointerButton::Primary);
}
/// Drag from `a` to `b` with `pointer` over a few frames (Alt held when `alt`). Returns whether a
/// drag-and-drop payload was held over `b` before the release.
fn drag_by(app: &mut VectorcraftApp, ctx: &egui::Context, a: egui::Pos2, b: egui::Pos2, alt: bool, pointer: egui::PointerButton) -> bool {
frame(app, ctx, vec![egui::Event::PointerMoved(a)], alt);
frame(app, ctx, vec![button_by(a, true, pointer)], alt);
frame(app, ctx, vec![egui::Event::PointerMoved(a + vec2(0.0, 6.0))], alt);
frame(app, ctx, vec![egui::Event::PointerMoved(b)], alt);
let held = egui::DragAndDrop::has_any_payload(ctx);
frame(app, ctx, vec![button_by(b, false, pointer)], alt);
frame(app, ctx, vec![], alt);
held
}
#[test]
@@ -1290,6 +1298,104 @@ fn the_selected_art_square_drags_the_selection_to_another_layer() {
assert_eq!(app.session.active().unwrap().selection.objects.len(), 2, "still selected");
}
/// The undo steps of the active document.
fn undo_len(app: &VectorcraftApp) -> usize {
app.session.active().unwrap().history.undo.len()
}
/// Drags from `a` to `b` with the secondary and then the middle button, each of which must carry
/// nothing and leave the document and its undo steps as they were (`state` reads what the drag
/// would change); then the same drag with the primary button, which must change `state`.
fn only_the_primary_button_drags<S: PartialEq + std::fmt::Debug>(
app: &mut VectorcraftApp,
ctx: &egui::Context,
(a, b): (egui::Pos2, egui::Pos2),
state: impl Fn(&VectorcraftApp) -> S,
what: &str,
) {
let (before, undo) = (state(app), undo_len(app));
for pointer in [egui::PointerButton::Secondary, egui::PointerButton::Middle] {
let held = drag_by(app, ctx, a, b, false, pointer);
assert!(!held, "{what}: a {pointer:?} drag carries nothing");
assert_eq!(state(app), before, "{what}: a {pointer:?} drag changes nothing");
assert_eq!(undo_len(app), undo, "{what}: a {pointer:?} drag records no undo step");
}
drag_by(app, ctx, a, b, false, egui::PointerButton::Primary);
assert_ne!(state(app), before, "{what}: the primary button drags");
}
/// Only the primary button drags in the panel: a drag with the secondary or middle button moves no
/// row, guide or selected art, hides or locks no row and moves no appearance, and records no undo
/// step. The secondary button still opens a row's menu.
#[test]
fn only_the_primary_button_drags_rows_guides_art_columns_and_appearances() {
let node = |app: &VectorcraftApp, id: NodeId| app.session.active().unwrap().doc.node(id).unwrap().clone();
let rows = |app: &mut VectorcraftApp, ctx: &egui::Context| frame(app, ctx, vec![], false).0;
// A row, onto the middle of another layer's row.
let (mut app, [.., a, _]) = nested();
app.session.execute("layer.new", &json!({"top": true, "name": "Top"})).unwrap();
let ctx = egui::Context::default();
let c = rows(&mut app, &ctx);
let parent = |app: &VectorcraftApp| app.session.active().unwrap().doc.parent_of(a);
only_the_primary_button_drags(&mut app, &ctx, (egui::pos2(150.0, c[5].y), egui::pos2(150.0, c[0].y)), parent, "a row");
// A guide row, onto another layer's row.
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap();
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20}));
run(&mut app, "guide.add", json!({"vertical": true, "pos": 50}));
run(&mut app, "select.none", json!({}));
run(&mut app, "layer.new", json!({}));
expand(&mut app, &json!({})).unwrap();
let ctx = egui::Context::default();
let c = rows(&mut app, &ctx);
let guide_row = egui::pos2(150.0, (c[1].y + c[2].y) / 2.0);
let layer = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides()[0].layer;
only_the_primary_button_drags(&mut app, &ctx, (guide_row, egui::pos2(150.0, c[0].y)), layer, "a guide row");
// The selected-art square, onto the layer's row.
let (mut app, [_, _, g, _, a, _]) = nested();
app.session.execute("select.set", &json!({"ids": [a.0, g.0]})).unwrap();
let ctx = egui::Context::default();
let c = rows(&mut app, &ctx);
let kids = |app: &VectorcraftApp| {
let d = &app.session.active().unwrap().doc;
d.node(d.layers[0].id).unwrap().children().unwrap().iter().map(|n| n.id).collect::<Vec<_>>()
};
let square = c[1] + vec2(TARGET_X - SQUARE_X, 0.0);
only_the_primary_button_drags(&mut app, &ctx, (square, egui::pos2(150.0, c[0].y)), kids, "the selected-art square");
// Down the eye and the lock columns, from the group's row to a rectangle's.
for (x, what) in [(12.0, "the eye column"), (37.0, "the lock column")] {
let (mut app, [_, _, g, b, a, _]) = nested();
let ctx = egui::Context::default();
let c = rows(&mut app, &ctx);
let marks = move |app: &VectorcraftApp| [g, b, a].map(|id| (node(app, id).visible, node(app, id).locked));
only_the_primary_button_drags(&mut app, &ctx, (egui::pos2(x, c[2].y), egui::pos2(x, c[4].y)), marks, what);
}
// A target circle, onto another row's circle.
let (mut app, _, [a, b]) = two_rects();
app.session.execute("transparency.set", &json!({"ids": [a.0], "opacity": 30})).unwrap();
let ctx = egui::Context::default();
let c = rows(&mut app, &ctx);
let opacities = move |app: &VectorcraftApp| [a, b].map(|id| node(app, id).opacity);
only_the_primary_button_drags(&mut app, &ctx, (c[2], c[1]), opacities, "a target circle");
// A secondary click on a row opens its menu.
let (mut app, _, _) = two_rects();
let ctx = egui::Context::default();
let c = frame(&mut app, &ctx, vec![], false).0;
let at = egui::pos2(150.0, c[0].y);
let right = egui::PointerButton::Secondary;
for e in [egui::Event::PointerMoved(at), button_by(at, true, right), button_by(at, false, right)] {
frame(&mut app, &ctx, vec![e], false);
}
assert_eq!(texts(&mut app, &ctx, vec![], "Collect for Export"), 1, "the row menu is open");
}
#[test]
fn the_panel_menu_lists_every_operation() {
let (mut app, _) = nested();
+1
View File
@@ -822,6 +822,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- These dialog sliders, each beside a number field, respond to the keyboard: those of the effect, tool option, brush and transform dialogs, the Perspective Grid, the 3D dialogs, and the flattener options, which the Flattener Preview panel also shows. A click, or Tab, gives a slider the keyboard focus, and an outline shows it. Then → and ↑ step the field's number up to the next whole number and ← and ↓ step it down to the next whole number, Shift with an arrow and Page Up and Page Down step it by ten, and Home and End set the slider's first and last value; no step goes past either end. Screen readers' increment and decrement actions step the slider as → and ← do. The Color panel's, Swatch Options', the Navigator's and the Character panel's sliders, and the Color Picker's channel slider, respond only to the pointer. The keys follow the WAI-ARIA slider pattern (<https://www.w3.org/WAI/ARIA/apg/patterns/slider/>); the reference app's help does not describe keys on a dialog's slider, so the step sizes are VectorCraft's own.
- Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent.
- A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`).
- In the Layers panel only the primary mouse button drags. A drag with the right or middle button on a row, a guide row, the selection square, the eye or lock column or a target circle moves, copies, hides, locks and transfers nothing and records no undo step, and a right-click still opens the row's menu (`panels::layers` test `only_the_primary_button_drags_rows_guides_art_columns_and_appearances`).
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set.
- A floating panel's OS window hides while a tool group's flyout or the All Tools drawer is open in the main window, as it does under a modal dialog or a menu, and shows again when the flyout or the drawer closes (#5813; `a_tool_flyout_hides_the_palette_windows_until_it_closes`).
- On the desktop a floating panel in an OS window stores the monitor it is on and reopens there at the next launch. When that monitor is no longer connected, the panel opens on the main window's monitor at the same offset (#5582). Outside macOS, a desktop whose monitors have different scale factors keeps opening floating panels on the main window's monitor. Test: `panel_docking::native_tests::a_floating_panel_reopens_on_the_monitor_where_it_was_left`.