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
This commit is contained in:
2026-03-26 23:54:46 +01:00
parent 3a6f98455a
commit b0e3428696
4 changed files with 128 additions and 22 deletions

View File

@@ -9,6 +9,7 @@ import ovh.gasser.newshapes.util.Streamable;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Collection;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
@@ -38,6 +39,15 @@ public class Selection implements Streamable<Shape> {
notifyListeners(); notifyListeners();
} }
public void addAll(Collection<Shape> shapes) {
if (shapes == null) {
return;
}
for (Shape shape : shapes) {
add(shape);
}
}
public boolean isEmpty() { public boolean isEmpty() {
return selectedShapes.isEmpty(); return selectedShapes.isEmpty();
} }

View File

@@ -32,6 +32,9 @@ public class Controller {
private ResizeHandle activeHandle; private ResizeHandle activeHandle;
private Point resizeOrigin; private Point resizeOrigin;
private boolean resizeMode; private boolean resizeMode;
private Point selectionBoxStart;
private Point selectionBoxEnd;
private boolean boxSelecting;
Controller(ShapesView view, SCollection model) { Controller(ShapesView view, SCollection model) {
this.view = view; this.view = view;
@@ -54,6 +57,11 @@ public class Controller {
resizing = false; resizing = false;
activeHandle = null; activeHandle = null;
resizeOrigin = null; resizeOrigin = null;
boxSelecting = false;
selectionBoxStart = null;
selectionBoxEnd = null;
view.setCurrentSelectionBox(null);
view.repaint();
} }
}; };
this.view.addMouseMotionListener(adapter); this.view.addMouseMotionListener(adapter);
@@ -67,10 +75,24 @@ public class Controller {
} }
private void handleMouseDragged(MouseEvent evt) { 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) { if (resizeMode && resizing && activeHandle != null) {
logger.debug("RESIZING with handle {} at ({}, {})", activeHandle, evt.getX(), evt.getY());
int dx = evt.getX() - resizeOrigin.x; int dx = evt.getX() - resizeOrigin.x;
int dy = evt.getY() - resizeOrigin.y; int dy = evt.getY() - resizeOrigin.y;
logger.debug("About to resize {} shapes, dx={}, dy={}", selection.getSelectedShapes().size(), dx, dy);
for (Shape shape : selection) { for (Shape shape : selection) {
logger.debug("Calling resize on shape: {}", shape);
shape.resize(activeHandle, dx, dy); shape.resize(activeHandle, dx, dy);
} }
resizeOrigin = evt.getPoint(); resizeOrigin = evt.getPoint();
@@ -85,8 +107,12 @@ public class Controller {
} else { } else {
resizing = false; resizing = false;
activeHandle = null; activeHandle = null;
if (lastMousePos == null) {
lastMousePos = evt.getPoint();
}
int dx = evt.getX() - lastMousePos.x; int dx = evt.getX() - lastMousePos.x;
int dy = evt.getY() - lastMousePos.y; int dy = evt.getY() - lastMousePos.y;
logger.debug("MOVING shapes with delta ({}, {})", dx, dy);
for (Shape shape : selection) { for (Shape shape : selection) {
shape.translate(dx, dy); shape.translate(dx, dy);
} }
@@ -104,9 +130,11 @@ public class Controller {
resizing = false; resizing = false;
activeHandle = null; activeHandle = null;
resizeOrigin = null; resizeOrigin = null;
boxSelecting = false; // Clear box selection mode when clicking
if (resizeMode && !selection.isEmpty()) { if (resizeMode && !selection.isEmpty()) {
ResizeHandle handle = getHandleAt(evt.getPoint()); ResizeHandle handle = getHandleAt(evt.getPoint());
logger.debug("In resize mode, checking handle at ({}, {}): {}", evt.getX(), evt.getY(), handle);
if (handle != null) { if (handle != null) {
resizing = true; resizing = true;
activeHandle = handle; activeHandle = handle;
@@ -122,18 +150,19 @@ public class Controller {
if (!evt.isShiftDown()) { if (!evt.isShiftDown()) {
resetSelection(); resetSelection();
} }
if (!selection.getSelectedShapes().contains(s)) {
lastMousePos = evt.getPoint(); lastMousePos = evt.getPoint();
selection.add(s); selection.add(s);
logger.debug("Selecting {}", s); logger.debug("Selecting {}", s);
},
this::resetSelection
);
view.repaint();
} }
},
<<<<<<< HEAD () -> {
public void enterTextMode() { // Clicked on empty space - start box selection
addingText = true; resetSelection();
selectionBoxStart = evt.getPoint();
boxSelecting = true;
}
);
} }
private void placeTextAt(Point point) { private void placeTextAt(Point point) {
@@ -153,20 +182,56 @@ public class Controller {
} }
private ResizeHandle getHandleAt(Point point) { private ResizeHandle getHandleAt(Point point) {
final int handleSize = 5; final int handleSize = 10;
for (Shape shape : selection) { for (Shape shape : selection) {
Rectangle bounds = shape.getBounds(); Rectangle bounds = shape.getBounds();
int cx = bounds.x + bounds.width / 2;
int cy = bounds.y + bounds.height / 2;
if (point.x < cx && point.y < cy) return ResizeHandle.NW; // Always-present SE corner handle
if (point.x > cx && point.y < cy) return ResizeHandle.NE; if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize &&
if (point.x < cx && point.y > cy) return ResizeHandle.SW; point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) {
if (point.x > cx && point.y > cy) return ResizeHandle.SE; return ResizeHandle.SE;
if (point.y < cy) return ResizeHandle.N; }
if (point.y > cy) return ResizeHandle.S;
if (point.x < cx) return ResizeHandle.W; // Resize handles are only active in resize mode
if (point.x > cx) return ResizeHandle.E; 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; return null;
} }
@@ -253,4 +318,19 @@ public class Controller {
.filter(s -> s.getBounds().contains(evt.getPoint())) .filter(s -> s.getBounds().contains(evt.getPoint()))
.findFirst(); .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);
}
} }

View File

@@ -135,7 +135,7 @@ public class ShapeDraftman implements ShapeVisitor {
if ((selAttrs != null) && (selAttrs.selected)){ if ((selAttrs != null) && (selAttrs.selected)){
Rectangle bounds = s.getBounds(); Rectangle bounds = s.getBounds();
this.g2d.setColor(Color.RED); 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 - handleSize, bounds.y - handleSize, handleSize, handleSize);
this.g2d.drawRect(bounds.x + bounds.width, bounds.y + bounds.height, handleSize, handleSize); this.g2d.drawRect(bounds.x + bounds.width, bounds.y + bounds.height, handleSize, handleSize);
if (resizeMode) { 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();
}
} }

View File

@@ -13,6 +13,7 @@ public class ShapesView extends JPanel {
private final Controller controller; private final Controller controller;
private ShapeVisitor draftman; private ShapeVisitor draftman;
private boolean resizeMode; private boolean resizeMode;
private Rectangle currentSelectionBox;
public ShapesView(SCollection model) { public ShapesView(SCollection model) {
this.model = model; this.model = model;
@@ -25,6 +26,7 @@ public class ShapesView extends JPanel {
this.draftman = new ShapeDraftman(g); this.draftman = new ShapeDraftman(g);
((ShapeDraftman) this.draftman).setResizeMode(resizeMode); ((ShapeDraftman) this.draftman).setResizeMode(resizeMode);
model.accept(draftman); model.accept(draftman);
((ShapeDraftman) this.draftman).drawSelectionBox(currentSelectionBox);
} }
public Controller getController() { public Controller getController() {
@@ -38,4 +40,8 @@ public class ShapesView extends JPanel {
public void setResizeMode(boolean resizeMode) { public void setResizeMode(boolean resizeMode) {
this.resizeMode = resizeMode; this.resizeMode = resizeMode;
} }
public void setCurrentSelectionBox(Rectangle box) {
this.currentSelectionBox = box;
}
} }