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}