explorer refresh improvement

This commit is contained in:
Bane Stojkovic
2026-10-13 17:06:05 +02:00
parent 1ec8805c91
commit 840ab08401
4 changed files with 235 additions and 52 deletions
@@ -605,6 +605,10 @@
<changeType>IMPROVEMENT</changeType>
<description>Include 'Potentiometer (Miniature)' and 'Trimmer Potentiometer' in Guitar Wiring Analyzer</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>IMPROVEMENT</changeType>
<description>Improve performance of 'Project Explorer' pane</description>
</org.diylc.appframework.update.Change>
<org.diylc.appframework.update.Change>
<changeType>BUG_FIX</changeType>
<description>Fixed the issue of disappearing components in some edge cases</description>
@@ -5,6 +5,7 @@ import java.awt.Dimension;
import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.event.HierarchyEvent;
import java.awt.event.ItemEvent;
import java.awt.event.ItemListener;
import java.awt.event.MouseAdapter;
@@ -14,6 +15,7 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -67,6 +69,9 @@ public class ExplorerPane extends JPanel {
private JComboBox<Sort> sortBox;
private ListSelectionListener selectionListener;
private boolean stale;
private ExplorerSnapshot snapshot;
public ExplorerPane(ISwingUI swingUI, IPlugInPort plugInPort) {
super();
this.swingUI = swingUI;
@@ -105,6 +110,12 @@ public class ExplorerPane extends JPanel {
add(getSortBox(), gbc);
setPreferredSize(new Dimension(240, 200));
addHierarchyListener(e -> {
if ((e.getChangeFlags() & HierarchyEvent.SHOWING_CHANGED) != 0 && isShowing()) {
refreshIfStale();
}
});
}
public JLabel getTitleLabel() {
@@ -238,6 +249,13 @@ public class ExplorerPane extends JPanel {
});
componentList.setModel(getComponentListModel());
componentList.setFont(DEFAULT_FONT);
// without a fixed height every content change re-measures each row through the HTML
// renderer, which dominates the cost of a refresh on large projects
componentList.setFixedCellHeight(new DefaultListCellRenderer()
.getListCellRendererComponent(componentList,
"<html><font color='#c0c0c0'>[L0]</font> Xg (<font color='#666666'>Xg</font>)</html>",
0, false, false)
.getPreferredSize().height);
componentList.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
componentList.setCellRenderer(new ComponentListCellRenderer(plugInPort.getComponentTypes()));
selectionListener = new ListSelectionListener() {
@@ -324,10 +342,36 @@ public class ExplorerPane extends JPanel {
public void setComponents(List<IDIYComponent<?>> components,
Collection<IDIYComponent<?>> selectedComponents) {
this.getComponentListModel().setComponents(components);
ExplorerSnapshot newSnapshot =
ExplorerSnapshot.of(components, plugInPort.getComponentGroups());
// moving, rotating or nudging leaves everything the list shows untouched
if (!newSnapshot.equals(snapshot)) {
snapshot = newSnapshot;
this.getComponentListModel().setComponents(components);
}
setSelection(new HashSet<IDIYComponent<?>>(selectedComponents));
}
/**
* Called instead of {@link #setComponents} while the pane is not showing. When the project
* itself was replaced the list is emptied so it does not hold on to the old one.
*/
public void markStale(boolean projectReplaced) {
stale = true;
if (projectReplaced && snapshot != null) {
snapshot = null;
this.getComponentListModel().setComponents(new ArrayList<IDIYComponent<?>>());
}
}
private void refreshIfStale() {
if (stale) {
stale = false;
setComponents(plugInPort.getCurrentProject().getComponents(),
plugInPort.getSelectedComponents());
}
}
public void setSelection(Set<IDIYComponent<?>> selectedComponents) {
List<ComponentGroupItem> selectedValuesList = componentList.getSelectedValuesList();
@@ -348,61 +392,16 @@ public class ExplorerPane extends JPanel {
if (selectedComponents.size() == 0) {
list.clearSelection();
} else {
// Build a map from component to its group (if any)
Set<ComponentGroup> groups = plugInPort.getComponentGroups();
Map<IDIYComponent<?>, Set<IDIYComponent<?>>> componentToGroup = new java.util.HashMap<>();
if (groups != null && !groups.isEmpty()) {
// Build a map from UUID to component for quick lookup
List<IDIYComponent<?>> allComponents = plugInPort.getCurrentProject().getComponents();
Map<UUID, IDIYComponent<?>> idToComponent = allComponents.stream()
.collect(Collectors.toMap(IDIYComponent::getId, comp -> comp));
for (ComponentGroup group : groups) {
// Convert ComponentGroup (with UUIDs) to Set<IDIYComponent<?>>
Set<IDIYComponent<?>> componentSet = group.getComponentIds().stream()
.map(idToComponent::get)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toSet());
for (IDIYComponent<?> component : componentSet) {
componentToGroup.put(component, componentSet);
}
}
}
// Find indices of items to select
List<ComponentGroupItem> components = this.getComponentListModel().getComponents();
Map<IDIYComponent<?>, Integer> rowIndex = this.getComponentListModel().getRowIndex();
Set<Integer> selectedIndices = new HashSet<>();
Set<Set<IDIYComponent<?>>> selectedGroups = new HashSet<>();
for (IDIYComponent<?> selected : selectedComponents) {
Set<IDIYComponent<?>> 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;
}
}
Integer index = rowIndex.get(selected);
if (index != null) {
selectedIndices.add(index);
}
}
int[] indices = selectedIndices.stream().mapToInt(i -> i).toArray();
int[] indices = selectedIndices.stream().mapToInt(i -> i).sorted().toArray();
list.getSelectionModel().setValueIsAdjusting(true);
list.setSelectedIndices(indices);
@@ -428,6 +427,7 @@ public class ExplorerPane extends JPanel {
private List<IDIYComponent<?>> componentsRaw;
private List<ComponentGroupItem> components;
private Map<IDIYComponent<?>, Integer> rowIndex = new HashMap<>();
private String searchText;
private Sort sort = Sort.Z_INDEX;
@@ -443,6 +443,10 @@ public class ExplorerPane extends JPanel {
return components;
}
public Map<IDIYComponent<?>, Integer> getRowIndex() {
return rowIndex;
}
public Sort getSort() {
return sort;
}
@@ -485,6 +489,13 @@ public class ExplorerPane extends JPanel {
// Build list with groups and ungrouped components
this.components = buildComponentListWithGroups(sortedComponents);
Map<IDIYComponent<?>, Integer> newRowIndex = new HashMap<>();
for (int i = 0; i < this.components.size(); i++) {
for (IDIYComponent<?> component : this.components.get(i).getGroup()) {
newRowIndex.put(component, i);
}
}
this.rowIndex = newRowIndex;
int newSize = this.components.size();
@@ -716,4 +727,30 @@ public class ExplorerPane extends JPanel {
return group.hashCode();
}
}
/**
* Everything the list reads from the project, copied so that in-place edits of components
* and groups are detected. Components compare by identity, so an undo that swaps in clones
* still forces a rebuild.
*/
private record ExplorerSnapshot(List<IDIYComponent<?>> components, List<String> labels,
Map<Set<UUID>, String> groups) {
static ExplorerSnapshot of(List<IDIYComponent<?>> components, Set<ComponentGroup> groups) {
List<String> labels = new ArrayList<>(components.size() * 3);
for (IDIYComponent<?> component : components) {
labels.add(component.getName());
// toString() drives the name and type sorts
labels.add(component.toString());
labels.add(component.getValueForDisplay());
}
Map<Set<UUID>, String> groupNames = new HashMap<>();
if (groups != null) {
for (ComponentGroup group : groups) {
groupNames.put(new HashSet<>(group.getComponentIds()), group.getName());
}
}
return new ExplorerSnapshot(new ArrayList<>(components), labels, groupNames);
}
}
}
@@ -57,12 +57,19 @@ public class ExplorerPlugin implements IPlugIn {
@Override
public EnumSet<EventType> getSubscribedEventTypes() {
return EnumSet.of(EventType.FILE_STATUS_CHANGED, EventType.PROJECT_LOADED, EventType.PROJECT_MODIFIED, EventType.SELECTION_CHANGED);
return EnumSet.of(EventType.PROJECT_LOADED, EventType.PROJECT_MODIFIED,
EventType.SELECTION_CHANGED);
}
@SuppressWarnings("unchecked")
@Override
public void processMessage(EventType eventType, Object... params) {
// the dispatcher offers no way to unsubscribe, so a collapsed pane only records that it
// has to catch up once it is shown again
if (!getExplorerPane().isShowing()) {
getExplorerPane().markStale(eventType == EventType.PROJECT_LOADED);
return;
}
switch (eventType) {
case PROJECT_MODIFIED:
getExplorerPane().setComponents(((Project)params[1]).getComponents(), plugInPort.getSelectedComponents());
@@ -72,6 +79,7 @@ public class ExplorerPlugin implements IPlugIn {
break;
case SELECTION_CHANGED:
getExplorerPane().setSelection((Set<IDIYComponent<?>>)params[0]);
break;
default:
break;
}
+134
View File
@@ -0,0 +1,134 @@
# Project Explorer Refresh Cost
`ExplorerPane` (`diylc-swing/.../plugins/explorer`) rebuilds its whole list on every
`PROJECT_MODIFIED`, `PROJECT_LOADED` and `SELECTION_CHANGED`, even when the panel is collapsed
and even when the change cannot affect what it shows. This plan removes both costs without
touching `diylc-core` or the event contract.
## 1. How the explorer is driven today
- `Presenter` uses `MessageDispatcher<EventType>(true)` — **synchronous**. Every
`processMessage` runs on the EDT inside the call that changed the project, so explorer work
adds directly to the latency of the edit that triggered it.
- The dispatcher reads `getSubscribedEventTypes()` **once, at registration**, and there is no
unregister path on `IPlugInPort`. "Disconnecting" therefore means returning early from
`processMessage`, which costs one method call per event and is equivalent in practice.
- `ExplorerPlugin.processMessage`:
- `PROJECT_MODIFIED` / `PROJECT_LOADED` → `ExplorerPane.setComponents(all, selection)`.
- `SELECTION_CHANGED` → `setSelection(...)`. (It also falls through to `default`; harmless.)
- `FILE_STATUS_CHANGED` is subscribed but unused.
- What `setComponents` costs, for *n* components:
1. sort/filter/reverse copy of the component list;
2. `buildComponentListWithGroups` — a UUID→component map over the whole project plus a
set per group;
3. `fireContentsChanged(0, n)` — with no fixed cell height, `BasicListUI` re-measures
**every row** through the renderer, and most rows are `<html>` labels, so each row is
an HTML parse. This is the dominant cost;
4. `setSelection` rebuilds the group map again and does a linear scan per selected item.
### Which interactions reach the explorer
Read from `Presenter`; to be confirmed by measurement (step 0).
| Interaction | Events reaching the explorer | Changes the list? |
| --- | --- | --- |
| Mouse drag of components / control points, per mouse move | none — `dragOver` fires only `REPAINT` | — |
| Drag start (`dragStarted`) | `SELECTION_CHANGED` | selection only |
| Drag end (`dragEnded`) | `SELECTION_CHANGED` + `PROJECT_MODIFIED("Drag")` | no |
| Arrow-key nudge (`nudgeSelection`), per key repeat | `PROJECT_MODIFIED("Move Selection")` | no |
| Rotate / mirror / align / distribute | `PROJECT_MODIFIED` | no |
| Click on canvas (even on the already-selected part) | `SELECTION_CHANGED` | selection only |
| Edit properties (name, value) | `PROJECT_MODIFIED("Edit Selection")` | yes |
| Add / delete / duplicate / paste, z-order, group / ungroup, undo / redo | `PROJECT_MODIFIED` or `PROJECT_LOADED` | yes |
So the explorer does not rebuild on every mouse move; it rebuilds once per *gesture* and once
per *key repeat*, and each rebuild is O(n) HTML layouts. On a large project that is the stall
felt at the start and end of each drag and the lag when holding an arrow key. If profiling in
step 0 shows explorer work *during* a drag, something else is dispatching and needs finding
before this plan is enough.
## 2. Decisions
**Visibility is checked with `isShowing()` and a `HierarchyListener`, not the config key.**
`CollapsiblePanel` hides the pane with `setVisible(false)`; `isShowing()` also covers an
ancestor being hidden and the frame not yet being displayed. `HierarchyEvent.SHOWING_CHANGED`
fires for all of those, whereas listening to `ConfigPlugin.PROJECT_EXPLORER` would miss
everything except the menu toggle.
**A hidden pane keeps no state in sync; it just remembers that it is stale.** On becoming
visible it pulls the current project and selection from `IPlugInPort` rather than replaying
events. On `PROJECT_LOADED` while hidden it also drops its model so it does not pin the
previous project's object graph.
**"Does this change affect the list" is decided by comparing a snapshot of what the list
shows, not by the action string.** `PROJECT_MODIFIED` carries a description (`"Drag"`,
`"Move Selection"`, …) that could be whitelisted, but those strings are scattered through
`Presenter`, are not a contract, and a missed one shows stale data. A snapshot is robust:
the pane records exactly the inputs its model and renderer read, and skips the rebuild when
they are unchanged. Capturing it is O(n) plain field reads, far cheaper than O(n) HTML
layouts.
The snapshot is a small private record (per `typed-records-over-string-keyed-maps`), holding
copies, not live references, because `Presenter` mutates components and groups in place:
- the component list **by identity, in z-order**;
- per component: `getName()`, `toString()` (used by the NAME/TYPE sorts) and
`getValueForDisplay()`;
- per group: name and set of member UUIDs (`ComponentGroup` has no `equals`, and a rename
mutates the existing instance).
The layer badge comes from `ComponentType.getZOrder()`, which is static per class, so it is
covered by identity.
**Selection sync is O(selected), not O(n).** `refresh()` builds a
`Map<IDIYComponent<?>, Integer>` from component to row index alongside the row list.
`setSelection` uses it instead of rebuilding the group map and scanning rows. The existing
"selection already matches" early-out stays.
**Rows get a fixed height.** `setFixedCellHeight` from the font metrics stops `BasicListUI`
from measuring every row through the HTML renderer whenever contents change. Rows are a
single line, so nothing is lost. This also speeds up the rebuilds that remain necessary.
**No debounce timer for now.** With the snapshot check, a nudge burst costs one O(n)
comparison per repeat and no layout. A `javax.swing.Timer` coalescer would add latency to
genuine edits for little gain; revisit only if step 0 shows the comparison itself matters.
## 3. Changes
All in `diylc-swing/src/main/java/org/diylc/swing/plugins/explorer/`. No core, library or
file-format changes; no regression-suite impact (the explorer is not rendered in exports).
1. **`ExplorerPane`**
- Field `boolean stale`; method `refreshIfStale()` that, when showing, calls
`setComponents(plugInPort.getCurrentProject().getComponents(),
plugInPort.getSelectedComponents())`.
- `HierarchyListener` in the constructor: on `SHOWING_CHANGED` with `isShowing()`, call
`refreshIfStale()`.
- `setComponents`: build the snapshot; if equal to the previous one, skip
`ComponentListModel.setComponents` and go straight to `setSelection`.
- `ComponentListModel.refresh()`: also build the component→row map.
- `setSelection`: use the row map.
- `getComponentList()`: `setFixedCellHeight(...)`.
- Snapshot record declared at the bottom of the file with the other nested types.
2. **`ExplorerPlugin.processMessage`**
- First line: if `!explorerPane.isShowing()`, mark the pane stale (and on `PROJECT_LOADED`
clear its model), then return.
- Add the missing `break` after `SELECTION_CHANGED`.
- Drop `FILE_STATUS_CHANGED` from `getSubscribedEventTypes()`.
Search and sort changes call `refresh()` directly and bypass the snapshot
check; the snapshot only gates the event-driven path.
## 4. Status and next steps
Everything in §3 is implemented in the working tree and `diylc-swing` compiles. Not yet done:
1. **Measure.** On a large project (several hundred components), time `setComponents` and
`setSelection`, or run a profiler, while dragging, holding an arrow key and editing a
value. Compare against `master` to confirm the table in §1 and the gain.
2. **Manual check in the running app.** Collapse the explorer, edit, then expand: the list and
selection should be correct. Repeat with undo/redo and file open while it is collapsed.
Also check: rename and value edits in the property editor; group, rename the group, then
ungroup; z-order drag inside the explorer; search and sort while visible; row height
looks right under each look-and-feel/theme.
3. Add a debounce only if step 1 shows the snapshot comparison itself is significant.