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.DataSet;
042import org.openstreetmap.josm.data.osm.OsmPrimitive;
043import org.openstreetmap.josm.gui.SideButton;
044import org.openstreetmap.josm.gui.layer.OsmDataLayer;
045import org.openstreetmap.josm.gui.layer.OsmDataLayer.CommandQueueListener;
046import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
047import org.openstreetmap.josm.tools.FilteredCollection;
048import org.openstreetmap.josm.tools.GBC;
049import org.openstreetmap.josm.tools.ImageProvider;
050import org.openstreetmap.josm.tools.InputMapUtils;
051import org.openstreetmap.josm.tools.Predicate;
052import org.openstreetmap.josm.tools.Shortcut;
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(new SideButton[] {
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     * Interface to provide a callback for enabled state update.
193     */
194    protected interface IEnabledStateUpdating {
195        void updateEnabledState();
196    }
197
198    /**
199     * Wires updater for enabled state to the events. Also updates dialog title if needed.
200     * @param updater updater
201     * @param tree tree on which wire updater
202     */
203    protected void wireUpdateEnabledStateUpdater(final IEnabledStateUpdating updater, JTree tree) {
204        addShowNotifyListener(updater);
205
206        tree.addTreeSelectionListener(new TreeSelectionListener() {
207            @Override
208            public void valueChanged(TreeSelectionEvent e) {
209                updater.updateEnabledState();
210            }
211        });
212
213        tree.getModel().addTreeModelListener(new TreeModelListener() {
214            @Override
215            public void treeNodesChanged(TreeModelEvent e) {
216                updater.updateEnabledState();
217                updateTitle();
218            }
219
220            @Override
221            public void treeNodesInserted(TreeModelEvent e) {
222                updater.updateEnabledState();
223                updateTitle();
224            }
225
226            @Override
227            public void treeNodesRemoved(TreeModelEvent e) {
228                updater.updateEnabledState();
229                updateTitle();
230            }
231
232            @Override
233            public void treeStructureChanged(TreeModelEvent e) {
234                updater.updateEnabledState();
235                updateTitle();
236            }
237        });
238    }
239
240    @Override
241    public void showNotify() {
242        buildTrees();
243        for (IEnabledStateUpdating listener : showNotifyListener) {
244            listener.updateEnabledState();
245        }
246        Main.main.undoRedo.addCommandQueueListener(this);
247    }
248
249    /**
250     * Simple listener setup to update the button enabled state when the side dialog shows.
251     */
252    private final transient Set<IEnabledStateUpdating> showNotifyListener = new LinkedHashSet<>();
253
254    private void addShowNotifyListener(IEnabledStateUpdating listener) {
255        showNotifyListener.add(listener);
256    }
257
258    @Override
259    public void hideNotify() {
260        undoTreeModel.setRoot(new DefaultMutableTreeNode());
261        redoTreeModel.setRoot(new DefaultMutableTreeNode());
262        Main.main.undoRedo.removeCommandQueueListener(this);
263    }
264
265    /**
266     * Build the trees of undo and redo commands (initially or when
267     * they have changed).
268     */
269    private void buildTrees() {
270        setTitle(tr("Command Stack"));
271        if (Main.getLayerManager().getEditLayer() == null)
272            return;
273
274        List<Command> undoCommands = Main.main.undoRedo.commands;
275        DefaultMutableTreeNode undoRoot = new DefaultMutableTreeNode();
276        for (int i = 0; i < undoCommands.size(); ++i) {
277            undoRoot.add(getNodeForCommand(undoCommands.get(i), i));
278        }
279        undoTreeModel.setRoot(undoRoot);
280
281        List<Command> redoCommands = Main.main.undoRedo.redoCommands;
282        DefaultMutableTreeNode redoRoot = new DefaultMutableTreeNode();
283        for (int i = 0; i < redoCommands.size(); ++i) {
284            redoRoot.add(getNodeForCommand(redoCommands.get(i), i));
285        }
286        redoTreeModel.setRoot(redoRoot);
287        if (redoTreeModel.getChildCount(redoRoot) > 0) {
288            redoTree.scrollRowToVisible(0);
289            scrollPane.getHorizontalScrollBar().setValue(0);
290        }
291
292        separator.setVisible(!undoCommands.isEmpty() || !redoCommands.isEmpty());
293        spacer.setVisible(undoCommands.isEmpty() && !redoCommands.isEmpty());
294
295        // if one tree is empty, move selection to the other
296        switch (lastOperation) {
297        case UNDO:
298            if (undoCommands.isEmpty()) {
299                lastOperation = UndoRedoType.REDO;
300            }
301            break;
302        case REDO:
303            if (redoCommands.isEmpty()) {
304                lastOperation = UndoRedoType.UNDO;
305            }
306            break;
307        }
308
309        // select the next command to undo/redo
310        switch (lastOperation) {
311        case UNDO:
312            undoTree.setSelectionRow(undoTree.getRowCount()-1);
313            break;
314        case REDO:
315            redoTree.setSelectionRow(0);
316            break;
317        }
318
319        undoTree.scrollRowToVisible(undoTreeModel.getChildCount(undoRoot)-1);
320        scrollPane.getHorizontalScrollBar().setValue(0);
321    }
322
323    /**
324     * Wraps a command in a CommandListMutableTreeNode.
325     * Recursively adds child commands.
326     * @param c the command
327     * @param idx index
328     * @return the resulting node
329     */
330    protected CommandListMutableTreeNode getNodeForCommand(PseudoCommand c, int idx) {
331        CommandListMutableTreeNode node = new CommandListMutableTreeNode(c, idx);
332        if (c.getChildren() != null) {
333            List<PseudoCommand> children = new ArrayList<>(c.getChildren());
334            for (int i = 0; i < children.size(); ++i) {
335                node.add(getNodeForCommand(children.get(i), i));
336            }
337        }
338        return node;
339    }
340
341    /**
342     * Return primitives that are affected by some command
343     * @param path GUI elements
344     * @return collection of affected primitives, onluy usable ones
345     */
346    protected static FilteredCollection<? extends OsmPrimitive> getAffectedPrimitives(TreePath path) {
347        PseudoCommand c = ((CommandListMutableTreeNode) path.getLastPathComponent()).getCommand();
348        final OsmDataLayer currentLayer = Main.getLayerManager().getEditLayer();
349        return new FilteredCollection<>(
350                c.getParticipatingPrimitives(),
351                new Predicate<OsmPrimitive>() {
352                    @Override
353                    public boolean evaluate(OsmPrimitive o) {
354                        OsmPrimitive p = currentLayer.data.getPrimitiveById(o);
355                        return p != null && p.isUsable();
356                    }
357                }
358        );
359    }
360
361    @Override
362    public void commandChanged(int queueSize, int redoSize) {
363        if (!isVisible())
364            return;
365        buildTrees();
366    }
367
368    /**
369     * Action that selects the objects that take part in a command.
370     */
371    public class SelectAction extends AbstractAction implements IEnabledStateUpdating {
372
373        /**
374         * Constructs a new {@code SelectAction}.
375         */
376        public SelectAction() {
377            putValue(NAME, tr("Select"));
378            putValue(SHORT_DESCRIPTION, tr("Selects the objects that take part in this command (unless currently deleted)"));
379            new ImageProvider("dialogs", "select").getResource().attachImageIcon(this, true);
380        }
381
382        @Override
383        public void actionPerformed(ActionEvent e) {
384            TreePath path;
385            if (!undoTree.isSelectionEmpty()) {
386                path = undoTree.getSelectionPath();
387            } else if (!redoTree.isSelectionEmpty()) {
388                path = redoTree.getSelectionPath();
389            } else
390                throw new IllegalStateException();
391
392            DataSet dataSet = Main.getLayerManager().getEditDataSet();
393            if (dataSet == null) return;
394            dataSet.setSelected(getAffectedPrimitives(path));
395        }
396
397        @Override
398        public void updateEnabledState() {
399            setEnabled(!undoTree.isSelectionEmpty() || !redoTree.isSelectionEmpty());
400        }
401    }
402
403    /**
404     * Action that selects the objects that take part in a command, then zoom to them.
405     */
406    public class SelectAndZoomAction extends SelectAction {
407        /**
408         * Constructs a new {@code SelectAndZoomAction}.
409         */
410        public SelectAndZoomAction() {
411            putValue(NAME, tr("Select and zoom"));
412            putValue(SHORT_DESCRIPTION,
413                    tr("Selects the objects that take part in this command (unless currently deleted), then and zooms to it"));
414            new ImageProvider("dialogs/autoscale", "selection").getResource().attachImageIcon(this, true);
415        }
416
417        @Override
418        public void actionPerformed(ActionEvent e) {
419            super.actionPerformed(e);
420            AutoScaleAction.autoScale("selection");
421        }
422    }
423
424    /**
425     * undo / redo switch to reduce duplicate code
426     */
427    protected enum UndoRedoType {
428        UNDO,
429        REDO
430    }
431
432    /**
433     * Action to undo or redo all commands up to (and including) the seleced item.
434     */
435    protected class UndoRedoAction extends AbstractAction implements IEnabledStateUpdating {
436        private final UndoRedoType type;
437        private final JTree tree;
438
439        /**
440         * constructor
441         * @param type decide whether it is an undo action or a redo action
442         */
443        public UndoRedoAction(UndoRedoType type) {
444            this.type = type;
445            if (UndoRedoType.UNDO == type) {
446                tree = undoTree;
447                putValue(NAME, tr("Undo"));
448                putValue(SHORT_DESCRIPTION, tr("Undo the selected and all later commands"));
449                new ImageProvider("undo").getResource().attachImageIcon(this, true);
450            } else {
451                tree = redoTree;
452                putValue(NAME, tr("Redo"));
453                putValue(SHORT_DESCRIPTION, tr("Redo the selected and all earlier commands"));
454                new ImageProvider("redo").getResource().attachImageIcon(this, true);
455            }
456        }
457
458        @Override
459        public void actionPerformed(ActionEvent e) {
460            lastOperation = type;
461            TreePath path = tree.getSelectionPath();
462
463            // we can only undo top level commands
464            if (path.getPathCount() != 2)
465                throw new IllegalStateException();
466
467            int idx = ((CommandListMutableTreeNode) path.getLastPathComponent()).getIndex();
468
469            // calculate the number of commands to undo/redo; then do it
470            switch (type) {
471            case UNDO:
472                int numUndo = ((DefaultMutableTreeNode) undoTreeModel.getRoot()).getChildCount() - idx;
473                Main.main.undoRedo.undo(numUndo);
474                break;
475            case REDO:
476                int numRedo = idx+1;
477                Main.main.undoRedo.redo(numRedo);
478                break;
479            }
480            Main.map.repaint();
481        }
482
483        @Override
484        public void updateEnabledState() {
485            // do not allow execution if nothing is selected or a sub command was selected
486            setEnabled(!tree.isSelectionEmpty() && tree.getSelectionPath().getPathCount() == 2);
487        }
488    }
489
490    class MouseEventHandler extends PopupMenuLauncher {
491
492        MouseEventHandler() {
493            super(new CommandStackPopup());
494        }
495
496        @Override
497        public void mouseClicked(MouseEvent evt) {
498            if (isDoubleClick(evt)) {
499                selectAndZoomAction.actionPerformed(null);
500            }
501        }
502    }
503
504    private class CommandStackPopup extends JPopupMenu {
505        CommandStackPopup() {
506            add(selectAction);
507            add(selectAndZoomAction);
508        }
509    }
510}