// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.gui.dialogs; import static org.openstreetmap.josm.tools.I18n.tr; import java.awt.Component; import java.awt.Dimension; import java.awt.GridBagLayout; import java.awt.event.ActionEvent; import java.awt.event.KeyEvent; import java.awt.event.MouseEvent; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import javax.swing.AbstractAction; import javax.swing.Box; import javax.swing.JComponent; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JScrollPane; import javax.swing.JSeparator; import javax.swing.JTree; import javax.swing.event.TreeModelEvent; import javax.swing.event.TreeModelListener; import javax.swing.event.TreeSelectionEvent; import javax.swing.event.TreeSelectionListener; import javax.swing.tree.DefaultMutableTreeNode; import javax.swing.tree.DefaultTreeCellRenderer; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.TreePath; import javax.swing.tree.TreeSelectionModel; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.actions.AutoScaleAction; import org.openstreetmap.josm.command.Command; import org.openstreetmap.josm.command.PseudoCommand; import org.openstreetmap.josm.data.osm.DataSet; import org.openstreetmap.josm.data.osm.OsmPrimitive; import org.openstreetmap.josm.gui.SideButton; import org.openstreetmap.josm.gui.layer.OsmDataLayer; import org.openstreetmap.josm.gui.layer.OsmDataLayer.CommandQueueListener; import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher; import org.openstreetmap.josm.tools.GBC; import org.openstreetmap.josm.tools.ImageProvider; import org.openstreetmap.josm.tools.InputMapUtils; import org.openstreetmap.josm.tools.Shortcut; import org.openstreetmap.josm.tools.SubclassFilteredCollection; /** * Dialog displaying list of all executed commands (undo/redo buffer). * @since 94 */ public class CommandStackDialog extends ToggleDialog implements CommandQueueListener { private final DefaultTreeModel undoTreeModel = new DefaultTreeModel(new DefaultMutableTreeNode()); private final DefaultTreeModel redoTreeModel = new DefaultTreeModel(new DefaultMutableTreeNode()); private final JTree undoTree = new JTree(undoTreeModel); private final JTree redoTree = new JTree(redoTreeModel); private final transient UndoRedoSelectionListener undoSelectionListener; private final transient UndoRedoSelectionListener redoSelectionListener; private final JScrollPane scrollPane; private final JSeparator separator = new JSeparator(); // only visible, if separator is the top most component private final Component spacer = Box.createRigidArea(new Dimension(0, 3)); // last operation is remembered to select the next undo/redo entry in the list // after undo/redo command private UndoRedoType lastOperation = UndoRedoType.UNDO; // Actions for context menu and Enter key private final SelectAction selectAction = new SelectAction(); private final SelectAndZoomAction selectAndZoomAction = new SelectAndZoomAction(); /** * Constructs a new {@code CommandStackDialog}. */ public CommandStackDialog() { super(tr("Command Stack"), "commandstack", tr("Open a list of all commands (undo buffer)."), Shortcut.registerShortcut("subwindow:commandstack", tr("Toggle: {0}", tr("Command Stack")), KeyEvent.VK_O, Shortcut.ALT_SHIFT), 100); undoTree.addMouseListener(new MouseEventHandler()); undoTree.setRootVisible(false); undoTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); undoTree.setShowsRootHandles(true); undoTree.expandRow(0); undoTree.setCellRenderer(new CommandCellRenderer()); undoSelectionListener = new UndoRedoSelectionListener(undoTree); undoTree.getSelectionModel().addTreeSelectionListener(undoSelectionListener); InputMapUtils.unassignCtrlShiftUpDown(undoTree, JComponent.WHEN_FOCUSED); redoTree.addMouseListener(new MouseEventHandler()); redoTree.setRootVisible(false); redoTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); redoTree.setShowsRootHandles(true); redoTree.expandRow(0); redoTree.setCellRenderer(new CommandCellRenderer()); redoSelectionListener = new UndoRedoSelectionListener(redoTree); redoTree.getSelectionModel().addTreeSelectionListener(redoSelectionListener); InputMapUtils.unassignCtrlShiftUpDown(redoTree, JComponent.WHEN_FOCUSED); JPanel treesPanel = new JPanel(new GridBagLayout()); treesPanel.add(spacer, GBC.eol()); spacer.setVisible(false); treesPanel.add(undoTree, GBC.eol().fill(GBC.HORIZONTAL)); separator.setVisible(false); treesPanel.add(separator, GBC.eol().fill(GBC.HORIZONTAL)); treesPanel.add(redoTree, GBC.eol().fill(GBC.HORIZONTAL)); treesPanel.add(Box.createRigidArea(new Dimension(0, 0)), GBC.std().weight(0, 1)); treesPanel.setBackground(redoTree.getBackground()); wireUpdateEnabledStateUpdater(selectAction, undoTree); wireUpdateEnabledStateUpdater(selectAction, redoTree); UndoRedoAction undoAction = new UndoRedoAction(UndoRedoType.UNDO); wireUpdateEnabledStateUpdater(undoAction, undoTree); UndoRedoAction redoAction = new UndoRedoAction(UndoRedoType.REDO); wireUpdateEnabledStateUpdater(redoAction, redoTree); scrollPane = (JScrollPane) createLayout(treesPanel, true, Arrays.asList(new SideButton[] { new SideButton(selectAction), new SideButton(undoAction), new SideButton(redoAction) })); InputMapUtils.addEnterAction(undoTree, selectAndZoomAction); InputMapUtils.addEnterAction(redoTree, selectAndZoomAction); } private static class CommandCellRenderer extends DefaultTreeCellRenderer { @Override public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded, boolean leaf, int row, boolean hasFocus) { super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus); DefaultMutableTreeNode v = (DefaultMutableTreeNode) value; if (v.getUserObject() instanceof JLabel) { JLabel l = (JLabel) v.getUserObject(); setIcon(l.getIcon()); setText(l.getText()); } return this; } } private void updateTitle() { int undo = undoTreeModel.getChildCount(undoTreeModel.getRoot()); int redo = redoTreeModel.getChildCount(redoTreeModel.getRoot()); if (undo > 0 || redo > 0) { setTitle(tr("Command Stack: Undo: {0} / Redo: {1}", undo, redo)); } else { setTitle(tr("Command Stack")); } } /** * Selection listener for undo and redo area. * If one is clicked, takes away the selection from the other, so * it behaves as if it was one component. */ private class UndoRedoSelectionListener implements TreeSelectionListener { private final JTree source; UndoRedoSelectionListener(JTree source) { this.source = source; } @Override public void valueChanged(TreeSelectionEvent e) { if (source == undoTree) { redoTree.getSelectionModel().removeTreeSelectionListener(redoSelectionListener); redoTree.clearSelection(); redoTree.getSelectionModel().addTreeSelectionListener(redoSelectionListener); } if (source == redoTree) { undoTree.getSelectionModel().removeTreeSelectionListener(undoSelectionListener); undoTree.clearSelection(); undoTree.getSelectionModel().addTreeSelectionListener(undoSelectionListener); } } } /** * Wires updater for enabled state to the events. Also updates dialog title if needed. * @param updater updater * @param tree tree on which wire updater */ protected void wireUpdateEnabledStateUpdater(final IEnabledStateUpdating updater, JTree tree) { addShowNotifyListener(updater); tree.addTreeSelectionListener(e -> updater.updateEnabledState()); tree.getModel().addTreeModelListener(new TreeModelListener() { @Override public void treeNodesChanged(TreeModelEvent e) { updater.updateEnabledState(); updateTitle(); } @Override public void treeNodesInserted(TreeModelEvent e) { updater.updateEnabledState(); updateTitle(); } @Override public void treeNodesRemoved(TreeModelEvent e) { updater.updateEnabledState(); updateTitle(); } @Override public void treeStructureChanged(TreeModelEvent e) { updater.updateEnabledState(); updateTitle(); } }); } @Override public void showNotify() { buildTrees(); for (IEnabledStateUpdating listener : showNotifyListener) { listener.updateEnabledState(); } Main.main.undoRedo.addCommandQueueListener(this); } /** * Simple listener setup to update the button enabled state when the side dialog shows. */ private final transient Set<IEnabledStateUpdating> showNotifyListener = new LinkedHashSet<>(); private void addShowNotifyListener(IEnabledStateUpdating listener) { showNotifyListener.add(listener); } @Override public void hideNotify() { undoTreeModel.setRoot(new DefaultMutableTreeNode()); redoTreeModel.setRoot(new DefaultMutableTreeNode()); Main.main.undoRedo.removeCommandQueueListener(this); } /** * Build the trees of undo and redo commands (initially or when * they have changed). */ private void buildTrees() { setTitle(tr("Command Stack")); if (Main.getLayerManager().getEditLayer() == null) return; List<Command> undoCommands = Main.main.undoRedo.commands; DefaultMutableTreeNode undoRoot = new DefaultMutableTreeNode(); for (int i = 0; i < undoCommands.size(); ++i) { undoRoot.add(getNodeForCommand(undoCommands.get(i), i)); } undoTreeModel.setRoot(undoRoot); List<Command> redoCommands = Main.main.undoRedo.redoCommands; DefaultMutableTreeNode redoRoot = new DefaultMutableTreeNode(); for (int i = 0; i < redoCommands.size(); ++i) { redoRoot.add(getNodeForCommand(redoCommands.get(i), i)); } redoTreeModel.setRoot(redoRoot); if (redoTreeModel.getChildCount(redoRoot) > 0) { redoTree.scrollRowToVisible(0); scrollPane.getHorizontalScrollBar().setValue(0); } separator.setVisible(!undoCommands.isEmpty() || !redoCommands.isEmpty()); spacer.setVisible(undoCommands.isEmpty() && !redoCommands.isEmpty()); // if one tree is empty, move selection to the other switch (lastOperation) { case UNDO: if (undoCommands.isEmpty()) { lastOperation = UndoRedoType.REDO; } break; case REDO: if (redoCommands.isEmpty()) { lastOperation = UndoRedoType.UNDO; } break; } // select the next command to undo/redo switch (lastOperation) { case UNDO: undoTree.setSelectionRow(undoTree.getRowCount()-1); break; case REDO: redoTree.setSelectionRow(0); break; } undoTree.scrollRowToVisible(undoTreeModel.getChildCount(undoRoot)-1); scrollPane.getHorizontalScrollBar().setValue(0); } /** * Wraps a command in a CommandListMutableTreeNode. * Recursively adds child commands. * @param c the command * @param idx index * @return the resulting node */ protected CommandListMutableTreeNode getNodeForCommand(PseudoCommand c, int idx) { CommandListMutableTreeNode node = new CommandListMutableTreeNode(c, idx); if (c.getChildren() != null) { List<PseudoCommand> children = new ArrayList<>(c.getChildren()); for (int i = 0; i < children.size(); ++i) { node.add(getNodeForCommand(children.get(i), i)); } } return node; } /** * Return primitives that are affected by some command * @param path GUI elements * @return collection of affected primitives, onluy usable ones */ protected static Collection<? extends OsmPrimitive> getAffectedPrimitives(TreePath path) { PseudoCommand c = ((CommandListMutableTreeNode) path.getLastPathComponent()).getCommand(); final OsmDataLayer currentLayer = Main.getLayerManager().getEditLayer(); return new SubclassFilteredCollection<>( c.getParticipatingPrimitives(), o -> { OsmPrimitive p = currentLayer.data.getPrimitiveById(o); return p != null && p.isUsable(); } ); } @Override public void commandChanged(int queueSize, int redoSize) { if (!isVisible()) return; buildTrees(); } /** * Action that selects the objects that take part in a command. */ public class SelectAction extends AbstractAction implements IEnabledStateUpdating { /** * Constructs a new {@code SelectAction}. */ public SelectAction() { putValue(NAME, tr("Select")); putValue(SHORT_DESCRIPTION, tr("Selects the objects that take part in this command (unless currently deleted)")); new ImageProvider("dialogs", "select").getResource().attachImageIcon(this, true); } @Override public void actionPerformed(ActionEvent e) { TreePath path; if (!undoTree.isSelectionEmpty()) { path = undoTree.getSelectionPath(); } else if (!redoTree.isSelectionEmpty()) { path = redoTree.getSelectionPath(); } else throw new IllegalStateException(); DataSet dataSet = Main.getLayerManager().getEditDataSet(); if (dataSet == null) return; dataSet.setSelected(getAffectedPrimitives(path)); } @Override public void updateEnabledState() { setEnabled(!undoTree.isSelectionEmpty() || !redoTree.isSelectionEmpty()); } } /** * Action that selects the objects that take part in a command, then zoom to them. */ public class SelectAndZoomAction extends SelectAction { /** * Constructs a new {@code SelectAndZoomAction}. */ public SelectAndZoomAction() { putValue(NAME, tr("Select and zoom")); putValue(SHORT_DESCRIPTION, tr("Selects the objects that take part in this command (unless currently deleted), then and zooms to it")); new ImageProvider("dialogs/autoscale", "selection").getResource().attachImageIcon(this, true); } @Override public void actionPerformed(ActionEvent e) { super.actionPerformed(e); AutoScaleAction.autoScale("selection"); } } /** * undo / redo switch to reduce duplicate code */ protected enum UndoRedoType { UNDO, REDO } /** * Action to undo or redo all commands up to (and including) the seleced item. */ protected class UndoRedoAction extends AbstractAction implements IEnabledStateUpdating { private final UndoRedoType type; private final JTree tree; /** * constructor * @param type decide whether it is an undo action or a redo action */ public UndoRedoAction(UndoRedoType type) { this.type = type; if (UndoRedoType.UNDO == type) { tree = undoTree; putValue(NAME, tr("Undo")); putValue(SHORT_DESCRIPTION, tr("Undo the selected and all later commands")); new ImageProvider("undo").getResource().attachImageIcon(this, true); } else { tree = redoTree; putValue(NAME, tr("Redo")); putValue(SHORT_DESCRIPTION, tr("Redo the selected and all earlier commands")); new ImageProvider("redo").getResource().attachImageIcon(this, true); } } @Override public void actionPerformed(ActionEvent e) { lastOperation = type; TreePath path = tree.getSelectionPath(); // we can only undo top level commands if (path.getPathCount() != 2) throw new IllegalStateException(); int idx = ((CommandListMutableTreeNode) path.getLastPathComponent()).getIndex(); // calculate the number of commands to undo/redo; then do it switch (type) { case UNDO: int numUndo = ((DefaultMutableTreeNode) undoTreeModel.getRoot()).getChildCount() - idx; Main.main.undoRedo.undo(numUndo); break; case REDO: int numRedo = idx+1; Main.main.undoRedo.redo(numRedo); break; } Main.map.repaint(); } @Override public void updateEnabledState() { // do not allow execution if nothing is selected or a sub command was selected setEnabled(!tree.isSelectionEmpty() && tree.getSelectionPath().getPathCount() == 2); } } class MouseEventHandler extends PopupMenuLauncher { MouseEventHandler() { super(new CommandStackPopup()); } @Override public void mouseClicked(MouseEvent evt) { if (isDoubleClick(evt)) { selectAndZoomAction.actionPerformed(null); } } } private class CommandStackPopup extends JPopupMenu { CommandStackPopup() { add(selectAction); add(selectAndZoomAction); } } }