From aacc101f3a4e9286d7fcd3d67fddd4da08f36ccc Mon Sep 17 00:00:00 2001 From: SiboVG Date: Wed, 16 Feb 2022 20:25:41 +0100 Subject: [PATCH] [fixes #358] Implement multi-selection for component tree - Move up, down, Delete, Cut, Copy, Paste --- .../sf/openrocket/gui/main/BasicFrame.java | 2 +- .../gui/main/DocumentSelectionModel.java | 23 +- .../gui/main/OpenRocketClipboard.java | 91 ++++- .../sf/openrocket/gui/main/RocketActions.java | 354 +++++++++++++++--- 4 files changed, 411 insertions(+), 59 deletions(-) diff --git a/swing/src/net/sf/openrocket/gui/main/BasicFrame.java b/swing/src/net/sf/openrocket/gui/main/BasicFrame.java index 1cd414091..a084939f7 100644 --- a/swing/src/net/sf/openrocket/gui/main/BasicFrame.java +++ b/swing/src/net/sf/openrocket/gui/main/BasicFrame.java @@ -181,7 +181,7 @@ public class BasicFrame extends JFrame { // Create the component tree selection model that will be used componentSelectionModel = new DefaultTreeSelectionModel(); - componentSelectionModel.setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); + componentSelectionModel.setSelectionMode(TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION); // Obtain the simulation selection model that will be used simulationPanel = new SimulationPanel(document); diff --git a/swing/src/net/sf/openrocket/gui/main/DocumentSelectionModel.java b/swing/src/net/sf/openrocket/gui/main/DocumentSelectionModel.java index e69997c36..5d9277df7 100644 --- a/swing/src/net/sf/openrocket/gui/main/DocumentSelectionModel.java +++ b/swing/src/net/sf/openrocket/gui/main/DocumentSelectionModel.java @@ -79,9 +79,20 @@ public class DocumentSelectionModel { * @return the currently selected rocket component, or null. */ public RocketComponent getSelectedComponent() { - if (componentSelection == null || componentSelection.size() == 0) return null; + if (componentSelection.size() == 0) return null; return componentSelection.get(0); } + + /** + * Return the currently selected rocket components. Returns null + * if no rocket component is selected. + * + * @return the currently selected rocket components, or null. + */ + public List getSelectedComponents() { + if (componentSelection.size() == 0) return null; + return componentSelection; + } public void setSelectedComponent(RocketComponent component) { componentSelection.clear(); @@ -142,7 +153,7 @@ public class DocumentSelectionModel { public void clearComponentSelection() { - if (componentSelection == null || componentSelection.size() == 0) + if (componentSelection.size() == 0) return; componentSelection.clear(); @@ -177,15 +188,17 @@ public class DocumentSelectionModel { @Override public void valueChanged(TreeSelectionEvent e) { - TreePath path = componentTreeSelectionModel.getSelectionPath(); - if (path == null) { + TreePath[] paths = componentTreeSelectionModel.getSelectionPaths(); + if (paths == null || paths.length == 0) { componentSelection.clear(); fireDocumentSelection(DocumentSelectionListener.COMPONENT_SELECTION_CHANGE); return; } componentSelection.clear(); - componentSelection.add((RocketComponent)path.getLastPathComponent()); + for (TreePath path : paths) { + componentSelection.add((RocketComponent)path.getLastPathComponent()); + } clearSimulationSelection(); fireDocumentSelection(DocumentSelectionListener.COMPONENT_SELECTION_CHANGE); diff --git a/swing/src/net/sf/openrocket/gui/main/OpenRocketClipboard.java b/swing/src/net/sf/openrocket/gui/main/OpenRocketClipboard.java index 5541efb0c..cda0a7e85 100644 --- a/swing/src/net/sf/openrocket/gui/main/OpenRocketClipboard.java +++ b/swing/src/net/sf/openrocket/gui/main/OpenRocketClipboard.java @@ -1,6 +1,8 @@ package net.sf.openrocket.gui.main; import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedList; import java.util.List; import net.sf.openrocket.document.Simulation; @@ -8,10 +10,10 @@ import net.sf.openrocket.rocketcomponent.RocketComponent; public final class OpenRocketClipboard { - private static RocketComponent clipboardComponent = null; + private static final List clipboardComponents = new LinkedList<>(); private static Simulation[] clipboardSimulations = null; - private static List listeners = new ArrayList(); + private static final List listeners = new ArrayList(); private OpenRocketClipboard() { // Disallow instantiation @@ -26,16 +28,91 @@ public final class OpenRocketClipboard { * @return the rocket component contained in the clipboard, or null * if the clipboard does not currently contain a rocket component. */ - public static RocketComponent getClipboardComponent() { - return clipboardComponent; + public static List getClipboardComponents() { + return clipboardComponents; } - public static void setClipboard(RocketComponent component) { - clipboardComponent = component; + public static void setClipboard(List components) { + clipboardComponents.clear(); + components = RocketActions.copyComponentsMaintainParent(components); + if (components != null) { + clipboardComponents.addAll(components); + } + filterClipboardComponents(new LinkedList<>(clipboardComponents)); clipboardSimulations = null; fireClipboardChanged(); } + + /** + * Filters out children from clipboardComponents and the children's list of the components, based on the selection + * (see {@link #filterClipboardComponent} to see how the actual filtering is done. + * @param components components to be filtered + */ + private static void filterClipboardComponents(List components) { + if (components == null || components.size() == 0) return; + List checkedComponents = new ArrayList<>(); + for (RocketComponent component : components) { + // Make sure a parent component is processed before the child components + RocketComponent temp = component; + while (temp.getParent() != null && clipboardComponents.contains(temp.getParent()) + && !checkedComponents.contains(temp.getParent())) { + filterClipboardComponent(temp.getParent()); + checkedComponents.add(temp.getParent()); + temp = temp.getParent(); + } + filterClipboardComponent(component); + checkedComponents.add(component); + } + } + + /** + * Iteratively (over all children of component and their children), filter clipboard components according to + * the following rules: + * - If all children of component are selected, then remove those children from clipboardComponents, but keep them + * as children of component + * - If one of the children of component is selected, but some are not, then remove the unselected children as child + * from component, but keep the selected children as children of component + remove the selected children from + * clipboardComponents + * - If no children are selected, then no children are removed from component + no children are removed from + * clipboardComponents + * @param component component to filter + */ + private static void filterClipboardComponent(RocketComponent component) { + if (component == null) return; + + boolean allChildrenSelected = clipboardComponents.containsAll(component.getChildren()); + boolean someChildrenSelected = false; + for (RocketComponent child : component.getChildren()) { + if (clipboardComponents.contains(child)) { + someChildrenSelected = true; + break; + } + } + + if (allChildrenSelected) { + for (RocketComponent child : component.getChildren()) { + clipboardComponents.remove(child); + filterClipboardComponents(child.getChildren()); + } + return; + } + + if (someChildrenSelected) { + for (RocketComponent child : component.getChildren()) { + if (!clipboardComponents.contains(child)) { + component.removeChild(child); + } else { + clipboardComponents.remove(child); + filterClipboardComponents(child.getChildren()); + } + } + } else { + for (RocketComponent child : component.getChildren()) { + filterClipboardComponents(child.getChildren()); + } + } + } public static Simulation[] getClipboardSimulations() { @@ -46,7 +123,7 @@ public final class OpenRocketClipboard { public static void setClipboard(Simulation[] simulations) { clipboardSimulations = simulations.clone(); - clipboardComponent = null; + clipboardComponents.clear(); fireClipboardChanged(); } diff --git a/swing/src/net/sf/openrocket/gui/main/RocketActions.java b/swing/src/net/sf/openrocket/gui/main/RocketActions.java index a220c9e8b..7f8a6c083 100644 --- a/swing/src/net/sf/openrocket/gui/main/RocketActions.java +++ b/swing/src/net/sf/openrocket/gui/main/RocketActions.java @@ -5,6 +5,9 @@ import java.awt.Toolkit; import java.awt.event.ActionEvent; import java.awt.event.KeyEvent; import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; import javax.swing.AbstractAction; import javax.swing.Action; @@ -181,6 +184,14 @@ public class RocketActions { return true; } + private boolean isDeletable(List components) { + if (components == null || components.size() == 0) return false; + for (RocketComponent component : components) { + if (!isDeletable(component)) return false; + } + return true; + } + private void delete(RocketComponent c) { if (!isDeletable(c)) { throw new IllegalArgumentException("Report bug! Component " + c + @@ -191,6 +202,16 @@ public class RocketActions { parent.removeChild(c); } + private void delete(List components) { + if (!isDeletable(components)) { + throw new IllegalArgumentException("Report bug! Components not deletable."); + } + + for (RocketComponent component : components) { + delete(component); + } + } + private boolean isCopyable(RocketComponent c) { if (c==null) return false; @@ -199,6 +220,119 @@ public class RocketActions { return true; } + private boolean isCopyable(List components) { + if (components == null || components.size() == 0) return false; + for (RocketComponent component : components) { + if (!isCopyable(component)) return false; + } + return true; + } + + /** + * Copies components, but with maintaining parent-relations with the newly copied components + * @param components components to copy + * @return copied components + */ + public static List copyComponentsMaintainParent(List components) { + if (components == null) return null; + List result = new LinkedList<>(); + for (RocketComponent component : components) { + result.add(component.copy()); + } + + for (int i = 0; i < components.size(); i++) { + if (components.contains(components.get(i).getParent())) { + RocketComponent oldChild = components.get(i); + RocketComponent oldParent = oldChild.getParent(); + + int index = components.indexOf(oldParent); + int childPos = oldParent.getChildPosition(oldChild); + + RocketComponent newChild = result.get(i); + RocketComponent newParent = result.get(index); + + // Add the newly copied child to the parent + newParent.addChild(newChild, childPos); + + // Remove the old child from the parent + newParent.removeChild(oldChild); + } + } + + return result; + } + + /** + * Iteratively checks whether the list of components contains the parent or super-parent (parent of parent of parent of...) + * of component. + * @param components list of components that may contain the parent + * @param component component to check the parent for + * @return true if the list contains the parent, false if not + */ + public static boolean listContainsParent(List components, RocketComponent component) { + RocketComponent c = component; + while (c.getParent() != null) { + if (components.contains(c.getParent())) { + return true; + } + c = c.getParent(); + } + return false; + } + + /** + * If the children of a parent are not selected, add them to the selection. Do this recursively for the children + * of the children as well. + * @param selections list of currently selected components + * @param parent parent component to parse the children of + */ + private void selectAllUnselectedInParent(List selections, RocketComponent parent) { + if (parent.getChildCount() == 0) return; + + boolean noChildrenSelected = true; + for (RocketComponent child : parent.getChildren()) { + if (selections.contains(child)) { + noChildrenSelected = false; + break; + } + } + + // Add children to selection if none of them were selected + if (noChildrenSelected) { + selections.addAll(parent.getChildren()); + } + + // Recursively select all unselected children. Unselected children will not undergo this recursive updating. + for (RocketComponent child : parent.getChildren()) { + if (!noChildrenSelected && selections.contains(child)) { + selectAllUnselectedInParent(selections, child); + } + } + } + + /** + * This method fills in some selections in selections. If there is a parent where none of its children are selected, + * add all the children to the selection. If there is a component whose parent is not selected, but it does have + * a super-parent in the selection, add the parent to the selection. + * @param selections component selections to be filled up + */ + private void fillInMissingSelections(List selections) { + List initSelections = new LinkedList<>(selections); + for (RocketComponent component : initSelections) { + selectAllUnselectedInParent(selections, component); + + // If there is a component in the selection, but its parent (or the parent of the parent) is still + // not selected, add it to the selection + RocketComponent temp = component; + if (listContainsParent(selections, temp) && !selections.contains(temp.getParent())) { + while (!selections.contains(temp.getParent())) { + selections.add(temp.getParent()); + temp = temp.getParent(); + } + } + } + } + private boolean isSimulationSelected() { Simulation[] selection = selectionModel.getSelectedSimulations(); @@ -269,8 +403,26 @@ public class RocketActions { return null; } - - + + /** + * Return the component and position to which the current clipboard + * should be pasted. Returns null if the clipboard is empty or if the + * clipboard cannot be pasted to the current selection. + * + * @param clipboard the component on the clipboard. + * @return a Pair with both components defined, or null. + */ + private List> getPastePositions(List clipboard) { + List> result = new LinkedList<>(); + for (RocketComponent component : clipboard) { + Pair position = getPastePosition(component); + if (position != null) { + result.add(position); + } + } + return result; + } + @@ -306,15 +458,30 @@ public class RocketActions { @Override public void actionPerformed(ActionEvent e) { - RocketComponent c = selectionModel.getSelectedComponent(); + List components = new ArrayList<>(selectionModel.getSelectedComponents()); + if (components.size() == 0) return; + components.sort(Comparator.comparing(c -> c.getParent().getChildPosition(c))); - if (isDeletable(c)) { + if (components.size() == 1) { + document.addUndoPosition("Delete " + components.get(0).getComponentName()); + } else { + document.addUndoPosition("Delete components"); + } + + for (RocketComponent component : components) { + deleteComponent(component); + } + } + + private void deleteComponent(RocketComponent component) { + if (isDeletable(component)) { ComponentConfigDialog.hideDialog(); - c.getRocket().removeComponentChangeListener(ComponentConfigDialog.getDialog()); + try { + component.getRocket().removeComponentChangeListener(ComponentConfigDialog.getDialog()); - document.addUndoPosition("Delete " + c.getComponentName()); - delete(c); + delete(component); + } catch (IllegalStateException ignored) { } } } @@ -419,15 +586,28 @@ public class RocketActions { @Override public void actionPerformed(ActionEvent e) { - RocketComponent c = selectionModel.getSelectedComponent(); + List components = selectionModel.getSelectedComponents(); + if (components != null) { + components = new ArrayList<>(components); + fillInMissingSelections(components); + + components.sort(Comparator.comparing(c -> c.getParent() != null ? -c.getParent().getChildPosition(c) : 0)); + } Simulation[] sims = selectionModel.getSelectedSimulations(); - if (isDeletable(c) && isCopyable(c)) { + if (isDeletable(components) && isCopyable(components)) { ComponentConfigDialog.hideDialog(); - - document.addUndoPosition("Cut " + c.getComponentName()); - OpenRocketClipboard.setClipboard(c.copy()); - delete(c); + + if (components.size() == 1) { + document.addUndoPosition("Cut " + components.get(0).getComponentName()); + } else { + document.addUndoPosition("Cut components"); + } + + List copiedComponents = new LinkedList<>(copyComponentsMaintainParent(components)); + + OpenRocketClipboard.setClipboard(copiedComponents); + delete(components); parentFrame.selectTab(BasicFrame.COMPONENT_TAB); } else if (isSimulationSelected()) { @@ -446,8 +626,8 @@ public class RocketActions { @Override public void clipboardChanged() { - RocketComponent c = selectionModel.getSelectedComponent(); - this.setEnabled((isDeletable(c) && isCopyable(c)) || isSimulationSelected()); + List components = selectionModel.getSelectedComponents(); + this.setEnabled((isDeletable(components) && isCopyable(components)) || isSimulationSelected()); } } @@ -472,13 +652,21 @@ public class RocketActions { @Override public void actionPerformed(ActionEvent e) { - RocketComponent c = selectionModel.getSelectedComponent(); + List components = selectionModel.getSelectedComponents(); + if (components != null) { + components = new ArrayList<>(components); + fillInMissingSelections(components); + + components.sort(Comparator.comparing(c -> c.getParent() != null ? -c.getParent().getChildPosition(c) : 0)); + } Simulation[] sims = selectionModel.getSelectedSimulations(); - if (isCopyable(c)) { - OpenRocketClipboard.setClipboard(c.copy()); + if (isCopyable(components)) { + List copiedComponents = new LinkedList<>(copyComponentsMaintainParent(components)); + + OpenRocketClipboard.setClipboard(copiedComponents); parentFrame.selectTab(BasicFrame.COMPONENT_TAB); - } else if (sims.length >= 0) { + } else if (sims.length > 0) { Simulation[] simsCopy = new Simulation[sims.length]; for (int i=0; i < sims.length; i++) { @@ -520,17 +708,33 @@ public class RocketActions { @Override public void actionPerformed(ActionEvent e) { - RocketComponent clipboard = OpenRocketClipboard.getClipboardComponent(); + List components = new LinkedList<>(OpenRocketClipboard.getClipboardComponents()); Simulation[] sims = OpenRocketClipboard.getClipboardSimulations(); - - Pair position = getPastePosition(clipboard); - if (position != null) { + + if (components.size() > 0) { ComponentConfigDialog.hideDialog(); - RocketComponent pasted = clipboard.copy(); - document.addUndoPosition("Paste " + pasted.getComponentName()); - position.getU().addChild(pasted, position.getV()); - selectionModel.setSelectedComponent(pasted); + List pasted = new LinkedList<>(); + for (RocketComponent component : components) { + pasted.add(component.copy()); + } + + List> positions = new LinkedList<>(); + for (RocketComponent component : pasted) { + positions.add(getPastePosition(component)); + } + + if (pasted.size() == 1) { + document.addUndoPosition("Paste " + pasted.get(0).getComponentName()); + } else { + document.addUndoPosition("Paste components"); + } + + for (int i = 0; i < pasted.size(); i++) { + positions.get(i).getU().addChild(pasted.get(i), positions.get(i).getV()); + } + + selectionModel.setSelectedComponents(pasted); parentFrame.selectTab(BasicFrame.COMPONENT_TAB); @@ -556,7 +760,7 @@ public class RocketActions { @Override public void clipboardChanged() { this.setEnabled( - (getPastePosition(OpenRocketClipboard.getClipboardComponent()) != null) || + (getPastePositions(OpenRocketClipboard.getClipboardComponents()).size() > 0) || (OpenRocketClipboard.getClipboardSimulations() != null)); } } @@ -656,22 +860,40 @@ public class RocketActions { @Override public void actionPerformed(ActionEvent e) { - RocketComponent selected = selectionModel.getSelectedComponent(); - if (!canMove(selected)) + List components = selectionModel.getSelectedComponents(); + if (components == null || components.size() == 0) return; + components = new ArrayList<>(components); + components.sort(Comparator.comparing(c -> c.getParent() != null ? c.getParent().getChildPosition(c) : 0)); + + if (components.size() == 1) { + document.addUndoPosition("Move " + components.get(0).getComponentName()); + } else { + document.addUndoPosition("Move components"); + } + + for (RocketComponent component : components) { + // Only move top components, don't move its children + if (!listContainsParent(components, component)) { + moveUp(component); + } + } + rocket.fireComponentChangeEvent(ComponentChangeEvent.TREE_CHANGE); + selectionModel.setSelectedComponents(components); + } + + private void moveUp(RocketComponent component) { + if (!canMove(component)) return; - + ComponentConfigDialog.hideDialog(); - RocketComponent parent = selected.getParent(); - document.addUndoPosition("Move "+selected.getComponentName()); - parent.moveChild(selected, parent.getChildPosition(selected)-1); - rocket.fireComponentChangeEvent( ComponentChangeEvent.TREE_CHANGE ); - selectionModel.setSelectedComponent(selected); + RocketComponent parent = component.getParent(); + parent.moveChild(component, parent.getChildPosition(component) - 1); } @Override public void clipboardChanged() { - this.setEnabled(canMove(selectionModel.getSelectedComponent())); + this.setEnabled(canMove(selectionModel.getSelectedComponents())); } private boolean canMove(RocketComponent c) { @@ -682,6 +904,17 @@ public class RocketActions { return true; return false; } + + private boolean canMove(List components) { + if (components == null || components.size() == 0) + return false; + + for (RocketComponent component : components) { + if (!listContainsParent(components, component) && !canMove(component)) + return false; + } + return true; + } } @@ -702,22 +935,40 @@ public class RocketActions { @Override public void actionPerformed(ActionEvent e) { - RocketComponent selected = selectionModel.getSelectedComponent(); - if (!canMove(selected)) + List components = selectionModel.getSelectedComponents(); + if (components == null || components.size() == 0) return; + components = new ArrayList<>(components); + components.sort(Comparator.comparing(c -> c.getParent() != null ? -c.getParent().getChildPosition(c) : 0)); + + if (components.size() == 1) { + document.addUndoPosition("Move " + components.get(0).getComponentName()); + } else { + document.addUndoPosition("Move components"); + } + + for (RocketComponent component : components) { + // Only move top components, don't move its children + if (!listContainsParent(components, component)) { + moveDown(component); + } + } + rocket.fireComponentChangeEvent(ComponentChangeEvent.TREE_CHANGE); + selectionModel.setSelectedComponents(components); + } + + private void moveDown(RocketComponent component) { + if (!canMove(component)) return; - + ComponentConfigDialog.hideDialog(); - RocketComponent parent = selected.getParent(); - document.addUndoPosition("Move "+selected.getComponentName()); - parent.moveChild(selected, parent.getChildPosition(selected)+1); - rocket.fireComponentChangeEvent( ComponentChangeEvent.TREE_CHANGE ); - selectionModel.setSelectedComponent(selected); + RocketComponent parent = component.getParent(); + parent.moveChild(component, parent.getChildPosition(component) + 1); } @Override public void clipboardChanged() { - this.setEnabled(canMove(selectionModel.getSelectedComponent())); + this.setEnabled(canMove(selectionModel.getSelectedComponents())); } private boolean canMove(RocketComponent c) { @@ -728,6 +979,17 @@ public class RocketActions { return true; return false; } + + private boolean canMove(List components) { + if (components == null || components.size() == 0) + return false; + + for (RocketComponent component : components) { + if (!listContainsParent(components, component) && !canMove(component)) + return false; + } + return true; + } }