Panels: item drags start only with the primary button, as Layers does

A right or middle drag on a swatch or color group, a brush, a symbol, a
graphic style, a library item or group, a character or paragraph style,
an Appearance row or thumbnail, the Fill and Stroke squares, a Recent
Colors chip or the Gradient panel's thumbnail started the same drag as
the primary button, so it could reorder, copy, delete or apply the item.
These drags now start only with the primary button, as Layers rows
(#7016) and Artboards rows (#7005) do: `widgets::drag_source`, the
Swatches tiles, library panel items, text style rows and Appearance rows
check `drag_started_by(Primary)`, and the Appearance thumbnail shows the
grabbing hand only for a primary drag. Right-click menus and the Graphic
Styles panel's held right-button preview are unchanged.
This commit is contained in:
dotCooCoo
2026-11-13 01:22:30 -08:00
parent 3b48070751
commit 78ee4d04b6
10 changed files with 178 additions and 15 deletions
+3 -3
View File
@@ -558,7 +558,7 @@ fn delete_key(app: &mut VectorcraftApp, ui: &mut Ui) {
fn thumbnail_drag(ui: &mut Ui, th: Rect, id: NodeId) {
let resp = ui.interact(th, ui.scope_id().with("ap-thumb"), Sense::drag()).on_hover_text(tl!("Drag onto art to apply this appearance"));
widgets::drag_source(ui, &resp, || PanelDrag::Appearance(id));
if resp.dragged() {
if resp.dragged_by(egui::PointerButton::Primary) {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
}
@@ -704,13 +704,13 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row:
let lit = |item: Option<usize>, k: usize| effects.as_ref().is_some_and(|(list, ks)| *list == item && ks.contains(&k));
let mut rows = vec![(Slot::Object { top: true }, object_row)];
let mut stopped = false;
// Starts or ends a row drag.
// Starts (primary button only) or ends a row drag.
let frozen = only.is_some();
let mut track = |resp: &egui::Response, what: Dragged| {
if frozen {
return;
}
if resp.drag_started() {
if resp.drag_started_by(egui::PointerButton::Primary) {
set_pstate(&ctx, "ap-drag", Some(what));
// Where the row was grabbed, for its dimmed copy under the pointer.
// (From where the button went down: the drag starts once the pointer has moved on.)
+3 -2
View File
@@ -690,10 +690,11 @@ fn body<K: LibraryKind>(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
bottom::<K>(app, ui, id);
}
/// An item of library `id` dragged out of its tile or row `resp` carries what `K` says it does.
/// An item of library `id` dragged out of its tile or row `resp` with the primary button carries
/// what `K` says it does.
fn drag_out<K: LibraryKind>(ui: &Ui, resp: &egui::Response, id: &str, row: &Row<K::Item>) {
if row.item.is_some()
&& resp.drag_started()
&& resp.drag_started_by(egui::PointerButton::Primary)
&& let Some(drag) = K::drag(id, row.name)
{
egui::DragAndDrop::set_payload(ui.ctx(), drag);
+4 -3
View File
@@ -117,14 +117,15 @@ pub(super) struct TileEvents {
}
/// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e`
/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it
/// starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it.
/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A primary-button
/// drag from it starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held
/// over it.
pub(super) fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) {
let name = e.name();
if matches!(e, Entry::Swatch { .. } | Entry::Folder(_)) && resp.double_clicked() {
ev.edit = Some(e.clone());
}
if resp.drag_started() {
if resp.drag_started_by(egui::PointerButton::Primary) {
let with_sel = sel.iter().any(|s| s == name);
let names = items
.iter()
@@ -533,6 +533,50 @@ fn dragging_tiles_reorders_and_moves_into_groups_as_one_undo_step_each() {
assert_eq!((names_of(&app, None), app.session.doc().unwrap().history.undo.len()), (before, undo));
}
/// Press `pointer` at `from`, drag to `to` and release there, a frame per step from `time`. Returns
/// whether a drag-and-drop payload was held over `to` before the release.
fn drag_by(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, pointer: PointerButton, time: f64) -> bool {
let button = |pos, pressed| Event::PointerButton { pos, button: pointer, pressed, modifiers: Modifiers::NONE };
frame(app, ctx, vec![Event::PointerMoved(from), button(from, true)], time, show);
frame(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], time + 0.1, show);
frame(app, ctx, vec![Event::PointerMoved(to)], time + 0.2, show);
let held = egui::DragAndDrop::has_any_payload(ctx);
frame(app, ctx, vec![Event::PointerMoved(to), button(to, false)], time + 0.3, show);
frame(app, ctx, vec![], time + 0.4, show);
held
}
#[test]
fn only_a_primary_drag_moves_swatches_and_groups() {
let mut app = app();
let ctx = context();
frame(&mut app, &ctx, vec![], 0.0, show);
frame(&mut app, &ctx, vec![], 0.5, show);
let undo = app.session.doc().unwrap().history.undo.len();
let (before, grays) = (names_of(&app, None), names_of(&app, Some("Grays")));
let groups = |app: &VectorcraftApp| app.session.active().unwrap().doc.swatch_groups.iter().map(|g| g.name.clone()).collect::<Vec<_>>();
let group_order = groups(&app);
let mut time = 1.0;
for pointer in [PointerButton::Secondary, PointerButton::Middle] {
// A swatch onto the left half of Red, and onto a folder.
let held = drag_by(&mut app, &ctx, tile_center(&ctx, "Amber"), tile_center(&ctx, "Red") - vec2(4.0, 0.0), pointer, time);
assert!(!held, "a {pointer:?} drag carries no swatch");
let held = drag_by(&mut app, &ctx, tile_center(&ctx, "Amber"), tile_center(&ctx, "Grays"), pointer, time + 1.0);
assert!(!held, "a {pointer:?} drag carries no swatch into a group");
// A group onto another group.
let held = drag_by(&mut app, &ctx, tile_center(&ctx, "Grays"), tile_center(&ctx, "Brights") - vec2(4.0, 0.0), pointer, time + 2.0);
assert!(!held, "a {pointer:?} drag carries no group");
assert_eq!((names_of(&app, None), names_of(&app, Some("Grays")), groups(&app)), (before.clone(), grays.clone(), group_order.clone()));
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "a {pointer:?} drag records no undo step");
time += 3.0;
}
// The primary button still moves the swatch.
assert!(drag_by(&mut app, &ctx, tile_center(&ctx, "Amber"), tile_center(&ctx, "Red") - vec2(4.0, 0.0), PointerButton::Primary, time));
let n = names_of(&app, None);
assert_eq!(n[n.iter().position(|x| x == "Red").unwrap() - 1], "Amber");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
}
#[test]
fn art_dropped_on_the_panel_becomes_a_pattern_swatch() {
let mut app = app();
+50 -3
View File
@@ -8,7 +8,12 @@ use super::tests_appearance::{app_with_rect, click, frame_events, frame_raw, run
use super::*;
fn pointer(pos: egui::Pos2, pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }
pointer_by(pos, pressed, modifiers, egui::PointerButton::Primary)
}
/// `button` pressed (or released) at `pos` with `modifiers`.
fn pointer_by(pos: egui::Pos2, pressed: bool, modifiers: egui::Modifiers, button: egui::PointerButton) -> egui::Event {
egui::Event::PointerButton { pos, button, pressed, modifiers }
}
/// One frame of the Appearance panel with `events` and `modifiers` held.
@@ -19,12 +24,17 @@ fn panel_frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::
/// Drag from `from` to `to` in the Appearance panel, `modifiers` held on release.
fn drag(ctx: &egui::Context, app: &mut VectorcraftApp, from: egui::Pos2, to: egui::Pos2, modifiers: egui::Modifiers) {
drag_by(ctx, app, from, to, modifiers, egui::PointerButton::Primary);
}
/// [`drag`] with `button`.
fn drag_by(ctx: &egui::Context, app: &mut VectorcraftApp, from: egui::Pos2, to: egui::Pos2, modifiers: egui::Modifiers, button: egui::PointerButton) {
let none = egui::Modifiers::NONE;
panel_frame(ctx, app, vec![egui::Event::PointerMoved(from)], none);
panel_frame(ctx, app, vec![pointer(from, true, none)], none);
panel_frame(ctx, app, vec![pointer_by(from, true, none, button)], none);
panel_frame(ctx, app, vec![egui::Event::PointerMoved(from + egui::vec2(0.0, 8.0))], none);
panel_frame(ctx, app, vec![egui::Event::PointerMoved(to)], modifiers);
panel_frame(ctx, app, vec![pointer(to, false, modifiers)], modifiers);
panel_frame(ctx, app, vec![pointer_by(to, false, modifiers, button)], modifiers);
panel_frame(ctx, app, vec![], none);
}
@@ -233,3 +243,40 @@ fn the_thumbnail_drags_the_appearance_and_the_properties_fx_button_opens_the_eff
let texts = frame_raw(&props, &mut app, Default::default(), properties::show);
assert!(texts.iter().any(|(t, _)| t == "Distort & Transform"), "{texts:?}");
}
/// A secondary or middle drag of the thumbnail carries no appearance and shows no grabbing hand,
/// and one of a fill or stroke row neither moves nor copies it; the primary button still does.
#[test]
fn only_a_primary_drag_carries_the_thumbnail_or_a_row() {
let mut app = app_with_rect(); // [Fill, Stroke]
let ctx = egui::Context::default();
let none = egui::Modifiers::NONE;
let kinds = |app: &VectorcraftApp| first_selected(app).unwrap().appearance.items.iter().map(|i| i.kind_name()).collect::<Vec<_>>();
let undo = app.session.doc().unwrap().history.undo.len();
for button in [egui::PointerButton::Secondary, egui::PointerButton::Middle] {
let texts = panel_frame(&ctx, &mut app, vec![], none);
let label = text_rect(&texts, "Rectangle");
let thumb = egui::pos2(label.left() - 28.0, label.center().y);
for e in [egui::Event::PointerMoved(thumb), pointer_by(thumb, true, none, button)] {
panel_frame(&ctx, &mut app, vec![e], none);
}
let held = thumb + egui::vec2(30.0, 30.0);
let mut out =
ctx.run_ui(egui::RawInput { events: vec![egui::Event::PointerMoved(held)], ..Default::default() }, |ui| appearance::show(&mut app, ui));
out.textures_delta.clear();
assert!(!egui::DragAndDrop::has_any_payload(&ctx), "a {button:?} drag of the thumbnail carries nothing");
assert_ne!(out.platform_output.cursor_icon, egui::CursorIcon::Grabbing, "{button:?}");
panel_frame(&ctx, &mut app, vec![pointer_by(held, false, none, button)], none);
panel_frame(&ctx, &mut app, vec![], none);
// The fill dragged onto the stroke row, with and without Alt.
for modifiers in [none, egui::Modifiers::ALT] {
let texts = panel_frame(&ctx, &mut app, vec![], none);
drag_by(&ctx, &mut app, row_of(&texts, "Fill:"), row_of(&texts, "Stroke:") + egui::vec2(0.0, 9.0), modifiers, button);
assert_eq!(kinds(&app), ["fill", "stroke"], "a {button:?} drag moves and copies nothing");
}
}
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "no undo step");
let texts = panel_frame(&ctx, &mut app, vec![], none);
drag(&ctx, &mut app, row_of(&texts, "Fill:"), row_of(&texts, "Stroke:"), egui::Modifiers::ALT);
assert_eq!(kinds(&app), ["fill", "stroke", "fill"], "a primary Alt-drag copies the fill");
}
@@ -106,6 +106,37 @@ fn clicking_a_library_symbol_adds_it_to_the_documents_symbols_in_one_step() {
assert!(last_open(&app.ui).is_none());
}
/// A secondary or middle drag out of a library tile carries no symbol and adds nothing; a primary
/// drag still carries it.
#[test]
fn only_a_primary_drag_carries_a_library_symbol_out() {
let mut app = app();
let ctx = context();
app.run("window.symbolLibrary", json!({"library": "Basic Shapes"})).unwrap();
let undo = undo_len(&app);
let mut time = 0.0;
let mut drag = |app: &mut VectorcraftApp, pointer: PointerButton| {
let at = tile(app, &ctx, "Burst", time);
let button = |pos, pressed| Event::PointerButton { pos, button: pointer, pressed, modifiers: Modifiers::NONE };
frame(&ctx, vec![Event::PointerMoved(at), button(at, true)], time + 0.5, |ui| library_panel::show_window(app, ui.ctx()));
for (i, d) in [20.0, 60.0].into_iter().enumerate() {
frame(&ctx, vec![Event::PointerMoved(at + vec2(d, d))], time + 0.6 + 0.1 * i as f64, |ui| library_panel::show_window(app, ui.ctx()));
}
let carried = egui::DragAndDrop::payload::<PanelDrag>(&ctx).map(|p| (*p).clone());
let end = at + vec2(60.0, 60.0);
frame(&ctx, vec![button(end, false)], time + 0.9, |ui| library_panel::show_window(app, ui.ctx()));
frame(&ctx, vec![], time + 1.0, |ui| library_panel::show_window(app, ui.ctx()));
time += 2.0;
carried
};
for pointer in [PointerButton::Secondary, PointerButton::Middle] {
assert_eq!(drag(&mut app, pointer), None, "a {pointer:?} drag carries nothing");
}
assert_eq!((symbols(&app).len(), undo_len(&app)), (0, undo), "nothing is added");
let carried = drag(&mut app, PointerButton::Primary);
assert_eq!(carried, Some(PanelDrag::LibrarySymbol { library: "basic-shapes".into(), name: "Burst".into() }));
}
#[test]
fn the_window_menu_and_the_symbols_panel_open_symbol_libraries() {
let mut app = app();
+1 -1
View File
@@ -273,7 +273,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
// Overrides: an attribute the style sets that the selection doesn't match.
let overridden = is_cur && cur.as_ref().is_some_and(|(_, m)| attrs.iter().any(|(k, v)| m.get(k) != Some(v)));
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click_and_drag());
if resp.drag_started() {
if resp.drag_started_by(egui::PointerButton::Primary) {
egui::DragAndDrop::set_payload(ui.ctx(), RowDrag { kind, name: name.clone() });
}
if scroll_to.as_deref() == Some(name.as_str()) {
@@ -184,6 +184,44 @@ fn dragging_a_style_onto_delete_deletes_it() {
}
}
/// A secondary or middle drag of a style row onto Create New Style or Delete carries no row and
/// runs nothing; a primary drag still deletes.
#[test]
fn only_a_primary_drag_carries_a_style_row() {
for kind in [Kind::Char, Kind::Para] {
let (mut app, _) = drag_app();
let ctx = egui::Context::default();
let before = styles(&mut app, kind);
let undo = app.session.doc().unwrap().history.undo.len();
let commands = app.run_count;
for pointer in [egui::PointerButton::Secondary, egui::PointerButton::Middle] {
for from_right in [40.0, 12.0] {
let button = std::cell::Cell::new(egui::Pos2::ZERO);
let mut draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| {
ui.set_width(280.0);
let right = ui.max_rect().right();
show(app, ui, kind);
button.set(egui::pos2(right - from_right, ui.min_rect().bottom() - 12.0));
};
frame_events(&ctx, &mut app, vec![], &mut draw);
let row = text_rect(&frame_events(&ctx, &mut app, vec![], &mut draw), "Body").center();
let press = |pos, pressed| egui::Event::PointerButton { pos, button: pointer, pressed, modifiers: Default::default() };
frame_events(&ctx, &mut app, vec![egui::Event::PointerMoved(row), press(row, true)], &mut draw);
frame_events(&ctx, &mut app, vec![egui::Event::PointerMoved(row + vec2(0.0, 12.0))], &mut draw);
let to = button.get();
frame_events(&ctx, &mut app, vec![egui::Event::PointerMoved(to)], &mut draw);
assert!(!egui::DragAndDrop::has_any_payload(&ctx), "{}: a {pointer:?} drag carries no row", kind.what());
frame_events(&ctx, &mut app, vec![press(to, false)], &mut draw);
frame_events(&ctx, &mut app, vec![], &mut draw);
}
}
assert_eq!((app.run_count, styles(&mut app, kind)), (commands, before.clone()), "{}: nothing runs", kind.what());
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "no undo step");
assert!(drag_row_onto_footer(&ctx, &mut app, kind, "Body", 12.0, false), "{} Styles: Delete is outlined", kind.what());
assert_eq!(styles(&mut app, kind).len(), before.len() - 1, "{}: a primary drag deletes Body", kind.what());
}
}
fn capture_app() -> (VectorcraftApp, NodeId, egui::Context) {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
+3 -3
View File
@@ -88,10 +88,10 @@ impl PanelDrag {
}
}
/// Make `resp` (which senses drags) a source of the [`PanelDrag`] `drag` builds when a drag starts
/// on it.
/// Make `resp` (which senses drags) a source of the [`PanelDrag`] `drag` builds when a drag with the
/// primary button starts on it. A secondary or middle drag carries nothing.
pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) {
if resp.drag_started() {
if resp.drag_started_by(egui::PointerButton::Primary) {
egui::DragAndDrop::set_payload(ui.ctx(), drag());
}
}
+1
View File
@@ -831,6 +831,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- 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`).
- The other panels' item drags start only with the primary mouse button too: swatches and color groups, brushes, symbols, graphic styles, library items and groups, character and paragraph styles, Appearance rows and its thumbnail, the Fill and Stroke squares, Recent Colors and the Gradient panel's thumbnail. A right or middle drag on one carries nothing, moves, copies, deletes and applies nothing and records no undo step; right-click menus and the Graphic Styles panel's held right-button preview stay as they were (`panels::swatches` test `only_a_primary_drag_moves_swatches_and_groups`, `panels::text_styles` test `only_a_primary_drag_carries_a_style_row`, `panels::tests_symbol_libraries` test `only_a_primary_drag_carries_a_library_symbol_out`, `panels::tests_effectedit` test `only_a_primary_drag_carries_the_thumbnail_or_a_row`).
- 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`.