7.7 KiB
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
PresenterusesMessageDispatcher<EventType>(true)— synchronous. EveryprocessMessageruns 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 onIPlugInPort. "Disconnecting" therefore means returning early fromprocessMessage, 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 todefault; harmless.)FILE_STATUS_CHANGEDis subscribed but unused.
- What
setComponentscosts, for n components:- sort/filter/reverse copy of the component list;
buildComponentListWithGroups— a UUID→component map over the whole project plus a set per group;fireContentsChanged(0, n)— with no fixed cell height,BasicListUIre-measures every row through the renderer, and most rows are<html>labels, so each row is an HTML parse. This is the dominant cost;setSelectionrebuilds 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) andgetValueForDisplay(); - per group: name and set of member UUIDs (
ComponentGrouphas noequals, 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).
ExplorerPane- Field
boolean stale; methodrefreshIfStale()that, when showing, callssetComponents(plugInPort.getCurrentProject().getComponents(), plugInPort.getSelectedComponents()). HierarchyListenerin the constructor: onSHOWING_CHANGEDwithisShowing(), callrefreshIfStale().setComponents: build the snapshot; if equal to the previous one, skipComponentListModel.setComponentsand go straight tosetSelection.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.
- Field
ExplorerPlugin.processMessage- First line: if
!explorerPane.isShowing(), mark the pane stale (and onPROJECT_LOADEDclear its model), then return. - Add the missing
breakafterSELECTION_CHANGED. - Drop
FILE_STATUS_CHANGEDfromgetSubscribedEventTypes().
- First line: if
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:
- Measure. On a large project (several hundred components), time
setComponentsandsetSelection, or run a profiler, while dragging, holding an arrow key and editing a value. Compare againstmasterto confirm the table in §1 and the gain. - 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.
- Add a debounce only if step 1 shows the snapshot comparison itself is significant.