error(String message) {
+ return () -> new UserInputException(message);
+ }
+}
diff --git a/src/nl/jeroenhoek/josm/gridify/ui/GridSizePanel.java b/src/nl/jeroenhoek/josm/gridify/ui/GridSizePanel.java
new file mode 100644
index 0000000..7e4772d
--- /dev/null
+++ b/src/nl/jeroenhoek/josm/gridify/ui/GridSizePanel.java
@@ -0,0 +1,90 @@
+// License: GPL. For details, see LICENSE file.
+package nl.jeroenhoek.josm.gridify.ui;
+
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JSpinner;
+import java.awt.Font;
+
+/**
+ * UI widget that allows the user to set the grid division; i.e., how many rows and columns.
+ */
+public class GridSizePanel extends JPanel {
+ private final JSpinner spinnerRows;
+ private final JSpinner spinnerColumns;
+
+ private final ChangeCallback changeCallback;
+
+ private int rows;
+ private int columns;
+
+ public GridSizePanel(ChangeCallback changeCallback, int rows, int columns) {
+ this.changeCallback = changeCallback;
+ this.rows = rows;
+ this.columns = columns;
+ setLayout(new BoxLayout(this, BoxLayout.X_AXIS));
+
+ spinnerRows = new PositiveSpinner(rows);
+ spinnerColumns = new PositiveSpinner(columns);
+ JLabel xLabel = new JLabel("×");
+ xLabel.setFont(new Font("Monospaced", Font.PLAIN, 18));
+
+
+ add(spinnerRows);
+ add(Box.createHorizontalStrut(10));
+ add(xLabel);
+ add(Box.createHorizontalStrut(10));
+ add(spinnerColumns);
+
+ spinnerRows.addChangeListener(e -> setRowCount((int) spinnerRows.getValue()));
+ spinnerColumns.addChangeListener(e -> setColumnCount((int) spinnerColumns.getValue()));
+ }
+
+ void setRowCount(int rows) {
+ this.rows = rows;
+ this.spinnerRows.setValue(rows);
+ changeCallback.changed(getRowCount(), getColumnCount());
+ }
+
+ void setColumnCount(int columns) {
+ this.columns = columns;
+ this.spinnerColumns.setValue(columns);
+ changeCallback.changed(getRowCount(), getColumnCount());
+ }
+
+ void nudgeRowCount(Nudge direction) {
+ if (direction == Nudge.INCREMENT || this.rows > 1) {
+ this.rows += direction == Nudge.INCREMENT ? 1 : -1;
+ this.spinnerRows.setValue(rows);
+ changeCallback.changed(getRowCount(), getColumnCount());
+ }
+ }
+
+ void nudgeColumnCount(Nudge direction) {
+ if (direction == Nudge.INCREMENT || this.columns > 1) {
+ this.columns += direction == Nudge.INCREMENT ? 1 : -1;
+ this.spinnerColumns.setValue(columns);
+ changeCallback.changed(getRowCount(), getColumnCount());
+ }
+ }
+
+ public int getRowCount() {
+ return this.rows;
+ }
+
+ public int getColumnCount() {
+ return this.columns;
+ }
+
+ @FunctionalInterface
+ interface ChangeCallback {
+ void changed(int rows, int columns);
+ }
+
+ public enum Nudge {
+ INCREMENT,
+ DECREMENT
+ }
+}
diff --git a/src/nl/jeroenhoek/josm/gridify/ui/GridifySettingsDialog.java b/src/nl/jeroenhoek/josm/gridify/ui/GridifySettingsDialog.java
new file mode 100644
index 0000000..9f15007
--- /dev/null
+++ b/src/nl/jeroenhoek/josm/gridify/ui/GridifySettingsDialog.java
@@ -0,0 +1,151 @@
+// License: GPL. For details, see LICENSE file.
+package nl.jeroenhoek.josm.gridify.ui;
+
+import nl.jeroenhoek.josm.gridify.InputData;
+import nl.jeroenhoek.josm.gridify.Operation;
+import nl.jeroenhoek.josm.gridify.ui.GridSizePanel.Nudge;
+import org.openstreetmap.josm.Main;
+import org.openstreetmap.josm.gui.ExtendedDialog;
+
+import javax.swing.BorderFactory;
+import javax.swing.Box;
+import javax.swing.BoxLayout;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.border.Border;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+/**
+ * The modal dialog presented to the user when executing the Gridify action.
+ */
+public class GridifySettingsDialog extends ExtendedDialog {
+ private final InputData inputData;
+
+ private GridSizePanel gridSizePanel;
+ private OperationChooser operationChooser;
+ private SourceWayPanel sourceWayPanel;
+
+ private final int ROWS_DEFAULT = 2;
+ private final int COLUMNS_DEFAULT = 3;
+
+ public GridifySettingsDialog(InputData inputData) {
+ super(Main.parent, tr("Gridify preview"), tr("Gridify"), tr("Cancel"));
+ this.inputData = inputData;
+ }
+
+ @Override
+ public void setupDialog() {
+ final Insets insetsDefault = new Insets(0, 0, 10, 0);
+ final Insets insetsIndent = new Insets(0, 30, 10, 0);
+ final Border underline = BorderFactory.createMatteBorder(0, 0, 1, 0, Color.black);
+
+
+ Preview preview = new Preview(inputData.getGridExtrema(), this);
+
+ JPanel rootPanel = new JPanel();
+ setMinimumSize(new Dimension(550, 360));
+ rootPanel.setLayout(new BoxLayout(rootPanel, BoxLayout.X_AXIS));
+
+ JPanel controlPanel = new JPanel();
+ controlPanel.setLayout(new GridBagLayout());
+ GridBagConstraints constraints = new GridBagConstraints();
+ constraints.fill = GridBagConstraints.HORIZONTAL;
+
+ JLabel operationChooserLabel = new JLabel(tr("Output shape"));
+ operationChooserLabel.setBorder(underline);
+ controlPanel.add(operationChooserLabel, constraints);
+
+ operationChooser = new OperationChooser(Operation.BLOCKS);
+ constraints.gridy = 1;
+ constraints.insets = insetsIndent;
+ controlPanel.add(operationChooser, constraints);
+
+ JLabel gridSizePanelLabel = new JLabel(tr("Grid size"));
+ gridSizePanelLabel.setBorder(underline);
+ constraints.gridy = 2;
+ constraints.insets = insetsDefault;
+ controlPanel.add(gridSizePanelLabel, constraints);
+
+ gridSizePanel = new GridSizePanel(
+ preview::updateRowsColumns,
+ ROWS_DEFAULT,
+ COLUMNS_DEFAULT
+ );
+ constraints.gridy = 3;
+ constraints.insets = insetsIndent;
+ controlPanel.add(gridSizePanel, constraints);
+
+ if (inputData.getSourceWay().isPresent()) {
+ JLabel wayLabel = new JLabel(tr("Source way"));
+ wayLabel.setBorder(underline);
+ constraints.gridy = 4;
+ constraints.insets = insetsDefault;
+ controlPanel.add(wayLabel, constraints);
+
+ boolean deleteSourceWay = inputData.getSourceWay().get().isNew();
+
+ sourceWayPanel = new SourceWayPanel(true, deleteSourceWay);
+ constraints.gridy = 5;
+ constraints.insets = insetsIndent;
+ controlPanel.add(sourceWayPanel, constraints);
+ }
+
+ rootPanel.add(controlPanel);
+ rootPanel.add(Box.createHorizontalGlue());
+
+ rootPanel.add(preview);
+
+ setContent(rootPanel, false);
+ setButtonIcons("ok.png", "cancel.png");
+ setDefaultButton(1);
+
+ super.setupDialog();
+ }
+
+ @Override
+ public Dimension getMinimumSize() {
+ return getPreferredSize();
+ }
+
+ public int getRowCount() {
+ return gridSizePanel == null
+ ? ROWS_DEFAULT
+ : gridSizePanel.getRowCount();
+ }
+
+ public int getColumnCount() {
+ return gridSizePanel == null
+ ? COLUMNS_DEFAULT
+ : gridSizePanel.getColumnCount();
+ }
+
+ public void nudgeRowCount(Nudge direction) {
+ if (gridSizePanel != null) {
+ gridSizePanel.nudgeRowCount(direction);
+ }
+ }
+
+ public void nudgeColumnCount(Nudge direction) {
+ if (gridSizePanel != null) {
+ gridSizePanel.nudgeColumnCount(direction);
+ }
+ }
+
+ public Operation getOperation() {
+ return operationChooser.getSelected();
+ }
+
+ public boolean copyTags() {
+ return inputData.getSourceWay().isPresent() && sourceWayPanel.copyTags();
+ }
+
+ public boolean deleteSourceWay() {
+ return inputData.getSourceWay().isPresent() && sourceWayPanel.deleteSourceWay();
+ }
+}
diff --git a/src/nl/jeroenhoek/josm/gridify/ui/OperationChooser.java b/src/nl/jeroenhoek/josm/gridify/ui/OperationChooser.java
new file mode 100644
index 0000000..c1e179c
--- /dev/null
+++ b/src/nl/jeroenhoek/josm/gridify/ui/OperationChooser.java
@@ -0,0 +1,41 @@
+// License: GPL. For details, see LICENSE file.
+package nl.jeroenhoek.josm.gridify.ui;
+
+import nl.jeroenhoek.josm.gridify.Operation;
+
+import javax.swing.BoxLayout;
+import javax.swing.ButtonGroup;
+import javax.swing.JPanel;
+import javax.swing.JRadioButton;
+
+/**
+ * UI widget for choosing the {@link Operation} to perform.
+ */
+public class OperationChooser extends JPanel {
+ private Operation selected;
+
+ public OperationChooser(Operation defaultOperation) {
+ setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
+
+ ButtonGroup group = new ButtonGroup();
+ selected = defaultOperation;
+
+ for (Operation operation : Operation.values()) {
+ JRadioButton button = new JRadioButton(operation.toString());
+ button.addActionListener(e -> {
+ if (button.isSelected()) {
+ selected = operation;
+ }
+ });
+ if (operation == defaultOperation) {
+ button.setSelected(true);
+ }
+ group.add(button);
+ add(button);
+ }
+ }
+
+ public Operation getSelected() {
+ return selected;
+ }
+}
diff --git a/src/nl/jeroenhoek/josm/gridify/ui/PositiveSpinner.java b/src/nl/jeroenhoek/josm/gridify/ui/PositiveSpinner.java
new file mode 100644
index 0000000..a189f74
--- /dev/null
+++ b/src/nl/jeroenhoek/josm/gridify/ui/PositiveSpinner.java
@@ -0,0 +1,49 @@
+// License: GPL. For details, see LICENSE file.
+package nl.jeroenhoek.josm.gridify.ui;
+
+import javax.swing.JFormattedTextField;
+import javax.swing.JSpinner;
+import javax.swing.SpinnerNumberModel;
+import java.awt.Dimension;
+import java.awt.Font;
+
+/**
+ * A {@link JSpinner} limited to a range of 1 to 1000. This class also adds a mouse wheel listener allowing users to
+ * scroll the value up or down.
+ */
+public class PositiveSpinner extends JSpinner {
+ public PositiveSpinner(int defaultValue) {
+ super(new SpinnerNumberModel(defaultValue, 1, 1000, 1));
+ JFormattedTextField field = ((DefaultEditor) getEditor()).getTextField();
+ field.setColumns(3);
+ field.setFont(new Font("Monospaced", Font.PLAIN, 18));
+
+ // Kind of weird that the default JSpinner doesn't do this.
+ addMouseWheelListener(e -> {
+ int ticks = e.getWheelRotation();
+ Object newValue = null;
+ if (ticks > 0) {
+ for (int tick = 0; tick < ticks; tick++) {
+ Object previous = getPreviousValue();
+ if (previous == null) break;
+ newValue = previous;
+ }
+ } else if (ticks < 0) {
+ for (int tick = 0; tick > ticks; tick--) {
+ Object next = getNextValue();
+ if (next == null) break;
+ newValue = next;
+ }
+ }
+
+ if (newValue != null) {
+ setValue(newValue);
+ }
+ });
+ }
+
+ @Override
+ public Dimension getMaximumSize() {
+ return getPreferredSize();
+ }
+}
diff --git a/src/nl/jeroenhoek/josm/gridify/ui/Preview.java b/src/nl/jeroenhoek/josm/gridify/ui/Preview.java
new file mode 100644
index 0000000..974a8a4
--- /dev/null
+++ b/src/nl/jeroenhoek/josm/gridify/ui/Preview.java
@@ -0,0 +1,266 @@
+// License: GPL. For details, see LICENSE file.
+package nl.jeroenhoek.josm.gridify.ui;
+
+import nl.jeroenhoek.josm.gridify.GridExtrema;
+import nl.jeroenhoek.josm.gridify.GridExtrema.Dimensions;
+import nl.jeroenhoek.josm.gridify.ui.GridSizePanel.Nudge;
+import org.openstreetmap.josm.data.coor.EastNorth;
+import org.openstreetmap.josm.data.osm.Node;
+
+import javax.swing.JPanel;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+import static java.awt.event.InputEvent.CTRL_DOWN_MASK;
+import static java.awt.event.InputEvent.SHIFT_DOWN_MASK;
+
+/**
+ * UI widget that shows an abstracted live preview of the operation to be performed. Because shapes are rarely neatly
+ * aligned to the cardinal directions of the compass, terms like 'rows' and 'columns' become rather abstract. So to
+ * prevent confusion, we simply show the user how his grid will be divided.
+ *
+ * The preview panel is updated whenever the values held by the {@link GridSizePanel} are changed.
+ */
+public class Preview extends JPanel {
+ final int CANVAS_SIZE = 280;
+ final int PADDING = 10;
+
+ final Dimension size = new Dimension(CANVAS_SIZE + 2 * PADDING, CANVAS_SIZE + 2 * PADDING);
+
+ Grid grid;
+
+ public Preview(GridExtrema gridExtrema, GridifySettingsDialog settingsDialog) {
+ Dimensions dimensions = gridExtrema.getDimensions();
+
+ // Compute the multiplication factor and offsets that convert between EastNorth and local preview canvas
+ // coordinates.
+ double longestDistance = dimensions.longestOfWidthAndHeight();
+ double fraction = CANVAS_SIZE / longestDistance;
+
+ double offsetX = (longestDistance - (dimensions.getMaxX() - dimensions.getMinX())) / 2.0;
+ double offsetY = (longestDistance - (dimensions.getMaxY() - dimensions.getMinY())) / 2.0;
+
+ grid = new Grid(
+ toCanvasCoordinates(dimensions, fraction, offsetX, offsetY, gridExtrema.getNodeOne()),
+ toCanvasCoordinates(dimensions, fraction, offsetX, offsetY, gridExtrema.getNodeTwo()),
+ toCanvasCoordinates(dimensions, fraction, offsetX, offsetY, gridExtrema.getNodeThree()),
+ toCanvasCoordinates(dimensions, fraction, offsetX, offsetY, gridExtrema.getNodeFour())
+ );
+ grid.setRows(settingsDialog.getRowCount());
+ grid.setColumns(settingsDialog.getColumnCount());
+
+ // Allow users to adjust the row/column count by using the mouse wheel on top of the preview.
+ // This is just an extra; the two spinners are the more visible way of adjusting the row and
+ // column count.
+ addMouseWheelListener(e -> {
+ int ticks = e.getWheelRotation();
+ int modifiers = e.getModifiersEx();
+ // Only if shift is down (but not ctrl).
+ boolean doOnlyRows = (modifiers & (SHIFT_DOWN_MASK | CTRL_DOWN_MASK)) == SHIFT_DOWN_MASK;
+ // Only if ctrl is down (but not shift).
+ boolean doOnlyColumns = (modifiers & (SHIFT_DOWN_MASK | CTRL_DOWN_MASK)) == CTRL_DOWN_MASK;
+ if (ticks > 0) {
+ for (int tick = 0; tick < ticks; tick++) {
+ if (doOnlyRows) {
+ settingsDialog.nudgeRowCount(Nudge.DECREMENT);
+ } else if (doOnlyColumns) {
+ settingsDialog.nudgeColumnCount(Nudge.DECREMENT);
+ } else {
+ settingsDialog.nudgeRowCount(Nudge.DECREMENT);
+ settingsDialog.nudgeColumnCount(Nudge.DECREMENT);
+ }
+ }
+ } else if (ticks < 0) {
+ for (int tick = 0; tick > ticks; tick--) {
+ if (doOnlyRows) {
+ settingsDialog.nudgeRowCount(Nudge.INCREMENT);
+ } else if (doOnlyColumns) {
+ settingsDialog.nudgeColumnCount(Nudge.INCREMENT);
+ } else {
+ settingsDialog.nudgeRowCount(Nudge.INCREMENT);
+ settingsDialog.nudgeColumnCount(Nudge.INCREMENT);
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ public Dimension getMinimumSize() {
+ return size;
+ }
+
+ @Override
+ public Dimension getPreferredSize() {
+ return size;
+ }
+
+ @Override
+ public void paintComponent(Graphics g) {
+ super.paintComponent(g);
+
+ // Use a BufferedImage to enable drawing anti-aliased lines.
+ BufferedImage image = new BufferedImage(
+ CANVAS_SIZE + 2 * PADDING,
+ CANVAS_SIZE + 2 * PADDING,
+ BufferedImage.TYPE_INT_RGB);
+ Graphics2D ig = image.createGraphics();
+
+ ig.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+
+ // Background.
+ ig.setColor(Color.BLACK);
+ ig.fillRect(0, 0, 300, 300);
+
+ // Draw the grid.
+ ig.setColor(Color.RED);
+ grid.withLines((start, stop) -> ig.drawLine(start.x, start.y, stop.x, stop.y));
+
+ g.drawImage(image, 0, 0, this);
+ }
+
+ public void updateRowsColumns(int rows, int columns) {
+ this.grid.setRows(rows);
+ this.grid.setColumns(columns);
+ repaint();
+ }
+
+ /**
+ * Turn {@link EastNorth} coordinates into our local integer coordinates for the preview canvas.
+ *
+ * @param dimensions Bounding box used to determine the scaling factor.
+ * @param fraction Scaling factor.
+ * @param offsetX X offset (to centre the grid in the preview canvas).
+ * @param offsetY Y offset (to centre the grid in the preview canvas).
+ * @param node Node to convert.
+ * @return Local preview canvas coordinates.
+ */
+ Coordinates toCanvasCoordinates(Dimensions dimensions,
+ double fraction,
+ double offsetX,
+ double offsetY,
+ Node node) {
+ EastNorth eastNorth = node.getEastNorth();
+ double x = eastNorth.getX();
+ double y = eastNorth.getY();
+
+ Coordinates coordinates = new Coordinates();
+
+ x -= dimensions.getMinX();
+ y -= dimensions.getMinY();
+
+ x += offsetX;
+ y += offsetY;
+
+ x *= fraction;
+ y *= fraction;
+
+ coordinates.x = PADDING + (int) Math.round(x);
+ // The y coordinates are 'upside down'.
+ coordinates.y = (CANVAS_SIZE - (int) Math.round(y)) + PADDING;
+
+ return coordinates;
+ }
+
+ /**
+ * Simple integer coordinate pair.
+ */
+ public static class Coordinates {
+ int x;
+ int y;
+
+ @Override
+ public String toString() {
+ return "x=" + x + ", y=" + y;
+ }
+ }
+
+ /**
+ * Representation of the grid the user wants to generate. The four coordinates specified in a clockwise order.
+ */
+ public static class Grid {
+ Coordinates one;
+ Coordinates two;
+ Coordinates three;
+ Coordinates four;
+
+ int rows;
+ int columns;
+
+ public Grid(Coordinates one, Coordinates two, Coordinates three, Coordinates four) {
+ this.one = one;
+ this.two = two;
+ this.three = three;
+ this.four = four;
+ }
+
+ /**
+ * Perform an operation on each line of the grid.
+ *
+ * @param consumer Consumer that receives two coordinate pairs for each line of the grid, both rows and columns.
+ */
+ void withLines(BiConsumer consumer) {
+ List top = range(one, two, columns);
+ List bottom = range(four, three, columns);
+ List left = range(one, four, rows);
+ List right = range(two, three, rows);
+
+ // All columns.
+ for (int i = 0; i < top.size(); i++) {
+ consumer.accept(top.get(i), bottom.get(i));
+ }
+ // All rows.
+ for (int i = 0; i < left.size(); i++) {
+ consumer.accept(left.get(i), right.get(i));
+ }
+ }
+
+ /**
+ * Generate a list of coordinates representing a number of points evenly distributed along a line.
+ *
+ * @param a Start of the line.
+ * @param b End of the line.
+ * @param n Number of point to add to the line.
+ * @return A list of coordinates, including the start and end of the line.
+ */
+ private List range(Coordinates a, Coordinates b, int n) {
+ List points = new ArrayList<>();
+ points.add(a);
+
+ if (n > 1) {
+ float dx = (b.x - a.x) / (float) n;
+ float dy = (b.y - a.y) / (float) n;
+
+ for (int i = 1; i < n; i++) {
+ Coordinates between = new Coordinates();
+ between.x = a.x + (Math.round(dx * i));
+ between.y = a.y + (Math.round(dy * i));
+ points.add(between);
+ }
+ }
+
+ points.add(b);
+ return points;
+ }
+
+ public void setRows(int rows) {
+ this.rows = rows;
+ }
+
+ public void setColumns(int columns) {
+ this.columns = columns;
+ }
+
+ @Override
+ public String toString() {
+ return one + "|" + two + "|" + three + "|" + four;
+ }
+ }
+}
diff --git a/src/nl/jeroenhoek/josm/gridify/ui/SourceWayPanel.java b/src/nl/jeroenhoek/josm/gridify/ui/SourceWayPanel.java
new file mode 100644
index 0000000..f5503b6
--- /dev/null
+++ b/src/nl/jeroenhoek/josm/gridify/ui/SourceWayPanel.java
@@ -0,0 +1,50 @@
+// License: GPL. For details, see LICENSE file.
+package nl.jeroenhoek.josm.gridify.ui;
+
+import org.openstreetmap.josm.data.osm.Way;
+
+import javax.swing.BoxLayout;
+import javax.swing.JCheckBox;
+import javax.swing.JPanel;
+
+import static org.openstreetmap.josm.tools.I18n.tr;
+
+/**
+ * UI widget that shows options relating to the {@link Way} the user selected.
+ */
+public class SourceWayPanel extends JPanel {
+ private final JCheckBox copyTagsButton;
+ private final JCheckBox deleteSourceWayButton;
+
+ public SourceWayPanel(boolean copyTags, boolean deleteSourceWay) {
+ setLayout(new BoxLayout(this, BoxLayout.Y_AXIS));
+
+ copyTagsButton = new JCheckBox(tr("Copy tags from source way"));
+ copyTagsButton.setSelected(copyTags);
+
+ add(copyTagsButton);
+
+ deleteSourceWayButton = new JCheckBox(tr("Delete source way"));
+ deleteSourceWayButton.setSelected(deleteSourceWay);
+
+ add(deleteSourceWayButton);
+ }
+
+ /**
+ * Should the tags from the source way be reused?
+ *
+ * @return True, if the tags should be copied to the newly generated ways.
+ */
+ public boolean copyTags() {
+ return copyTagsButton.isSelected();
+ }
+
+ /**
+ * Should the source way be deleted?
+ *
+ * @return True if the user wants the source to be deleted.
+ */
+ public boolean deleteSourceWay() {
+ return deleteSourceWayButton.isSelected();
+ }
+}