-
Notifications
You must be signed in to change notification settings - Fork 1
Add some plans (no app logic changed) #548
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,313 @@ | ||
| # Logical / Visual Split: Architecture Plan | ||
|
|
||
| > Goal: C++ owns logical graph structure (which term is at which level/column). | ||
| > QML owns pixel geometry and all animations. | ||
|
|
||
| ## Current Architecture (as-is) | ||
|
|
||
| ### Layer Stack | ||
|
|
||
| ```plaintext | ||
| source/graph/ ← pure graph theory, zero Qt/visual coupling | ||
| Forest<NodeT, EdgeT> BFS levels, cycle removal, connected components | ||
| Graph<NodeT, EdgeT> bondedSubgraphs() per connected component | ||
|
|
||
| source/model/ ← where the two worlds collide | ||
| PaintedTerm : Node<TermData> + GraphicItem (hand-rolled scene proxy) | ||
| PaintedEdge : Edge<PaintedTerm, …> + GraphicItem | ||
| PaintedForest: Forest<PaintedTerm, …> + NodeVerticalStack[] (one stack per BFS level) | ||
| TermGroup god-class: builds graph + computes pixel layout | ||
|
|
||
| TermsModel / EdgesModel ← QAbstractListModel bridge | ||
| setGroup() → beginResetModel() destroys ALL QML delegates on every group switch | ||
|
|
||
| QML | ||
| NodesScene.qml Repeater over TermsModel; imperative edge rebuild | ||
| Term.qml x: rect.x; y: rect.y (final pixels from C++) | ||
| Edge.qml ShapePath with pt1/pt2 (final pixels from C++) | ||
| ``` | ||
|
|
||
| ### What C++ Sends to QML Today | ||
|
|
||
| | Model | Role | Value | | ||
| |--- |--- |--- | | ||
| | `TermsModel` | `rect` | `QRectF(scenePos, pixelSize)` — final pixels | | ||
| | `TermsModel` | `radius`, `type`, `area`, `isSelected`, `term`, `definition` | display data | | ||
| | `EdgesModel` | `pt1`, `pt2` | `QPointF` scene centers — final pixels | | ||
| | `EdgesModel` | `isSelected`, `edgeType`, `edgeSelection` | display data | | ||
| | `MainScene` | `sceneRect` | total canvas bounding box — final pixels | | ||
|
|
||
| ### TermGroup Constructor Flow | ||
|
|
||
| ```plaintext | ||
| TermGroup(info, termData) | ||
| ├─ create PaintedTerm list | ||
| ├─ collapseSynonyms() | ||
| ├─ searchAllConnections() → edges, ghost nodes | ||
| ├─ bondedSubgraphs() → one PaintedForest per connected component | ||
| ├─ allBrokenEdges() pruning | ||
| ├─ addTreeRectsToScene() → GraphicItem parent chain | ||
| ├─ addOrphansToParents() | ||
| ├─ setTreeCoords() → PaintedForest::setTreeNodeCoords() | ||
| │ └─ NodeVerticalStack::placeTerms() → term->setPos(QPointF) ← pixels set here | ||
| ├─ setOrphCoords() → orphan row-wrap layout → term->setPos() | ||
| ├─ updateRectsPositions() → positions each forest rect inside group | ||
| └─ updateBaseRectSize() → total group bounding box | ||
| ``` | ||
|
|
||
| ### Edge Rendering Anti-Pattern | ||
|
|
||
| Edges are **not** in a `Repeater`. On every `edgesChanged` signal, `NodesScene.qml` does: | ||
|
|
||
| ```js | ||
| function updateEdges() { | ||
| // destroy all existing ShapePath objects | ||
| for (let i = 0; i < data.length; ++i) data[i].destroy(); | ||
| // read all edge data imperatively via raw role indices | ||
| data = edgesData().filter(...).map(e => edgeComponent.createObject(parent, e)); | ||
| } | ||
| ``` | ||
|
|
||
| Full rebuild on every signal. Zero animation possible with this approach. | ||
|
|
||
| ## Problems | ||
|
|
||
| ### 1. `TermGroup` God-Class | ||
|
|
||
| One class handles: graph construction, synonym collapsing, BFS subgraph decomposition, per-forest pixel layout, orphan row-wrap layout, group bounding rect computation — all in the constructor. | ||
| Logical structure and pixel geometry are inseparable. | ||
|
|
||
| ### 2. `GraphicItem` Is Mute | ||
|
|
||
| `GraphicItem::mPosition` is a plain `QPointF` — no `QProperty`, no signal on change. | ||
| QML learns about position updates only via `beginResetModel` (= destroy all delegates). | ||
|
|
||
| ### 3. `beginResetModel` Blocks Animation | ||
|
|
||
| `TermsModel::setGroup()` calls `beginResetModel` / `endResetModel` → the QML `Repeater` tears down | ||
| and recreates **all** delegates. No transition animation is possible: old delegates are gone before new ones appear. | ||
|
|
||
| ### 4. Imperative Edge Rebuild | ||
|
|
||
| `createObject` / `destroy` cycle on every `edgesChanged`. | ||
| Delegates have no stable lifetime — impossible to animate `pt1`/`pt2` between states. | ||
|
|
||
| ## Layout Hierarchy (Two Levels) | ||
|
|
||
| A `TermGroup` contains **multiple** forests (one per connected component) **plus** orphan terms: | ||
|
|
||
| ```plaintext | ||
| TermGroup | ||
| ├── PaintedForest 0 (connected component, left-to-right tree) | ||
| │ ├── NodeVerticalStack[0] (depth 0 — roots) | ||
| │ ├── NodeVerticalStack[1] (depth 1) | ||
| │ └── NodeVerticalStack[N] (depth N — leaves) | ||
| ├── PaintedForest 1 | ||
| ├── … | ||
| └── Orphans (isolated terms, row-wrapped) | ||
| ``` | ||
|
|
||
| This means layout has two distinct responsibilities: | ||
|
|
||
| - `ForestLayout` — assigns `{depth, stackPos}` per term *within one forest*. | ||
| Extracted from `PaintedForest::setTreeNodeCoords()` + `NodeVerticalStack::placeTerms()`. | ||
|
|
||
| - `GroupLayout` — stacks multiple forest rects vertically inside the group + positions orphan block. | ||
| Extracted from `TermGroup::updateRectsPositions()` + `setOrphCoords()`. | ||
|
|
||
| ## Target Architecture | ||
|
|
||
| ### Core Idea | ||
|
|
||
| C++ computes **logical position** only | ||
| QML translates logical position → pixel position and owns all animations | ||
|
|
||
| ```plaintext | ||
| graph::Forest<TermData, EdgeData> ← unchanged, pure logic | ||
|
|
||
| ForestLayout ← NEW: per-forest logical layout | ||
| input: Forest<TermData, EdgeData> | ||
| output: map<uuid, {depth, stackPos}> | ||
|
|
||
| GroupLayout ← NEW: group-level arrangement | ||
| input: vector<ForestLayout> + orphan list | ||
| output: ForestPosition per term (adds forestIndex) or null for orphans | ||
|
|
||
| TermsModel ← CHANGED: stable identity, logical roles | ||
| roles: uuid, forestIndex, depth, stackPos, | ||
| text, termAndSynonyms, definition, nodeType, area, isSelected | ||
| update strategy: dataChanged() per item, never beginResetModel | ||
|
|
||
| QML: Repeater { model: Api.scene.terms } | ||
| Term.qml: | ||
| x: model.depth * (nodeWidth + depthSpacing) | ||
| y: model.stackPos * (nodeHeight + stackSpacing) | ||
| + model.forestIndex * forestVerticalOffset | ||
| Behavior on x { NumberAnimation { duration: 300; easing.type: Easing.InOutCubic } } | ||
| Behavior on y { NumberAnimation { duration: 300; easing.type: Easing.InOutCubic } } | ||
| ``` | ||
|
|
||
| ### `ForestPosition` and `GroupPosition` Structs | ||
|
|
||
| ```cpp | ||
| struct ForestPosition { | ||
| int depth; // column (left-to-right depth in tree) | ||
| int stackPos; // row within column (vertical position in stack) | ||
| }; | ||
| ``` | ||
|
|
||
| ```cpp | ||
| struct GroupPosition { | ||
| int forestIndex; // which forest (connected component) this term belongs to | ||
| ForestPosition pos; | ||
| }; | ||
| ``` | ||
|
|
||
| ### What Stays in C++ | ||
|
|
||
| - Forest topology: which term is at which `{forestIndex, depth, stackPos}` | ||
| - Node identity (uuid), text, definition, type, area | ||
| - Edge topology: `{rootUuid, leafUuid, edgeType}` | ||
| - Selection state | ||
|
|
||
| ### What Moves to QML | ||
|
|
||
| - Pixel spacing constants (`nodeWidth`, `nodeHeight`, `depthSpacing`, `stackSpacing`, `forestVerticalSpacing`) | ||
| - `x = depth * (nodeWidth + depthSpacing)` | ||
| - `y = stackPos * (nodeHeight + stackSpacing) + forestIndex * forestVerticalSpacing` | ||
| - `sceneRect`: derived from max `depth` and `stackPos` across all terms | ||
| - All colors, radius, corner styles (already in QML) | ||
| - All animations (`Behavior on x/y/width/height`, edge `pt1`/`pt2`) | ||
|
|
||
| ### Edge Endpoint Resolution | ||
|
|
||
| Eventual goal: `EdgesModel` exposes `{rootUuid, leafUuid}`. | ||
| QML resolves pixel endpoints from the live positions of term delegates by uuid. | ||
| (Intermediate step: C++ can still provide `pt1`/`pt2` derived from logical coords while QML transitions.) | ||
|
|
||
| ## Migration Plan | ||
|
|
||
| Each phase leaves the application in a working, buildable state. | ||
|
|
||
| ### Phase A — `ForestPosition` + `GroupPosition` + `ForestLayout` + `GroupLayout` (C++ only) | ||
|
|
||
| - [ ] Create `source/model/group/ForestPosition.hpp` — `struct ForestPosition { int forestIndex; int depth; int stackPos; }` | ||
|
|
||
| - [ ] Create `ForestLayout` class: takes one `Forest<>`, returns `map<uuid, {depth, stackPos}>` | ||
| Extract logic from `PaintedForest::setTreeNodeCoords()` + `NodeVerticalStack::placeTerms()` | ||
| - [ ] Create `GroupLayout` class: takes `vector<ForestLayout>` + orphan list, assigns `forestIndex` | ||
| Extract logic from `TermGroup::updateRectsPositions()` + `setOrphCoords()` | ||
| Orphans get `forestIndex = -1` | ||
| - [ ] `TermGroup` delegates to `ForestLayout` + `GroupLayout`, still calls `GraphicItem::setPos()` (backward compat) | ||
| - [ ] Add unit tests for `ForestLayout` in `test/` | ||
|
|
||
| **Verify:** `./project.py --test --preset desktop_dev` green, visual output unchanged. | ||
|
|
||
| ### Phase B — Expose Logical Roles in `TermsModel` | ||
|
|
||
| - [ ] Add roles `forestIndex`, `depth`, `stackPos` to `TermsModel` | ||
| alongside existing `rect` role — fully backward compatible | ||
| - [ ] QML still uses `rect.x`/`rect.y` — no visual change | ||
|
|
||
| **Verify:** build passes, roles queryable from QML debug console. | ||
|
|
||
| ### Phase C — Split `MainScene` | ||
|
|
||
| `MainScene` currently owns: group loading, model management, selection state, hit-testing, search, term name↔uuid resolution. | ||
|
|
||
| - [ ] Extract `SelectionManager`: holds selected term, hit-test (`setMouseClick`), `getTermPosition`, `search`, `termUuidToName`, `termNameToUuid` | ||
| - [ ] `MainScene` delegates to `SelectionManager`; becomes thinner: group loading + model ownership only | ||
| - [ ] Expose `SelectionManager` as `Api.scene.selection` or keep behind `MainScene` — decide at implementation time | ||
|
|
||
| **Verify:** no QML changes needed, all existing invokables still work. | ||
|
|
||
| ### Phase D — `TermsModel` with Stable Identity | ||
|
|
||
| This phase **unblocks animations**. | ||
|
|
||
| - [ ] Track current term UUIDs in `TermsModel` | ||
| - [ ] On `setGroup()`: diff old vs new uuid sets | ||
| - removed terms → `beginRemoveRows` / `endRemoveRows` | ||
| - added terms → `beginInsertRows` / `endInsertRows` | ||
| - changed `ForestPosition` → `emit dataChanged(forestIndex, depth, stackPos roles)` | ||
| - [ ] `Repeater` delegates survive group switches; only truly new/removed terms are created/destroyed | ||
|
|
||
| **Verify:** switch groups repeatedly — delegates reuse is visible via `Component.onCompleted` logging. | ||
|
|
||
| ### Phase E — QML Computes Geometry | ||
|
|
||
| - [ ] `NodesScene.qml`: define spacing constants | ||
| `readonly property real nodeWidth: 180`, `nodeHeight: 60`, `depthSpacing: 40`, `stackSpacing: 20`, `forestVerticalSpacing: 60` | ||
| - [ ] `Term.qml`: replace `x: rect.x; y: rect.y` with: | ||
|
|
||
| ```qml | ||
| x: model.depth * (NodesScene.nodeWidth + NodesScene.depthSpacing) | ||
| y: model.stackPos * (NodesScene.nodeHeight + NodesScene.stackSpacing) | ||
| + Math.max(0, model.forestIndex) * NodesScene.forestVerticalSpacing | ||
| ``` | ||
|
|
||
| - [ ] `NodesScene.qml`: derive `width`/`height` from `maxDepth`/`maxStackPos` properties on `TermsModel` | ||
| (or compute in QML via `Repeater` childrenRect) | ||
| Remove `Api.scene.sceneRect` dependency | ||
| - [ ] Remove `rect` role from `TermsModel` once QML no longer reads it | ||
|
|
||
| **Verify:** pixel geometry matches previous layout (tune spacing constants to match `AppStyle::Sizes` values). | ||
|
|
||
| ### Phase F — `EdgesModel` uuid-Based + Edge `Repeater` | ||
|
|
||
| - [ ] Add roles `rootUuid`, `leafUuid` to `EdgesModel` (alongside existing `pt1`/`pt2`) | ||
| - [ ] `NodesScene.qml`: replace `createObject`/`destroy` imperative loop with `Repeater { model: Api.scene.edges }` | ||
| - [ ] `Edge.qml`: resolve `pt1`/`pt2` from term delegate positions by uuid | ||
| (use a `Map` in `NodesScene` keyed by uuid → delegate item, or a `ListModel` lookup) | ||
| - [ ] Remove `pt1`/`pt2` roles from `EdgesModel` once QML no longer reads them | ||
|
|
||
| **Verify:** all edges render correctly with Repeater. | ||
|
|
||
| ### Phase G — Animations (future) | ||
|
|
||
| - [ ] `Term.qml`: add `Behavior on x { NumberAnimation { duration: 300; easing.type: Easing.InOutCubic } }` | ||
| - [ ] `Term.qml`: add `Behavior on y { … }` | ||
| - [ ] `Edge.qml`: animate `pt1`/`pt2` (requires uuid-based endpoint resolution from Phase F) | ||
| - [ ] Verify graph transition animation when switching groups | ||
|
|
||
| ### Phase H — Optional: `QCanvasPainter` Acceleration Track (Qt 6.12+) | ||
|
|
||
| Use this phase only if profiling still shows edge rendering as the main bottleneck after Phases D-F. | ||
| Reference (applies to this phase only): Qt Blog, "Qt Canvas Painter: Accelerated performance using paths" — https://www.qt.io/blog/qt-canvas-painter-accelerated-performance-using-paths | ||
|
|
||
| - [ ] Upgrade runtime/tooling to Qt 6.12+ in a dedicated branch and validate desktop build + tests | ||
| (`QCanvasPainter`/`QCanvasPath` are not available in current Qt 6.11.x baseline) | ||
| - [ ] Introduce custom edge renderer item (`QCanvasPainterItem`-based) while keeping terms in current QML delegates | ||
| - [ ] Build and cache edge geometry via `QCanvasPath` instead of rebuilding JS/QML `ShapePath` objects | ||
| - [ ] Split path groups by update frequency, e.g. `STATIC_EDGES` and `SELECTED_EDGES` | ||
| - [ ] Invalidate/rebuild cached paths only for changed groups (selection change, graph diff), not for whole scene | ||
| - [ ] Keep pan/zoom and color animation on GPU-side transforms/brush changes without path rebuild | ||
| - [ ] Add feature flag to switch between current QML edge renderer and CanvasPainter renderer for A/B checks | ||
|
|
||
| **Verify:** compare FPS and frame time on large groups, confirm lower CPU usage and no visual regressions. | ||
|
|
||
| **Exit criteria for keeping Phase H:** measurable gain against Phase F baseline (for example, stable 60 FPS on target scenes and lower average frame time). | ||
|
|
||
| --- | ||
|
|
||
| ## Files Affected | ||
|
|
||
| | File | Change | | ||
| |---|---| | ||
| | `source/model/group/ForestPosition.hpp` | **NEW** — logical position struct | | ||
| | `source/model/group/ForestLayout.hpp/.cpp` | **NEW** — per-forest layout | | ||
| | `source/model/group/GroupLayout.hpp/.cpp` | **NEW** — group-level arrangement | | ||
| | `source/model/group/termgroup.cpp` | delegate to ForestLayout + GroupLayout | | ||
| | `source/model/group/paintedforest.cpp` | extract layout logic into ForestLayout | | ||
| | `source/model/group/nodeverticalstack.cpp` | extract into ForestLayout or keep as impl detail | | ||
| | `source/model/term/termsmodel.hpp/.cpp` | add logical roles; replace beginResetModel with diff | | ||
| | `source/model/edge/edgesmodel.hpp/.cpp` | add rootUuid/leafUuid roles | | ||
| | `source/MainScene.hpp/.cpp` | extract SelectionManager | | ||
| | `source/Molecules/NodesScene.qml` | spacing constants; edge Repeater; sceneRect from QML | | ||
| | `source/Atoms/Term.qml` | x/y from depth/stackPos; add Behavior (Phase G) | | ||
| | `source/Atoms/Edge.qml` | uuid-based pt1/pt2 resolution; animate (Phase G) | | ||
| | `source/*` (new custom renderer files) | optional `QCanvasPainterItem` edge renderer (Phase H) | | ||
| | build/toolchain files (`project.py`, CI, devcontainer) | optional Qt 6.12+ migration work for Phase H | | ||
| | `test/` | unit tests for ForestLayout | | ||
|
|
||
| > `source/graph/` — **not touched**. Already clean. | ||
| > `source/model/graphicitem/` — can be retired incrementally as QML takes over geometry. | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.