diff --git a/.github/workflows/ci-align.yml b/.github/workflows/ci-align.yml new file mode 100644 index 000000000..6cf48f291 --- /dev/null +++ b/.github/workflows/ci-align.yml @@ -0,0 +1,38 @@ +# Continuous integration for the align and distribute feature. +# +# Continuous integration means every change is built and tested automatically +# when it is proposed, so problems show up at the moment of the pull request +# instead of at merge time. This workflow builds the whole multi-module project +# and runs the tests on every pull request and on pushes to my feature branches. +# +# The file is named ci-align.yml on purpose. A teammate already added a ci.yml on +# another branch, so a separate name lets both pipelines run side by side and +# avoids a conflict on the workflow file when the branches merge. +name: CI Align and Distribute + +on: + pull_request: + push: + branches: + - "phongsakon/**" + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Check out the code + uses: actions/checkout@v4 + + # The project compiles at Java 1.8 source level but is built with JDK 11, + # so the runner uses Temurin 11 to match the local toolchain. + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "11" + cache: maven + + # A clean build of every module. Tests run by default, which is the point + # of the pipeline, so there is no -DskipTests here. + - name: Build and test with Maven + run: mvn --batch-mode --no-transfer-progress clean install diff --git a/jhotdraw-core/pom.xml b/jhotdraw-core/pom.xml index 7c276da85..3f8182570 100644 --- a/jhotdraw-core/pom.xml +++ b/jhotdraw-core/pom.xml @@ -35,6 +35,33 @@ 6.8.21 test + + + junit + junit + 4.13.2 + test + + + + com.tngtech.jgiven + jgiven-junit + 1.3.1 + test + + + org.assertj + assertj-core + 3.24.2 + test + ${project.groupId} jhotdraw-actions diff --git a/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/AlignAction.java b/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/AlignAction.java index 9b682f099..b9d15ac75 100644 --- a/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/AlignAction.java +++ b/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/AlignAction.java @@ -16,9 +16,29 @@ import org.jhotdraw.util.ResourceBundleUtil; /** - * Aligns the selected figures. + * Aligns the selected figures to a shared edge or centre line. * - * XXX - Fire edit events + *

This is the controller for the align feature. When the user presses an + * align button, Swing calls {@link #actionPerformed}.

+ * + *

What I changed while refactoring. Originally each of the six directions was + * a subclass that overrode the alignment method with the same loop, and only the + * line that computed the movement differed. I lifted that shared loop up into one + * method here, {@link #alignFigures}. This is the Form Template Method + * refactoring, the base class holds the skeleton of the algorithm and the part + * that varies is delegated out, in this case to the {@link Alignment} enum. The + * user sees no difference, which is the defining rule of a refactoring, so the + * existing callers in ButtonFactory and AlignToolBar still work unchanged.

+ * + *

Java notes to self.

+ * * * @author Werner Randelshofer * @version $Id$ @@ -26,17 +46,28 @@ public abstract class AlignAction extends AbstractSelectedAction { private static final long serialVersionUID = 1L; + // Which direction this action aligns to. "final" means it is set once in the + // constructor and can never change afterwards, which is what we want here. + private final Alignment alignment; + // The label texts and icons, looked up by key from a resource file. This is + // how JHotDraw supports several languages. protected ResourceBundleUtil labels = ResourceBundleUtil.getBundle("org.jhotdraw.draw.Labels"); /** - * Creates a new instance. + * Creates an align action for the given direction. The subclasses below call + * this with their own Alignment value. */ - public AlignAction(DrawingEditor editor) { + public AlignAction(DrawingEditor editor, Alignment alignment) { super(editor); + this.alignment = alignment; updateEnabledState(); } + /** + * Decides when the button is clickable. Aligning needs at least two figures, + * because a single figure has nothing to line up against. + */ @Override public void updateEnabledState() { if (getView() != null) { @@ -47,6 +78,11 @@ public void updateEnabledState() { } } + /** + * Runs when the user clicks the button. The two fireUndoableEditHappened + * calls wrap the work in a single CompositeEdit, so one press of undo reverts + * the whole alignment rather than moving figures back one at a time. + */ @Override public void actionPerformed(java.awt.event.ActionEvent e) { CompositeEdit edit = new CompositeEdit(labels.getString("edit.align.text")); @@ -55,10 +91,39 @@ public void actionPerformed(java.awt.event.ActionEvent e) { fireUndoableEditHappened(edit); } - protected abstract void alignFigures(Collection
selectedFigures, Rectangle2D.Double selectionBounds); + /** + * The template method, the loop every direction shares. + * + *

It walks the selected figures, asks the {@link Alignment} strategy how + * far each one must move, and applies that move. The direction specific + * decision is the single call to {@code alignment.delta(...)}, so no subclass + * has to repeat the loop.

+ * + *

Step by step for each figure. {@code isTransformable} skips locked + * figures. {@code willChange} tells the figure a change is starting so it can + * prepare to repaint. An AffineTransform is Java's way to describe a move, and + * here we use only its translate part. {@code transform} applies it, + * {@code changed} signals the change is done, and the TransformEdit records + * the move so it can be undone.

+ */ + protected void alignFigures(Collection
selectedFigures, Rectangle2D.Double selectionBounds) { + for (Figure f : getView().getSelectedFigures()) { + if (f.isTransformable()) { + f.willChange(); + Rectangle2D.Double b = f.getBounds(); + Point2D.Double d = alignment.delta(b, selectionBounds); + AffineTransform tx = new AffineTransform(); + tx.translate(d.x, d.y); + f.transform(tx); + f.changed(); + fireUndoableEditHappened(new TransformEdit(f, tx)); + } + } + } /** - * Returns the bounds of the selected figures. + * Returns the smallest rectangle that contains all the selected figures. + * Every direction lines up against the edges of this combined box. */ protected Rectangle2D.Double getSelectionBounds() { Rectangle2D.Double bounds = null; @@ -66,41 +131,29 @@ protected Rectangle2D.Double getSelectionBounds() { if (bounds == null) { bounds = f.getBounds(); } else { + // add() grows the rectangle so it also covers this figure. bounds.add(f.getBounds()); } } return bounds; } + // The six concrete actions. Each one only has to remember which Alignment it + // is and set up its label. All the real work lives in the base class above. + public static class North extends AlignAction { private static final long serialVersionUID = 1L; public North(DrawingEditor editor) { - super(editor); + super(editor, Alignment.NORTH); labels.configureAction(this, "edit.alignNorth"); } public North(DrawingEditor editor, ResourceBundleUtil labels) { - super(editor); + super(editor, Alignment.NORTH); labels.configureAction(this, "edit.alignNorth"); } - - @Override - protected void alignFigures(Collection
selectedFigures, Rectangle2D.Double selectionBounds) { - double y = selectionBounds.y; - for (Figure f : getView().getSelectedFigures()) { - if (f.isTransformable()) { - f.willChange(); - Rectangle2D.Double b = f.getBounds(); - AffineTransform tx = new AffineTransform(); - tx.translate(0, y - b.y); - f.transform(tx); - f.changed(); - fireUndoableEditHappened(new TransformEdit(f, tx)); - } - } - } } public static class East extends AlignAction { @@ -108,30 +161,14 @@ public static class East extends AlignAction { private static final long serialVersionUID = 1L; public East(DrawingEditor editor) { - super(editor); + super(editor, Alignment.EAST); labels.configureAction(this, "edit.alignEast"); } public East(DrawingEditor editor, ResourceBundleUtil labels) { - super(editor); + super(editor, Alignment.EAST); labels.configureAction(this, "edit.alignEast"); } - - @Override - protected void alignFigures(Collection
selectedFigures, Rectangle2D.Double selectionBounds) { - double x = selectionBounds.x + selectionBounds.width; - for (Figure f : getView().getSelectedFigures()) { - if (f.isTransformable()) { - f.willChange(); - Rectangle2D.Double b = f.getBounds(); - AffineTransform tx = new AffineTransform(); - tx.translate(x - b.x - b.width, 0); - f.transform(tx); - f.changed(); - fireUndoableEditHappened(new TransformEdit(f, tx)); - } - } - } } public static class West extends AlignAction { @@ -139,30 +176,14 @@ public static class West extends AlignAction { private static final long serialVersionUID = 1L; public West(DrawingEditor editor) { - super(editor); + super(editor, Alignment.WEST); labels.configureAction(this, "edit.alignWest"); } public West(DrawingEditor editor, ResourceBundleUtil labels) { - super(editor); + super(editor, Alignment.WEST); labels.configureAction(this, "edit.alignWest"); } - - @Override - protected void alignFigures(Collection
selectedFigures, Rectangle2D.Double selectionBounds) { - double x = selectionBounds.x; - for (Figure f : getView().getSelectedFigures()) { - if (f.isTransformable()) { - f.willChange(); - Rectangle2D.Double b = f.getBounds(); - AffineTransform tx = new AffineTransform(); - tx.translate(x - b.x, 0); - f.transform(tx); - f.changed(); - fireUndoableEditHappened(new TransformEdit(f, tx)); - } - } - } } public static class South extends AlignAction { @@ -170,30 +191,14 @@ public static class South extends AlignAction { private static final long serialVersionUID = 1L; public South(DrawingEditor editor) { - super(editor); + super(editor, Alignment.SOUTH); labels.configureAction(this, "edit.alignSouth"); } public South(DrawingEditor editor, ResourceBundleUtil labels) { - super(editor); + super(editor, Alignment.SOUTH); labels.configureAction(this, "edit.alignSouth"); } - - @Override - protected void alignFigures(Collection
selectedFigures, Rectangle2D.Double selectionBounds) { - double y = selectionBounds.y + selectionBounds.height; - for (Figure f : getView().getSelectedFigures()) { - if (f.isTransformable()) { - f.willChange(); - Rectangle2D.Double b = f.getBounds(); - AffineTransform tx = new AffineTransform(); - tx.translate(0, y - b.y - b.height); - f.transform(tx); - f.changed(); - fireUndoableEditHappened(new TransformEdit(f, tx)); - } - } - } } public static class Vertical extends AlignAction { @@ -201,30 +206,14 @@ public static class Vertical extends AlignAction { private static final long serialVersionUID = 1L; public Vertical(DrawingEditor editor) { - super(editor); + super(editor, Alignment.VERTICAL); labels.configureAction(this, "edit.alignVertical"); } public Vertical(DrawingEditor editor, ResourceBundleUtil labels) { - super(editor); + super(editor, Alignment.VERTICAL); labels.configureAction(this, "edit.alignVertical"); } - - @Override - protected void alignFigures(Collection
selectedFigures, Rectangle2D.Double selectionBounds) { - double y = selectionBounds.y + selectionBounds.height / 2; - for (Figure f : getView().getSelectedFigures()) { - if (f.isTransformable()) { - f.willChange(); - Rectangle2D.Double b = f.getBounds(); - AffineTransform tx = new AffineTransform(); - tx.translate(0, y - b.y - b.height / 2); - f.transform(tx); - f.changed(); - fireUndoableEditHappened(new TransformEdit(f, tx)); - } - } - } } public static class Horizontal extends AlignAction { @@ -232,29 +221,13 @@ public static class Horizontal extends AlignAction { private static final long serialVersionUID = 1L; public Horizontal(DrawingEditor editor) { - super(editor); + super(editor, Alignment.HORIZONTAL); labels.configureAction(this, "edit.alignHorizontal"); } public Horizontal(DrawingEditor editor, ResourceBundleUtil labels) { - super(editor); + super(editor, Alignment.HORIZONTAL); labels.configureAction(this, "edit.alignHorizontal"); } - - @Override - protected void alignFigures(Collection
selectedFigures, Rectangle2D.Double selectionBounds) { - double x = selectionBounds.x + selectionBounds.width / 2; - for (Figure f : getView().getSelectedFigures()) { - if (f.isTransformable()) { - f.willChange(); - Rectangle2D.Double b = f.getBounds(); - AffineTransform tx = new AffineTransform(); - tx.translate(x - b.x - b.width / 2, 0); - f.transform(tx); - f.changed(); - fireUndoableEditHappened(new TransformEdit(f, tx)); - } - } - } } } diff --git a/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/Alignment.java b/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/Alignment.java new file mode 100644 index 000000000..890bd5f03 --- /dev/null +++ b/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/Alignment.java @@ -0,0 +1,109 @@ +/* + * Alignment.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; + +/** + * The six ways the editor can line up a group of selected figures. + * + *

I pulled this out of AlignAction while refactoring. Before, the six + * directions each lived in their own subclass, and every subclass repeated the + * same loop over the figures. The only thing that really differed between them + * was one line of arithmetic, the amount each figure has to move. That + * repetition is the Duplicated Code smell from Kerievsky chapter 4. Moving the + * arithmetic here is the Extract Class step that removes it.

+ * + *

Java note to self. An enum is normally just a fixed list of named values, + * but in Java each value is allowed to carry its own behaviour by overriding a + * method. That is what the constants below do, each one gives its own version of + * delta(). So this single enum acts like a small family of six strategies + * without needing six separate classes.

+ * + *

Everything in here is pure geometry. It only reads rectangles and returns a + * movement vector. There is no Swing and no drawing model, which is exactly what + * makes it easy to unit test on its own.

+ * + *

Coordinate reminder. A Rectangle2D.Double stores x and y as its top left + * corner, plus width and height. Larger y means further down the screen. So the + * top edge of a figure is figure.y and its bottom edge is figure.y + height.

+ */ +public enum Alignment { + + /** Line the figures up on the top edge of the selection. */ + NORTH { + @Override + public Point2D.Double delta(Rectangle2D.Double figure, Rectangle2D.Double selection) { + // Move vertically only. dx is 0. dy lifts the figure's top (figure.y) + // up to the selection's top (selection.y). + return new Point2D.Double(0, selection.y - figure.y); + } + }, + /** Line the figures up on the bottom edge of the selection. */ + SOUTH { + @Override + public Point2D.Double delta(Rectangle2D.Double figure, Rectangle2D.Double selection) { + double targetBottom = selection.y + selection.height; + // The figure's own bottom is figure.y + figure.height. dy is the gap + // between that and the target bottom. + return new Point2D.Double(0, targetBottom - figure.y - figure.height); + } + }, + /** Line the figures up on the left edge of the selection. */ + WEST { + @Override + public Point2D.Double delta(Rectangle2D.Double figure, Rectangle2D.Double selection) { + // Move horizontally only, left edge to left edge. + return new Point2D.Double(selection.x - figure.x, 0); + } + }, + /** Line the figures up on the right edge of the selection. */ + EAST { + @Override + public Point2D.Double delta(Rectangle2D.Double figure, Rectangle2D.Double selection) { + double targetRight = selection.x + selection.width; + return new Point2D.Double(targetRight - figure.x - figure.width, 0); + } + }, + /** Centre the figures on the horizontal middle line of the selection. */ + VERTICAL { + @Override + public Point2D.Double delta(Rectangle2D.Double figure, Rectangle2D.Double selection) { + double targetCentreY = selection.y + selection.height / 2; + // Compare the figure's own centre, not its edge, to the target. + return new Point2D.Double(0, targetCentreY - figure.y - figure.height / 2); + } + }, + /** Centre the figures on the vertical middle line of the selection. */ + HORIZONTAL { + @Override + public Point2D.Double delta(Rectangle2D.Double figure, Rectangle2D.Double selection) { + double targetCentreX = selection.x + selection.width / 2; + return new Point2D.Double(targetCentreX - figure.x - figure.width / 2, 0); + } + }; + + /** + * Works out how far a figure has to move to line up with the selection. + * + *

The result is a vector, given as a Point2D.Double where x is the + * sideways move and y is the up or down move. A zero vector means the figure + * is already in place, so the caller can skip it. Returning a value instead + * of moving the figure directly keeps this method free of side effects, + * which is what lets a test call it and simply check the number.

+ * + *

This is declared abstract, which means the enum itself does not provide + * a body. Each constant above is forced to supply its own. That is the trick + * that lets one method name behave six different ways.

+ * + * @param figure the bounding box of the figure being moved + * @param selection the bounding box of the whole selection + * @return the (dx, dy) movement, never null + */ + public abstract Point2D.Double delta(Rectangle2D.Double figure, Rectangle2D.Double selection); +} diff --git a/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/DistributeAction.java b/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/DistributeAction.java new file mode 100644 index 000000000..7d5f9eaca --- /dev/null +++ b/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/DistributeAction.java @@ -0,0 +1,142 @@ +/* + * DistributeAction.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action; + +import java.awt.geom.AffineTransform; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.List; +import org.jhotdraw.draw.DrawingEditor; +import org.jhotdraw.draw.figure.Figure; +import org.jhotdraw.draw.event.TransformEdit; +import org.jhotdraw.undo.CompositeEdit; +import org.jhotdraw.util.ResourceBundleUtil; + +/** + * Distributes the selected figures so the gaps between them are equal. + * + *

This is the new feature. The change request asked to align and distribute, + * alignment already existed and was tidied up first, so this class adds the + * distribute half on top of the clean structure.

+ * + *

I wrote it to mirror {@link AlignAction} on purpose. It is an + * {@link AbstractSelectedAction}, it wraps its work in a CompositeEdit so the + * whole thing is one undo, and it hands the actual arithmetic to the + * {@link Distribution} enum, exactly the way AlignAction hands its arithmetic to + * Alignment. Reusing that shape is the payoff of the earlier refactoring. The new + * feature drops into a tidy pattern instead of adding another copy of an old + * loop.

+ * + *

This also shows two of the SOLID principles in practice. The action handles + * the editor and undo, the enum handles the geometry, so each has a single + * responsibility. And adding distribution needed no change to AlignAction, which + * is the open closed idea, the code is open to a new operation while the old one + * stays closed to edits.

+ */ +public abstract class DistributeAction extends AbstractSelectedAction { + + private static final long serialVersionUID = 1L; + private final Distribution distribution; + protected ResourceBundleUtil labels + = ResourceBundleUtil.getBundle("org.jhotdraw.draw.Labels"); + + public DistributeAction(DrawingEditor editor, Distribution distribution) { + super(editor); + this.distribution = distribution; + updateEnabledState(); + } + + /** + * Distribution needs at least three figures, two to pin the ends and one in + * the middle to move, so the button stays disabled below that. + */ + @Override + public void updateEnabledState() { + if (getView() != null) { + setEnabled(getView().isEnabled() + && getView().getSelectionCount() > 2); + } else { + setEnabled(false); + } + } + + @Override + public void actionPerformed(java.awt.event.ActionEvent e) { + CompositeEdit edit = new CompositeEdit(labels.getString("edit.distribute.text")); + fireUndoableEditHappened(edit); + distributeFigures(new ArrayList<>(getView().getSelectedFigures())); + fireUndoableEditHappened(edit); + } + + /** + * The template method shared by both directions. + * + *

It asks the {@link Distribution} strategy for the new leading coordinate + * of every figure, then shifts each transformable figure by the difference + * between where it is and where it should be. Only the one axis moves, the + * other shift stays zero, so the figures keep their position on the other + * axis. A shift of zero means the figure is already in place and is skipped.

+ */ + protected void distributeFigures(List
figures) { + List boxes = new ArrayList<>(); + for (Figure f : figures) { + boxes.add(f.getBounds()); + } + double[] targets = distribution.newPositions(boxes); + for (int i = 0; i < figures.size(); i++) { + Figure f = figures.get(i); + if (!f.isTransformable()) { + continue; + } + double current = distribution.position(boxes.get(i)); + double shift = targets[i] - current; + if (shift == 0) { + continue; + } + f.willChange(); + AffineTransform tx = new AffineTransform(); + if (distribution == Distribution.HORIZONTAL) { + tx.translate(shift, 0); + } else { + tx.translate(0, shift); + } + f.transform(tx); + f.changed(); + fireUndoableEditHappened(new TransformEdit(f, tx)); + } + } + + public static class Horizontal extends DistributeAction { + + private static final long serialVersionUID = 1L; + + public Horizontal(DrawingEditor editor) { + super(editor, Distribution.HORIZONTAL); + labels.configureAction(this, "edit.distributeHorizontal"); + } + + public Horizontal(DrawingEditor editor, ResourceBundleUtil labels) { + super(editor, Distribution.HORIZONTAL); + labels.configureAction(this, "edit.distributeHorizontal"); + } + } + + public static class Vertical extends DistributeAction { + + private static final long serialVersionUID = 1L; + + public Vertical(DrawingEditor editor) { + super(editor, Distribution.VERTICAL); + labels.configureAction(this, "edit.distributeVertical"); + } + + public Vertical(DrawingEditor editor, ResourceBundleUtil labels) { + super(editor, Distribution.VERTICAL); + labels.configureAction(this, "edit.distributeVertical"); + } + } +} diff --git a/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/Distribution.java b/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/Distribution.java new file mode 100644 index 000000000..c86b8461e --- /dev/null +++ b/jhotdraw-core/src/main/java/org/jhotdraw/draw/action/Distribution.java @@ -0,0 +1,127 @@ +/* + * Distribution.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action; + +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * The two axes along which the editor can space figures out evenly. + * + *

This is the new half of the feature. It sits next to {@link Alignment} on + * purpose. Alignment moves every figure onto one shared line, while distribution + * spreads the figures so the gaps between neighbours are equal. Both are pure + * geometry over bounding boxes, so neither one touches Swing or the drawing + * model and both stay easy to unit test.

+ * + *

Java note to self. Just like Alignment, this is an enum where each constant + * supplies its own behaviour. The two constants below differ only in which axis + * they read, x and width for horizontal, y and height for vertical. Pulling + * those two readings into {@code position} and {@code size} lets the real + * algorithm in {@code newPositions} be written once for both directions.

+ * + *

The maths runs over the whole selection at once, not one figure at a time, + * because where a figure ends up depends on the order and size of the others. + * The two outermost figures stay where they are and the ones in between are + * spread so the spacing is uniform.

+ */ +public enum Distribution { + + /** Spread figures left to right so the horizontal gaps are equal. */ + HORIZONTAL { + @Override + public double position(Rectangle2D.Double r) { + return r.x; + } + + @Override + public double size(Rectangle2D.Double r) { + return r.width; + } + }, + /** Spread figures top to bottom so the vertical gaps are equal. */ + VERTICAL { + @Override + public double position(Rectangle2D.Double r) { + return r.y; + } + + @Override + public double size(Rectangle2D.Double r) { + return r.height; + } + }; + + /** The leading coordinate of the box on this axis, x or y. */ + public abstract double position(Rectangle2D.Double r); + + /** The extent of the box on this axis, width or height. */ + public abstract double size(Rectangle2D.Double r); + + /** + * Works out a new leading coordinate for every box so the gaps between + * neighbours come out equal. + * + *

The result is a plain array of coordinates in the same order as the + * input list, so the caller can match each new value back to its figure by + * index. Returning numbers rather than moving figures keeps this testable.

+ * + *

Distribution only makes sense with three or more figures, two to pin the + * ends and at least one in the middle to move. With two or fewer the method + * returns the coordinates unchanged. The action enforces the same minimum + * before it even offers the command, so this is just a safety net.

+ * + * @param boxes the bounding boxes of the selected figures, in any order + * @return the new leading coordinate per box, lined up with the input by index + */ + public double[] newPositions(List boxes) { + int n = boxes.size(); + double[] result = new double[n]; + for (int i = 0; i < n; i++) { + result[i] = position(boxes.get(i)); + } + if (n < 3) { + return result; + } + + // Sort a copy of the indices by leading coordinate, so we can walk the + // figures from one end of the selection to the other. We sort indices + // rather than the boxes themselves so the answer still maps back by index. + List order = new ArrayList<>(); + for (int i = 0; i < n; i++) { + order.add(i); + } + order.sort(Comparator.comparingDouble(i -> position(boxes.get(i)))); + + int firstIdx = order.get(0); + int lastIdx = order.get(n - 1); + double start = position(boxes.get(firstIdx)); + double end = position(boxes.get(lastIdx)); + + // Add up the space the figures themselves take, so the space left over + // can be shared out as n-1 equal gaps. + double totalSize = 0; + for (Rectangle2D.Double b : boxes) { + totalSize += size(b); + } + double span = (end + size(boxes.get(lastIdx))) - start; + double gap = (span - totalSize) / (n - 1); + + // Walk the figures in order, placing each one right after the previous + // one plus a single uniform gap. The first and last keep their place. + double cursor = start; + for (int k = 0; k < n; k++) { + int idx = order.get(k); + result[idx] = cursor; + cursor += size(boxes.get(idx)) + gap; + } + result[lastIdx] = end; + return result; + } +} diff --git a/jhotdraw-core/src/main/resources/org/jhotdraw/draw/Labels.properties b/jhotdraw-core/src/main/resources/org/jhotdraw/draw/Labels.properties index 21b22b608..7a74e65df 100644 --- a/jhotdraw-core/src/main/resources/org/jhotdraw/draw/Labels.properties +++ b/jhotdraw-core/src/main/resources/org/jhotdraw/draw/Labels.properties @@ -33,6 +33,16 @@ edit.alignWest.mnemonic= edit.alignWest.text= edit.alignWest.largeIcon=${imageDir}/alignWest.png edit.alignWest.toolTipText=Align Left +# Labels for the new distribute feature. The action reads edit.distribute.text +# for the undo name shown in the menu, and the two distributeHorizontal and +# distributeVertical keys for the buttons. I left the icons out so the buttons +# fall back to their text, which keeps the change small and avoids adding new +# image files to the project. +edit.distribute.text=Distribute +edit.distributeHorizontal.text=Distribute Horizontally +edit.distributeHorizontal.toolTipText=Even horizontal gaps +edit.distributeVertical.text=Distribute Vertically +edit.distributeVertical.toolTipText=Even vertical gaps attribute.opacity.text=Opacity attribute.opacity.toolTipText=Opacity attribute.backgroundColor.toolTipText=Background Color diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/AlignmentTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/AlignmentTest.java new file mode 100644 index 000000000..60a7cb104 --- /dev/null +++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/AlignmentTest.java @@ -0,0 +1,120 @@ +/* + * AlignmentTest.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action; + +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import org.junit.Test; +import static org.junit.Assert.assertEquals; + +/** + * Unit tests for the {@link Alignment} geometry. + * + *

These test the pure part of the align feature, the movement each direction + * works out. This is exactly what the refactoring made possible. Before, the + * same arithmetic was buried inside Swing action subclasses and could only be + * checked by clicking the real buttons. Now it is a plain method from two + * rectangles to a vector, so a test just sets up the input, calls the method, + * and checks the number, with no editor and no mocks.

+ * + *

Java notes to self. The {@code @Test} annotation marks a method JUnit runs. + * {@code assertEquals(expected, actual, EPS)} compares two doubles and the third + * argument is the tolerance, because comparing decimals for exact equality is + * unreliable. The fixture below is two boxes, a fixed selection and a smaller + * figure that moves, and each test comment states the expected delta.

+ */ +public class AlignmentTest { + + private static final double EPS = 1e-9; + + /** Selection bounds: x=0, y=0, width=100, height=100. */ + private Rectangle2D.Double selection() { + return new Rectangle2D.Double(0, 0, 100, 100); + } + + /** A 20 by 20 figure sitting near the middle, at (40, 40). */ + private Rectangle2D.Double figure() { + return new Rectangle2D.Double(40, 40, 20, 20); + } + + // ---- Best case, each direction moves the figure where it should ---- + + @Test + public void northMovesTopEdgeToSelectionTop() { + // top of selection is y=0, figure top is y=40, so dy = -40 and dx = 0 + Point2D.Double d = Alignment.NORTH.delta(figure(), selection()); + assertEquals(0, d.x, EPS); + assertEquals(-40, d.y, EPS); + } + + @Test + public void southMovesBottomEdgeToSelectionBottom() { + // selection bottom is y=100, figure bottom is 40+20=60, so dy = +40 + Point2D.Double d = Alignment.SOUTH.delta(figure(), selection()); + assertEquals(0, d.x, EPS); + assertEquals(40, d.y, EPS); + } + + @Test + public void westMovesLeftEdgeToSelectionLeft() { + // selection left is x=0, figure left is x=40, so dx = -40 + Point2D.Double d = Alignment.WEST.delta(figure(), selection()); + assertEquals(-40, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void eastMovesRightEdgeToSelectionRight() { + // selection right is x=100, figure right is 40+20=60, so dx = +40 + Point2D.Double d = Alignment.EAST.delta(figure(), selection()); + assertEquals(40, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void verticalCentresOnHorizontalMidline() { + // selection mid y = 50, figure centre y = 40 + 10 = 50, already centred + Point2D.Double d = Alignment.VERTICAL.delta(figure(), selection()); + assertEquals(0, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void horizontalCentresOnVerticalMidline() { + // selection mid x = 50, figure centre x = 50, already centred + Point2D.Double d = Alignment.HORIZONTAL.delta(figure(), selection()); + assertEquals(0, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + // ---- Boundary cases ---- + + @Test + public void alreadyAlignedFigureDoesNotMove() { + // A figure already flush with the top should get a zero delta for NORTH. + Rectangle2D.Double flushTop = new Rectangle2D.Double(10, 0, 20, 20); + Point2D.Double d = Alignment.NORTH.delta(flushTop, selection()); + assertEquals(0, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void figureLargerThanSelectionStillProducesConsistentDelta() { + // A figure wider than the selection still lines its left edge up for WEST. + Rectangle2D.Double wide = new Rectangle2D.Double(5, 40, 200, 20); + Point2D.Double d = Alignment.WEST.delta(wide, selection()); + assertEquals(-5, d.x, EPS); + assertEquals(0, d.y, EPS); + } + + @Test + public void deltaIsNeverNull() { + for (Alignment a : Alignment.values()) { + assertEquals(false, a.delta(figure(), selection()) == null); + } + } +} diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/DistributionTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/DistributionTest.java new file mode 100644 index 000000000..b17c612fc --- /dev/null +++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/DistributionTest.java @@ -0,0 +1,109 @@ +/* + * DistributionTest.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action; + +import java.awt.geom.Rectangle2D; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertArrayEquals; + +/** + * Unit tests for the {@link Distribution} geometry. + * + *

Distribution is the new feature, so it gets its own best case and boundary + * tests. The method under test maps a list of boxes to a list of new + * coordinates, so the tests check the returned array directly.

+ * + *

Java note to self. {@code assertArrayEquals(expected, actual, EPS)} checks + * two double arrays element by element within a tolerance. The best case fixes + * three boxes with an uneven gap and checks the middle one moves to even the + * spacing. The boundary cases are the two inputs where distribution must do + * nothing, a selection of two boxes and of one box, because there is no inner + * figure to move.

+ */ +public class DistributionTest { + + private static final double EPS = 1e-9; + + private Rectangle2D.Double box(double x, double y, double w, double h) { + return new Rectangle2D.Double(x, y, w, h); + } + + // ---- Best case ---- + + @Test + public void horizontalEqualisesGapsBetweenThreeBoxes() { + // Three 10 wide boxes. Left at x=0, right at x=100, middle bunched at 20. + // Total width = 30, span = 110, leftover = 80 over 2 gaps = 40 each. + // Positions become 0, then 0+10+40=50, then the end stays at 100. + List boxes = Arrays.asList( + box(0, 0, 10, 10), + box(20, 0, 10, 10), + box(100, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + assertArrayEquals(new double[]{0, 50, 100}, xs, EPS); + } + + @Test + public void verticalEqualisesGapsBetweenThreeBoxes() { + // Same idea on the y axis. + List boxes = Arrays.asList( + box(0, 0, 10, 10), + box(0, 20, 10, 10), + box(0, 100, 10, 10)); + double[] ys = Distribution.VERTICAL.newPositions(boxes); + assertArrayEquals(new double[]{0, 50, 100}, ys, EPS); + } + + @Test + public void distributionKeepsTheOutermostBoxesFixed() { + List boxes = Arrays.asList( + box(0, 0, 10, 10), + box(33, 0, 10, 10), + box(70, 0, 10, 10), + box(100, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + // first and last never move + assertEquals(0, xs[0], EPS); + assertEquals(100, xs[3], EPS); + } + + @Test + public void inputOrderDoesNotChangeTheResultMapping() { + // The same three boxes as the best case but supplied out of order. The + // result lines up with the input by index, so the box that started at + // x=20 (index 0 here) still ends up at the middle coordinate 50. + List boxes = Arrays.asList( + box(20, 0, 10, 10), + box(100, 0, 10, 10), + box(0, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + assertEquals(50, xs[0], EPS); + assertEquals(100, xs[1], EPS); + assertEquals(0, xs[2], EPS); + } + + // ---- Boundary cases ---- + + @Test + public void twoBoxesAreLeftUnchanged() { + List boxes = Arrays.asList( + box(0, 0, 10, 10), + box(100, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + assertArrayEquals(new double[]{0, 100}, xs, EPS); + } + + @Test + public void oneBoxIsLeftUnchanged() { + List boxes = Arrays.asList(box(42, 0, 10, 10)); + double[] xs = Distribution.HORIZONTAL.newPositions(boxes); + assertArrayEquals(new double[]{42}, xs, EPS); + } +} diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeScenarioTest.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeScenarioTest.java new file mode 100644 index 000000000..4f22fad05 --- /dev/null +++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeScenarioTest.java @@ -0,0 +1,58 @@ +/* + * AlignDistributeScenarioTest.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action.bdd; + +import com.tngtech.jgiven.junit.SimpleScenarioTest; +import org.jhotdraw.draw.action.Alignment; +import org.jhotdraw.draw.action.Distribution; +import org.junit.Test; + +/** + * BDD scenarios for the align and distribute user story. + * + *

This maps the user story onto runnable scenarios. The story reads, as + * someone editing a drawing I want to align or distribute the figures I have + * selected so the diagram looks tidy. Each test below is one acceptance + * criterion written as Given a selection, When I run the command, Then the + * figures end up where the story promises.

+ * + *

Java note to self. Extending {@code SimpleScenarioTest} + * is what gives this class the {@code given()}, {@code when()}, and {@code then()} + * methods, each returning the one stage so the steps can be chained. I used the + * Simple variant because a single stage holds all three step groups.

+ */ +public class AlignDistributeScenarioTest extends SimpleScenarioTest { + + @Test + public void aligning_to_the_north_lines_up_the_top_edges() { + given().a_figure_at_x_$_y_$_with_width_$_height_$(0, 10, 20, 20) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(40, 60, 20, 20) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(80, 30, 20, 20) + .and().the_figures_are_selected(); + when().I_align_them_to_the(Alignment.NORTH); + then().every_figure_shares_the_same_top_edge(); + } + + @Test + public void distributing_horizontally_makes_the_gaps_equal() { + given().a_figure_at_x_$_y_$_with_width_$_height_$(0, 0, 10, 10) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(20, 0, 10, 10) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(100, 0, 10, 10) + .and().the_figures_are_selected(); + when().I_distribute_them(Distribution.HORIZONTAL); + then().the_gaps_between_neighbours_are_equal(); + } + + @Test + public void distributing_two_figures_changes_nothing() { + given().a_figure_at_x_$_y_$_with_width_$_height_$(0, 0, 10, 10) + .and().a_figure_at_x_$_y_$_with_width_$_height_$(100, 0, 10, 10) + .and().the_figures_are_selected(); + when().I_distribute_them(Distribution.HORIZONTAL); + then().the_positions_are_unchanged(); + } +} diff --git a/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeStage.java b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeStage.java new file mode 100644 index 000000000..8d21c1cbd --- /dev/null +++ b/jhotdraw-core/src/test/java/org/jhotdraw/draw/action/bdd/AlignDistributeStage.java @@ -0,0 +1,115 @@ +/* + * AlignDistributeStage.java + * + * New file added for the align and distribute feature. + * JHotDraw is distributed under the GNU LGPL v2.1. + */ +package org.jhotdraw.draw.action.bdd; + +import com.tngtech.jgiven.Stage; +import com.tngtech.jgiven.annotation.ProvidedScenarioState; +import com.tngtech.jgiven.annotation.Quoted; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.util.ArrayList; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.jhotdraw.draw.action.Alignment; +import org.jhotdraw.draw.action.Distribution; + +/** + * A single JGiven stage holding the Given, When, and Then steps for the align + * and distribute scenarios. + * + *

Where a unit test speaks in coordinates, BDD describes the feature in the + * words of the user story. Each public method below is one line of a + * Given-When-Then sentence, and JGiven turns the method names into a readable + * report. The stage holds the scenario state, the figures and the result, so the + * scenario class itself stays short and declarative.

+ * + *

Java notes to self. A JGiven stage is a normal class whose methods return + * {@code this} so they can be chained. {@code @ProvidedScenarioState} marks the + * fields JGiven shares between the steps. {@code @Quoted} just makes the argument + * show up in quotes in the generated report. One stage is enough here because + * the feature is small, a bigger feature would split Given, When, and Then into + * three stages.

+ */ +public class AlignDistributeStage extends Stage { + + @ProvidedScenarioState + private final List figures = new ArrayList<>(); + + @ProvidedScenarioState + private Rectangle2D.Double selectionBounds; + + @ProvidedScenarioState + private final List alignDeltas = new ArrayList<>(); + + @ProvidedScenarioState + private double[] distributedPositions; + + // ---- Given ---- + + public AlignDistributeStage a_figure_at_x_$_y_$_with_width_$_height_$( + @Quoted double x, @Quoted double y, @Quoted double w, @Quoted double h) { + figures.add(new Rectangle2D.Double(x, y, w, h)); + return this; + } + + public AlignDistributeStage the_figures_are_selected() { + selectionBounds = null; + for (Rectangle2D.Double f : figures) { + if (selectionBounds == null) { + selectionBounds = (Rectangle2D.Double) f.clone(); + } else { + selectionBounds.add(f); + } + } + return this; + } + + // ---- When ---- + + public AlignDistributeStage I_align_them_to_the(@Quoted Alignment alignment) { + alignDeltas.clear(); + for (Rectangle2D.Double f : figures) { + alignDeltas.add(alignment.delta(f, selectionBounds)); + } + return this; + } + + public AlignDistributeStage I_distribute_them(@Quoted Distribution distribution) { + distributedPositions = distribution.newPositions(figures); + return this; + } + + // ---- Then ---- + + public AlignDistributeStage every_figure_shares_the_same_top_edge() { + // Applying the NORTH delta should land every figure on the same y. + List tops = new ArrayList<>(); + for (int i = 0; i < figures.size(); i++) { + tops.add(figures.get(i).y + alignDeltas.get(i).y); + } + Assertions.assertThat(tops).allMatch(t -> Math.abs(t - tops.get(0)) < 1e-9); + return this; + } + + public AlignDistributeStage the_gaps_between_neighbours_are_equal() { + // Sort the new leading coordinates and check the gaps all match. + double[] sorted = distributedPositions.clone(); + java.util.Arrays.sort(sorted); + double firstGap = sorted[1] - sorted[0]; + for (int i = 2; i < sorted.length; i++) { + Assertions.assertThat(sorted[i] - sorted[i - 1]).isCloseTo(firstGap, Assertions.within(1e-9)); + } + return this; + } + + public AlignDistributeStage the_positions_are_unchanged() { + for (int i = 0; i < figures.size(); i++) { + Assertions.assertThat(distributedPositions[i]).isCloseTo(figures.get(i).x, Assertions.within(1e-9)); + } + return this; + } +} diff --git a/jhotdraw-gui/src/main/java/org/jhotdraw/gui/action/ButtonFactory.java b/jhotdraw-gui/src/main/java/org/jhotdraw/gui/action/ButtonFactory.java index 1470a011b..f634d3939 100644 --- a/jhotdraw-gui/src/main/java/org/jhotdraw/gui/action/ButtonFactory.java +++ b/jhotdraw-gui/src/main/java/org/jhotdraw/gui/action/ButtonFactory.java @@ -68,6 +68,7 @@ import org.jhotdraw.draw.DrawingView; import org.jhotdraw.draw.action.AbstractSelectedAction; import org.jhotdraw.draw.action.AlignAction; +import org.jhotdraw.draw.action.DistributeAction; import org.jhotdraw.draw.action.ApplyAttributesAction; import org.jhotdraw.draw.action.AttributeAction; import org.jhotdraw.draw.action.AttributeToggler; @@ -1683,6 +1684,26 @@ public static void addAlignmentButtonsTo(JToolBar bar, final DrawingEditor edito dsp.add(d); } + /** + * Adds the two distribute buttons to the toolbar. + * + * Concept location showed that the alignment buttons are built here, so the + * new distribute buttons go in the same place. This is the small secondary + * change the feature needs, the one spot in the existing code that has to be + * touched so the new action is reachable from the user interface. + */ + public static void addDistributeButtonsTo(JToolBar bar, final DrawingEditor editor) { + addDistributeButtonsTo(bar, editor, new LinkedList<>()); + } + + public static void addDistributeButtonsTo(JToolBar bar, final DrawingEditor editor, java.util.List dsp) { + AbstractSelectedAction d; + bar.add(d = new DistributeAction.Horizontal(editor)).setFocusable(false); + dsp.add(d); + bar.add(d = new DistributeAction.Vertical(editor)).setFocusable(false); + dsp.add(d); + } + /** * Creates a button which toggles between two GridConstrainer for a * DrawingView.