diff --git a/new-shapes.wiki b/new-shapes.wiki new file mode 160000 index 0000000..731a57b --- /dev/null +++ b/new-shapes.wiki @@ -0,0 +1 @@ +Subproject commit 731a57b080f9baa4d03ba3513d642345b410beaa diff --git a/src/main/java/ovh/gasser/newshapes/ui/Controller.java b/src/main/java/ovh/gasser/newshapes/ui/Controller.java index f1b2205..a3fbb7f 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/Controller.java +++ b/src/main/java/ovh/gasser/newshapes/ui/Controller.java @@ -5,6 +5,15 @@ import org.slf4j.LoggerFactory; import ovh.gasser.newshapes.HTMLExporter; import ovh.gasser.newshapes.Selection; import ovh.gasser.newshapes.attributes.ColorAttributes; +import ovh.gasser.newshapes.persistence.DrawingSerializer; +import ovh.gasser.newshapes.command.AddShapeCommand; +import ovh.gasser.newshapes.command.ChangeColorCommand; +import ovh.gasser.newshapes.command.Command; +import ovh.gasser.newshapes.command.CommandHistory; +import ovh.gasser.newshapes.command.CommandHistoryListener; +import ovh.gasser.newshapes.command.MoveShapeCommand; +import ovh.gasser.newshapes.command.RemoveShapeCommand; +import ovh.gasser.newshapes.command.ResizeShapeCommand; import ovh.gasser.newshapes.shapes.ResizeHandle; import ovh.gasser.newshapes.shapes.SCollection; import ovh.gasser.newshapes.shapes.SText; @@ -13,18 +22,28 @@ import ovh.gasser.newshapes.ui.listeners.SelectionListener; import javax.swing.*; import java.awt.*; +import java.awt.event.InputEvent; import java.awt.event.KeyAdapter; import java.awt.event.KeyEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; public class Controller { - private final static Logger logger = LoggerFactory.getLogger(Controller.class); + private static final Logger logger = LoggerFactory.getLogger(Controller.class); + private final ShapesView view; private final SCollection model; private final Selection selection; + private final CommandHistory commandHistory; private Point lastMousePos; private boolean addingText; @@ -35,11 +54,24 @@ public class Controller { private Point selectionBoxStart; private Point selectionBoxEnd; private boolean boxSelecting; + private Point dragStartMousePos; + private Map dragStartBounds = Map.of(); + private final Runnable onModelChanged; + private java.util.List clipboard = java.util.List.of(); + private int pasteCount = 0; + private static final int PASTE_OFFSET = 20; + private boolean additiveBoxSelection; Controller(ShapesView view, SCollection model) { + this(view, model, () -> { }); + } + + Controller(ShapesView view, SCollection model, Runnable onModelChanged) { this.view = view; this.model = model; + this.onModelChanged = onModelChanged; this.selection = new Selection(); + this.commandHistory = new CommandHistory(); var adapter = new MouseAdapter() { @Override @@ -54,14 +86,7 @@ public class Controller { @Override public void mouseReleased(MouseEvent evt) { - resizing = false; - activeHandle = null; - resizeOrigin = null; - boxSelecting = false; - selectionBoxStart = null; - selectionBoxEnd = null; - view.setCurrentSelectionBox(null); - view.repaint(); + handleMouseReleased(evt); } }; this.view.addMouseMotionListener(adapter); @@ -74,6 +99,249 @@ public class Controller { }); } + public void addShape(Shape shape) { + executeAndRefresh(new AddShapeCommand(model, shape)); + } + + public void addSelectionChangeListener(SelectionListener listener) { + selection.addListener(listener); + } + + public void addHistoryChangeListener(CommandHistoryListener listener) { + commandHistory.addListener(listener); + } + + public boolean canUndo() { + return commandHistory.canUndo(); + } + + public boolean canRedo() { + return commandHistory.canRedo(); + } + + public void undo() { + if (!commandHistory.canUndo()) { + return; + } + + resetSelection(); + commandHistory.undo(); + view.repaint(); + } + + public void redo() { + if (!commandHistory.canRedo()) { + return; + } + + resetSelection(); + commandHistory.redo(); + view.repaint(); + } + + public void saveDrawing(java.io.File file) { + try { + new DrawingSerializer().save(model, file); + logger.info("Saved drawing to {}", file.getAbsolutePath()); + } catch (java.io.IOException e) { + logger.error("Failed to save drawing: {}", e.getMessage()); + JOptionPane.showMessageDialog(view, "Failed to save: " + e.getMessage(), "Save Error", JOptionPane.ERROR_MESSAGE); + } + } + + public void loadDrawing(java.io.File file) { + try { + SCollection loaded = new DrawingSerializer().load(file); + model.stream().toList().forEach(model::remove); + loaded.forEach(model::add); + resetSelection(); + view.repaint(); + logger.info("Loaded drawing from {}", file.getAbsolutePath()); + } catch (java.io.IOException e) { + logger.error("Failed to load drawing: {}", e.getMessage()); + JOptionPane.showMessageDialog(view, "Failed to load: " + e.getMessage(), "Load Error", JOptionPane.ERROR_MESSAGE); + } + } + + public void enterTextMode() { + addingText = true; + } + + public void deleteSelected() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.isEmpty()) { + return; + } + + logger.debug("Deleting selected shape(s)"); + executeAndRefresh(new RemoveShapeCommand(model, selectedShapes)); + resetSelection(); + } + + public void copySelection() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.isEmpty()) { + logger.debug("No selection to copy"); + return; + } + + clipboard = selectedShapes.stream() + .map(Shape::clone) + .toList(); + pasteCount = 0; + } + + public void cutSelection() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.isEmpty()) { + logger.debug("No selection to cut"); + return; + } + + clipboard = selectedShapes.stream() + .map(Shape::clone) + .toList(); + pasteCount = 0; + + for (Shape shape : selectedShapes) { + model.remove(shape); + } + resetSelection(); + onModelChanged.run(); + view.repaint(); + } + + public void pasteClipboard() { + if (clipboard.isEmpty()) { + logger.debug("Clipboard is empty"); + return; + } + + pasteCount++; + int offset = PASTE_OFFSET * pasteCount; + + resetSelection(); + + for (Shape original : clipboard) { + Shape clone = original.clone(); + clone.translate(offset, offset); + model.add(clone); + selection.add(clone); + } + + onModelChanged.run(); + view.repaint(); + } + + public void group() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.size() < 2) { + logger.debug("Need at least 2 shapes to group"); + return; + } + + int minIndex = selectedShapes.stream() + .mapToInt(model::indexOf) + .min() + .orElse(0); + + for (Shape shape : selectedShapes) { + model.remove(shape); + shape.addAttributes(new ovh.gasser.newshapes.attributes.SelectionAttributes(false)); + } + + Shape[] shapesArray = selectedShapes.toArray(new Shape[0]); + SCollection group = SCollection.of(shapesArray); + + model.insert(minIndex, group); + resetSelection(); + selection.add(group); + onModelChanged.run(); + view.repaint(); + } + + public void ungroup() { + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.size() != 1) { + logger.debug("Can only ungroup a single selected group"); + return; + } + + Shape selected = selectedShapes.get(0); + if (!(selected instanceof SCollection group)) { + logger.debug("Selected shape is not a group"); + return; + } + + List children = new ArrayList<>(); + group.forEach(children::add); + + model.remove(group); + + for (Shape child : children) { + model.add(child); + } + + resetSelection(); + for (Shape child : children) { + selection.add(child); + } + onModelChanged.run(); + view.repaint(); + } + + public void changeSelectionColor() { + changeColors(shape -> new ColorAttributes(false, true, Color.BLACK, randomColor())); + } + + public void changeSelectionFillColor(Color filledColor) { + if (filledColor == null) { + return; + } + + changeColors(shape -> { + ColorAttributes current = currentColor(shape); + if (shape instanceof SText) { + return new ColorAttributes(current.filled, current.stroked, filledColor, filledColor); + } + return new ColorAttributes(true, current.stroked, filledColor, current.strokedColor); + }); + } + + public void changeSelectionBorderColor(Color strokedColor) { + if (strokedColor == null) { + return; + } + + changeColors(shape -> { + ColorAttributes current = currentColor(shape); + if (shape instanceof SText) { + return new ColorAttributes(current.filled, current.stroked, strokedColor, strokedColor); + } + return new ColorAttributes(current.filled, true, current.filledColor, strokedColor); + }); + } + + public void setSelectionBorder(boolean state) { + changeColors(shape -> { + if (shape instanceof SText) { + return null; + } + ColorAttributes current = currentColor(shape); + return new ColorAttributes(current.filled, state, current.filledColor, current.strokedColor); + }); + } + + public void setSelectionFilled(boolean state) { + changeColors(shape -> { + if (shape instanceof SText) { + return null; + } + ColorAttributes current = currentColor(shape); + return new ColorAttributes(state, current.stroked, current.filledColor, current.strokedColor); + }); + } + private void handleMouseDragged(MouseEvent evt) { if (boxSelecting) { selectionBoxEnd = evt.getPoint(); @@ -84,35 +352,19 @@ public class Controller { 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(); - } else if (resizeMode && !selection.isEmpty()) { - lastMousePos = evt.getPoint(); - ResizeHandle handle = getHandleAt(evt.getPoint()); - if (handle != null) { - resizing = true; - activeHandle = handle; - resizeOrigin = evt.getPoint(); - } - } else { - resizing = false; - activeHandle = null; + } else if (!selection.isEmpty()) { 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); } @@ -127,18 +379,17 @@ public class Controller { return; } - resizing = false; - activeHandle = null; - resizeOrigin = null; - boxSelecting = false; // Clear box selection mode when clicking + resizeDragState(); + boxSelecting = false; + view.setCurrentSelectionBox(null); 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; resizeOrigin = evt.getPoint(); + beginDrag(evt.getPoint()); view.repaint(); return; } @@ -151,96 +402,86 @@ public class Controller { resetSelection(); } if (!selection.getSelectedShapes().contains(s)) { - lastMousePos = evt.getPoint(); selection.add(s); - logger.debug("Selecting {}", s); } + beginDrag(evt.getPoint()); }, () -> { - // Clicked on empty space - start box selection - resetSelection(); + additiveBoxSelection = evt.isShiftDown(); + if (!additiveBoxSelection) { + resetSelection(); + } selectionBoxStart = evt.getPoint(); + selectionBoxEnd = evt.getPoint(); boxSelecting = true; + dragStartMousePos = null; + dragStartBounds = Map.of(); } ); } - private void placeTextAt(Point point) { - String input = JOptionPane.showInputDialog(view, "Enter text:", "Add text", JOptionPane.PLAIN_MESSAGE); - addingText = false; - if (input == null) { - return; - } - - model.add(SText.create(point.x, point.y, input)); - resetSelection(); + private void handleMouseReleased(MouseEvent evt) { + finishDragCommand(); + resizeDragState(); + boxSelecting = false; + selectionBoxStart = null; + selectionBoxEnd = null; + dragStartMousePos = null; + dragStartBounds = Map.of(); + view.setCurrentSelectionBox(null); view.repaint(); } - public void enterTextMode() { - addingText = true; - } + private void handleKeyPressed(KeyEvent evt) { + int modifiers = evt.getModifiersEx(); - private ResizeHandle getHandleAt(Point point) { - final int handleSize = 10; - for (Shape shape : selection) { - Rectangle bounds = shape.getBounds(); - - // 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; - } - - // 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; + if ((modifiers & InputEvent.CTRL_DOWN_MASK) != 0) { + switch (evt.getKeyCode()) { + case KeyEvent.VK_Z -> { + if ((modifiers & InputEvent.SHIFT_DOWN_MASK) != 0) { + redo(); + } else { + undo(); + } + evt.consume(); + return; } - // 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; + case KeyEvent.VK_Y -> { + redo(); + evt.consume(); + return; } - // 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; + case KeyEvent.VK_C -> { + copySelection(); + evt.consume(); + return; } - // 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; + case KeyEvent.VK_X -> { + cutSelection(); + evt.consume(); + return; } - // 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; + case KeyEvent.VK_V -> { + pasteClipboard(); + evt.consume(); + return; } - // 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; + case KeyEvent.VK_G -> { + group(); + evt.consume(); + return; } - // 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; + case KeyEvent.VK_U -> { + ungroup(); + evt.consume(); + return; } } } - return null; - } - private void handleKeyPressed(KeyEvent evt) { switch (evt.getKeyCode()) { case KeyEvent.VK_R -> toggleResizeMode(); case KeyEvent.VK_DELETE -> deleteSelected(); - case KeyEvent.VK_C -> copySelection(); case KeyEvent.VK_A -> changeSelectionColor(); case KeyEvent.VK_H -> exportHtml(); default -> logger.warn("Pressed unhandled key: {}", evt.getKeyChar()); @@ -264,55 +505,134 @@ public class Controller { } } - private void changeSelectionColor(){ - if (selection == null) { - logger.debug("No selection to change color of"); + private void placeTextAt(Point point) { + String input = JOptionPane.showInputDialog(view, "Enter text:", "Add text", JOptionPane.PLAIN_MESSAGE); + addingText = false; + if (input == null) { return; } - for (Shape s : selection) { - if (s instanceof SCollection collection) { - collection.forEach(shape -> shape.addAttributes(new ColorAttributes(false, true, Color.BLACK, new Color((int) (Math.random() * 0x1000000))))); - } else { - s.addAttributes(new ColorAttributes(false, true, Color.BLACK, new Color((int) (Math.random() * 0x1000000)))); + addShape(SText.create(point.x, point.y, input)); + resetSelection(); + } + + private ResizeHandle getHandleAt(Point point) { + final int handleSize = 10; + for (Shape shape : selection) { + Rectangle bounds = shape.getBounds(); + + 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 (resizeMode) { + if (point.x >= bounds.x - handleSize && point.x <= bounds.x && + point.y >= bounds.y - handleSize && point.y <= bounds.y) { + return ResizeHandle.NW; + } + 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; + } + 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; + } + 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; + } + if (point.x >= edgeX && point.x <= edgeX + handleSize && + point.y >= bounds.y + bounds.height && point.y <= bounds.y + bounds.height + handleSize) { + return ResizeHandle.S; + } + 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; + } + if (point.x >= bounds.x + bounds.width && point.x <= bounds.x + bounds.width + handleSize && + point.y >= edgeY && point.y <= edgeY + handleSize) { + return ResizeHandle.E; + } } } - - view.repaint(); + return null; } - private void copySelection() { - if (selection == null) { - logger.debug("No selection to copy"); + private void changeColors(Function updater) { + Map before = new LinkedHashMap<>(); + Map after = new LinkedHashMap<>(); + List targets = flattenSelection(selection.getSelectedShapes()); + + for (Shape shape : targets) { + ColorAttributes current = currentColor(shape); + if (current == null) { + continue; + } + + ColorAttributes updated = updater.apply(shape); + if (updated == null || sameColor(current, updated)) { + continue; + } + + before.put(shape, copy(current)); + after.put(shape, copy(updated)); + } + + if (before.isEmpty()) { return; } - for (Shape shape : selection) { - this.model.add(shape.clone()); - } - - view.repaint(); + executeAndRefresh(new ChangeColorCommand(before.keySet(), before, after)); } - public void deleteSelected() { - if (selection == null) return; - logger.debug("Deleting selected shape(s)"); - for (Shape s : selection) { - this.model.remove(s); + private void beginDrag(Point point) { + lastMousePos = point; + dragStartMousePos = point; + dragStartBounds = captureBounds(selection.getSelectedShapes()); + } + + private void finishDragCommand() { + if (boxSelecting || dragStartBounds.isEmpty()) { + return; } - resetSelection(); + + List selectedShapes = selection.getSelectedShapes(); + if (selectedShapes.isEmpty()) { + return; + } + + if (resizing && boundsChanged(selectedShapes, dragStartBounds)) { + Map afterBounds = captureBounds(selectedShapes); + Command command = new ResizeShapeCommand(selectedShapes, dragStartBounds, afterBounds); + command.undo(); + executeAndRefresh(command); + return; + } + + if (boundsChanged(selectedShapes, dragStartBounds)) { + Shape reference = selectedShapes.get(0); + Rectangle before = dragStartBounds.get(reference); + Rectangle after = reference.getBounds(); + Command command = new MoveShapeCommand(selectedShapes, after.x - before.x, after.y - before.y); + command.undo(); + executeAndRefresh(command); + } + } + + private void executeAndRefresh(Command command) { + commandHistory.execute(command); + onModelChanged.run(); view.repaint(); } private void resetSelection() { - logger.debug("Resetting selection"); selection.clear(); } - public void addSelectionChangeListener(SelectionListener listener) { - selection.addListener(listener); - } - private Optional getTarget(MouseEvent evt, SCollection sc) { return sc.stream() .filter(s -> s.getBounds().contains(evt.getPoint())) @@ -328,9 +648,71 @@ public class Controller { } private void updateSelectionFromBox(Rectangle box) { - resetSelection(); + if (!additiveBoxSelection) { + resetSelection(); + } model.stream() .filter(s -> s.getBounds().intersects(box)) .forEach(selection::add); } + + private void resizeDragState() { + resizing = false; + activeHandle = null; + resizeOrigin = null; + lastMousePos = null; + } + + private Map captureBounds(Collection shapes) { + Map bounds = new LinkedHashMap<>(); + for (Shape shape : shapes) { + bounds.put(shape, shape.getBounds()); + } + return bounds; + } + + private boolean boundsChanged(Collection shapes, Map before) { + for (Shape shape : shapes) { + Rectangle previous = before.get(shape); + if (previous != null && !previous.equals(shape.getBounds())) { + return true; + } + } + return false; + } + + private List flattenSelection(Collection selectedShapes) { + LinkedHashSet flattened = new LinkedHashSet<>(); + for (Shape shape : selectedShapes) { + flattenShape(shape, flattened); + } + return new ArrayList<>(flattened); + } + + private void flattenShape(Shape shape, Collection flattened) { + if (shape instanceof SCollection collection) { + collection.forEach(child -> flattenShape(child, flattened)); + return; + } + flattened.add(shape); + } + + private ColorAttributes currentColor(Shape shape) { + return (ColorAttributes) shape.getAttributes(ColorAttributes.ID); + } + + private ColorAttributes copy(ColorAttributes attrs) { + return new ColorAttributes(attrs.filled, attrs.stroked, attrs.filledColor, attrs.strokedColor); + } + + private boolean sameColor(ColorAttributes left, ColorAttributes right) { + return left.filled == right.filled + && left.stroked == right.stroked + && left.filledColor.equals(right.filledColor) + && left.strokedColor.equals(right.strokedColor); + } + + private Color randomColor() { + return new Color((int) (Math.random() * 0x1000000)); + } } diff --git a/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java b/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java index 766531d..549667b 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java +++ b/src/main/java/ovh/gasser/newshapes/ui/ShapeDraftman.java @@ -117,6 +117,30 @@ public class ShapeDraftman implements ShapeVisitor { drawHandlerIfSelected(text); } + @Override + public void visitPolygon(SPolygon polygon) { + ColorAttributes colAttrs = (ColorAttributes) polygon.getAttributes(ColorAttributes.ID); + if (colAttrs == null) { + colAttrs = DEFAULT_COLOR_ATTRIBUTES; + } + + java.util.List points = polygon.getPoints(); + int[] xPoints = points.stream().mapToInt(p -> p.x).toArray(); + int[] yPoints = points.stream().mapToInt(p -> p.y).toArray(); + int nPoints = points.size(); + + if (colAttrs.filled) { + this.g2d.setColor(colAttrs.filledColor); + this.g2d.fillPolygon(xPoints, yPoints, nPoints); + } + if (colAttrs.stroked) { + this.g2d.setColor(colAttrs.strokedColor); + this.g2d.drawPolygon(xPoints, yPoints, nPoints); + } + + drawHandlerIfSelected(polygon); + } + private Color resolveTextColor(ColorAttributes attrs) { if (attrs == null) { return Color.BLACK; diff --git a/src/main/java/ovh/gasser/newshapes/ui/ShapesView.java b/src/main/java/ovh/gasser/newshapes/ui/ShapesView.java index f76715a..58e925c 100644 --- a/src/main/java/ovh/gasser/newshapes/ui/ShapesView.java +++ b/src/main/java/ovh/gasser/newshapes/ui/ShapesView.java @@ -16,8 +16,12 @@ public class ShapesView extends JPanel { private Rectangle currentSelectionBox; public ShapesView(SCollection model) { + this(model, () -> { }); + } + + public ShapesView(SCollection model, Runnable onModelChanged) { this.model = model; - this.controller = new Controller(this, model); + this.controller = new Controller(this, model, onModelChanged); } @Override diff --git a/src/test/java/ovh/gasser/newshapes/ui/BoxSelectionTest.java b/src/test/java/ovh/gasser/newshapes/ui/BoxSelectionTest.java new file mode 100644 index 0000000..fde233a --- /dev/null +++ b/src/test/java/ovh/gasser/newshapes/ui/BoxSelectionTest.java @@ -0,0 +1,128 @@ +package ovh.gasser.newshapes.ui; + +import org.junit.jupiter.api.Test; +import ovh.gasser.newshapes.attributes.SelectionAttributes; +import ovh.gasser.newshapes.shapes.SCollection; +import ovh.gasser.newshapes.shapes.SRectangle; +import ovh.gasser.newshapes.shapes.Shape; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.InputEvent; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.lang.reflect.InvocationTargetException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BoxSelectionTest { + + @Test + void draggingSelectionBoxSelectsAllIntersectingShapes() throws Exception { + SRectangle first = SRectangle.create(20, 20, 30, 30); + SRectangle second = SRectangle.create(80, 80, 30, 30); + SRectangle outside = SRectangle.create(160, 160, 30, 30); + ShapesView view = createView(first, second, outside); + + dragSelection(view, new Point(5, 5), new Point(100, 100), 0); + + assertTrue(isSelected(first)); + assertTrue(isSelected(second)); + assertFalse(isSelected(outside)); + } + + @Test + void shiftDraggingSelectionBoxAddsToExistingSelection() throws Exception { + SRectangle first = SRectangle.create(10, 10, 30, 30); + SRectangle second = SRectangle.create(80, 10, 30, 30); + SRectangle outside = SRectangle.create(160, 10, 30, 30); + ShapesView view = createView(first, second, outside); + + click(view, new Point(20, 20), 0); + dragSelection(view, new Point(70, 5), new Point(120, 50), InputEvent.SHIFT_DOWN_MASK); + + assertTrue(isSelected(first)); + assertTrue(isSelected(second)); + assertFalse(isSelected(outside)); + } + + @Test + void shapesViewPaintsSelectionBoxDuringRendering() throws Exception { + ShapesView view = createView(); + + BufferedImage withoutSelectionBox = paintView(view, null); + BufferedImage withSelectionBox = paintView(view, new Rectangle(10, 10, 40, 40)); + + assertTrue(imagesDiffer(withoutSelectionBox, withSelectionBox)); + } + + private ShapesView createView(Shape... shapes) throws InvocationTargetException, InterruptedException { + final ShapesView[] ref = new ShapesView[1]; + SwingUtilities.invokeAndWait(() -> { + ref[0] = new ShapesView(SCollection.of(shapes)); + ref[0].setOpaque(true); + ref[0].setBackground(Color.WHITE); + ref[0].setSize(240, 240); + }); + return ref[0]; + } + + private void click(ShapesView view, Point point, int modifiers) throws InvocationTargetException, InterruptedException { + dispatch(view, MouseEvent.MOUSE_PRESSED, point, modifiers); + dispatch(view, MouseEvent.MOUSE_RELEASED, point, modifiers); + } + + private void dragSelection(ShapesView view, Point start, Point end, int modifiers) throws InvocationTargetException, InterruptedException { + dispatch(view, MouseEvent.MOUSE_PRESSED, start, modifiers); + dispatch(view, MouseEvent.MOUSE_DRAGGED, end, modifiers); + dispatch(view, MouseEvent.MOUSE_RELEASED, end, modifiers); + } + + private void dispatch(ShapesView view, int eventId, Point point, int modifiers) + throws InvocationTargetException, InterruptedException { + SwingUtilities.invokeAndWait(() -> view.dispatchEvent(new MouseEvent( + view, + eventId, + System.currentTimeMillis(), + modifiers, + point.x, + point.y, + 1, + false, + MouseEvent.BUTTON1 + ))); + } + + private BufferedImage paintView(ShapesView view, Rectangle box) + throws InvocationTargetException, InterruptedException { + final BufferedImage[] ref = new BufferedImage[1]; + SwingUtilities.invokeAndWait(() -> { + view.setCurrentSelectionBox(box); + BufferedImage image = new BufferedImage(240, 240, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = image.createGraphics(); + try { + view.paint(graphics); + } finally { + graphics.dispose(); + } + ref[0] = image; + }); + return ref[0]; + } + + private boolean imagesDiffer(BufferedImage first, BufferedImage second) { + for (int y = 0; y < first.getHeight(); y++) { + for (int x = 0; x < first.getWidth(); x++) { + if (first.getRGB(x, y) != second.getRGB(x, y)) { + return true; + } + } + } + return false; + } + + private boolean isSelected(Shape shape) { + return ((SelectionAttributes) shape.getAttributes(SelectionAttributes.ID)).selected; + } +}