From 6e4cd6bf8cd2dbad3876ded2b15e8058411cddce Mon Sep 17 00:00:00 2001 From: Bane Stojkovic Date: Sat, 10 Oct 2026 12:57:35 +0200 Subject: [PATCH] orthogonal shift drag --- .../org/diylc/common/IMouseProcessor.java | 5 +- .../java/org/diylc/presenter/Presenter.java | 83 ++++++++++++++++--- .../main/java/org/diylc/test/TestPlayer.java | 4 +- .../diylc-core/src/main/resources/update.xml | 4 + .../canvas/CanvasDragGestureListener.java | 3 +- .../test/java/org/diylc/PresenterTests.java | 57 ++++++++++++- 6 files changed, 141 insertions(+), 15 deletions(-) diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/IMouseProcessor.java b/diylc/diylc-core/src/main/java/org/diylc/common/IMouseProcessor.java index a26f6181e..d95ff104d 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/common/IMouseProcessor.java +++ b/diylc/diylc-core/src/main/java/org/diylc/common/IMouseProcessor.java @@ -59,8 +59,11 @@ public interface IMouseProcessor { * @param point * @param dragAction * @param forceSelectionRect + * @param shiftDown when true, the whole drag is constrained to the horizontal or vertical + * direction of the initial gesture. Shift alone maps to the same drag action as no + * modifier, so it has to be passed separately. */ - void dragStarted(Point point, int dragAction, boolean forceSelectionRect); + void dragStarted(Point point, int dragAction, boolean forceSelectionRect, boolean shiftDown); /** * Checks if it's possible to drop over the specified point. diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java index 355bcc69d..9ee942184 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java +++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java @@ -120,7 +120,11 @@ public class Presenter implements IPlugInPort { public static final int ICON_SIZE = 32; - private static final int MAX_RECENT_FILES = 20; + private static final int MAX_RECENT_FILES = 20; + + // screen pixels the cursor has to travel with shift held before the dominant direction is + // picked, so that hand jitter right after pressing shift does not choose the axis + private static final int AXIS_LOCK_THRESHOLD = 4; private Project currentProject; @@ -160,6 +164,12 @@ public class Presenter implements IPlugInPort { private Project preDragProject = null; private int dragAction; private Point2D previousScaledPoint; + // Shift held when a drag starts constrains it to the horizontal or vertical line through the + // drag start point for the whole drag. The state is not followed mid-drag because native DnD on + // macOS keeps reporting the modifiers captured at the start. + private Point2D dragStartPoint = null; + private boolean dragShiftDown = false; + private AxisLock lockedAxis = null; private DIYTest test = null; @@ -659,7 +669,9 @@ public class Presenter implements IPlugInPort { } else { // On the second click, add the component to the // project. - addPendingComponentsToProject(scaledPoint, componentTypeSlot, template, model, oldProject); + addPendingComponentsToProject( + constrainToAxis(instantiationManager.getFirstControlPoint(), scaledPoint, shiftDown), + componentTypeSlot, template, model, oldProject); } break; default: @@ -916,7 +928,8 @@ public class Presenter implements IPlugInPort { instantiationManager.updateSingleClick(previousScaledPoint, isSnapToGrid(), currentProject.getGridSpacing()); } else { - refresh = instantiationManager.updatePointByPoint(previousScaledPoint); + refresh = instantiationManager.updatePointByPoint( + constrainToAxis(instantiationManager.getFirstControlPoint(), previousScaledPoint, shiftDown)); } if (refresh) { messageDispatcher.dispatchMessage(EventType.REPAINT); @@ -1282,15 +1295,16 @@ public class Presenter implements IPlugInPort { } @Override - public void dragStarted(Point point, int dragAction, boolean forceSelectionRect) { - LOG.debug(String.format("dragStarted(%s, %s)", point, dragAction)); - + public void dragStarted(Point point, int dragAction, boolean forceSelectionRect, boolean shiftDown) { + LOG.debug(String.format("dragStarted(%s, %s, %s)", point, dragAction, shiftDown)); + // record a test step if needed if (test != null) { Map params = new HashMap(); params.put("point", point); params.put("dragAction", dragAction); params.put("forceSelectionRect", forceSelectionRect); + params.put("shiftDown", shiftDown); test.addStep(DIYTest.DRAG_START, params); } @@ -1311,6 +1325,9 @@ public class Presenter implements IPlugInPort { this.preDragProject = currentProject.clone(); Point2D scaledPoint = scalePoint(point); this.previousDragPoint = scaledPoint; + this.dragStartPoint = new Point2D.Double(scaledPoint.getX(), scaledPoint.getY()); + this.dragShiftDown = shiftDown; + this.lockedAxis = null; List> components = forceSelectionRect ? null : findComponentsAtScaled(scaledPoint, false); if (controlPointMap != null && !this.controlPointMap.isEmpty()) { // If we're dragging control points reset selection. @@ -1498,12 +1515,15 @@ public class Presenter implements IPlugInPort { Point2D scaledPoint = scalePoint(point); if (controlPointMap != null && !controlPointMap.isEmpty()) { // We're dragging control point(s). - int dx = (int) (scaledPoint.getX() - previousDragPoint.getX()); - int dy = (int) (scaledPoint.getY() - previousDragPoint.getY()); + Point2D targetPoint = constrainToAxis(dragStartPoint, scaledPoint, dragShiftDown); + int dx = (int) (targetPoint.getX() - previousDragPoint.getX()); + int dy = (int) (targetPoint.getY() - previousDragPoint.getY()); - Point2D actualD = moveComponents(this.controlPointMap, dx, dy, isSnapToGrid(), isSnapToObjects()); - if (actualD == null) + Point2D actualD = moveComponents(this.controlPointMap, dx, dy, isSnapToGrid(), isSnapToObjects(), + lockedAxis); + if (actualD == null) { return true; + } // if there's at least one component with only one control point being dragged it means that we are resizing if (controlPointMap.size() == 1 && controlPointMap.entrySet().stream() @@ -1573,7 +1593,36 @@ public class Presenter implements IPlugInPort { return new ResizeDimensions(width, height, length); } + /** + * Projects the point onto the horizontal or vertical line through the anchor while shift is held. + * The axis is picked from the dominant direction of the gesture once it leaves the dead zone and + * stays locked until shift is released. + */ + private Point2D constrainToAxis(Point2D anchor, Point2D point, boolean shiftDown) { + if (!shiftDown || anchor == null || point == null) { + lockedAxis = null; + return point; + } + double dx = point.getX() - anchor.getX(); + double dy = point.getY() - anchor.getY(); + if (lockedAxis == null) { + if (Math.max(Math.abs(dx), Math.abs(dy)) < AXIS_LOCK_THRESHOLD / drawingManager.getZoomLevel()) { + return new Point2D.Double(anchor.getX(), anchor.getY()); + } + lockedAxis = Math.abs(dx) >= Math.abs(dy) ? AxisLock.HORIZONTAL : AxisLock.VERTICAL; + } + if (lockedAxis == AxisLock.HORIZONTAL) { + return new Point2D.Double(point.getX(), anchor.getY()); + } + return new Point2D.Double(anchor.getX(), point.getY()); + } + private Point2D moveComponents(Map, Set> controlPointMap, int dx, int dy, boolean snapToGrid, boolean snapToObjects) { + return moveComponents(controlPointMap, dx, dy, snapToGrid, snapToObjects, null); + } + + private Point2D moveComponents(Map, Set> controlPointMap, int dx, int dy, + boolean snapToGrid, boolean snapToObjects, AxisLock lockedAxis) { // After we make the transfer and snap to grid, calculate actual dx // and dy. We'll use them to translate the previous drag point. double actualDx = 0; @@ -1620,6 +1669,13 @@ public class Presenter implements IPlugInPort { actualDy = dy; } + // snapping an off-grid point would otherwise nudge it across the locked axis + if (lockedAxis == AxisLock.HORIZONTAL && dy == 0) { + actualDy = 0; + } else if (lockedAxis == AxisLock.VERTICAL && dx == 0) { + actualDx = 0; + } + if (actualDx == 0 && actualDy == 0) { // Nothing to move. return null; @@ -1887,6 +1943,9 @@ public class Presenter implements IPlugInPort { messageDispatcher.dispatchMessage(EventType.REPAINT); dragInProgress = false; + dragStartPoint = null; + dragShiftDown = false; + lockedAxis = null; } @Override @@ -3198,4 +3257,8 @@ public class Presenter implements IPlugInPort { public Set getComponentGroups() { return currentProject.getGroupsEx(); } + + private enum AxisLock { + HORIZONTAL, VERTICAL + } } diff --git a/diylc/diylc-core/src/main/java/org/diylc/test/TestPlayer.java b/diylc/diylc-core/src/main/java/org/diylc/test/TestPlayer.java index e6cf453b8..d9777a3f7 100644 --- a/diylc/diylc-core/src/main/java/org/diylc/test/TestPlayer.java +++ b/diylc/diylc-core/src/main/java/org/diylc/test/TestPlayer.java @@ -39,7 +39,9 @@ public class TestPlayer { res.add(new StepResult(i, step.getAction(), "OK", false)); break; case DIYTest.DRAG_START: - plugInPort.dragStarted((Point)step.getParams().get("point"), (int)step.getParams().get("dragAction"), (boolean)step.getParams().get("forceSelectionRect")); + // tests recorded before shift was passed in carry no value for it + plugInPort.dragStarted((Point)step.getParams().get("point"), (int)step.getParams().get("dragAction"), (boolean)step.getParams().get("forceSelectionRect"), + Boolean.TRUE.equals(step.getParams().get("shiftDown"))); res.add(new StepResult(i, step.getAction(), "OK", false)); break; case DIYTest.DRAG_OVER: diff --git a/diylc/diylc-core/src/main/resources/update.xml b/diylc/diylc-core/src/main/resources/update.xml index f3e8b5ce0..6a6a4ef76 100644 --- a/diylc/diylc-core/src/main/resources/update.xml +++ b/diylc/diylc-core/src/main/resources/update.xml @@ -589,6 +589,10 @@ NEW_FEATURE Added 'Align & Distribute' to the Edit menu and the right-click menu - align the selected components by their left, right, top or bottom edges or their centers, or space them evenly by their centers or by the gaps between them + + NEW_FEATURE + Holding 'Shift' when starting to drag components or control points keeps the whole drag strictly horizontal or vertical, in the direction of the initial gesture. Holding 'Shift' while drawing a new wire or two-point component does the same + https://github.com/bancika/diy-layout-creator/releases diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/canvas/CanvasDragGestureListener.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/canvas/CanvasDragGestureListener.java index d6d40ae76..10d0d6e6c 100644 --- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/canvas/CanvasDragGestureListener.java +++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/canvas/CanvasDragGestureListener.java @@ -54,7 +54,8 @@ class CanvasDragGestureListener implements DragGestureListener { forceReSelection = me.getButton() != MouseEvent.BUTTON1; } canvasPanel.setClickInProgress(false); - presenter.dragStarted(dge.getDragOrigin(), dge.getDragAction(), forceReSelection); + presenter.dragStarted(dge.getDragOrigin(), dge.getDragAction(), forceReSelection, + e.isShiftDown()); // Get the appropriate cursor from the presenter (e.g., HAND_CURSOR over components) // Preserve the cursor context (HAND_CURSOR, CROSSHAIR_CURSOR, etc.) for drag operations // On Linux, DEFAULT_CURSOR during drag can show "No" cursor, so use MOVE_CURSOR as fallback diff --git a/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java b/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java index 989fb3b1f..4c6e63f8e 100644 --- a/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java +++ b/diylc/diylc-swing/src/test/java/org/diylc/PresenterTests.java @@ -1,6 +1,7 @@ package org.diylc; import java.awt.Point; +import java.awt.geom.Point2D; import java.io.IOException; import java.util.ArrayList; import java.util.EnumSet; @@ -17,6 +18,7 @@ import org.diylc.common.ComponentType; import org.diylc.common.EventType; import org.diylc.common.IPlugIn; import org.diylc.common.IPlugInPort; +import org.diylc.core.IDIYComponent; import org.diylc.core.Template; import org.diylc.presenter.Presenter; import org.diylc.swing.ActionFactory; @@ -101,7 +103,7 @@ public class PresenterTests extends TestBase { Assert.assertNotNull(receivedTooltip[0]); // Start drag - should hide tooltip - presenter.dragStarted(new Point(1000, 1000), java.awt.dnd.DnDConstants.ACTION_MOVE, false); + presenter.dragStarted(new Point(1000, 1000), java.awt.dnd.DnDConstants.ACTION_MOVE, false, false); Assert.assertNull(receivedTooltip[0]); // End drag @@ -221,7 +223,58 @@ public class PresenterTests extends TestBase { treeModel.refresh(); Assert.assertTrue(presenter.getVisibleComponentTypes().containsKey("Tubes")); } - + + @Test + public void testShiftDragLocksAxis() { + instantiateTwoClick("Passive", "Resistor", new Point(1000, 1000), new Point(1200, 1000)); + IDIYComponent resistor = presenter.getCurrentProject().getComponents().get(0); + Point2D start = new Point2D.Double(resistor.getControlPoint(1).getX(), resistor.getControlPoint(1).getY()); + + // hovering a control point of the selected component arms it for dragging + presenter.mouseMoved(new Point(1200, 1000), false, false, false); + presenter.dragStarted(new Point(1200, 1000), java.awt.dnd.DnDConstants.ACTION_MOVE, false, true); + + // mostly horizontal gesture locks the horizontal axis + presenter.dragOver(new Point(1300, 1030)); + Assert.assertTrue(resistor.getControlPoint(1).getX() > start.getX()); + Assert.assertEquals(start.getY(), resistor.getControlPoint(1).getY()); + + // the axis holds for the rest of the drag even when the cursor swings toward the vertical + presenter.dragOver(new Point(1300, 1200)); + Assert.assertEquals(start.getY(), resistor.getControlPoint(1).getY()); + presenter.dragEnded(new Point(1300, 1200)); + } + + @Test + public void testDragWithoutShiftIsFree() { + instantiateTwoClick("Passive", "Resistor", new Point(1000, 1000), new Point(1200, 1000)); + IDIYComponent resistor = presenter.getCurrentProject().getComponents().get(0); + Point2D start = new Point2D.Double(resistor.getControlPoint(1).getX(), resistor.getControlPoint(1).getY()); + + presenter.mouseMoved(new Point(1200, 1000), false, false, false); + presenter.dragStarted(new Point(1200, 1000), java.awt.dnd.DnDConstants.ACTION_MOVE, false, false); + presenter.dragOver(new Point(1300, 1100)); + Assert.assertTrue(resistor.getControlPoint(1).getX() > start.getX()); + Assert.assertTrue(resistor.getControlPoint(1).getY() > start.getY()); + presenter.dragEnded(new Point(1300, 1100)); + } + + @Test + public void testShiftLocksAxisWhilePlacing() { + Map> componentTypes = presenter.getComponentTypes(); + ComponentType resistorType = componentTypes.get("Passive").stream() + .filter(x -> x.getName().equals("Resistor")).findFirst().get(); + presenter.setNewComponentTypeSlot(resistorType, null, null, false); + presenter.mouseClicked(new Point(1000, 1000), IPlugInPort.BUTTON1, false, false, false, 1); + presenter.mouseMoved(new Point(1030, 1200), false, true, false); + presenter.mouseClicked(new Point(1030, 1200), IPlugInPort.BUTTON1, false, true, false, 1); + + Assert.assertEquals(1, presenter.getCurrentProject().getComponents().size()); + IDIYComponent resistor = presenter.getCurrentProject().getComponents().get(0); + Assert.assertEquals(resistor.getControlPoint(0).getX(), resistor.getControlPoint(1).getX()); + Assert.assertTrue(resistor.getControlPoint(1).getY() > resistor.getControlPoint(0).getY()); + } + // @Test // public void testLoadVariants() { // try {