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.
+ *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(CollectionI 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(ListThis 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(ListThese 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. + ListThis 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
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