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