001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.dialogs;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Component;
007import java.awt.Dimension;
008import java.awt.GridBagLayout;
009import java.awt.event.ActionEvent;
010import java.awt.event.KeyEvent;
011import java.awt.event.MouseEvent;
012import java.util.ArrayList;
013import java.util.Arrays;
014import java.util.Collection;
015import java.util.LinkedHashSet;
016import java.util.List;
017import java.util.Set;
018
019import javax.swing.AbstractAction;
020import javax.swing.Box;
021import javax.swing.JComponent;
022import javax.swing.JLabel;
023import javax.swing.JPanel;
024import javax.swing.JPopupMenu;
025import javax.swing.JScrollPane;
026import javax.swing.JSeparator;
027import javax.swing.JTree;
028import javax.swing.event.TreeModelEvent;
029import javax.swing.event.TreeModelListener;
030import javax.swing.event.TreeSelectionEvent;
031import javax.swing.event.TreeSelectionListener;
032import javax.swing.tree.DefaultMutableTreeNode;
033import javax.swing.tree.DefaultTreeCellRenderer;
034import javax.swing.tree.DefaultTreeModel;
035import javax.swing.tree.MutableTreeNode;
036import javax.swing.tree.TreePath;
037import javax.swing.tree.TreeSelectionModel;
038
039import org.openstreetmap.josm.actions.AutoScaleAction;
040import org.openstreetmap.josm.actions.AutoScaleAction.AutoScaleMode;
041import org.openstreetmap.josm.command.Command;
042import org.openstreetmap.josm.command.PseudoCommand;
043import org.openstreetmap.josm.data.UndoRedoHandler;
044import org.openstreetmap.josm.data.UndoRedoHandler.CommandAddedEvent;
045import org.openstreetmap.josm.data.UndoRedoHandler.CommandQueueCleanedEvent;
046import org.openstreetmap.josm.data.UndoRedoHandler.CommandQueuePreciseListener;
047import org.openstreetmap.josm.data.UndoRedoHandler.CommandRedoneEvent;
048import org.openstreetmap.josm.data.UndoRedoHandler.CommandUndoneEvent;
049import org.openstreetmap.josm.data.osm.DataSet;
050import org.openstreetmap.josm.data.osm.OsmPrimitive;
051import org.openstreetmap.josm.gui.MainApplication;
052import org.openstreetmap.josm.gui.SideButton;
053import org.openstreetmap.josm.gui.layer.OsmDataLayer;
054import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
055import org.openstreetmap.josm.tools.GBC;
056import org.openstreetmap.josm.tools.ImageProvider;
057import org.openstreetmap.josm.tools.InputMapUtils;
058import org.openstreetmap.josm.tools.Shortcut;
059import org.openstreetmap.josm.tools.SubclassFilteredCollection;
060
061/**
062 * Dialog displaying list of all executed commands (undo/redo buffer).
063 * @since 94
064 */
065public class CommandStackDialog extends ToggleDialog implements CommandQueuePreciseListener {
066
067    private final DefaultTreeModel undoTreeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
068    private final DefaultTreeModel redoTreeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
069
070    private final JTree undoTree = new JTree(undoTreeModel);
071    private final JTree redoTree = new JTree(redoTreeModel);
072
073    private DefaultMutableTreeNode undoRoot;
074    private DefaultMutableTreeNode redoRoot;
075
076    private final transient UndoRedoSelectionListener undoSelectionListener;
077    private final transient UndoRedoSelectionListener redoSelectionListener;
078
079    private final JScrollPane scrollPane;
080    private final JSeparator separator = new JSeparator();
081    // only visible, if separator is the top most component
082    private final Component spacer = Box.createRigidArea(new Dimension(0, 3));
083
084    // last operation is remembered to select the next undo/redo entry in the list
085    // after undo/redo command
086    private UndoRedoType lastOperation = UndoRedoType.UNDO;
087
088    // Actions for context menu and Enter key
089    private final SelectAction selectAction = new SelectAction();
090    private final SelectAndZoomAction selectAndZoomAction = new SelectAndZoomAction();
091
092    /**
093     * Constructs a new {@code CommandStackDialog}.
094     */
095    public CommandStackDialog() {
096        super(tr("Command Stack"), "commandstack", tr("Open a list of all commands (undo buffer)."),
097                Shortcut.registerShortcut("subwindow:commandstack", tr("Toggle: {0}",
098                tr("Command Stack")), KeyEvent.VK_O, Shortcut.ALT_SHIFT), 100);
099        undoTree.addMouseListener(new MouseEventHandler());
100        undoTree.setRootVisible(false);
101        undoTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
102        undoTree.setShowsRootHandles(true);
103        undoTree.expandRow(0);
104        undoTree.setCellRenderer(new CommandCellRenderer());
105        undoSelectionListener = new UndoRedoSelectionListener(undoTree);
106        undoTree.getSelectionModel().addTreeSelectionListener(undoSelectionListener);
107        InputMapUtils.unassignCtrlShiftUpDown(undoTree, JComponent.WHEN_FOCUSED);
108
109        redoTree.addMouseListener(new MouseEventHandler());
110        redoTree.setRootVisible(false);
111        redoTree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
112        redoTree.setShowsRootHandles(true);
113        redoTree.expandRow(0);
114        redoTree.setCellRenderer(new CommandCellRenderer());
115        redoSelectionListener = new UndoRedoSelectionListener(redoTree);
116        redoTree.getSelectionModel().addTreeSelectionListener(redoSelectionListener);
117        InputMapUtils.unassignCtrlShiftUpDown(redoTree, JComponent.WHEN_FOCUSED);
118
119        JPanel treesPanel = new JPanel(new GridBagLayout());
120
121        treesPanel.add(spacer, GBC.eol());
122        spacer.setVisible(false);
123        treesPanel.add(undoTree, GBC.eol().fill(GBC.HORIZONTAL));
124        separator.setVisible(false);
125        treesPanel.add(separator, GBC.eol().fill(GBC.HORIZONTAL));
126        treesPanel.add(redoTree, GBC.eol().fill(GBC.HORIZONTAL));
127        treesPanel.add(Box.createRigidArea(new Dimension(0, 0)), GBC.std().weight(0, 1));
128        treesPanel.setBackground(redoTree.getBackground());
129
130        wireUpdateEnabledStateUpdater(selectAction, undoTree);
131        wireUpdateEnabledStateUpdater(selectAction, redoTree);
132
133        UndoRedoAction undoAction = new UndoRedoAction(UndoRedoType.UNDO);
134        wireUpdateEnabledStateUpdater(undoAction, undoTree);
135
136        UndoRedoAction redoAction = new UndoRedoAction(UndoRedoType.REDO);
137        wireUpdateEnabledStateUpdater(redoAction, redoTree);
138
139        scrollPane = (JScrollPane) createLayout(treesPanel, true, Arrays.asList(
140            new SideButton(selectAction),
141            new SideButton(undoAction),
142            new SideButton(redoAction)
143        ));
144
145        InputMapUtils.addEnterAction(undoTree, selectAndZoomAction);
146        InputMapUtils.addEnterAction(redoTree, selectAndZoomAction);
147    }
148
149    private static class CommandCellRenderer extends DefaultTreeCellRenderer {
150        @Override
151        public Component getTreeCellRendererComponent(JTree tree, Object value, boolean sel, boolean expanded, boolean leaf, int row,
152                boolean hasFocus) {
153            super.getTreeCellRendererComponent(tree, value, sel, expanded, leaf, row, hasFocus);
154            DefaultMutableTreeNode v = (DefaultMutableTreeNode) value;
155            if (v.getUserObject() instanceof JLabel) {
156                JLabel l = (JLabel) v.getUserObject();
157                setIcon(l.getIcon());
158                setText(l.getText());
159            }
160            return this;
161        }
162    }
163
164    private void updateTitle() {
165        int undo = undoTreeModel.getChildCount(undoTreeModel.getRoot());
166        int redo = redoTreeModel.getChildCount(redoTreeModel.getRoot());
167        if (undo > 0 || redo > 0) {
168            setTitle(tr("Command Stack: Undo: {0} / Redo: {1}", undo, redo));
169        } else {
170            setTitle(tr("Command Stack"));
171        }
172    }
173
174    /**
175     * Selection listener for undo and redo area.
176     * If one is clicked, takes away the selection from the other, so
177     * it behaves as if it was one component.
178     */
179    private class UndoRedoSelectionListener implements TreeSelectionListener {
180        private final JTree source;
181
182        UndoRedoSelectionListener(JTree source) {
183            this.source = source;
184        }
185
186        @Override
187        public void valueChanged(TreeSelectionEvent e) {
188            if (source == undoTree) {
189                redoTree.getSelectionModel().removeTreeSelectionListener(redoSelectionListener);
190                redoTree.clearSelection();
191                redoTree.getSelectionModel().addTreeSelectionListener(redoSelectionListener);
192            }
193            if (source == redoTree) {
194                undoTree.getSelectionModel().removeTreeSelectionListener(undoSelectionListener);
195                undoTree.clearSelection();
196                undoTree.getSelectionModel().addTreeSelectionListener(undoSelectionListener);
197            }
198        }
199    }
200
201    /**
202     * Wires updater for enabled state to the events. Also updates dialog title if needed.
203     * @param updater updater
204     * @param tree tree on which wire updater
205     */
206    protected void wireUpdateEnabledStateUpdater(final IEnabledStateUpdating updater, JTree tree) {
207        addShowNotifyListener(updater);
208
209        tree.addTreeSelectionListener(e -> updater.updateEnabledState());
210
211        tree.getModel().addTreeModelListener(new TreeModelListener() {
212            @Override
213            public void treeNodesChanged(TreeModelEvent e) {
214                updater.updateEnabledState();
215                updateTitle();
216            }
217
218            @Override
219            public void treeNodesInserted(TreeModelEvent e) {
220                treeNodesChanged(e);
221            }
222
223            @Override
224            public void treeNodesRemoved(TreeModelEvent e) {
225                treeNodesChanged(e);
226            }
227
228            @Override
229            public void treeStructureChanged(TreeModelEvent e) {
230                treeNodesChanged(e);
231            }
232        });
233    }
234
235    @Override
236    public void showNotify() {
237        buildTrees();
238        for (IEnabledStateUpdating listener : showNotifyListener) {
239            listener.updateEnabledState();
240        }
241        UndoRedoHandler.getInstance().addCommandQueuePreciseListener(this);
242    }
243
244    /**
245     * Simple listener setup to update the button enabled state when the side dialog shows.
246     */
247    private final transient Set<IEnabledStateUpdating> showNotifyListener = new LinkedHashSet<>();
248
249    private void addShowNotifyListener(IEnabledStateUpdating listener) {
250        showNotifyListener.add(listener);
251    }
252
253    @Override
254    public void hideNotify() {
255        undoRoot = new DefaultMutableTreeNode();
256        redoRoot = new DefaultMutableTreeNode();
257        undoTreeModel.setRoot(undoRoot);
258        redoTreeModel.setRoot(redoRoot);
259        UndoRedoHandler.getInstance().removeCommandQueuePreciseListener(this);
260    }
261
262    /**
263     * Build the trees of undo and redo commands (initially or when
264     * they have changed).
265     */
266    private void buildTrees() {
267        setTitle(tr("Command Stack"));
268        buildUndoTree();
269        buildRedoTree();
270        ensureTreesConsistency();
271    }
272
273    private void buildUndoTree() {
274        List<Command> undoCommands = UndoRedoHandler.getInstance().getUndoCommands();
275        undoRoot = new DefaultMutableTreeNode();
276        for (int i = 0; i < undoCommands.size(); ++i) {
277            undoRoot.add(getNodeForCommand(undoCommands.get(i)));
278        }
279        undoTreeModel.setRoot(undoRoot);
280    }
281
282    private void buildRedoTree() {
283        List<Command> redoCommands = UndoRedoHandler.getInstance().getRedoCommands();
284        redoRoot = new DefaultMutableTreeNode();
285        for (int i = 0; i < redoCommands.size(); ++i) {
286            redoRoot.add(getNodeForCommand(redoCommands.get(i)));
287        }
288        redoTreeModel.setRoot(redoRoot);
289    }
290
291    private void ensureTreesConsistency() {
292        List<Command> undoCommands = UndoRedoHandler.getInstance().getUndoCommands();
293        List<Command> redoCommands = UndoRedoHandler.getInstance().getRedoCommands();
294        if (redoTreeModel.getChildCount(redoRoot) > 0) {
295            redoTree.scrollRowToVisible(0);
296            scrollPane.getHorizontalScrollBar().setValue(0);
297        }
298
299        separator.setVisible(!undoCommands.isEmpty() || !redoCommands.isEmpty());
300        spacer.setVisible(undoCommands.isEmpty() && !redoCommands.isEmpty());
301
302        // if one tree is empty, move selection to the other
303        switch (lastOperation) {
304        case UNDO:
305            if (undoCommands.isEmpty()) {
306                lastOperation = UndoRedoType.REDO;
307            }
308            break;
309        case REDO:
310            if (redoCommands.isEmpty()) {
311                lastOperation = UndoRedoType.UNDO;
312            }
313            break;
314        }
315
316        // select the next command to undo/redo
317        switch (lastOperation) {
318        case UNDO:
319            undoTree.setSelectionRow(undoTree.getRowCount()-1);
320            break;
321        case REDO:
322            redoTree.setSelectionRow(0);
323            break;
324        }
325
326        undoTree.scrollRowToVisible(undoTreeModel.getChildCount(undoRoot)-1);
327        scrollPane.getHorizontalScrollBar().setValue(0);
328    }
329
330    /**
331     * Wraps a command in a CommandListMutableTreeNode.
332     * Recursively adds child commands.
333     * @param c the command
334     * @return the resulting node
335     */
336    protected CommandListMutableTreeNode getNodeForCommand(PseudoCommand c) {
337        CommandListMutableTreeNode node = new CommandListMutableTreeNode(c);
338        if (c.getChildren() != null) {
339            List<PseudoCommand> children = new ArrayList<>(c.getChildren());
340            for (int i = 0; i < children.size(); ++i) {
341                node.add(getNodeForCommand(children.get(i)));
342            }
343        }
344        return node;
345    }
346
347    /**
348     * Return primitives that are affected by some command
349     * @param path GUI elements
350     * @return collection of affected primitives, onluy usable ones
351     */
352    protected static Collection<? extends OsmPrimitive> getAffectedPrimitives(TreePath path) {
353        PseudoCommand c = ((CommandListMutableTreeNode) path.getLastPathComponent()).getCommand();
354        final OsmDataLayer currentLayer = MainApplication.getLayerManager().getEditLayer();
355        return new SubclassFilteredCollection<>(
356                c.getParticipatingPrimitives(),
357                o -> {
358                    OsmPrimitive p = currentLayer.data.getPrimitiveById(o);
359                    return p != null && p.isUsable();
360                }
361        );
362    }
363
364    protected boolean redoTreeIsEmpty() {
365        return redoTree.getRowCount() == 0;
366    }
367
368    @Override
369    public void cleaned(CommandQueueCleanedEvent e) {
370        if (isVisible()) {
371            buildTrees();
372        }
373    }
374
375    @Override
376    public void commandAdded(CommandAddedEvent e) {
377        if (isVisible()) {
378            undoRoot.add(getNodeForCommand(e.getCommand()));
379            undoTreeModel.nodeStructureChanged(undoRoot);
380            // fix 16911: make sure that redo tree is rebuild with empty list
381            if (!redoTreeIsEmpty())
382                buildRedoTree();
383            ensureTreesConsistency();
384        }
385    }
386
387    @Override
388    public void commandUndone(CommandUndoneEvent e) {
389        if (isVisible()) {
390            swapNode(undoTreeModel, undoRoot, undoRoot.getChildCount() - 1, redoTreeModel, redoRoot, 0);
391        }
392    }
393
394    @Override
395    public void commandRedone(CommandRedoneEvent e) {
396        if (isVisible()) {
397            swapNode(redoTreeModel, redoRoot, 0, undoTreeModel, undoRoot, undoRoot.getChildCount());
398        }
399    }
400
401    private void swapNode(DefaultTreeModel srcModel, DefaultMutableTreeNode srcRoot, int srcIndex,
402                          DefaultTreeModel dstModel, DefaultMutableTreeNode dstRoot, int dstIndex) {
403        MutableTreeNode node = (MutableTreeNode) srcRoot.getChildAt(srcIndex);
404        srcRoot.remove(node);
405        srcModel.nodeStructureChanged(srcRoot);
406        dstRoot.insert(node, dstIndex);
407        dstModel.nodeStructureChanged(dstRoot);
408        ensureTreesConsistency();
409    }
410
411    /**
412     * Action that selects the objects that take part in a command.
413     */
414    public class SelectAction extends AbstractAction implements IEnabledStateUpdating {
415
416        /**
417         * Constructs a new {@code SelectAction}.
418         */
419        public SelectAction() {
420            putValue(NAME, tr("Select"));
421            putValue(SHORT_DESCRIPTION, tr("Selects the objects that take part in this command (unless currently deleted)"));
422            new ImageProvider("dialogs", "select").getResource().attachImageIcon(this, true);
423        }
424
425        @Override
426        public void actionPerformed(ActionEvent e) {
427            TreePath path;
428            if (!undoTree.isSelectionEmpty()) {
429                path = undoTree.getSelectionPath();
430            } else if (!redoTree.isSelectionEmpty()) {
431                path = redoTree.getSelectionPath();
432            } else
433                throw new IllegalStateException();
434
435            DataSet dataSet = MainApplication.getLayerManager().getEditDataSet();
436            if (dataSet == null) return;
437            dataSet.setSelected(getAffectedPrimitives(path));
438        }
439
440        @Override
441        public void updateEnabledState() {
442            setEnabled(!undoTree.isSelectionEmpty() || !redoTree.isSelectionEmpty());
443        }
444    }
445
446    /**
447     * Action that selects the objects that take part in a command, then zoom to them.
448     */
449    public class SelectAndZoomAction extends SelectAction {
450        /**
451         * Constructs a new {@code SelectAndZoomAction}.
452         */
453        public SelectAndZoomAction() {
454            putValue(NAME, tr("Select and zoom"));
455            putValue(SHORT_DESCRIPTION,
456                    tr("Selects the objects that take part in this command (unless currently deleted), then and zooms to it"));
457            new ImageProvider("dialogs/autoscale", "selection").getResource().attachImageIcon(this, true);
458        }
459
460        @Override
461        public void actionPerformed(ActionEvent e) {
462            super.actionPerformed(e);
463            AutoScaleAction.autoScale(AutoScaleMode.SELECTION);
464        }
465    }
466
467    /**
468     * undo / redo switch to reduce duplicate code
469     */
470    protected enum UndoRedoType {
471        UNDO,
472        REDO
473    }
474
475    /**
476     * Action to undo or redo all commands up to (and including) the seleced item.
477     */
478    protected class UndoRedoAction extends AbstractAction implements IEnabledStateUpdating {
479        private final UndoRedoType type;
480        private final JTree tree;
481
482        /**
483         * constructor
484         * @param type decide whether it is an undo action or a redo action
485         */
486        public UndoRedoAction(UndoRedoType type) {
487            this.type = type;
488            if (UndoRedoType.UNDO == type) {
489                tree = undoTree;
490                putValue(NAME, tr("Undo"));
491                putValue(SHORT_DESCRIPTION, tr("Undo the selected and all later commands"));
492                new ImageProvider("undo").getResource().attachImageIcon(this, true);
493            } else {
494                tree = redoTree;
495                putValue(NAME, tr("Redo"));
496                putValue(SHORT_DESCRIPTION, tr("Redo the selected and all earlier commands"));
497                new ImageProvider("redo").getResource().attachImageIcon(this, true);
498            }
499        }
500
501        @Override
502        public void actionPerformed(ActionEvent e) {
503            lastOperation = type;
504            TreePath path = tree.getSelectionPath();
505
506            // we can only undo top level commands
507            if (path.getPathCount() != 2)
508                throw new IllegalStateException();
509
510            int idx = ((CommandListMutableTreeNode) path.getLastPathComponent()).getIndex();
511
512            // calculate the number of commands to undo/redo; then do it
513            switch (type) {
514            case UNDO:
515                int numUndo = ((DefaultMutableTreeNode) undoTreeModel.getRoot()).getChildCount() - idx;
516                UndoRedoHandler.getInstance().undo(numUndo);
517                break;
518            case REDO:
519                int numRedo = idx+1;
520                UndoRedoHandler.getInstance().redo(numRedo);
521                break;
522            }
523            MainApplication.getMap().repaint();
524        }
525
526        @Override
527        public void updateEnabledState() {
528            // do not allow execution if nothing is selected or a sub command was selected
529            setEnabled(!tree.isSelectionEmpty() && tree.getSelectionPath().getPathCount() == 2);
530        }
531    }
532
533    class MouseEventHandler extends PopupMenuLauncher {
534
535        MouseEventHandler() {
536            super(new CommandStackPopup());
537        }
538
539        @Override
540        public void mouseClicked(MouseEvent evt) {
541            if (isDoubleClick(evt)) {
542                selectAndZoomAction.actionPerformed(null);
543            }
544        }
545    }
546
547    private class CommandStackPopup extends JPopupMenu {
548        CommandStackPopup() {
549            add(selectAction);
550            add(selectAndZoomAction);
551        }
552    }
553}