diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/IGroupProcessor.java b/diylc/diylc-core/src/main/java/org/diylc/common/IGroupProcessor.java
new file mode 100644
index 00000000..4b84fcf7
--- /dev/null
+++ b/diylc/diylc-core/src/main/java/org/diylc/common/IGroupProcessor.java
@@ -0,0 +1,51 @@
+/*
+ DIY Layout Creator (DIYLC).
+ Copyright (c) 2009-2025 held jointly by the individual authors.
+
+ This file is part of DIYLC.
+
+ DIYLC is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ DIYLC is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with DIYLC. If not, see .
+
+*/
+package org.diylc.common;
+
+import java.util.Set;
+import org.diylc.core.IDIYComponent;
+
+/**
+ * Interface for component grouping operations.
+ *
+ * @author Branislav Stojkovic
+ */
+public interface IGroupProcessor {
+
+ /**
+ * Returns the set of component groups in the current project.
+ * Each group is a set of components that are grouped together.
+ *
+ * @return set of component groups
+ */
+ Set>> getComponentGroups();
+
+ /**
+ * Groups all selected components.
+ */
+ void groupSelectedComponents();
+
+ /**
+ * Ungroups all selected components.
+ */
+ void ungroupSelectedComponents();
+}
+
diff --git a/diylc/diylc-core/src/main/java/org/diylc/common/ISelectionProcessor.java b/diylc/diylc-core/src/main/java/org/diylc/common/ISelectionProcessor.java
index 19c34c3d..494ec5f1 100644
--- a/diylc/diylc-core/src/main/java/org/diylc/common/ISelectionProcessor.java
+++ b/diylc/diylc-core/src/main/java/org/diylc/common/ISelectionProcessor.java
@@ -31,7 +31,7 @@ import org.diylc.core.ExpansionMode;
import org.diylc.core.IDIYComponent;
import org.diylc.core.measures.Size;
-public interface ISelectionProcessor {
+public interface ISelectionProcessor extends IGroupProcessor {
/**
* Returns the current {@link ComponentTransferable}.
@@ -89,16 +89,6 @@ public interface ISelectionProcessor {
*/
void rotateSelection(int direction);
- /**
- * Groups all selected components.
- */
- void groupSelectedComponents();
-
- /**
- * Ungroups all selected components.
- */
- void ungroupSelectedComponents();
-
/**
* Mirrors selected components in the given axis.
*
diff --git a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java
index ec439335..318e77cb 100644
--- a/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java
+++ b/diylc/diylc-core/src/main/java/org/diylc/presenter/Presenter.java
@@ -2912,4 +2912,9 @@ public class Presenter implements IPlugInPort {
public DrawingManager getDrawingManager() {
return drawingManager;
}
+
+ @Override
+ public Set>> getComponentGroups() {
+ return currentProject.getGroups();
+ }
}
diff --git a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/explorer/ExplorerPane.java b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/explorer/ExplorerPane.java
index 939a7163..1b8d2017 100644
--- a/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/explorer/ExplorerPane.java
+++ b/diylc/diylc-swing/src/main/java/org/diylc/swing/plugins/explorer/ExplorerPane.java
@@ -60,9 +60,10 @@ public class ExplorerPane extends JPanel {
private JTextField searchField;
private JScrollPane componentScrollPane;
private ComponentListModel componentListModel;
- private JList> componentList;
+ private JList componentList;
private ComponentPopupMenu popupMenu;
private JComboBox sortBox;
+ private ListSelectionListener selectionListener;
public ExplorerPane(ISwingUI swingUI, IPlugInPort plugInPort) {
super();
@@ -144,9 +145,9 @@ public class ExplorerPane extends JPanel {
return componentScrollPane;
}
- public JList> getComponentList() {
+ public JList getComponentList() {
if (componentList == null) {
- componentList = new DragDropList>(new IDragDropListListener() {
+ componentList = new DragDropList(new IDragDropListListener() {
@Override
public boolean dropComplete(int selectedIndex, int dropTargetIndex) {
@@ -154,7 +155,82 @@ public class ExplorerPane extends JPanel {
swingUI.showMessage(REORDERING_NOT_POSSIBLE, "Error", ISwingUI.ERROR_MESSAGE);
return false;
}
- plugInPort.moveSelectionToZIndex(getComponentListModel().getSize() - dropTargetIndex);
+ // The list is displayed in reverse order (highest z-index first, index 0 = last in component list)
+ // Component list is in normal order (lowest z-index first, index 0 = first component)
+ // In INSERT mode, dropTargetIndex is where the item will be inserted AFTER removal
+ int listSize = getComponentListModel().getSize();
+ int componentCount = plugInPort.getCurrentProject().getComponents().size();
+
+ // The list size may be different from component count due to groups
+ // listSize = componentCount - groupedComponents + groups
+ // We need to find which component will be at dropTargetIndex in the final list
+ // and convert that to a component index
+
+ // Find the ComponentGroupItem that will be at dropTargetIndex after removal
+ // We want to insert before the component that will be at dropTargetIndex after insertion
+ List components = getComponentListModel().getComponents();
+
+ int componentAtTargetListIndex;
+
+ if (dropTargetIndex >= listSize - 1) {
+ // Dropping at the end
+ plugInPort.moveSelectionToZIndex(componentCount);
+ return true;
+ } else if (selectedIndex < dropTargetIndex) {
+ // Dragging up: removal happens before target
+ // After removal, the component that was at dropTargetIndex is now at dropTargetIndex-1
+ // After insertion at dropTargetIndex, it will be at dropTargetIndex+1
+ // So we want to insert before the component currently at dropTargetIndex
+ componentAtTargetListIndex = dropTargetIndex;
+ } else {
+ // Dragging down: removal happens after target
+ // After removal, the component at dropTargetIndex stays at dropTargetIndex
+ // After insertion at dropTargetIndex, it will be at dropTargetIndex+1
+ // So we want to insert before the component currently at dropTargetIndex
+ componentAtTargetListIndex = dropTargetIndex;
+ }
+
+ if (componentAtTargetListIndex >= listSize) {
+ // Insert at the end
+ plugInPort.moveSelectionToZIndex(componentCount);
+ return true;
+ }
+
+ // Get the ComponentGroupItem at that position
+ ComponentGroupItem targetItem = components.get(componentAtTargetListIndex);
+
+ // Find the highest z-index component in this group (or the component itself if single)
+ // Since the list is sorted by z-index (reversed), this is the component that determines
+ // the position of this group in the list
+ IDIYComponent> targetComponent = null;
+ int maxComponentIndex = -1;
+ List> allComponents = plugInPort.getCurrentProject().getComponents();
+
+ for (IDIYComponent> comp : targetItem.getGroup()) {
+ int compIndex = allComponents.indexOf(comp);
+ if (compIndex > maxComponentIndex) {
+ maxComponentIndex = compIndex;
+ targetComponent = comp;
+ }
+ }
+
+ if (targetComponent == null || maxComponentIndex < 0) {
+ return false;
+ }
+
+ // We want to insert before the component at maxComponentIndex
+ // moveSelectionToZIndex will automatically decrement zIndex for each selected component
+ // that's before the target, so we can use maxComponentIndex directly
+ // However, we need to add 1 because we want to insert at dropTargetIndex, not before it
+ int targetComponentIndex = maxComponentIndex + 1;
+
+ if (targetComponentIndex < 0) {
+ targetComponentIndex = 0;
+ } else if (targetComponentIndex > componentCount) {
+ targetComponentIndex = componentCount;
+ }
+
+ plugInPort.moveSelectionToZIndex(targetComponentIndex);
return true;
}
});
@@ -162,20 +238,28 @@ public class ExplorerPane extends JPanel {
componentList.setFont(DEFAULT_FONT);
componentList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
componentList.setCellRenderer(new ComponentListCellRenderer(plugInPort.getComponentTypes()));
- componentList.addListSelectionListener(new ListSelectionListener() {
+ selectionListener = new ListSelectionListener() {
@Override
public void valueChanged(ListSelectionEvent e) {
if (e.getValueIsAdjusting())
return;
- List> selectedValuesList = componentList.getSelectedValuesList();
- if (selectedValuesList.size() > 0 && !new HashSet>(selectedValuesList)
+ List selectedValuesList = componentList.getSelectedValuesList();
+ Set> expandedSelection = new HashSet<>();
+
+ for (ComponentGroupItem selected : selectedValuesList) {
+ // Always add all components from the group item
+ expandedSelection.addAll(selected.getGroup());
+ }
+
+ if (expandedSelection.size() > 0 && !expandedSelection
.equals(new HashSet>(plugInPort.getSelectedComponents()))) {
- plugInPort.setSelection(selectedValuesList, true);
+ plugInPort.setSelection(expandedSelection, true);
}
}
- });
+ };
+ componentList.addListSelectionListener(selectionListener);
componentList.addMouseListener(new MouseAdapter() {
@@ -205,8 +289,13 @@ public class ExplorerPane extends JPanel {
if (plugInPort.getNewComponentTypeSlot() == null && (e.isPopupTrigger()
|| (pressedEvent != null && pressedEvent.isPopupTrigger()))) {
- final List> selectedValues = componentList.getSelectedValuesList();
- getComponentPopupMenu().prepareAndShowAt(selectedValues, selectedValues, e.getX(),
+ final List selectedValues = componentList.getSelectedValuesList();
+ // Extract actual components from selected items
+ List> components = new ArrayList<>();
+ for (ComponentGroupItem item : selectedValues) {
+ components.addAll(item.getGroup());
+ }
+ getComponentPopupMenu().prepareAndShowAt(components, components, e.getX(),
e.getY());
}
}
@@ -238,46 +327,94 @@ public class ExplorerPane extends JPanel {
}
public void setSelection(Set> selectedComponents) {
- List> selectedValuesList = componentList.getSelectedValuesList();
- if (new HashSet>(selectedValuesList)
- .equals(new HashSet>(plugInPort.getSelectedComponents())))
+ List selectedValuesList = componentList.getSelectedValuesList();
+
+ // Extract components from selected values
+ Set> currentSelected = new HashSet<>();
+ for (ComponentGroupItem item : selectedValuesList) {
+ currentSelected.addAll(item.getGroup());
+ }
+
+ if (currentSelected.equals(selectedComponents))
return;
- final JList> list = this.getComponentList();
+ final JList list = this.getComponentList();
- if (selectedComponents.size() == 0) {
- list.clearSelection();
- } else {
+ // Temporarily remove listener to prevent infinite loop
+ list.removeListSelectionListener(selectionListener);
+ try {
+ if (selectedComponents.size() == 0) {
+ list.clearSelection();
+ } else {
+ // Build a map from component to its group (if any)
+ Set>> groups = plugInPort.getComponentGroups();
+ Map, Set>> componentToGroup = new java.util.HashMap<>();
+ if (groups != null && !groups.isEmpty()) {
+ for (Set> group : groups) {
+ for (IDIYComponent> component : group) {
+ componentToGroup.put(component, group);
+ }
+ }
+ }
- int[] indices = new int[selectedComponents.size()];
- int n = 0;
- List> components = this.getComponentListModel().getComponents();
- for (int i = 0; i < components.size(); i++) {
- indices[n] = -1; // this should not happen
- if (selectedComponents.contains(components.get(i)))
- indices[n++] = i;
- if (n == indices.length)
- break;
+ // Find indices of items to select
+ List components = this.getComponentListModel().getComponents();
+ Set selectedIndices = new HashSet<>();
+ Set>> selectedGroups = new HashSet<>();
+
+ for (IDIYComponent> selected : selectedComponents) {
+ Set> group = componentToGroup.get(selected);
+ if (group != null) {
+ // Component is in a group
+ if (!selectedGroups.contains(group)) {
+ // Find the ComponentGroupItem for this group
+ for (int i = 0; i < components.size(); i++) {
+ ComponentGroupItem groupItem = components.get(i);
+ if (groupItem.getGroup().equals(group)) {
+ selectedIndices.add(i);
+ selectedGroups.add(group);
+ break;
+ }
+ }
+ }
+ } else {
+ // Component is not in any group, find the ComponentGroupItem that contains only this component
+ for (int i = 0; i < components.size(); i++) {
+ ComponentGroupItem groupItem = components.get(i);
+ if (groupItem.getGroup().size() == 1 && groupItem.getGroup().contains(selected)) {
+ selectedIndices.add(i);
+ break;
+ }
+ }
+ }
+ }
+
+ int[] indices = selectedIndices.stream().mapToInt(i -> i).toArray();
+
+ list.getSelectionModel().setValueIsAdjusting(true);
+ list.setSelectedIndices(indices);
+ list.getSelectionModel().setValueIsAdjusting(false);
+ if (indices.length > 0) {
+ int firstVisibleIndex = list.getFirstVisibleIndex();
+ int lastVisibleIndex = list.getLastVisibleIndex();
+ // if none of the selected items is visible, jump to the first one
+ if (!Arrays.stream(indices)
+ .anyMatch(index -> firstVisibleIndex <= index && index <= lastVisibleIndex))
+ list.ensureIndexIsVisible(indices[0]);
+ }
}
-
- list.getSelectionModel().setValueIsAdjusting(true);
- list.setSelectedIndices(indices);
- list.getSelectionModel().setValueIsAdjusting(false);
- int firstVisibleIndex = list.getFirstVisibleIndex();
- int lastVisibleIndex = list.getFirstVisibleIndex();
- // if none of the selected items is visible, jump to the first one
- if (!Arrays.stream(indices)
- .anyMatch(index -> firstVisibleIndex <= index && index <= lastVisibleIndex))
- list.ensureIndexIsVisible(indices[0]);
+ } finally {
+ // Re-add listener
+ list.addListSelectionListener(selectionListener);
}
}
- private class ComponentListModel extends AbstractListModel> {
+ private class ComponentListModel extends AbstractListModel {
private static final long serialVersionUID = 1L;
private List> componentsRaw;
- private List> components;
+ private List components;
private String searchText;
private Sort sort = Sort.Z_INDEX;
@@ -289,7 +426,7 @@ public class ExplorerPane extends JPanel {
refresh(oldSize);
}
- public List> getComponents() {
+ public List getComponents() {
return components;
}
@@ -310,27 +447,32 @@ public class ExplorerPane extends JPanel {
}
private void refresh(int oldSize) {
+ List> sortedComponents;
+
if (sort == Sort.NAME) {
- this.components =
+ sortedComponents =
componentsRaw.stream().sorted(Comparator.comparing(x -> x.toString().toLowerCase()))
.collect(Collectors.toList());
} else if (sort == Sort.TYPE) {
- this.components = componentsRaw.stream()
+ sortedComponents = componentsRaw.stream()
.sorted(Comparator.comparing(x -> x.getClass().getName().toLowerCase())
.thenComparing(x -> x.toString().toLowerCase()))
.collect(Collectors.toList());
} else {
List> componentsClone = new ArrayList>(componentsRaw);
Collections.reverse(componentsClone);
- this.components = componentsClone;
+ sortedComponents = componentsClone;
}
if (searchText != null && !searchText.trim().isEmpty()) {
- this.components = this.components.stream().filter(component -> {
+ sortedComponents = sortedComponents.stream().filter(component -> {
return component.getName().toLowerCase().contains(searchText);
}).collect(Collectors.toList());
}
+ // Build list with groups and ungrouped components
+ this.components = buildComponentListWithGroups(sortedComponents);
+
int newSize = this.components.size();
fireContentsChanged(this, 0, Math.min(newSize, oldSize));
@@ -339,6 +481,49 @@ public class ExplorerPane extends JPanel {
else if (newSize > oldSize)
fireIntervalAdded(this, oldSize, newSize);
}
+
+ /**
+ * Builds the component list, using ComponentGroupItem for all items.
+ * For grouped components, creates one ComponentGroupItem per group.
+ * For ungrouped components, creates a ComponentGroupItem with a single component.
+ */
+ private List buildComponentListWithGroups(List> sortedComponents) {
+ Set>> groups = plugInPort.getComponentGroups();
+
+ // Build a map from component to its group (if any)
+ Map, Set>> componentToGroup = new java.util.HashMap<>();
+ if (groups != null && !groups.isEmpty()) {
+ for (Set> group : groups) {
+ for (IDIYComponent> component : group) {
+ componentToGroup.put(component, group);
+ }
+ }
+ }
+
+ // Track which groups we've already added
+ Set>> addedGroups = new HashSet<>();
+ List result = new ArrayList<>();
+
+ for (IDIYComponent> component : sortedComponents) {
+ Set> group = componentToGroup.get(component);
+ if (group == null) {
+ // Component is not in any group, create a ComponentGroupItem with just this component
+ Set> singleComponentGroup = new HashSet<>();
+ singleComponentGroup.add(component);
+ result.add(new ComponentGroupItem(singleComponentGroup));
+ } else {
+ // Component is in a group
+ if (!addedGroups.contains(group)) {
+ // This is the first component from this group we've seen, add the group item
+ result.add(new ComponentGroupItem(group));
+ addedGroups.add(group);
+ }
+ // Otherwise, skip this component as we already have a group item for it
+ }
+ }
+
+ return result;
+ }
@Override
public int getSize() {
@@ -346,12 +531,12 @@ public class ExplorerPane extends JPanel {
}
@Override
- public IDIYComponent> getElementAt(int index) {
+ public ComponentGroupItem getElementAt(int index) {
return this.components.get(index);
}
}
- private static class ComponentListCellRenderer implements ListCellRenderer> {
+ private static class ComponentListCellRenderer implements ListCellRenderer {
// private static Color[] layerColors = new Color[] { Color.gray, Color.decode("#ff6363"),
// Color.decode("#293462"), Color.decode("#138588"),
@@ -367,32 +552,63 @@ public class ExplorerPane extends JPanel {
}
@Override
- public Component getListCellRendererComponent(JList extends IDIYComponent>> list,
- IDIYComponent> value, int index, boolean isSelected, boolean cellHasFocus) {
-
- ComponentType componentType = componentTypes.get(value.getClass().getCanonicalName());
+ public Component getListCellRendererComponent(JList extends ComponentGroupItem> list,
+ ComponentGroupItem value, int index, boolean isSelected, boolean cellHasFocus) {
JLabel renderer = (JLabel) defaultRenderer.getListCellRendererComponent(list, value, index,
isSelected, cellHasFocus);
- int layerId = (int) Math.round(componentType.getZOrder());
+ ComponentGroupItem groupItem = value;
+
+ if (groupItem.getComponentCount() == 1) {
+ // Render as individual component (ungrouped)
+ IDIYComponent> component = groupItem.getGroup().iterator().next();
+ ComponentType componentType = componentTypes.get(component.getClass().getCanonicalName());
+
+ if (componentType == null) {
+ renderer.setText(component.toString());
+ return renderer;
+ }
- String valueForDisplay = value.getValueForDisplay();
- if (isSelected) {
- if (valueForDisplay == null || valueForDisplay.trim().isEmpty()) {
- renderer.setText(String.format("[L%s] %s", layerId, value.getName()));
+ int layerId = (int) Math.round(componentType.getZOrder());
+
+ String valueForDisplay = component.getValueForDisplay();
+ if (isSelected) {
+ if (valueForDisplay == null || valueForDisplay.trim().isEmpty()) {
+ renderer.setText(String.format("[L%s] %s", layerId, component.getName()));
+ } else {
+ renderer
+ .setText(String.format("[L%s] %s (%s)", layerId, component.getName(), valueForDisplay));
+ }
} else {
- renderer
- .setText(String.format("[L%s] %s (%s)", layerId, value.getName(), valueForDisplay));
+ if (valueForDisplay == null || valueForDisplay.trim().isEmpty()) {
+ renderer.setText(String.format("[L%s] %s",
+ layerId, component.getName()));
+ } else {
+ renderer.setText(String.format(
+ "[L%s] %s (%s)",
+ layerId, component.getName(), valueForDisplay));
+ }
}
} else {
- if (valueForDisplay == null || valueForDisplay.trim().isEmpty()) {
- renderer.setText(String.format("[L%s] %s",
- layerId, value.getName()));
+ // Render as group
+ // Calculate maximum layer from components in the group
+ int maxLayerId = 0;
+ for (IDIYComponent> component : groupItem.getGroup()) {
+ ComponentType componentType = componentTypes.get(component.getClass().getCanonicalName());
+ if (componentType != null) {
+ int layerId = (int) Math.round(componentType.getZOrder());
+ if (layerId > maxLayerId) {
+ maxLayerId = layerId;
+ }
+ }
+ }
+
+ String text = "Group of " + groupItem.getComponentCount() + " components";
+ if (isSelected) {
+ renderer.setText(String.format("[L%s] %s", maxLayerId, text));
} else {
- renderer.setText(String.format(
- "[L%s] %s (%s)",
- layerId, value.getName(), valueForDisplay));
+ renderer.setText(String.format("[L%s] %s", maxLayerId, text));
}
}
@@ -414,4 +630,43 @@ public class ExplorerPane extends JPanel {
return LangUtil.translate(label);
}
}
+
+ /**
+ * Wrapper class to represent a group of components in the explorer list.
+ */
+ public static class ComponentGroupItem {
+ private final Set> group;
+
+ public ComponentGroupItem(Set> group) {
+ this.group = group;
+ }
+
+ public Set> getGroup() {
+ return group;
+ }
+
+ public int getComponentCount() {
+ return group.size();
+ }
+
+ @Override
+ public String toString() {
+ return "Group of " + group.size() + " components";
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj)
+ return true;
+ if (obj == null || getClass() != obj.getClass())
+ return false;
+ ComponentGroupItem other = (ComponentGroupItem) obj;
+ return group.equals(other.group);
+ }
+
+ @Override
+ public int hashCode() {
+ return group.hashCode();
+ }
+ }
}