Files
diy-layout-creator/diylc/docs/plans/explorer-refresh.md
T
2026-10-13 17:06:05 +02:00

135 lines
7.7 KiB
Markdown

# 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.