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