From 7da4444c18dee057c442896787caa2b628867c77 Mon Sep 17 00:00:00 2001 From: Bane Stojkovic Date: Wed, 7 Jan 2026 21:07:46 +0100 Subject: [PATCH] show groups in explorer --- .../org/diylc/common/IGroupProcessor.java | 51 +++ .../org/diylc/common/ISelectionProcessor.java | 12 +- .../java/org/diylc/presenter/Presenter.java | 5 + .../swing/plugins/explorer/ExplorerPane.java | 381 +++++++++++++++--- 4 files changed, 375 insertions(+), 74 deletions(-) create mode 100644 diylc/diylc-core/src/main/java/org/diylc/common/IGroupProcessor.java 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> list, - IDIYComponent value, int index, boolean isSelected, boolean cellHasFocus) { - - ComponentType componentType = componentTypes.get(value.getClass().getCanonicalName()); + public Component getListCellRendererComponent(JList 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(); + } + } }