From fdcd2b187315d47f682e8973bfcc6fd5f359cd74 Mon Sep 17 00:00:00 2001 From: Thibaud Date: Sat, 28 Mar 2026 00:55:53 +0100 Subject: [PATCH] Implement #34: Undo/Redo Stack with Command Pattern --- new-shapes.wiki | 1 + src/main/java/ovh/gasser/newshapes/App.java | 66 ++ .../newshapes/command/AddShapeCommand.java | 54 ++ .../newshapes/command/ChangeColorCommand.java | 44 ++ .../ovh/gasser/newshapes/command/Command.java | 11 + .../newshapes/command/CommandHistory.java | 85 +++ .../command/CommandHistoryListener.java | 5 + .../newshapes/command/MoveShapeCommand.java | 28 + .../newshapes/command/RemoveShapeCommand.java | 49 ++ .../newshapes/command/ResizeShapeCommand.java | 51 ++ .../ovh/gasser/newshapes/ui/Controller.java | 630 ++++++++++++++---- 11 files changed, 900 insertions(+), 124 deletions(-) create mode 160000 new-shapes.wiki create mode 100644 src/main/java/ovh/gasser/newshapes/command/AddShapeCommand.java create mode 100644 src/main/java/ovh/gasser/newshapes/command/ChangeColorCommand.java create mode 100644 src/main/java/ovh/gasser/newshapes/command/Command.java create mode 100644 src/main/java/ovh/gasser/newshapes/command/CommandHistory.java create mode 100644 src/main/java/ovh/gasser/newshapes/command/CommandHistoryListener.java create mode 100644 src/main/java/ovh/gasser/newshapes/command/MoveShapeCommand.java create mode 100644 src/main/java/ovh/gasser/newshapes/command/RemoveShapeCommand.java create mode 100644 src/main/java/ovh/gasser/newshapes/command/ResizeShapeCommand.java 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/App.java b/src/main/java/ovh/gasser/newshapes/App.java index 1ce223d..8674dba 100644 --- a/src/main/java/ovh/gasser/newshapes/App.java +++ b/src/main/java/ovh/gasser/newshapes/App.java @@ -11,6 +11,8 @@ import ovh.gasser.newshapes.ui.listeners.MenuEditListener; import javax.swing.*; import java.awt.*; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; import java.io.FileNotFoundException; public class App { @@ -19,6 +21,8 @@ public class App { private SCollection model; private JCheckBoxMenuItem editFill; private JCheckBoxMenuItem editBorder; + private JMenuItem editGroup; + private JMenuItem editUngroup; private App() throws HeadlessException { final JFrame frame = new JFrame("Reactive shapes"); @@ -71,12 +75,38 @@ public class App { private JMenu buildFileMenu(ShapesView sview) { JMenu menuFile = new JMenu("File"); + JMenuItem openItem = new JMenuItem("Open"); + JMenuItem saveItem = new JMenuItem("Save"); JMenuItem addRectItem = new JMenuItem("Add SRectangle"); JMenuItem addCircleItem = new JMenuItem("Add SCircle"); JMenuItem addTextItem = new JMenuItem("Add Text"); JMenuItem htmlExportItem = new JMenuItem("Export to HTML"); JMenuItem svgExportItem = new JMenuItem("Export to SVG"); JMenuItem exitItem = new JMenuItem("Exit"); + + openItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, InputEvent.CTRL_DOWN_MASK)); + saveItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK)); + + openItem.addActionListener(evt -> { + JFileChooser chooser = new JFileChooser(); + chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("JSON Files", "json")); + if (chooser.showOpenDialog(sview) == JFileChooser.APPROVE_OPTION) { + sview.getController().loadDrawing(chooser.getSelectedFile()); + } + }); + + saveItem.addActionListener(evt -> { + JFileChooser chooser = new JFileChooser(); + chooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("JSON Files", "json")); + if (chooser.showSaveDialog(sview) == JFileChooser.APPROVE_OPTION) { + java.io.File file = chooser.getSelectedFile(); + if (!file.getName().endsWith(".json")) { + file = new java.io.File(file.getAbsolutePath() + ".json"); + } + sview.getController().saveDrawing(file); + } + }); + addRectItem.addActionListener(new MenuAddListener("SRectangle", model, sview)); addCircleItem.addActionListener(new MenuAddListener("SCircle", model, sview)); addTextItem.addActionListener(evt -> sview.getController().enterTextMode()); @@ -95,6 +125,10 @@ public class App { } }); exitItem.addActionListener(evt -> System.exit(0)); + + menuFile.add(openItem); + menuFile.add(saveItem); + menuFile.addSeparator(); menuFile.add(addRectItem); menuFile.add(addCircleItem); menuFile.add(addTextItem); @@ -108,32 +142,62 @@ public class App { private JMenu buildEditMenu(ShapesView sview) { MenuEditListener editListener = new MenuEditListener(model, sview, sview.getController()); JMenu menuEdit = new JMenu("Edit"); + JMenuItem cutItem = new JMenuItem("Cut"); + JMenuItem copyItem = new JMenuItem("Copy"); + JMenuItem pasteItem = new JMenuItem("Paste"); JMenuItem editColor = new JMenuItem("Change color"); JMenuItem editBorderColor = new JMenuItem("Change border color"); JMenuItem deleteItem = new JMenuItem("Delete"); + editGroup = new JMenuItem("Group"); + editUngroup = new JMenuItem("Ungroup"); editFill = new JCheckBoxMenuItem("Fill Shape"); editBorder = new JCheckBoxMenuItem("Draw border"); + + cutItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_X, InputEvent.CTRL_DOWN_MASK)); + copyItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.CTRL_DOWN_MASK)); + pasteItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_V, InputEvent.CTRL_DOWN_MASK)); + editGroup.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_G, InputEvent.CTRL_DOWN_MASK)); + editUngroup.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_G, InputEvent.CTRL_DOWN_MASK | InputEvent.SHIFT_DOWN_MASK)); + + cutItem.addActionListener(evt -> sview.getController().cutSelection()); + copyItem.addActionListener(evt -> sview.getController().copySelection()); + pasteItem.addActionListener(evt -> sview.getController().pasteClipboard()); editColor.addActionListener(editListener); editBorderColor.addActionListener(editListener); deleteItem.addActionListener(editListener); + editGroup.addActionListener(evt -> sview.getController().group()); + editUngroup.addActionListener(evt -> sview.getController().ungroup()); editFill.addActionListener(editListener); editBorder.addActionListener(editListener); + editGroup.setEnabled(false); + editUngroup.setEnabled(false); + menuEdit.add(cutItem); + menuEdit.add(copyItem); + menuEdit.add(pasteItem); + menuEdit.addSeparator(); menuEdit.add(editColor); menuEdit.add(editBorderColor); menuEdit.add(deleteItem); menuEdit.addSeparator(); + menuEdit.add(editGroup); + menuEdit.add(editUngroup); + menuEdit.addSeparator(); menuEdit.add(editBorder); menuEdit.add(editFill); return menuEdit; } private void updateMenuState(Iterable selectedShapes) { + int selectionCount = 0; + boolean singleCollectionSelected = false; boolean hasToggleableShapes = false; boolean allFilled = true; boolean allStroked = true; for (Shape s : selectedShapes) { + selectionCount++; + singleCollectionSelected = selectionCount == 1 && s instanceof SCollection; if (s instanceof SText) { continue; } @@ -145,6 +209,8 @@ public class App { } } + editGroup.setEnabled(selectionCount > 1); + editUngroup.setEnabled(selectionCount == 1 && singleCollectionSelected); updateMenuItem(editFill, hasToggleableShapes, allFilled); updateMenuItem(editBorder, hasToggleableShapes, allStroked); } diff --git a/src/main/java/ovh/gasser/newshapes/command/AddShapeCommand.java b/src/main/java/ovh/gasser/newshapes/command/AddShapeCommand.java new file mode 100644 index 0000000..01bbf57 --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/command/AddShapeCommand.java @@ -0,0 +1,54 @@ +package ovh.gasser.newshapes.command; + +import ovh.gasser.newshapes.shapes.SCollection; +import ovh.gasser.newshapes.shapes.Shape; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +public class AddShapeCommand implements Command { + private final SCollection model; + private final List shapes; + + public AddShapeCommand(SCollection model, Shape shape) { + this(model, List.of(shape)); + } + + public AddShapeCommand(SCollection model, Collection shapes) { + this.model = model; + int baseIndex = model.size(); + List indexedShapes = new ArrayList<>(); + int offset = 0; + for (Shape shape : shapes) { + indexedShapes.add(new IndexedShape(baseIndex + offset, shape)); + offset++; + } + this.shapes = List.copyOf(indexedShapes); + } + + @Override + public void execute() { + shapes.stream() + .sorted(Comparator.comparingInt(IndexedShape::index)) + .forEach(entry -> { + if (!model.contains(entry.shape())) { + model.insert(entry.index(), entry.shape()); + } + }); + } + + @Override + public void undo() { + List reversed = new ArrayList<>(shapes); + reversed.sort(Comparator.comparingInt(IndexedShape::index).reversed()); + reversed.forEach(entry -> { + if (model.contains(entry.shape())) { + model.remove(entry.shape()); + } + }); + } + + private record IndexedShape(int index, Shape shape) {} +} diff --git a/src/main/java/ovh/gasser/newshapes/command/ChangeColorCommand.java b/src/main/java/ovh/gasser/newshapes/command/ChangeColorCommand.java new file mode 100644 index 0000000..23b3b1a --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/command/ChangeColorCommand.java @@ -0,0 +1,44 @@ +package ovh.gasser.newshapes.command; + +import ovh.gasser.newshapes.attributes.ColorAttributes; +import ovh.gasser.newshapes.shapes.Shape; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class ChangeColorCommand implements Command { + private final List before; + private final List after; + + public ChangeColorCommand(Collection shapes, Map before, Map after) { + this.before = snapshots(shapes, before); + this.after = snapshots(shapes, after); + } + + @Override + public void execute() { + apply(after); + } + + @Override + public void undo() { + apply(before); + } + + private static List snapshots(Collection shapes, Map colors) { + return shapes.stream() + .map(shape -> new ShapeColor(shape, copy(colors.get(shape)))) + .toList(); + } + + private void apply(List colors) { + colors.forEach(shapeColor -> shapeColor.shape().addAttributes(shapeColor.attributes())); + } + + private static ColorAttributes copy(ColorAttributes attrs) { + return new ColorAttributes(attrs.filled, attrs.stroked, attrs.filledColor, attrs.strokedColor); + } + + private record ShapeColor(Shape shape, ColorAttributes attributes) {} +} diff --git a/src/main/java/ovh/gasser/newshapes/command/Command.java b/src/main/java/ovh/gasser/newshapes/command/Command.java new file mode 100644 index 0000000..79c4da9 --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/command/Command.java @@ -0,0 +1,11 @@ +package ovh.gasser.newshapes.command; + +public interface Command { + void execute(); + + void undo(); + + default void redo() { + execute(); + } +} diff --git a/src/main/java/ovh/gasser/newshapes/command/CommandHistory.java b/src/main/java/ovh/gasser/newshapes/command/CommandHistory.java new file mode 100644 index 0000000..044d9aa --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/command/CommandHistory.java @@ -0,0 +1,85 @@ +package ovh.gasser.newshapes.command; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +public class CommandHistory { + public static final int DEFAULT_LIMIT = 100; + + private final Deque undoStack = new ArrayDeque<>(); + private final Deque redoStack = new ArrayDeque<>(); + private final List listeners = new ArrayList<>(); + private final int limit; + + public CommandHistory() { + this(DEFAULT_LIMIT); + } + + public CommandHistory(int limit) { + this.limit = Math.max(1, limit); + } + + public void execute(Command command) { + command.execute(); + undoStack.push(command); + trimUndoStack(); + redoStack.clear(); + notifyListeners(); + } + + public void undo() { + if (!canUndo()) { + return; + } + + Command command = undoStack.pop(); + command.undo(); + redoStack.push(command); + notifyListeners(); + } + + public void redo() { + if (!canRedo()) { + return; + } + + Command command = redoStack.pop(); + command.redo(); + undoStack.push(command); + trimUndoStack(); + notifyListeners(); + } + + public boolean canUndo() { + return !undoStack.isEmpty(); + } + + public boolean canRedo() { + return !redoStack.isEmpty(); + } + + public void clear() { + undoStack.clear(); + redoStack.clear(); + notifyListeners(); + } + + public void addListener(CommandHistoryListener listener) { + listeners.add(listener); + listener.onHistoryChanged(canUndo(), canRedo()); + } + + private void trimUndoStack() { + while (undoStack.size() > limit) { + undoStack.removeLast(); + } + } + + private void notifyListeners() { + boolean canUndo = canUndo(); + boolean canRedo = canRedo(); + listeners.forEach(listener -> listener.onHistoryChanged(canUndo, canRedo)); + } +} diff --git a/src/main/java/ovh/gasser/newshapes/command/CommandHistoryListener.java b/src/main/java/ovh/gasser/newshapes/command/CommandHistoryListener.java new file mode 100644 index 0000000..c23a3ca --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/command/CommandHistoryListener.java @@ -0,0 +1,5 @@ +package ovh.gasser.newshapes.command; + +public interface CommandHistoryListener { + void onHistoryChanged(boolean canUndo, boolean canRedo); +} diff --git a/src/main/java/ovh/gasser/newshapes/command/MoveShapeCommand.java b/src/main/java/ovh/gasser/newshapes/command/MoveShapeCommand.java new file mode 100644 index 0000000..bc3530f --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/command/MoveShapeCommand.java @@ -0,0 +1,28 @@ +package ovh.gasser.newshapes.command; + +import ovh.gasser.newshapes.shapes.Shape; + +import java.util.Collection; +import java.util.List; + +public class MoveShapeCommand implements Command { + private final List shapes; + private final int dx; + private final int dy; + + public MoveShapeCommand(Collection shapes, int dx, int dy) { + this.shapes = List.copyOf(shapes); + this.dx = dx; + this.dy = dy; + } + + @Override + public void execute() { + shapes.forEach(shape -> shape.translate(dx, dy)); + } + + @Override + public void undo() { + shapes.forEach(shape -> shape.translate(-dx, -dy)); + } +} diff --git a/src/main/java/ovh/gasser/newshapes/command/RemoveShapeCommand.java b/src/main/java/ovh/gasser/newshapes/command/RemoveShapeCommand.java new file mode 100644 index 0000000..764d3f1 --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/command/RemoveShapeCommand.java @@ -0,0 +1,49 @@ +package ovh.gasser.newshapes.command; + +import ovh.gasser.newshapes.shapes.SCollection; +import ovh.gasser.newshapes.shapes.Shape; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +public class RemoveShapeCommand implements Command { + private final SCollection model; + private final List shapes; + + public RemoveShapeCommand(SCollection model, Shape shape) { + this(model, List.of(shape)); + } + + public RemoveShapeCommand(SCollection model, Collection shapes) { + this.model = model; + this.shapes = shapes.stream() + .map(shape -> new IndexedShape(model.indexOf(shape), shape)) + .filter(entry -> entry.index() >= 0) + .sorted(Comparator.comparingInt(IndexedShape::index)) + .toList(); + } + + @Override + public void execute() { + List reversed = new ArrayList<>(shapes); + reversed.sort(Comparator.comparingInt(IndexedShape::index).reversed()); + reversed.forEach(entry -> { + if (model.contains(entry.shape())) { + model.remove(entry.shape()); + } + }); + } + + @Override + public void undo() { + shapes.forEach(entry -> { + if (!model.contains(entry.shape())) { + model.insert(entry.index(), entry.shape()); + } + }); + } + + private record IndexedShape(int index, Shape shape) {} +} diff --git a/src/main/java/ovh/gasser/newshapes/command/ResizeShapeCommand.java b/src/main/java/ovh/gasser/newshapes/command/ResizeShapeCommand.java new file mode 100644 index 0000000..ad42e50 --- /dev/null +++ b/src/main/java/ovh/gasser/newshapes/command/ResizeShapeCommand.java @@ -0,0 +1,51 @@ +package ovh.gasser.newshapes.command; + +import ovh.gasser.newshapes.shapes.AbstractShape; +import ovh.gasser.newshapes.shapes.Shape; + +import java.awt.*; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +public class ResizeShapeCommand implements Command { + private final List before; + private final List after; + + public ResizeShapeCommand(Collection shapes, Map before, Map after) { + this.before = snapshots(shapes, before); + this.after = snapshots(shapes, after); + } + + @Override + public void execute() { + apply(after); + } + + @Override + public void undo() { + apply(before); + } + + private static List snapshots(Collection shapes, Map states) { + return shapes.stream() + .map(shape -> new ShapeBounds(shape, states.get(shape))) + .toList(); + } + + private void apply(List states) { + states.forEach(state -> { + if (!(state.shape() instanceof AbstractShape abstractShape)) { + throw new IllegalArgumentException("Resize commands support AbstractShape instances only"); + } + abstractShape.setBounds(state.bounds()); + }); + } + + private record ShapeBounds(Shape shape, Rectangle bounds) { + private ShapeBounds(Shape shape, Rectangle bounds) { + this.shape = shape; + this.bounds = new Rectangle(bounds); + } + } +} 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)); + } }