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("Windows: {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 (Command undoCommand : undoCommands) {
277            undoRoot.add(getNodeForCommand(undoCommand));
278        }
279        undoTreeModel.setRoot(undoRoot);
280    }
281
282    private void buildRedoTree() {
283        List<Command> redoCommands = UndoRedoHandler.getInstance().getRedoCommands();
284        redoRoot = new DefaultMutableTreeNode();
285        for (Command redoCommand : redoCommands) {
286            redoRoot.add(getNodeForCommand(redoCommand));
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 (PseudoCommand child : children) {
341                node.add(getNodeForCommand(child));
342            }
343        }
344        return node;
345    }
346
347    /**
348     * Return primitives that are affected by some command
349     * @param c the command
350     * @return collection of affected primitives, only usable ones
351     */
352    protected static Collection<? extends OsmPrimitive> getAffectedPrimitives(PseudoCommand c) {
353        final OsmDataLayer currentLayer = MainApplication.getLayerManager().getEditLayer();
354        return new SubclassFilteredCollection<>(
355                c.getParticipatingPrimitives(),
356                o -> {
357                    OsmPrimitive p = currentLayer.data.getPrimitiveById(o);
358                    return p != null && p.isUsable();
359                }
360        );
361    }
362
363    protected boolean redoTreeIsEmpty() {
364        return redoTree.getRowCount() == 0;
365    }
366
367    @Override
368    public void cleaned(CommandQueueCleanedEvent e) {
369        if (isVisible()) {
370            buildTrees();
371        }
372    }
373
374    @Override
375    public void commandAdded(CommandAddedEvent e) {
376        if (isVisible()) {
377            undoRoot.add(getNodeForCommand(e.getCommand()));
378            undoTreeModel.nodeStructureChanged(undoRoot);
379            // fix 16911: make sure that redo tree is rebuild with empty list
380            if (!redoTreeIsEmpty())
381                buildRedoTree();
382            ensureTreesConsistency();
383        }
384    }
385
386    @Override
387    public void commandUndone(CommandUndoneEvent e) {
388        if (isVisible()) {
389            swapNode(undoTreeModel, undoRoot, undoRoot.getChildCount() - 1, redoTreeModel, redoRoot, 0);
390        }
391    }
392
393    @Override
394    public void commandRedone(CommandRedoneEvent e) {
395        if (isVisible()) {
396            swapNode(redoTreeModel, redoRoot, 0, undoTreeModel, undoRoot, undoRoot.getChildCount());
397        }
398    }
399
400    private void swapNode(DefaultTreeModel srcModel, DefaultMutableTreeNode srcRoot, int srcIndex,
401                          DefaultTreeModel dstModel, DefaultMutableTreeNode dstRoot, int dstIndex) {
402        MutableTreeNode node = (MutableTreeNode) srcRoot.getChildAt(srcIndex);
403        srcRoot.remove(node);
404        srcModel.nodeStructureChanged(srcRoot);
405        dstRoot.insert(node, dstIndex);
406        dstModel.nodeStructureChanged(dstRoot);
407        ensureTreesConsistency();
408    }
409
410    /**
411     * Action that selects the objects that take part in a command.
412     */
413    public class SelectAction extends AbstractAction implements IEnabledStateUpdating {
414
415        /**
416         * Constructs a new {@code SelectAction}.
417         */
418        public SelectAction() {
419            putValue(NAME, tr("Select"));
420            putValue(SHORT_DESCRIPTION, tr("Selects the objects that take part in this command (unless currently deleted)"));
421            new ImageProvider("dialogs", "select").getResource().attachImageIcon(this, true);
422        }
423
424        @Override
425        public void actionPerformed(ActionEvent e) {
426            PseudoCommand command = getSelectedCommand();
427            if (command == null) {
428                return;
429            }
430
431            DataSet dataSet = MainApplication.getLayerManager().getEditDataSet();
432            if (dataSet == null) return;
433            dataSet.setSelected(getAffectedPrimitives(command));
434        }
435
436        @Override
437        public void updateEnabledState() {
438            setEnabled(!undoTree.isSelectionEmpty() || !redoTree.isSelectionEmpty());
439        }
440    }
441
442    /**
443     * Returns the selected undo/redo command
444     * @return the selected undo/redo command or {@code null}
445     */
446    public PseudoCommand getSelectedCommand() {
447        TreePath path;
448        if (!undoTree.isSelectionEmpty()) {
449            path = undoTree.getSelectionPath();
450        } else if (!redoTree.isSelectionEmpty()) {
451            path = redoTree.getSelectionPath();
452        } else {
453            // see #19514 for a possible cause
454            return null;
455        }
456        return path != null ? ((CommandListMutableTreeNode) path.getLastPathComponent()).getCommand() : null;
457    }
458
459    /**
460     * Action that selects the objects that take part in a command, then zoom to them.
461     */
462    public class SelectAndZoomAction extends SelectAction {
463        /**
464         * Constructs a new {@code SelectAndZoomAction}.
465         */
466        public SelectAndZoomAction() {
467            putValue(NAME, tr("Select and zoom"));
468            putValue(SHORT_DESCRIPTION,
469                    tr("Selects the objects that take part in this command (unless currently deleted), then and zooms to it"));
470            new ImageProvider("dialogs/autoscale", "selection").getResource().attachImageIcon(this, true);
471        }
472
473        @Override
474        public void actionPerformed(ActionEvent e) {
475            super.actionPerformed(e);
476            AutoScaleAction.autoScale(AutoScaleMode.SELECTION);
477        }
478    }
479
480    /**
481     * undo / redo switch to reduce duplicate code
482     */
483    protected enum UndoRedoType {
484        UNDO,
485        REDO
486    }
487
488    /**
489     * Action to undo or redo all commands up to (and including) the seleced item.
490     */
491    protected class UndoRedoAction extends AbstractAction implements IEnabledStateUpdating {
492        private final UndoRedoType type;
493        private final JTree tree;
494
495        /**
496         * constructor
497         * @param type decide whether it is an undo action or a redo action
498         */
499        public UndoRedoAction(UndoRedoType type) {
500            this.type = type;
501            if (UndoRedoType.UNDO == type) {
502                tree = undoTree;
503                putValue(NAME, tr("Undo"));
504                putValue(SHORT_DESCRIPTION, tr("Undo the selected and all later commands"));
505                new ImageProvider("undo").getResource().attachImageIcon(this, true);
506            } else {
507                tree = redoTree;
508                putValue(NAME, tr("Redo"));
509                putValue(SHORT_DESCRIPTION, tr("Redo the selected and all earlier commands"));
510                new ImageProvider("redo").getResource().attachImageIcon(this, true);
511            }
512        }
513
514        @Override
515        public void actionPerformed(ActionEvent e) {
516            lastOperation = type;
517            TreePath path = tree.getSelectionPath();
518
519            // we can only undo top level commands
520            if (path.getPathCount() != 2)
521                throw new IllegalStateException();
522
523            int idx = ((CommandListMutableTreeNode) path.getLastPathComponent()).getIndex();
524
525            // calculate the number of commands to undo/redo; then do it
526            switch (type) {
527            case UNDO:
528                int numUndo = ((DefaultMutableTreeNode) undoTreeModel.getRoot()).getChildCount() - idx;
529                UndoRedoHandler.getInstance().undo(numUndo);
530                break;
531            case REDO:
532                int numRedo = idx+1;
533                UndoRedoHandler.getInstance().redo(numRedo);
534                break;
535            }
536            MainApplication.getMap().repaint();
537        }
538
539        @Override
540        public void updateEnabledState() {
541            // do not allow execution if nothing is selected or a sub command was selected
542            setEnabled(!tree.isSelectionEmpty() && tree.getSelectionPath().getPathCount() == 2);
543        }
544    }
545
546    class MouseEventHandler extends PopupMenuLauncher {
547
548        MouseEventHandler() {
549            super(new CommandStackPopup());
550        }
551
552        @Override
553        public void mouseClicked(MouseEvent evt) {
554            if (isDoubleClick(evt)) {
555                selectAndZoomAction.actionPerformed(null);
556            }
557        }
558    }
559
560    private class CommandStackPopup extends JPopupMenu {
561        CommandStackPopup() {
562            add(selectAction);
563            add(selectAndZoomAction);
564        }
565    }
566}