From b0e3428696facc36d2efafb9f1749a74c1a4b575 Mon Sep 17 00:00:00 2001 From: Thibaud Date: Thu, 26 Mar 2026 23:54:46 +0100 Subject: [PATCH] feat(ui): add box selection feature - Selection: add addAll() method for bulk shape addition - Controller: box selection with mouse drag on empty space - ShapeDraftman: drawSelectionBox() for rubber-band rendering - ShapesView: currentSelectionBox field and setter --- .../java/ovh/gasser/newshapes/Selection.java | 10 ++ .../ovh/gasser/newshapes/ui/Controller.java | 122 +++++++++++++++--- .../gasser/newshapes/ui/ShapeDraftman.java | 12 +- .../ovh/gasser/newshapes/ui/ShapesView.java | 6 + 4 files changed, 128 insertions(+), 22 deletions(-) diff --git a/src/main/java/ovh/gasser/newshapes/Selection.java b/src/main/java/ovh/gasser/newshapes/Selection.java index 6029c35..7ac0b5e 100644 --- a/src/main/java/ovh/gasser/newshapes/Selection.java +++ b/src/main/java/ovh/gasser/newshapes/Selection.java @@ -9,6 +9,7 @@ import ovh.gasser.newshapes.util.Streamable; import java.util.ArrayList; import java.util.Collections; +import java.util.Collection; import java.util.Iterator; import java.util.List; @@ -38,6 +39,15 @@ public class Selection implements Streamable { notifyListeners(); } + public void addAll(Collection shapes) { + if (shapes == null) { + return; + } + for (Shape shape : shapes) { + add(shape); + } + } + public boolean isEmpty() { return selectedShapes.isEmpty(); } diff --git a/src/main/java/ovh/gasser/newshapes/ui/Controller.java b/src/main/java/ovh/gasser/newshapes/ui/Controller.java index 2b8d22f..f1b2205 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/Controller.java +++ b/src/main/java/ovh/gasser/newshapes/ui/Controller.java @@ -32,6 +32,9 @@ public class Controller { private ResizeHandle activeHandle; private Point resizeOrigin; private boolean resizeMode; + private Point selectionBoxStart; + private Point selectionBoxEnd; + private boolean boxSelecting; Controller(ShapesView view, SCollection model) { this.view = view; @@ -54,6 +57,11 @@ public class Controller { resizing = false; activeHandle = null; resizeOrigin = null; + boxSelecting = false; + selectionBoxStart = null; + selectionBoxEnd = null; + view.setCurrentSelectionBox(null); + view.repaint(); } }; this.view.addMouseMotionListener(adapter); @@ -67,10 +75,24 @@ public class Controller { } private void handleMouseDragged(MouseEvent evt) { + if (boxSelecting) { + selectionBoxEnd = evt.getPoint(); + Rectangle box = createSelectionBox(selectionBoxStart, selectionBoxEnd); + view.setCurrentSelectionBox(box); + updateSelectionFromBox(box); + view.repaint(); + return; + } + + boxSelecting = false; + if (resizeMode && resizing && activeHandle != null) { + logger.debug("RESIZING with handle {} at ({}, {})", activeHandle, evt.getX(), evt.getY()); int dx = evt.getX() - resizeOrigin.x; int dy = evt.getY() - resizeOrigin.y; + logger.debug("About to resize {} shapes, dx={}, dy={}", selection.getSelectedShapes().size(), dx, dy); for (Shape shape : selection) { + logger.debug("Calling resize on shape: {}", shape); shape.resize(activeHandle, dx, dy); } resizeOrigin = evt.getPoint(); @@ -85,8 +107,12 @@ public class Controller { } else { resizing = false; activeHandle = null; + if (lastMousePos == null) { + lastMousePos = evt.getPoint(); + } int dx = evt.getX() - lastMousePos.x; int dy = evt.getY() - lastMousePos.y; + logger.debug("MOVING shapes with delta ({}, {})", dx, dy); for (Shape shape : selection) { shape.translate(dx, dy); } @@ -104,9 +130,11 @@ public class Controller { resizing = false; activeHandle = null; resizeOrigin = null; + boxSelecting = false; // Clear box selection mode when clicking if (resizeMode && !selection.isEmpty()) { ResizeHandle handle = getHandleAt(evt.getPoint()); + logger.debug("In resize mode, checking handle at ({}, {}): {}", evt.getX(), evt.getY(), handle); if (handle != null) { resizing = true; activeHandle = handle; @@ -122,18 +150,19 @@ public class Controller { if (!evt.isShiftDown()) { resetSelection(); } - lastMousePos = evt.getPoint(); - selection.add(s); - logger.debug("Selecting {}", s); + if (!selection.getSelectedShapes().contains(s)) { + lastMousePos = evt.getPoint(); + selection.add(s); + logger.debug("Selecting {}", s); + } }, - this::resetSelection + () -> { + // Clicked on empty space - start box selection + resetSelection(); + selectionBoxStart = evt.getPoint(); + boxSelecting = true; + } ); - view.repaint(); - } - -<<<<<<< HEAD - public void enterTextMode() { - addingText = true; } private void placeTextAt(Point point) { @@ -153,20 +182,56 @@ public class Controller { } private ResizeHandle getHandleAt(Point point) { - final int handleSize = 5; + final int handleSize = 10; for (Shape shape : selection) { Rectangle bounds = shape.getBounds(); - int cx = bounds.x + bounds.width / 2; - int cy = bounds.y + bounds.height / 2; + + // Always-present SE corner handle + if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && + point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { + return ResizeHandle.SE; + } - if (point.x < cx && point.y < cy) return ResizeHandle.NW; - if (point.x > cx && point.y < cy) return ResizeHandle.NE; - if (point.x < cx && point.y > cy) return ResizeHandle.SW; - if (point.x > cx && point.y > cy) return ResizeHandle.SE; - if (point.y < cy) return ResizeHandle.N; - if (point.y > cy) return ResizeHandle.S; - if (point.x < cx) return ResizeHandle.W; - if (point.x > cx) return ResizeHandle.E; + // Resize handles are only active in resize mode + if (resizeMode) { + // NW corner + if (point.x >= bounds.x - handleSize && point.x <= bounds.x && + point.y >= bounds.y - handleSize && point.y <= bounds.y) { + return ResizeHandle.NW; + } + // NE corner + if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && + point.y >= bounds.y - handleSize && point.y <= bounds.y) { + return ResizeHandle.NE; + } + // SW corner + if (point.x >= bounds.x - handleSize && point.x <= bounds.x && + point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { + return ResizeHandle.SW; + } + // N edge + int edgeX = bounds.x + bounds.width / 2 - handleSize / 2; + if (point.x >= edgeX && point.x <= edgeX + handleSize && + point.y >= bounds.y - handleSize && point.y <= bounds.y) { + return ResizeHandle.N; + } + // S edge + if (point.x >= edgeX && point.x <= edgeX + handleSize && + point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { + return ResizeHandle.S; + } + // W edge + int edgeY = bounds.y + bounds.height / 2 - handleSize / 2; + if (point.x >= bounds.x - handleSize && point.x <= bounds.x && + point.y >= edgeY && point.y <= edgeY + handleSize) { + return ResizeHandle.W; + } + // E edge + if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && + point.y >= edgeY && point.y <= edgeY + handleSize) { + return ResizeHandle.E; + } + } } return null; } @@ -253,4 +318,19 @@ public class Controller { .filter(s -> s.getBounds().contains(evt.getPoint())) .findFirst(); } + + private Rectangle createSelectionBox(Point start, Point end) { + int x = Math.min(start.x, end.x); + int y = Math.min(start.y, end.y); + int width = Math.abs(end.x - start.x); + int height = Math.abs(end.y - start.y); + return new Rectangle(x, y, width, height); + } + + private void updateSelectionFromBox(Rectangle box) { + resetSelection(); + model.stream() + .filter(s -> s.getBounds().intersects(box)) + .forEach(selection::add); + } } diff --git a/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java b/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java index 3d00f86..766531d 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java +++ b/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java @@ -135,7 +135,7 @@ public class ShapeDraftman implements ShapeVisitor { if ((selAttrs != null) && (selAttrs.selected)){ Rectangle bounds = s.getBounds(); this.g2d.setColor(Color.RED); - int handleSize = 5; + int handleSize = 10; this.g2d.drawRect(bounds.x - handleSize, bounds.y - handleSize, handleSize, handleSize); this.g2d.drawRect(bounds.x + bounds.width, bounds.y + bounds.height, handleSize, handleSize); if (resizeMode) { @@ -149,4 +149,14 @@ public class ShapeDraftman implements ShapeVisitor { } } + public void drawSelectionBox(Rectangle box) { + if (box == null) return; + g2d.setXORMode(Color.WHITE); + g2d.setColor(Color.BLUE); + g2d.setStroke(new BasicStroke(1, BasicStroke.CAP_BUTT, + BasicStroke.JOIN_MITER, 1, new float[]{4, 4}, 0)); + g2d.drawRect(box.x, box.y, box.width, box.height); + g2d.setPaintMode(); + } + } diff --git a/src/main/java/ovh/gasser/newshapes/ui/ShapesView.java b/src/main/java/ovh/gasser/newshapes/ui/ShapesView.java index 0ef9b33..f76715a 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/ShapesView.java +++ b/src/main/java/ovh/gasser/newshapes/ui/ShapesView.java @@ -13,6 +13,7 @@ public class ShapesView extends JPanel { private final Controller controller; private ShapeVisitor draftman; private boolean resizeMode; + private Rectangle currentSelectionBox; public ShapesView(SCollection model) { this.model = model; @@ -25,6 +26,7 @@ public class ShapesView extends JPanel { this.draftman = new ShapeDraftman(g); ((ShapeDraftman) this.draftman).setResizeMode(resizeMode); model.accept(draftman); + ((ShapeDraftman) this.draftman).drawSelectionBox(currentSelectionBox); } public Controller getController() { @@ -38,4 +40,8 @@ public class ShapesView extends JPanel { public void setResizeMode(boolean resizeMode) { this.resizeMode = resizeMode; } + + public void setCurrentSelectionBox(Rectangle box) { + this.currentSelectionBox = box; + } }