#pragma once // Split-tree model nodes: the in-memory QObject tree of one tab's pane // layout. The nodes ARE the model — AppModel mutates them in place and QML // binds them directly (no proxy mirror). Pure QtCore/QtQml; no Quick. // // Tagged sum: an abstract SplitNode base with a virtual kind() returning a // NodeKind enum, two concrete finals (SplitContainerNode, PaneNode), and an // exhaustive `visit()` dispatch. AppModel enforces the structural invariants // (every container has >=2 children, ratios renormalized when all measured, // every pane in exactly one position). // // JSON shapes mirror SplitContainer.serialize() / Shell.buildFromSession() // (modulo additive id fields): // node: { "type": "node", "axis": "v"|"h", "children": [...], // "splits": [float|-1, ...] } // leaf: { "type": "leaf", "paneId", ...spawn fields (cwd, cwdRel, // command, title, restart, restartArgv) written by the session // emitter from AppModel's pane table } // Axis strings follow the QML convention: "v" = children side by side // (SplitView orientation Qt.Horizontal), "h" = stacked vertically. In // memory the same concepts are SplitAxis::Horizontal / SplitAxis::Vertical. #include #include #include #include #include #include #include #include #include #include #include #include #include #include enum class SplitAxis : std::uint8_t { Horizontal, Vertical }; enum class SplitDirection : std::uint8_t { Left, Right, Up, Down }; // Session-JSON axis names. "v" = children side by side (SplitView // orientation Qt.Horizontal), "h" = children stacked (Qt.Vertical) — the // strings are the QML SplitContainer "axis" property values. [[nodiscard]] inline QString splitAxisToJson(SplitAxis axis) { return axis == SplitAxis::Horizontal ? QStringLiteral("v") : QStringLiteral("h"); } [[nodiscard]] inline SplitAxis splitAxisFromJson(const QString& name) { // Tolerance mirrors Shell.populateTabRoot: anything that is not "h" is "v". return name == QLatin1String("h") ? SplitAxis::Vertical : SplitAxis::Horizontal; } enum class NodeKind : std::uint8_t { Container, Pane }; // What a leaf pane shows. A terminal streams a shell, an editor shows one // file, and a chooser replaces itself with what the user picks. enum class PaneKind : std::uint8_t { Terminal, Editor, Picker }; // Session-JSON pane kind names: "terminal", "editor", "picker". An unknown // name reads as Terminal. [[nodiscard]] inline QString paneKindToJson(PaneKind kind) { switch (kind) { case PaneKind::Editor: return QStringLiteral("editor"); case PaneKind::Picker: return QStringLiteral("picker"); case PaneKind::Terminal: break; } return QStringLiteral("terminal"); } [[nodiscard]] inline PaneKind paneKindFromJson(const QString& name) { if (name == QLatin1String("editor")) { return PaneKind::Editor; } if (name == QLatin1String("picker")) { return PaneKind::Picker; } return PaneKind::Terminal; } class SplitContainerNode; class PaneNode; // Abstract tagged-sum base: the node kind is the tag; each concrete final // carries its own product fields. QML discriminates via the `isContainer` // bool (the kind enum stays available as an int-typed property). class SplitNode : public QObject { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("nodes are constructed by AppModel") Q_DISABLE_COPY_MOVE(SplitNode) Q_PROPERTY(NodeKind kind READ kind CONSTANT) Q_PROPERTY(bool isContainer READ isContainer CONSTANT) Q_ENUM(NodeKind) public: ~SplitNode() override; [[nodiscard]] virtual NodeKind kind() const = 0; [[nodiscard]] bool isContainer() const { return kind() == NodeKind::Container; } // Exhaustive visitor (dispatch on the virtual kind, no dynamic_cast). // A new NodeKind enumerator fails to compile here until the new case is // added (-Wswitch on the closed enum). Defined below, after the concrete // classes are complete (the dispatch casts need complete types). template decltype(auto) visit(Visitor&& visitor) const; // Typed accessors: nullptr when the node is the other kind. [[nodiscard]] SplitContainerNode* asContainer(); [[nodiscard]] const SplitContainerNode* asContainer() const; [[nodiscard]] PaneNode* asPane(); [[nodiscard]] const PaneNode* asPane() const; protected: explicit SplitNode(QObject* parent); }; class PaneNode final : public SplitNode { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("nodes are constructed by AppModel") Q_DISABLE_COPY_MOVE(PaneNode) // Identity only. Spawn state (cwd, command, title, restart flags) lives // in AppModel's pane table keyed by paneId — the pool is its live source, // the session loader its restore source. The pane kind IS node state: the // delegate swaps the leaf component from its notify. Q_PROPERTY(QString paneId READ paneId CONSTANT) Q_PROPERTY(PaneKind paneKind READ paneKind NOTIFY paneKindChanged) Q_PROPERTY(QString kindWord READ kindWord NOTIFY paneKindChanged) Q_PROPERTY(bool isTerminal READ isTerminal NOTIFY paneKindChanged) Q_PROPERTY(bool isEditor READ isEditor NOTIFY paneKindChanged) Q_PROPERTY(bool isPicker READ isPicker NOTIFY paneKindChanged) Q_ENUM(PaneKind) public: explicit PaneNode(QString paneId, QObject* parent, PaneKind kind = PaneKind::Terminal); ~PaneNode() override; [[nodiscard]] NodeKind kind() const override { return NodeKind::Pane; } [[nodiscard]] const QString& paneId() const { return m_paneId; } [[nodiscard]] PaneKind paneKind() const { return m_kind; } [[nodiscard]] QString kindWord() const { return paneKindToJson(m_kind); } [[nodiscard]] bool isTerminal() const { return m_kind == PaneKind::Terminal; } [[nodiscard]] bool isEditor() const { return m_kind == PaneKind::Editor; } [[nodiscard]] bool isPicker() const { return m_kind == PaneKind::Picker; } // AppModel-only mutation (the view never writes a kind). No-op when the // kind is unchanged; emits paneKindChanged. void setPaneKind(PaneKind kind); signals: void paneKindChanged(); private: QString m_paneId; PaneKind m_kind = PaneKind::Terminal; }; class SplitContainerNode final : public SplitNode { Q_OBJECT QML_ELEMENT QML_UNCREATABLE("nodes are constructed by AppModel") Q_DISABLE_COPY_MOVE(SplitContainerNode) // SplitAxis as a QML int: 0 = Horizontal (children side by side, // SplitView.orientation Qt.Horizontal), 1 = Vertical (stacked). Q_PROPERTY(int axis READ axis NOTIFY axisChanged) Q_PROPERTY(QString containerId READ containerId CONSTANT) Q_PROPERTY(QList children READ children NOTIFY childrenChanged) // Shares of the parent's extent, 0..1, index-parallel with children. // -1.0 = "unmeasured" (legacy sessions; QML coalesces it to an even 1/n // share via coalescedShare). Unmeasured entries only exist right after a // legacy-session load whose splits array held -1. Q_PROPERTY(QList ratios READ ratios NOTIFY ratiosChanged) public: explicit SplitContainerNode(SplitAxis axis, QString containerId, QObject* parent); ~SplitContainerNode() override; [[nodiscard]] NodeKind kind() const override { return NodeKind::Container; } [[nodiscard]] int axis() const; void setAxis(SplitAxis axis); // emits axisChanged [[nodiscard]] const QString& containerId() const { return m_containerId; } [[nodiscard]] const QList& children() const { return m_children; } [[nodiscard]] int childCount() const { return static_cast(m_children.size()); } [[nodiscard]] SplitNode* childAt(int index) const; [[nodiscard]] const QList& ratios() const { return m_ratios; } [[nodiscard]] qreal ratioAt(int index) const; void setRatioAt(int index, qreal ratio); // emits ratiosChanged void setRatios(QList ratios); // emits ratiosChanged // Insert `child` at `index` (clamped) with its ratio entry; QObject-parents // the child (the container owns the subtree) and keeps children/ratios // index-parallel. Emits childrenChanged + ratiosChanged. void insertChild(int index, SplitNode* child, qreal ratio); void appendChild(SplitNode* child, qreal ratio); // Remove and return the child at `index` (no ratio fixup; callers // renormalize). Clears the QObject parent: the caller owns the returned // node. Emits childrenChanged + ratiosChanged. [[nodiscard]] SplitNode* takeChild(int index); // --- range interface -------------------------------------------------- // Iterates the child nodes in layout order, so containers work with the // STL ranges library: for (const SplitNode* child : container), views, // and algorithms. const-only: children are owned, never mutated through // the range. using value_type = SplitNode*; using const_iterator = const SplitNode* const*; using iterator = const_iterator; [[nodiscard]] const_iterator begin() const { return m_children.constData(); } [[nodiscard]] const_iterator end() const { return m_children.constData() + m_children.size(); } [[nodiscard]] qsizetype size() const { return m_children.size(); } [[nodiscard]] bool empty() const { return m_children.isEmpty(); } signals: void axisChanged(); void childrenChanged(); void ratiosChanged(); private: SplitAxis m_axis; QString m_containerId; QList m_children; QList m_ratios; }; [[nodiscard]] inline QString newUuid() { return QUuid::createUuid().toString(QUuid::WithoutBraces); } // --- traversal ------------------------------------------------------------ // Visit containers and panes depth-first in layout order. The visitor takes // either a `const SplitContainerNode&` or a `const PaneNode&`. template void walk(const SplitNode& node, Visitor&& visitor) // NOLINT(misc-no-recursion,cppcoreguidelines-missing-std-forward) { node.visit([&](const auto& typed) { using T = std::decay_t; if constexpr (std::is_same_v) { visitor(typed); for (const SplitNode* child : typed.children()) { walk(*child, visitor); } } else { visitor(typed); } }); } [[nodiscard]] QStringList paneIdsInLayoutOrder(const SplitNode& root); // --- normalized pane-rect geometry ------------------------------------------- // The layout math one level up from the nodes: every pane's cell as a rect // in the tab's normalized 0..1 space. Mirrors SplitTreeNodeDelegate's // coalescedShare display math (measured ratio when > 0, else an even 1/n // share, shares normalized to sum 1). struct PaneRect { QString paneId; qreal x = 0; qreal y = 0; qreal w = 0; qreal h = 0; }; // Coalesced share of child `index` in `container` (see the ratios property: // measured ratio when > 0, else an even 1/n share). [[nodiscard]] qreal coalescedShare(const SplitContainerNode& container, int index); // Depth-first pane rects of `root` in layout order. [[nodiscard]] QList paneRectsNormalized(const SplitNode& root); // The pane nearest `fromPaneId` in `direction` ("left"|"right"|"up"|"down"), // scored from the tree's normalized pane rects. Kind agnostic: every pane is // a rect, so an editor or picker pane is a target like any other. "" when no // pane lies in that direction, or when either id is unknown. [[nodiscard]] QString spatialNeighborPaneId(const SplitNode& root, const QString& fromPaneId, const QString& direction); // The container with `containerId`, or nullptr. [[nodiscard]] SplitContainerNode* findContainer(SplitNode& root, const QString& containerId); [[nodiscard]] const SplitContainerNode* findContainer( const SplitNode& root, const QString& containerId); // --- mutation helper (AppModel enforces invariants around it) -------------- // Renormalize ratios so the container's MEASURED shares sum to 1 inside // 1e-6. Mirrors the QML coalescedShare semantics: measured + unmeasured // siblings can only coexist because QML coalesces each unmeasured child to // an even 1/n share WITHOUT any sum-to-1 fix-up. So this rescales ONLY // when every child is measured; if any child is unmeasured it leaves the // measured ones alone and lets QML handle the display. Cardinality: this is // the single invariant-enforcement entry point — every AppModel mutator // that touches ratios routes through it. Emits ratiosChanged when it // rescales. void renormalize(SplitContainerNode& container); // Set every container in the tree rooted at `root` (root included) to equal // shares: each child gets 1/n, so ratios stay measured and sum to 1 per // container at every nesting level. Emits ratiosChanged per touched // container. Pure ratio rewrite — no structural change, no path churn. void equalizeSplits(SplitNode& root); // --- JSON (the session "tree" value) --------------------------------------- // Node: { type:"node", axis:"v"|"h", children:[...], splits:[ratio|-1...] } // Leaf: { type:"leaf", paneId, ... } — spawn fields (cwd, cwdRel, command, // title, restart, restartArgv) are written by the session emitter from // AppModel's pane table, not here. [[nodiscard]] QJsonObject toJson(const SplitNode& node); // Builds an ownerless node tree (children QObject-parented to their // container during parse; the root has no parent — the caller parents it). // nullopt for unknown tree types (callers substitute an empty leaf). [[nodiscard]] std::unique_ptr fromJson(const QJsonObject& object); // Exhaustive visitor definitions: the dispatch casts need complete types, so // these live after the concrete classes. A new NodeKind enumerator fails to // compile here until the new case is added (-Wswitch on the closed enum). // Read-only by design: mutation goes through the typed asContainer/asPane // pointers. Each downcast is guarded by kind() — safe by construction. template decltype(auto) SplitNode::visit(Visitor&& visitor) const { switch (kind()) { case NodeKind::Container: { const auto& container = *static_cast(this); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) return std::forward(visitor)(container); } case NodeKind::Pane: { const auto& pane = *static_cast(this); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) return std::forward(visitor)(pane); } } Q_UNREACHABLE(); }