Align and Distribute
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>org.diylc</groupId>
|
||||
<artifactId>diylc-parent</artifactId>
|
||||
<version>6.7.0</version>
|
||||
<version>6.8.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>diylc-core</artifactId>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.common;
|
||||
|
||||
public enum AlignmentMode {
|
||||
|
||||
LEFT("Align Left"), RIGHT("Align Right"), TOP("Align Top"), BOTTOM("Align Bottom"),
|
||||
HORIZONTAL_CENTER("Align Horizontal Centers"), VERTICAL_CENTER("Align Vertical Centers");
|
||||
|
||||
private String label;
|
||||
|
||||
AlignmentMode(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.common;
|
||||
|
||||
public enum DistributionMode {
|
||||
|
||||
HORIZONTAL_CENTERS("Distribute Centers Horizontally"),
|
||||
HORIZONTAL_SPACING("Distribute Spacing Horizontally"),
|
||||
VERTICAL_CENTERS("Distribute Centers Vertically"),
|
||||
VERTICAL_SPACING("Distribute Spacing Vertically");
|
||||
|
||||
private String label;
|
||||
|
||||
DistributionMode(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public boolean isHorizontal() {
|
||||
return this == HORIZONTAL_CENTERS || this == HORIZONTAL_SPACING;
|
||||
}
|
||||
|
||||
public boolean isSpacing() {
|
||||
return this == HORIZONTAL_SPACING || this == VERTICAL_SPACING;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -114,6 +114,30 @@ public interface ISelectionProcessor extends IGroupProcessor {
|
||||
* @param includeStuckComponents
|
||||
*/
|
||||
void nudgeSelection(Size xOffset, Size yOffset, boolean includeStuckComponents);
|
||||
|
||||
/**
|
||||
* Moves selected components so that their outlines line up as specified. Grouped components move
|
||||
* together as a single unit.
|
||||
*
|
||||
* @param mode
|
||||
*/
|
||||
void alignSelection(AlignmentMode mode);
|
||||
|
||||
/**
|
||||
* Moves selected components so that they are evenly spaced as specified. Grouped components move
|
||||
* together as a single unit.
|
||||
*
|
||||
* @param mode
|
||||
*/
|
||||
void distributeSelection(DistributionMode mode);
|
||||
|
||||
/**
|
||||
* Returns the number of units that align and distribute operate on, where each group counts as
|
||||
* one unit.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
int getSelectionUnitCount();
|
||||
|
||||
/**
|
||||
* Selects components that match the criteria.
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.presenter;
|
||||
|
||||
import java.awt.geom.Point2D;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import org.diylc.common.AlignmentMode;
|
||||
import org.diylc.common.DistributionMode;
|
||||
|
||||
/**
|
||||
* Calculates how far each unit of a selection needs to move to be aligned or distributed. Units are
|
||||
* given by their bounding rectangles and the result holds one delta per rectangle, in input order.
|
||||
* Applying the deltas is left to the caller.
|
||||
*
|
||||
* @author Branislav Stojkovic
|
||||
*/
|
||||
public class AlignmentManager {
|
||||
|
||||
public static final int MIN_ALIGN_UNITS = 2;
|
||||
public static final int MIN_DISTRIBUTE_UNITS = 3;
|
||||
|
||||
public List<Point2D> align(List<Rectangle2D> unitBounds, AlignmentMode mode, Double gridStep) {
|
||||
List<Point2D> deltas = createZeroDeltas(unitBounds.size());
|
||||
if (unitBounds.size() < MIN_ALIGN_UNITS) {
|
||||
return deltas;
|
||||
}
|
||||
|
||||
Rectangle2D bounds = new Rectangle2D.Double();
|
||||
bounds.setRect(unitBounds.get(0));
|
||||
for (Rectangle2D r : unitBounds) {
|
||||
bounds.add(r);
|
||||
}
|
||||
|
||||
for (int i = 0; i < unitBounds.size(); i++) {
|
||||
Rectangle2D r = unitBounds.get(i);
|
||||
double dx = 0;
|
||||
double dy = 0;
|
||||
switch (mode) {
|
||||
case LEFT:
|
||||
dx = bounds.getMinX() - r.getMinX();
|
||||
break;
|
||||
case RIGHT:
|
||||
dx = bounds.getMaxX() - r.getMaxX();
|
||||
break;
|
||||
case HORIZONTAL_CENTER:
|
||||
dx = bounds.getCenterX() - r.getCenterX();
|
||||
break;
|
||||
case TOP:
|
||||
dy = bounds.getMinY() - r.getMinY();
|
||||
break;
|
||||
case BOTTOM:
|
||||
dy = bounds.getMaxY() - r.getMaxY();
|
||||
break;
|
||||
case VERTICAL_CENTER:
|
||||
dy = bounds.getCenterY() - r.getCenterY();
|
||||
break;
|
||||
}
|
||||
deltas.get(i).setLocation(snap(dx, gridStep), snap(dy, gridStep));
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
public List<Point2D> distribute(List<Rectangle2D> unitBounds, DistributionMode mode,
|
||||
Double gridStep) {
|
||||
List<Point2D> deltas = createZeroDeltas(unitBounds.size());
|
||||
int count = unitBounds.size();
|
||||
if (count < MIN_DISTRIBUTE_UNITS) {
|
||||
return deltas;
|
||||
}
|
||||
|
||||
boolean horizontal = mode.isHorizontal();
|
||||
// the sort is stable, so units that tie on both keys keep their input order
|
||||
List<Integer> order = new ArrayList<Integer>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
order.add(i);
|
||||
}
|
||||
order.sort(Comparator.<Integer>comparingDouble(i -> getCenter(unitBounds.get(i), horizontal))
|
||||
.thenComparingDouble(i -> getMin(unitBounds.get(i), horizontal)));
|
||||
|
||||
Rectangle2D first = unitBounds.get(order.get(0));
|
||||
Rectangle2D last = unitBounds.get(order.get(count - 1));
|
||||
|
||||
if (mode.isSpacing()) {
|
||||
double totalSize = 0;
|
||||
for (Rectangle2D r : unitBounds) {
|
||||
totalSize += getSize(r, horizontal);
|
||||
}
|
||||
// negative when the units do not fit between the outer edges, which makes them overlap evenly
|
||||
double gap = (getMax(last, horizontal) - getMin(first, horizontal) - totalSize) / (count - 1);
|
||||
double position = getMin(first, horizontal);
|
||||
for (int index : order) {
|
||||
Rectangle2D r = unitBounds.get(index);
|
||||
setDelta(deltas.get(index), position - getMin(r, horizontal), horizontal, gridStep);
|
||||
position += getSize(r, horizontal) + gap;
|
||||
}
|
||||
} else {
|
||||
double firstCenter = getCenter(first, horizontal);
|
||||
double step = (getCenter(last, horizontal) - firstCenter) / (count - 1);
|
||||
for (int k = 0; k < count; k++) {
|
||||
int index = order.get(k);
|
||||
double target = firstCenter + k * step;
|
||||
setDelta(deltas.get(index), target - getCenter(unitBounds.get(index), horizontal),
|
||||
horizontal, gridStep);
|
||||
}
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static List<Point2D> createZeroDeltas(int count) {
|
||||
List<Point2D> deltas = new ArrayList<Point2D>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
deltas.add(new Point2D.Double());
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static void setDelta(Point2D delta, double d, boolean horizontal, Double gridStep) {
|
||||
if (horizontal) {
|
||||
delta.setLocation(snap(d, gridStep), 0);
|
||||
} else {
|
||||
delta.setLocation(0, snap(d, gridStep));
|
||||
}
|
||||
}
|
||||
|
||||
private static double snap(double d, Double gridStep) {
|
||||
if (gridStep == null || gridStep <= 0) {
|
||||
return d;
|
||||
}
|
||||
return Math.round(d / gridStep) * gridStep;
|
||||
}
|
||||
|
||||
private static double getMin(Rectangle2D r, boolean horizontal) {
|
||||
return horizontal ? r.getMinX() : r.getMinY();
|
||||
}
|
||||
|
||||
private static double getMax(Rectangle2D r, boolean horizontal) {
|
||||
return horizontal ? r.getMaxX() : r.getMaxY();
|
||||
}
|
||||
|
||||
private static double getCenter(Rectangle2D r, boolean horizontal) {
|
||||
return horizontal ? r.getCenterX() : r.getCenterY();
|
||||
}
|
||||
|
||||
private static double getSize(Rectangle2D r, boolean horizontal) {
|
||||
return horizontal ? r.getWidth() : r.getHeight();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.swing.JOptionPane;
|
||||
@@ -139,6 +140,7 @@ public class Presenter implements IPlugInPort {
|
||||
private InstantiationManager instantiationManager;
|
||||
private VariantManager variantManager;
|
||||
private BuildingBlockManager buildingBlockManager;
|
||||
private AlignmentManager alignmentManager;
|
||||
|
||||
private Rectangle selectionRect;
|
||||
|
||||
@@ -188,6 +190,7 @@ public class Presenter implements IPlugInPort {
|
||||
instantiationManager = new InstantiationManager();
|
||||
variantManager = new VariantManager(configManager, projectFileManager.getXStream());
|
||||
buildingBlockManager = new BuildingBlockManager(configManager, projectFileManager.getXStream(), instantiationManager);
|
||||
alignmentManager = new AlignmentManager();
|
||||
|
||||
if (importVariantsAndBlocks) {
|
||||
variantManager.upgradeVariants(getComponentTypes());
|
||||
@@ -1112,6 +1115,114 @@ public class Presenter implements IPlugInPort {
|
||||
moveComponents(controlPointMap, dx, dy, false, false);
|
||||
notifyProjectModifiedIfNeeded(oldProject, "Move Selection", true, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void alignSelection(AlignmentMode mode) {
|
||||
LOG.debug(String.format("alignSelection(%s)", mode));
|
||||
moveSelectionUnits(mode.toString(),
|
||||
unitBounds -> alignmentManager.align(unitBounds, mode, getAlignmentGridStep()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void distributeSelection(DistributionMode mode) {
|
||||
LOG.debug(String.format("distributeSelection(%s)", mode));
|
||||
moveSelectionUnits(mode.toString(),
|
||||
unitBounds -> alignmentManager.distribute(unitBounds, mode, getAlignmentGridStep()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSelectionUnitCount() {
|
||||
return findSelectionUnits().size();
|
||||
}
|
||||
|
||||
private void moveSelectionUnits(String action,
|
||||
Function<List<Rectangle2D>, List<Point2D>> deltaCalculator) {
|
||||
// every outline has to be read before the first move, which clears the whole area map
|
||||
List<Set<IDIYComponent<?>>> units = new ArrayList<Set<IDIYComponent<?>>>();
|
||||
List<Rectangle2D> unitBounds = new ArrayList<Rectangle2D>();
|
||||
Set<IDIYComponent<?>> unitComponents = new HashSet<IDIYComponent<?>>();
|
||||
for (Set<IDIYComponent<?>> unit : findSelectionUnits()) {
|
||||
Rectangle2D bounds = getUnitBounds(unit);
|
||||
if (bounds != null) {
|
||||
units.add(unit);
|
||||
unitBounds.add(bounds);
|
||||
unitComponents.addAll(unit);
|
||||
}
|
||||
}
|
||||
List<Point2D> deltas = deltaCalculator.apply(unitBounds);
|
||||
|
||||
Project oldProject = currentProject.clone();
|
||||
for (int i = 0; i < units.size(); i++) {
|
||||
int dx = (int) Math.round(deltas.get(i).getX());
|
||||
int dy = (int) Math.round(deltas.get(i).getY());
|
||||
if (dx == 0 && dy == 0) {
|
||||
continue;
|
||||
}
|
||||
Map<IDIYComponent<?>, Set<Integer>> controlPointMap =
|
||||
new HashMap<IDIYComponent<?>, Set<Integer>>();
|
||||
for (IDIYComponent<?> c : units.get(i)) {
|
||||
if (c.getControlPointCount() > 0) {
|
||||
Set<Integer> pointIndices = new HashSet<Integer>();
|
||||
for (int j = 0; j < c.getControlPointCount(); j++) {
|
||||
pointIndices.add(j);
|
||||
}
|
||||
controlPointMap.put(c, pointIndices);
|
||||
}
|
||||
}
|
||||
if (controlPointMap.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
// a component that is a unit of its own must not also be dragged along by another unit,
|
||||
// otherwise it would move twice
|
||||
Set<IDIYComponent<?>> otherUnitComponents = new HashSet<IDIYComponent<?>>(unitComponents);
|
||||
otherUnitComponents.removeAll(units.get(i));
|
||||
includeStuckComponents(controlPointMap, otherUnitComponents);
|
||||
moveComponents(controlPointMap, dx, dy, false, false);
|
||||
}
|
||||
notifyProjectModifiedIfNeeded(oldProject, action, true, true);
|
||||
}
|
||||
|
||||
private List<Set<IDIYComponent<?>>> findSelectionUnits() {
|
||||
List<Set<IDIYComponent<?>>> units = new ArrayList<Set<IDIYComponent<?>>>();
|
||||
if (selectedComponents == null || selectedComponents.isEmpty()) {
|
||||
return units;
|
||||
}
|
||||
Set<IDIYComponent<?>> assigned = new HashSet<IDIYComponent<?>>();
|
||||
// walk the project rather than the selection so that the order of units is deterministic
|
||||
for (IDIYComponent<?> c : currentProject.getComponents()) {
|
||||
if (selectedComponents.contains(c) && !assigned.contains(c)) {
|
||||
Set<IDIYComponent<?>> unit = new HashSet<IDIYComponent<?>>(findAllGroupedComponents(c));
|
||||
unit.retainAll(selectedComponents);
|
||||
assigned.addAll(unit);
|
||||
units.add(unit);
|
||||
}
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
private Rectangle2D getUnitBounds(Set<IDIYComponent<?>> unit) {
|
||||
Rectangle2D bounds = null;
|
||||
for (IDIYComponent<?> c : unit) {
|
||||
ComponentArea area = drawingManager.getComponentArea(c);
|
||||
if (area == null || area.getOutlineArea() == null) {
|
||||
return null;
|
||||
}
|
||||
if (bounds == null) {
|
||||
bounds = area.getOutlineArea().getBounds2D();
|
||||
} else {
|
||||
bounds.add(area.getOutlineArea().getBounds2D());
|
||||
}
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private Double getAlignmentGridStep() {
|
||||
String snapTo = configManager.readString(IPlugInPort.SNAP_TO_KEY, IPlugInPort.SNAP_TO_DEFAULT);
|
||||
if (snapTo.equalsIgnoreCase(IPlugInPort.SNAP_TO_GRID)) {
|
||||
return currentProject.getGridSpacing().convertToPixels();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@Override
|
||||
@@ -1265,6 +1376,11 @@ public class Presenter implements IPlugInPort {
|
||||
* @param controlPointMap
|
||||
*/
|
||||
private void includeStuckComponents(Map<IDIYComponent<?>, Set<Integer>> controlPointMap) {
|
||||
includeStuckComponents(controlPointMap, Collections.emptySet());
|
||||
}
|
||||
|
||||
private void includeStuckComponents(Map<IDIYComponent<?>, Set<Integer>> controlPointMap,
|
||||
Set<IDIYComponent<?>> excludedComponents) {
|
||||
int oldSize = controlPointMap.size();
|
||||
LOG.trace("Expanding selected component map");
|
||||
for (IDIYComponent<?> component : currentProject.getComponents()) {
|
||||
@@ -1274,7 +1390,8 @@ public class Presenter implements IPlugInPort {
|
||||
// Do not process a control point if it's already in the map and
|
||||
// if it's locked.
|
||||
if ((!controlPointMap.containsKey(component) || !controlPointMap.get(component).contains(i))
|
||||
&& !isComponentLocked(component) && isComponentVisible(component)) {
|
||||
&& !isComponentLocked(component) && isComponentVisible(component)
|
||||
&& !excludedComponents.contains(component)) {
|
||||
if (component.isControlPointSticky(i)) {
|
||||
boolean componentMatches = false;
|
||||
for (Map.Entry<IDIYComponent<?>, Set<Integer>> entry : controlPointMap.entrySet()) {
|
||||
@@ -1321,7 +1438,7 @@ public class Presenter implements IPlugInPort {
|
||||
// As long as we're adding new components, do another iteration.
|
||||
if (newSize > oldSize) {
|
||||
LOG.trace("Component count changed, trying one more time.");
|
||||
includeStuckComponents(controlPointMap);
|
||||
includeStuckComponents(controlPointMap, excludedComponents);
|
||||
} else {
|
||||
LOG.trace("Component count didn't change, done with expanding.");
|
||||
for (Map.Entry<IDIYComponent<?>, Set<Integer>> entry : controlPointMap.entrySet()) {
|
||||
|
||||
@@ -576,5 +576,21 @@
|
||||
</changes>
|
||||
<url>https://github.com/bancika/diy-layout-creator/releases</url>
|
||||
</org.diylc.appframework.update.Version>
|
||||
<org.diylc.appframework.update.Version>
|
||||
<versionNumber>
|
||||
<major>6</major>
|
||||
<minor>8</minor>
|
||||
<build>0</build>
|
||||
</versionNumber>
|
||||
<releaseDate>2026-10-09 00:00:00.000 CET</releaseDate>
|
||||
<name></name>
|
||||
<changes>
|
||||
<org.diylc.appframework.update.Change>
|
||||
<changeType>NEW_FEATURE</changeType>
|
||||
<description>Added 'Align & Distribute' to the Edit menu and the right-click menu - align the selected components by their left, right, top or bottom edges or their centers, or space them evenly by their centers or by the gaps between them</description>
|
||||
</org.diylc.appframework.update.Change>
|
||||
</changes>
|
||||
<url>https://github.com/bancika/diy-layout-creator/releases</url>
|
||||
</org.diylc.appframework.update.Version>
|
||||
</a>
|
||||
</java.util.Arrays_-ArrayList>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.presenter;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import java.awt.geom.Point2D;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.diylc.common.AlignmentMode;
|
||||
import org.diylc.common.DistributionMode;
|
||||
import org.junit.Test;
|
||||
|
||||
public class AlignmentManagerTest {
|
||||
|
||||
private static final double EPS = 1e-9;
|
||||
|
||||
private AlignmentManager manager = new AlignmentManager();
|
||||
|
||||
private List<Rectangle2D> createUnits() {
|
||||
return Arrays.asList(new Rectangle2D.Double(10, 0, 20, 10),
|
||||
new Rectangle2D.Double(50, 30, 40, 20), new Rectangle2D.Double(0, 100, 10, 40));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlignLeft() {
|
||||
List<Point2D> deltas = manager.align(createUnits(), AlignmentMode.LEFT, null);
|
||||
assertDelta(-10, 0, deltas.get(0));
|
||||
assertDelta(-50, 0, deltas.get(1));
|
||||
assertDelta(0, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlignRight() {
|
||||
List<Point2D> deltas = manager.align(createUnits(), AlignmentMode.RIGHT, null);
|
||||
assertDelta(60, 0, deltas.get(0));
|
||||
assertDelta(0, 0, deltas.get(1));
|
||||
assertDelta(80, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlignHorizontalCenter() {
|
||||
// the selection spans x = 0..90, so every unit is centered on x = 45
|
||||
List<Point2D> deltas = manager.align(createUnits(), AlignmentMode.HORIZONTAL_CENTER, null);
|
||||
assertDelta(25, 0, deltas.get(0));
|
||||
assertDelta(-25, 0, deltas.get(1));
|
||||
assertDelta(40, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlignTop() {
|
||||
List<Point2D> deltas = manager.align(createUnits(), AlignmentMode.TOP, null);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(0, -30, deltas.get(1));
|
||||
assertDelta(0, -100, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlignBottom() {
|
||||
List<Point2D> deltas = manager.align(createUnits(), AlignmentMode.BOTTOM, null);
|
||||
assertDelta(0, 130, deltas.get(0));
|
||||
assertDelta(0, 90, deltas.get(1));
|
||||
assertDelta(0, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlignVerticalCenter() {
|
||||
// the selection spans y = 0..140, so every unit is centered on y = 70
|
||||
List<Point2D> deltas = manager.align(createUnits(), AlignmentMode.VERTICAL_CENTER, null);
|
||||
assertDelta(0, 65, deltas.get(0));
|
||||
assertDelta(0, 30, deltas.get(1));
|
||||
assertDelta(0, -50, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlignAlreadyAligned() {
|
||||
List<Rectangle2D> units = Arrays.asList(new Rectangle2D.Double(10, 0, 20, 10),
|
||||
new Rectangle2D.Double(10, 50, 5, 10));
|
||||
List<Point2D> deltas = manager.align(units, AlignmentMode.LEFT, null);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(0, 0, deltas.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlignSnapsToGrid() {
|
||||
List<Point2D> deltas = manager.align(createUnits(), AlignmentMode.HORIZONTAL_CENTER, 20d);
|
||||
assertDelta(20, 0, deltas.get(0));
|
||||
assertDelta(-20, 0, deltas.get(1));
|
||||
assertDelta(40, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAlignSingleUnitDoesNothing() {
|
||||
List<Point2D> deltas = manager.align(
|
||||
Collections.<Rectangle2D>singletonList(new Rectangle2D.Double(10, 10, 5, 5)),
|
||||
AlignmentMode.LEFT, null);
|
||||
assertEquals(1, deltas.size());
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDistributeCentersHorizontally() {
|
||||
// centers at 5, 15 and 105, given out of order; the outer two stay put
|
||||
List<Rectangle2D> units = Arrays.asList(new Rectangle2D.Double(100, 0, 10, 10),
|
||||
new Rectangle2D.Double(0, 0, 10, 10), new Rectangle2D.Double(5, 0, 20, 10));
|
||||
List<Point2D> deltas = manager.distribute(units, DistributionMode.HORIZONTAL_CENTERS, null);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(0, 0, deltas.get(1));
|
||||
assertDelta(40, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDistributeCentersVertically() {
|
||||
List<Rectangle2D> units = Arrays.asList(new Rectangle2D.Double(0, 0, 10, 10),
|
||||
new Rectangle2D.Double(0, 10, 10, 10), new Rectangle2D.Double(0, 20, 10, 10),
|
||||
new Rectangle2D.Double(0, 90, 10, 10));
|
||||
List<Point2D> deltas = manager.distribute(units, DistributionMode.VERTICAL_CENTERS, null);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(0, 20, deltas.get(1));
|
||||
assertDelta(0, 40, deltas.get(2));
|
||||
assertDelta(0, 0, deltas.get(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDistributeSpacingHorizontally() {
|
||||
// widths 10 + 30 + 20 across a span of 0..100 leave two gaps of 20
|
||||
List<Rectangle2D> units = Arrays.asList(new Rectangle2D.Double(0, 0, 10, 10),
|
||||
new Rectangle2D.Double(15, 0, 30, 10), new Rectangle2D.Double(80, 0, 20, 10));
|
||||
List<Point2D> deltas = manager.distribute(units, DistributionMode.HORIZONTAL_SPACING, null);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(15, 0, deltas.get(1));
|
||||
assertDelta(0, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDistributeSpacingVertically() {
|
||||
List<Rectangle2D> units = Arrays.asList(new Rectangle2D.Double(0, 0, 10, 10),
|
||||
new Rectangle2D.Double(0, 15, 10, 30), new Rectangle2D.Double(0, 80, 10, 20));
|
||||
List<Point2D> deltas = manager.distribute(units, DistributionMode.VERTICAL_SPACING, null);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(0, 15, deltas.get(1));
|
||||
assertDelta(0, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDistributeSpacingOverlapsWhenUnitsDoNotFit() {
|
||||
// widths 20 + 40 + 20 across a span of 0..50 leave two gaps of -15
|
||||
List<Rectangle2D> units = Arrays.asList(new Rectangle2D.Double(0, 0, 20, 10),
|
||||
new Rectangle2D.Double(10, 0, 40, 10), new Rectangle2D.Double(30, 0, 20, 10));
|
||||
List<Point2D> deltas = manager.distribute(units, DistributionMode.HORIZONTAL_SPACING, null);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(-5, 0, deltas.get(1));
|
||||
assertDelta(0, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDistributeTiesKeepInputOrder() {
|
||||
// the last two units share both their center and their left edge
|
||||
List<Rectangle2D> units = Arrays.asList(new Rectangle2D.Double(0, 0, 10, 10),
|
||||
new Rectangle2D.Double(40, 0, 10, 10), new Rectangle2D.Double(40, 0, 10, 10));
|
||||
List<Point2D> deltas = manager.distribute(units, DistributionMode.HORIZONTAL_CENTERS, null);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(-20, 0, deltas.get(1));
|
||||
assertDelta(0, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDistributeSnapsToGrid() {
|
||||
List<Rectangle2D> units = Arrays.asList(new Rectangle2D.Double(0, 0, 10, 10),
|
||||
new Rectangle2D.Double(10, 0, 10, 10), new Rectangle2D.Double(70, 0, 10, 10));
|
||||
List<Point2D> deltas = manager.distribute(units, DistributionMode.HORIZONTAL_CENTERS, 20d);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(20, 0, deltas.get(1));
|
||||
assertDelta(0, 0, deltas.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDistributeTwoUnitsDoesNothing() {
|
||||
List<Rectangle2D> units = Arrays.asList(new Rectangle2D.Double(0, 0, 10, 10),
|
||||
new Rectangle2D.Double(70, 0, 10, 10));
|
||||
List<Point2D> deltas = manager.distribute(units, DistributionMode.HORIZONTAL_SPACING, null);
|
||||
assertDelta(0, 0, deltas.get(0));
|
||||
assertDelta(0, 0, deltas.get(1));
|
||||
}
|
||||
|
||||
private static void assertDelta(double dx, double dy, Point2D delta) {
|
||||
assertEquals(dx, delta.getX(), EPS);
|
||||
assertEquals(dy, delta.getY(), EPS);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>org.diylc</groupId>
|
||||
<artifactId>diylc-parent</artifactId>
|
||||
<version>6.7.0</version>
|
||||
<version>6.8.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>diylc-library</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>org.diylc</groupId>
|
||||
<artifactId>diylc-parent</artifactId>
|
||||
<version>6.7.0</version>
|
||||
<version>6.8.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>diylc-swing</artifactId>
|
||||
|
||||
@@ -24,6 +24,8 @@ import org.apache.log4j.Logger;
|
||||
import org.diylc.swing.actions.analyze.*;
|
||||
import org.diylc.swingframework.IDrawingProvider;
|
||||
|
||||
import org.diylc.common.AlignmentMode;
|
||||
import org.diylc.common.DistributionMode;
|
||||
import org.diylc.common.INetlistAnalyzer;
|
||||
import org.diylc.common.IPlugInPort;
|
||||
import org.diylc.core.ExpansionMode;
|
||||
@@ -40,10 +42,12 @@ import org.diylc.swing.actions.RenumberAction;
|
||||
import org.diylc.swing.actions.ResetCategoriesAction;
|
||||
import org.diylc.swing.actions.ThemeAction;
|
||||
import org.diylc.swing.actions.ToggleAction;
|
||||
import org.diylc.swing.actions.edit.AlignSelectionAction;
|
||||
import org.diylc.swing.actions.edit.BringToFrontAction;
|
||||
import org.diylc.swing.actions.edit.CopyAction;
|
||||
import org.diylc.swing.actions.edit.CutAction;
|
||||
import org.diylc.swing.actions.edit.DeleteSelectionAction;
|
||||
import org.diylc.swing.actions.edit.DistributeSelectionAction;
|
||||
import org.diylc.swing.actions.edit.DuplicateAction;
|
||||
import org.diylc.swing.actions.edit.EditProjectAction;
|
||||
import org.diylc.swing.actions.edit.EditSelectionAction;
|
||||
@@ -233,6 +237,16 @@ public class ActionFactory {
|
||||
return new NudgeAction(plugInPort);
|
||||
}
|
||||
|
||||
public AlignSelectionAction createAlignSelectionAction(IPlugInPort plugInPort,
|
||||
AlignmentMode mode) {
|
||||
return new AlignSelectionAction(plugInPort, mode);
|
||||
}
|
||||
|
||||
public DistributeSelectionAction createDistributeSelectionAction(IPlugInPort plugInPort,
|
||||
DistributionMode mode) {
|
||||
return new DistributeSelectionAction(plugInPort, mode);
|
||||
}
|
||||
|
||||
public FindAction createFindAction(IPlugInPort plugInPort, ISwingUI swingUI) {
|
||||
return new FindAction(plugInPort, swingUI);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.swing.actions.edit;
|
||||
|
||||
import java.awt.event.ActionEvent;
|
||||
import javax.swing.AbstractAction;
|
||||
|
||||
import org.diylc.common.AlignmentMode;
|
||||
import org.diylc.common.IPlugInPort;
|
||||
import org.diylc.swing.ActionFactory;
|
||||
import org.diylc.utils.IconLoader;
|
||||
|
||||
public class AlignSelectionAction extends AbstractAction {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private IPlugInPort plugInPort;
|
||||
private AlignmentMode mode;
|
||||
|
||||
public AlignSelectionAction(IPlugInPort plugInPort, AlignmentMode mode) {
|
||||
super();
|
||||
this.plugInPort = plugInPort;
|
||||
this.mode = mode;
|
||||
putValue(AbstractAction.NAME, mode.toString());
|
||||
putValue(AbstractAction.SMALL_ICON, getIcon(mode).getIcon());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
ActionFactory.LOG.info("Align Selection triggered: " + mode);
|
||||
plugInPort.alignSelection(mode);
|
||||
}
|
||||
|
||||
private static IconLoader getIcon(AlignmentMode mode) {
|
||||
switch (mode) {
|
||||
case LEFT:
|
||||
return IconLoader.AlignLeft;
|
||||
case RIGHT:
|
||||
return IconLoader.AlignRight;
|
||||
case TOP:
|
||||
return IconLoader.AlignTop;
|
||||
case BOTTOM:
|
||||
return IconLoader.AlignBottom;
|
||||
case HORIZONTAL_CENTER:
|
||||
return IconLoader.AlignCenterHorizontal;
|
||||
default:
|
||||
return IconLoader.AlignCenterVertical;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
|
||||
DIY Layout Creator (DIYLC).
|
||||
Copyright (c) 2009-2025 held jointly by the individual authors.
|
||||
|
||||
This file is part of DIYLC.
|
||||
|
||||
DIYLC is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
DIYLC is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with DIYLC. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
*/
|
||||
package org.diylc.swing.actions.edit;
|
||||
|
||||
import java.awt.event.ActionEvent;
|
||||
import javax.swing.AbstractAction;
|
||||
|
||||
import org.diylc.common.DistributionMode;
|
||||
import org.diylc.common.IPlugInPort;
|
||||
import org.diylc.swing.ActionFactory;
|
||||
import org.diylc.utils.IconLoader;
|
||||
|
||||
public class DistributeSelectionAction extends AbstractAction {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private IPlugInPort plugInPort;
|
||||
private DistributionMode mode;
|
||||
|
||||
public DistributeSelectionAction(IPlugInPort plugInPort, DistributionMode mode) {
|
||||
super();
|
||||
this.plugInPort = plugInPort;
|
||||
this.mode = mode;
|
||||
putValue(AbstractAction.NAME, mode.toString());
|
||||
putValue(AbstractAction.SMALL_ICON, getIcon(mode).getIcon());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void actionPerformed(ActionEvent e) {
|
||||
ActionFactory.LOG.info("Distribute Selection triggered: " + mode);
|
||||
plugInPort.distributeSelection(mode);
|
||||
}
|
||||
|
||||
private static IconLoader getIcon(DistributionMode mode) {
|
||||
switch (mode) {
|
||||
case HORIZONTAL_CENTERS:
|
||||
return IconLoader.DistributeCentersHorizontal;
|
||||
case HORIZONTAL_SPACING:
|
||||
return IconLoader.DistributeSpacingHorizontal;
|
||||
case VERTICAL_CENTERS:
|
||||
return IconLoader.DistributeCentersVertical;
|
||||
default:
|
||||
return IconLoader.DistributeSpacingVertical;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,9 @@ import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ActionListener;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JMenu;
|
||||
@@ -39,18 +41,23 @@ import javax.swing.JPopupMenu;
|
||||
import org.apache.log4j.Logger;
|
||||
|
||||
import org.diylc.clipboard.ComponentTransferable;
|
||||
import org.diylc.common.AlignmentMode;
|
||||
import org.diylc.common.DistributionMode;
|
||||
import org.diylc.common.ComponentType;
|
||||
import org.diylc.common.IComponentTransformer;
|
||||
import org.diylc.common.IPlugInPort;
|
||||
import org.diylc.core.ExpansionMode;
|
||||
import org.diylc.core.IDIYComponent;
|
||||
import org.diylc.core.Template;
|
||||
import org.diylc.presenter.AlignmentManager;
|
||||
import org.diylc.swing.ActionFactory;
|
||||
import org.diylc.swing.actions.FlexibleLeadsAction;
|
||||
import org.diylc.swing.actions.edit.AlignSelectionAction;
|
||||
import org.diylc.swing.actions.edit.BringToFrontAction;
|
||||
import org.diylc.swing.actions.edit.CopyAction;
|
||||
import org.diylc.swing.actions.edit.CutAction;
|
||||
import org.diylc.swing.actions.edit.DeleteSelectionAction;
|
||||
import org.diylc.swing.actions.edit.DistributeSelectionAction;
|
||||
import org.diylc.swing.actions.edit.DuplicateAction;
|
||||
import org.diylc.swing.actions.edit.EditSelectionAction;
|
||||
import org.diylc.swing.actions.edit.ExpandSelectionAction;
|
||||
@@ -75,6 +82,7 @@ public class ComponentPopupMenu extends JPopupMenu implements ClipboardOwner {
|
||||
private JMenu selectionMenu;
|
||||
private JMenu expandMenu;
|
||||
private JMenu transformMenu;
|
||||
private JMenu alignMenu;
|
||||
private JMenu applyTemplateMenu;
|
||||
private JMenu applyModelMenu;
|
||||
private JMenu lockMenu;
|
||||
@@ -101,6 +109,10 @@ public class ComponentPopupMenu extends JPopupMenu implements ClipboardOwner {
|
||||
private MirrorSelectionAction mirrorHorizontallyAction;
|
||||
private MirrorSelectionAction mirrorVerticallyAction;
|
||||
private FlexibleLeadsAction flexibleLeadsAction;
|
||||
private Map<AlignmentMode, AlignSelectionAction> alignActions =
|
||||
new EnumMap<AlignmentMode, AlignSelectionAction>(AlignmentMode.class);
|
||||
private Map<DistributionMode, DistributeSelectionAction> distributeActions =
|
||||
new EnumMap<DistributionMode, DistributeSelectionAction>(DistributionMode.class);
|
||||
|
||||
private IPlugInPort plugInPort;
|
||||
private Clipboard clipboard;
|
||||
@@ -124,6 +136,7 @@ public class ComponentPopupMenu extends JPopupMenu implements ClipboardOwner {
|
||||
add(getEditSelectionAction());
|
||||
add(getDeleteSelectionAction());
|
||||
add(getTransformMenu());
|
||||
add(getAlignMenu());
|
||||
add(getSaveAsTemplateAction());
|
||||
add(getApplyTemplateMenu());
|
||||
add(getApplyModelMenu());
|
||||
@@ -194,6 +207,21 @@ public class ComponentPopupMenu extends JPopupMenu implements ClipboardOwner {
|
||||
return transformMenu;
|
||||
}
|
||||
|
||||
public JMenu getAlignMenu() {
|
||||
if (alignMenu == null) {
|
||||
alignMenu = new TranslatedMenu("Align & Distribute");
|
||||
alignMenu.setIcon(IconLoader.AlignDistribute.getIcon());
|
||||
for (AlignmentMode mode : AlignmentMode.values()) {
|
||||
alignMenu.add(getAlignAction(mode));
|
||||
}
|
||||
alignMenu.addSeparator();
|
||||
for (DistributionMode mode : DistributionMode.values()) {
|
||||
alignMenu.add(getDistributeAction(mode));
|
||||
}
|
||||
}
|
||||
return alignMenu;
|
||||
}
|
||||
|
||||
public JMenu getApplyTemplateMenu() {
|
||||
if (applyTemplateMenu == null) {
|
||||
applyTemplateMenu = new TranslatedMenu("Apply Variant");
|
||||
@@ -236,6 +264,13 @@ public class ComponentPopupMenu extends JPopupMenu implements ClipboardOwner {
|
||||
getMirrorHorizontallyAction().setEnabled(enabled);
|
||||
getMirrorVerticallyAction().setEnabled(enabled);
|
||||
getFlexibleLeadsAction().setEnabled(enabled);
|
||||
int unitCount = enabled ? plugInPort.getSelectionUnitCount() : 0;
|
||||
for (AlignmentMode mode : AlignmentMode.values()) {
|
||||
getAlignAction(mode).setEnabled(unitCount >= AlignmentManager.MIN_ALIGN_UNITS);
|
||||
}
|
||||
for (DistributionMode mode : DistributionMode.values()) {
|
||||
getDistributeAction(mode).setEnabled(unitCount >= AlignmentManager.MIN_DISTRIBUTE_UNITS);
|
||||
}
|
||||
|
||||
getSaveAsTemplateAction()
|
||||
.setEnabled(plugInPort.getSelectedComponents().size() == 1);
|
||||
@@ -429,6 +464,16 @@ public class ComponentPopupMenu extends JPopupMenu implements ClipboardOwner {
|
||||
return flexibleLeadsAction;
|
||||
}
|
||||
|
||||
public AlignSelectionAction getAlignAction(AlignmentMode mode) {
|
||||
return alignActions.computeIfAbsent(mode,
|
||||
m -> ActionFactory.getInstance().createAlignSelectionAction(plugInPort, m));
|
||||
}
|
||||
|
||||
public DistributeSelectionAction getDistributeAction(DistributionMode mode) {
|
||||
return distributeActions.computeIfAbsent(mode,
|
||||
m -> ActionFactory.getInstance().createDistributeSelectionAction(plugInPort, m));
|
||||
}
|
||||
|
||||
public SaveAsTemplateAction getSaveAsTemplateAction() {
|
||||
if (saveAsTemplateAction == null) {
|
||||
saveAsTemplateAction = ActionFactory.getInstance().createSaveAsTemplateAction(plugInPort);
|
||||
|
||||
@@ -27,26 +27,33 @@ import java.awt.datatransfer.ClipboardOwner;
|
||||
import java.awt.datatransfer.FlavorEvent;
|
||||
import java.awt.datatransfer.FlavorListener;
|
||||
import java.awt.datatransfer.Transferable;
|
||||
import java.util.EnumMap;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
import org.diylc.appframework.undo.IUndoListener;
|
||||
import org.diylc.appframework.undo.UndoHandler;
|
||||
|
||||
import org.diylc.clipboard.ComponentTransferable;
|
||||
import org.diylc.common.AlignmentMode;
|
||||
import org.diylc.common.DistributionMode;
|
||||
import org.diylc.common.EventType;
|
||||
import org.diylc.common.IComponentTransformer;
|
||||
import org.diylc.common.IPlugIn;
|
||||
import org.diylc.common.IPlugInPort;
|
||||
import org.diylc.core.ExpansionMode;
|
||||
import org.diylc.core.Project;
|
||||
import org.diylc.presenter.AlignmentManager;
|
||||
import org.diylc.swing.ActionFactory;
|
||||
import org.diylc.swing.ISwingUI;
|
||||
import org.diylc.swing.actions.FindAction;
|
||||
import org.diylc.swing.actions.FlexibleLeadsAction;
|
||||
import org.diylc.swing.actions.RenumberAction;
|
||||
import org.diylc.swing.actions.edit.AlignSelectionAction;
|
||||
import org.diylc.swing.actions.edit.BringToFrontAction;
|
||||
import org.diylc.swing.actions.edit.CopyAction;
|
||||
import org.diylc.swing.actions.edit.CutAction;
|
||||
import org.diylc.swing.actions.edit.DeleteSelectionAction;
|
||||
import org.diylc.swing.actions.edit.DistributeSelectionAction;
|
||||
import org.diylc.swing.actions.edit.DuplicateAction;
|
||||
import org.diylc.swing.actions.edit.EditSelectionAction;
|
||||
import org.diylc.swing.actions.edit.ExpandSelectionAction;
|
||||
@@ -65,6 +72,7 @@ public class EditMenuPlugin implements IPlugIn, ClipboardOwner {
|
||||
|
||||
private static final String EDIT_TITLE = "Edit";
|
||||
private static final String TRANSFORM_TITLE = "Transform Selection";
|
||||
private static final String ALIGN_TITLE = "Align & Distribute";
|
||||
private static final String RENUMBER_TITLE = "Renumber Selection";
|
||||
private static final String EXPAND_TITLE = "Expand Selection";
|
||||
|
||||
@@ -97,6 +105,10 @@ public class EditMenuPlugin implements IPlugIn, ClipboardOwner {
|
||||
private MirrorSelectionAction mirrorVerticallyAction;
|
||||
private FindAction findAction;
|
||||
private FlexibleLeadsAction flexibleLeadsAction;
|
||||
private Map<AlignmentMode, AlignSelectionAction> alignActions =
|
||||
new EnumMap<AlignmentMode, AlignSelectionAction>(AlignmentMode.class);
|
||||
private Map<DistributionMode, DistributeSelectionAction> distributeActions =
|
||||
new EnumMap<DistributionMode, DistributeSelectionAction>(DistributionMode.class);
|
||||
|
||||
private UndoHandler<Project> undoHandler;
|
||||
|
||||
@@ -297,6 +309,16 @@ public class EditMenuPlugin implements IPlugIn, ClipboardOwner {
|
||||
return flexibleLeadsAction;
|
||||
}
|
||||
|
||||
public AlignSelectionAction getAlignAction(AlignmentMode mode) {
|
||||
return alignActions.computeIfAbsent(mode,
|
||||
m -> ActionFactory.getInstance().createAlignSelectionAction(plugInPort, m));
|
||||
}
|
||||
|
||||
public DistributeSelectionAction getDistributeAction(DistributionMode mode) {
|
||||
return distributeActions.computeIfAbsent(mode,
|
||||
m -> ActionFactory.getInstance().createDistributeSelectionAction(plugInPort, m));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect(IPlugInPort plugInPort) {
|
||||
this.plugInPort = plugInPort;
|
||||
@@ -329,6 +351,14 @@ public class EditMenuPlugin implements IPlugIn, ClipboardOwner {
|
||||
swingUI.injectMenuAction(null, TRANSFORM_TITLE);
|
||||
swingUI.injectMenuAction(getGroupAction(), TRANSFORM_TITLE);
|
||||
swingUI.injectMenuAction(getUngroupAction(), TRANSFORM_TITLE);
|
||||
swingUI.injectSubmenu(ALIGN_TITLE, IconLoader.AlignDistribute.getIcon(), EDIT_TITLE);
|
||||
for (AlignmentMode mode : AlignmentMode.values()) {
|
||||
swingUI.injectMenuAction(getAlignAction(mode), ALIGN_TITLE);
|
||||
}
|
||||
swingUI.injectMenuAction(null, ALIGN_TITLE);
|
||||
for (DistributionMode mode : DistributionMode.values()) {
|
||||
swingUI.injectMenuAction(getDistributeAction(mode), ALIGN_TITLE);
|
||||
}
|
||||
swingUI.injectMenuAction(null, EDIT_TITLE);
|
||||
// swingUI.injectMenuAction(getSaveAsTemplateAction(), EDIT_TITLE);
|
||||
swingUI.injectSubmenu(RENUMBER_TITLE, IconLoader.Sort.getIcon(), EDIT_TITLE);
|
||||
@@ -395,6 +425,13 @@ public class EditMenuPlugin implements IPlugIn, ClipboardOwner {
|
||||
getSaveAsTemplateAction().setEnabled(enabled);
|
||||
getSaveAsBlockAction().setEnabled(enabled);
|
||||
getFlexibleLeadsAction().setEnabled(enabled);
|
||||
int unitCount = enabled ? plugInPort.getSelectionUnitCount() : 0;
|
||||
for (AlignmentMode mode : AlignmentMode.values()) {
|
||||
getAlignAction(mode).setEnabled(unitCount >= AlignmentManager.MIN_ALIGN_UNITS);
|
||||
}
|
||||
for (DistributionMode mode : DistributionMode.values()) {
|
||||
getDistributeAction(mode).setEnabled(unitCount >= AlignmentManager.MIN_DISTRIBUTE_UNITS);
|
||||
}
|
||||
}
|
||||
|
||||
// ClipboardOwner
|
||||
|
||||
@@ -36,8 +36,11 @@ import java.io.IOException;
|
||||
*/
|
||||
public enum IconLoader {
|
||||
|
||||
About("about.png"), Add("add.png"), ApplicationEdit("application_edit.png"), Arrow(
|
||||
"arrow.png"), BOM("bom.png"), Back("back.png"), Barcode("barcode.png"), BlackBoard(
|
||||
About("about.png"), Add("add.png"), AlignBottom("align_bottom.png"), AlignCenterHorizontal(
|
||||
"align_center_horizontal.png"), AlignCenterVertical(
|
||||
"align_center_vertical.png"), AlignDistribute("align_distribute.png"), AlignLeft(
|
||||
"align_left.png"), AlignRight("align_right.png"), AlignTop(
|
||||
"align_top.png"), ApplicationEdit("application_edit.png"), Arrow("arrow.png"), BOM("bom.png"), Back("back.png"), Barcode("barcode.png"), BlackBoard(
|
||||
"blackboard.png"), Brain("brain.png"), BrainBig("brain_big.png"), BranchAdd(
|
||||
"branch_add.png"), BriefcaseAdd("briefcase_add.png"), BriefcaseInto(
|
||||
"briefcase_into.png"), Bug("bug.png"), CSV("csv.png"), Chest("chest.png"), Cloud(
|
||||
@@ -49,7 +52,11 @@ public enum IconLoader {
|
||||
"component_add.png"), ComponentLarge("component_large.png"), ComponentPreferences(
|
||||
"component_preferences.png"), ComponentReplace("component_replace.png"), Copy(
|
||||
"copy.png"), Cut("cut.png"), Dashboard("dashboard.png"), DataFind("data_find.png"), Delete(
|
||||
"delete.png"), DiskBlue("disk_blue.png"), DocumentEdit("document_edit.png"), DocumentGerber(
|
||||
"delete.png"), DiskBlue("disk_blue.png"), DistributeCentersHorizontal(
|
||||
"distribute_centers_horizontal.png"), DistributeCentersVertical(
|
||||
"distribute_centers_vertical.png"), DistributeSpacingHorizontal(
|
||||
"distribute_spacing_horizontal.png"), DistributeSpacingVertical(
|
||||
"distribute_spacing_vertical.png"), DocumentEdit("document_edit.png"), DocumentGerber(
|
||||
"document_gerber.png"), DocumentInfo("document_info.png"), DocumentPlain(
|
||||
"document_plain.png"), DocumentPlainYellow("document_plain_yellow.png"), DocumentX2(
|
||||
"document_x2.png"), DocumentsGear("documents_gear.png"), Donate("donate.png"), Download(
|
||||
|
||||
|
After Width: | Height: | Size: 437 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 403 B |
|
After Width: | Height: | Size: 394 B |
|
After Width: | Height: | Size: 434 B |
|
After Width: | Height: | Size: 433 B |
|
After Width: | Height: | Size: 433 B |
|
After Width: | Height: | Size: 464 B |
|
After Width: | Height: | Size: 453 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 461 B |
@@ -0,0 +1,145 @@
|
||||
# Align & Distribute
|
||||
|
||||
Align or distribute the selected components by their drawn outline. The commands sit in an
|
||||
**Align & Distribute** submenu in both the Edit menu and the canvas popup menu.
|
||||
|
||||
## 1. Behaviour
|
||||
|
||||
Every operation works on the outline `Area` that `DrawingManager` records for each component
|
||||
(`ComponentArea.getOutlineArea()`). Because that area covers everything the component draws,
|
||||
including strokes and labels, alignment means visual alignment, not control-point alignment.
|
||||
|
||||
**Units.** The selection is split into *units*. A unit is one component group (the union of its
|
||||
members' outline areas) or one ungrouped component. A unit always moves rigidly, so groups are
|
||||
never pulled apart. A selected component with no recorded area, for example because it was not
|
||||
drawn, is left out of the operation.
|
||||
|
||||
**Reference.** Everything is measured against the bounding box of all units, so align and
|
||||
distribute never move anything outside the space the selection already covers.
|
||||
|
||||
| Command | Effect on each unit |
|
||||
| --- | --- |
|
||||
| Align Left / Right | Moves its left (right) edge to the bbox's left (right) edge |
|
||||
| Align Top / Bottom | Moves its top (bottom) edge to the bbox's top (bottom) edge |
|
||||
| Align Horizontal Centers | Moves its center X to the bbox's center X (units end up in a column) |
|
||||
| Align Vertical Centers | Moves its center Y to the bbox's center Y (units end up in a row) |
|
||||
| Distribute Centers Horizontally / Vertically | Sorts units by center, keeps the first and last in place and spaces the centers evenly between them |
|
||||
| Distribute Spacing Horizontally / Vertically | Sorts units by center, keeps the first unit's leading edge and the last unit's trailing edge in place, and makes every gap between neighbours equal. The gap can come out negative (overlap) when the units are wider than the span; that is allowed. |
|
||||
|
||||
Sorting breaks ties on the leading edge, then on project (z-)order, so the result is deterministic.
|
||||
|
||||
**Snapping.** When the snap-to-grid setting is on, each unit's delta is rounded to the project
|
||||
grid spacing (`CalcUtils.roundToGrid`). Control points therefore stay on the grid, at the cost of
|
||||
alignment that is only approximate to within half a grid step. With snapping off, alignment is
|
||||
exact to the pixel.
|
||||
|
||||
**Stuck components.** Each unit's move goes through `includeStuckComponents`, as a mouse drag
|
||||
does, so wires and leads attached to a moved part stretch to stay connected. A component that is
|
||||
itself a unit of the operation is never also dragged as another unit's stuck component, otherwise
|
||||
it would move twice.
|
||||
|
||||
**Undo.** A whole operation is one undo step, labelled with the command name.
|
||||
|
||||
**Enablement.** The align commands need at least 2 units and the distribute commands at least 3,
|
||||
since distributing 2 units leaves both in place. Units are counted, not components, so one
|
||||
selected group counts as 1.
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
```
|
||||
Swing actions ──► IPlugInPort (ISelectionProcessor) ──► Presenter ──► AlignmentManager
|
||||
│ (pure geometry)
|
||||
├─ DrawingManager.getComponentArea
|
||||
└─ includeStuckComponents + moveComponents
|
||||
```
|
||||
|
||||
### diylc-core
|
||||
|
||||
**`org.diylc.common.AlignmentMode`** (enum): `LEFT`, `RIGHT`, `TOP`, `BOTTOM`,
|
||||
`HORIZONTAL_CENTER`, `VERTICAL_CENTER`.
|
||||
|
||||
**`org.diylc.common.DistributionMode`** (enum): `HORIZONTAL_CENTERS`, `HORIZONTAL_SPACING`,
|
||||
`VERTICAL_CENTERS`, `VERTICAL_SPACING`.
|
||||
|
||||
Both enums carry a `toString()` display label, used for the menu text and the undo label, the
|
||||
same way other `org.diylc.common` enums do.
|
||||
|
||||
**`org.diylc.presenter.AlignmentManager`**: a stateless service, named like its siblings
|
||||
`VariantManager` and `BuildingBlockManager`, and created in the `Presenter` constructor. It does
|
||||
geometry only. It knows nothing about `Project`, `DrawingManager` or how a move is applied, which
|
||||
keeps it unit-testable with plain rectangles.
|
||||
|
||||
```java
|
||||
public List<Point2D> align(List<Rectangle2D> unitBounds, AlignmentMode mode, Double gridStep);
|
||||
public List<Point2D> distribute(List<Rectangle2D> unitBounds, DistributionMode mode, Double gridStep);
|
||||
```
|
||||
|
||||
Each call returns one delta per input rectangle, in input order. `gridStep == null` means no
|
||||
snapping. Fewer than 2 rectangles (or fewer than 3 for `distribute`) returns all-zero deltas.
|
||||
|
||||
**`ISelectionProcessor`** gains three methods, each with Javadoc as the rest of that interface has:
|
||||
|
||||
```java
|
||||
void alignSelection(AlignmentMode mode);
|
||||
void distributeSelection(DistributionMode mode);
|
||||
int getSelectionUnitCount(); // drives enablement in the Swing layer
|
||||
```
|
||||
|
||||
**`Presenter`** implements them as follows:
|
||||
|
||||
1. Build the units from `selectedComponents` using the existing group lookup
|
||||
(`findAllGroupedComponents`), walking the project in z-order. For each unit, combine the
|
||||
bounds of the outline areas from `drawingManager.getComponentArea(...)`. Skip any unit with a
|
||||
missing area.
|
||||
2. Ask `AlignmentManager` for deltas, passing the grid spacing in pixels only when snapping to the
|
||||
grid is on.
|
||||
3. Take `oldProject = currentProject.clone()` once. Then, for each unit whose rounded delta is
|
||||
non-zero: build a control-point map containing every point of the unit's components, run
|
||||
`includeStuckComponents` (an overload takes the set to exclude) with the other units'
|
||||
components excluded, and call
|
||||
`moveComponents(map, dx, dy, false, false)`. Snapping already happened in step 2, so the move
|
||||
itself must not snap.
|
||||
4. Call `notifyProjectModifiedIfNeeded(oldProject, mode.toString(), true, true)`.
|
||||
|
||||
All areas are read in step 1, before anything moves, because a move invalidates the moved
|
||||
components' areas in `DrawingManager`.
|
||||
|
||||
`moveComponents` refuses a move that would push a control point off the canvas or make points
|
||||
collide. That cannot happen to the units themselves, since they stay inside the selection bbox.
|
||||
It can in principle happen to a dragged stuck component at the canvas edge, and in that case only
|
||||
that unit's move is skipped. This is the same behaviour as a drag.
|
||||
|
||||
### diylc-swing
|
||||
|
||||
- **`actions/edit/AlignSelectionAction`** and **`actions/edit/DistributeSelectionAction`** follow the
|
||||
shape of `RotateSelectionAction`: they hold the mode, take the name from it, log one
|
||||
`ActionFactory.LOG.info(...)` line and delegate to `plugInPort`. Each gets a `create...` method in
|
||||
`ActionFactory`.
|
||||
- **`EditMenuPlugin`**: add `ALIGN_TITLE = "Align & Distribute"`, injected with `injectSubmenu`
|
||||
directly after the Transform Selection submenu. It holds the six align actions, a separator, then
|
||||
the four distribute actions, with lazy getters like the existing ones. `refreshActions()` sets
|
||||
enablement from `getSelectionUnitCount()`.
|
||||
- **`ComponentPopupMenu`**: add a `getAlignMenu()` `TranslatedMenu` after `getTransformMenu()`, with
|
||||
the same contents, and set enablement in `prepareAndShowAt`.
|
||||
|
||||
`refreshActions()` runs on `SELECTION_CHANGED`. Grouping or ungrouping without a selection change
|
||||
can leave the enablement stale until the next refresh. That is harmless, because the Presenter
|
||||
treats a too-small unit count as a no-op.
|
||||
|
||||
### Tests
|
||||
|
||||
`diylc-core/src/test/java/org/diylc/presenter/AlignmentManagerTest` covers each align mode, both
|
||||
distribute variants on both axes, ties in the sort order, negative spacing, grid snapping,
|
||||
already-aligned input (all-zero deltas) and the below-minimum unit counts. Presenter wiring and
|
||||
menus get no unit tests, as is usual for this codebase. Rendering, serialization and netlist output
|
||||
do not change, so no regression run is needed.
|
||||
|
||||
## 3. Open items
|
||||
|
||||
Everything in section 2 is implemented. The remaining items:
|
||||
|
||||
- **Icons** are in `diylc-swing-images/` (`align_*.png`, `distribute_*.png`) and registered in
|
||||
`IconLoader`. Each action picks its icon from its mode; the submenu uses `align_distribute.png`.
|
||||
- **Shortcuts.** None yet. Rotate already uses Alt+arrows, and nudge has its own shortcut.
|
||||
- **Action bar.** The commands are not on the context toolbar in `ActionBarPlugin`.
|
||||
- **Manual check** in the running app: groups, stuck wires, snap on and off, and undo.
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>org.diylc</groupId>
|
||||
<artifactId>diylc-parent</artifactId>
|
||||
<version>6.7.0</version>
|
||||
<version>6.8.0</version>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<name>DIY Layout Creator</name>
|
||||
|
||||