#pragma once // MissionControlOverlay's controller. Non-singleton, QML-instantiated: the // overlay creates one and injects the model singletons via DI properties. // // ONE POINTER OWNER. A single MouseArea on the overlay owns the pointer // for the overlay's whole life and relays generalized state here. Nothing // else takes a mouse grab, so there is never a hand-off: panes and drop // zones are plain Items that hit-test THEMSELVES against the published // pointer state and claim reactively. // // Why not hand the grab off? Verified empirically (real OS drag): once an // item's exclusive grab drops, Qt re-runs target discovery on the next // held-button move and re-grabs whichever area is topmost under the // cursor. Hover still reaches other items, but a destination zone can // never receive released(). So the design that works is: never drop it. // // This class owns: // - published pointer state (scene position, pressed, dragArmed), // - the drag session (begin with the source's identity, finish with // exactly one AppModel mutation, cancel), // - drop legality (a lone pane cannot leave its tab or cross into // another workspace as a new tab — the source workspace would die), // - the drag ghost's snapshot hand-off (pool->item(paneId) → // TerminalQuickItem::captureSnapshot()). // // It never touches the scene graph: no item walking, no class-name // matching, no geometry. Containment is computed by the view that owns // the geometry (each pane, each zone, each card, each row). // // The QML side keeps pure view state: open, carousel geometry/snap, the // ghost image, highlights — all bound reactively to the properties here. #include #include #include #include #include #include #include "AppModel.h" #include "PanePool.h" class MissionControlController : public QObject { Q_OBJECT QML_ELEMENT Q_DISABLE_COPY_MOVE(MissionControlController) // DI: the model. Injected by the overlay (AppModel is a singleton; the // page-level binding passes it down so this class stays Quick-free). Q_PROPERTY(AppModel* model READ model WRITE setModel NOTIFY modelChanged) // DI: the pane pool, for the drag ghost's snapshot hand-off. Q_PROPERTY(PanePool* pool READ pool WRITE setPool NOTIFY poolChanged) // --- published pointer state (the single owner relays into these) -------- // Current pointer position in scene (window) coordinates. Every // containment test in the view reads these; the ghost tracks them. Q_PROPERTY(qreal sceneX READ sceneX NOTIFY pointerChanged) Q_PROPERTY(qreal sceneY READ sceneY NOTIFY pointerChanged) // The position the current press landed at, so a pane can answer "was I // the press target" after the pointer has moved away. Q_PROPERTY(qreal pressSceneX READ pressSceneX NOTIFY pressChanged) Q_PROPERTY(qreal pressSceneY READ pressSceneY NOTIFY pressChanged) // A button is down. Q_PROPERTY(bool pressed READ pressed NOTIFY pressedChanged) // The press has moved past the drag threshold. Deliberately NOT cleared // on release — it is cleared on the NEXT press — so a release handler // can distinguish a click from a drag without any ordering assumption. Q_PROPERTY(bool dragArmed READ dragArmed NOTIFY dragArmedChanged) // --- drag state (view binds read-only) ----------------------------------- // A pane drag is in flight. Q_PROPERTY(bool dragActive READ dragActive NOTIFY dragActiveChanged) // PaneId of the dragged pane, empty while idle. Q_PROPERTY(QString draggedPaneId READ draggedPaneId NOTIFY dragActiveChanged) // The pooled TerminalQuickItem asked to captureSnapshot() at beginDrag; // QML connects its snapshotReady to feed the ghost image. Null while // idle. Q_PROPERTY(QObject* snapshotTarget READ snapshotTarget NOTIFY dragActiveChanged) // Ghost geometry: the press position within the source pane cell and // the cell's size, captured at beginDrag (the QML ghost image scales // and offsets by these). Q_PROPERTY(qreal ghostGrabX READ ghostGrabX NOTIFY dragActiveChanged) Q_PROPERTY(qreal ghostGrabY READ ghostGrabY NOTIFY dragActiveChanged) Q_PROPERTY(qreal ghostBaseW READ ghostBaseW NOTIFY dragActiveChanged) Q_PROPERTY(qreal ghostBaseH READ ghostBaseH NOTIFY dragActiveChanged) public: explicit MissionControlController(QObject* parent = nullptr); ~MissionControlController() override = default; [[nodiscard]] AppModel* model() const; void setModel(AppModel* model); [[nodiscard]] PanePool* pool() const; void setPool(PanePool* pool); [[nodiscard]] qreal sceneX() const { return m_scene.x(); } [[nodiscard]] qreal sceneY() const { return m_scene.y(); } [[nodiscard]] qreal pressSceneX() const { return m_press.x(); } [[nodiscard]] qreal pressSceneY() const { return m_press.y(); } [[nodiscard]] bool pressed() const { return m_pressed; } [[nodiscard]] bool dragArmed() const { return m_dragArmed; } [[nodiscard]] bool dragActive() const { return m_dragActive; } [[nodiscard]] QString draggedPaneId() const { return m_draggedPaneId; } [[nodiscard]] QObject* snapshotTarget() const { return m_snapshotTarget.data(); } [[nodiscard]] qreal ghostGrabX() const { return m_grab.x(); } [[nodiscard]] qreal ghostGrabY() const { return m_grab.y(); } [[nodiscard]] qreal ghostBaseW() const { return m_ghostBase.width(); } [[nodiscard]] qreal ghostBaseH() const { return m_ghostBase.height(); } // --- pointer ingestion --------------------------------------------------- // The overlay's single pointer owner relays here in scene coordinates. // Every view-side claim happens synchronously inside the notifies these // emit, so pointerReleased can tell "nothing claimed this drop" from // "a zone already finished the drag" just by looking at dragActive. Q_INVOKABLE void pointerPressed(qreal sceneX, qreal sceneY); Q_INVOKABLE void pointerMoved(qreal sceneX, qreal sceneY); // Publishes the release, then cancels any drag still active: QML signal // delivery is synchronous, so by the time the notify returns every hot // MissionDropZone has already called finishDrag(). A drag still live // here was released over nothing legal. Q_INVOKABLE void pointerReleased(qreal sceneX, qreal sceneY); // --- drag engine --------------------------------------------------------- // Claimed by the MissionPaneTarget whose rect contains the press point, // on the rising edge of dragArmed. grabX/grabY are the press position // within the pane cell; baseW/baseH are that cell's size (the ghost // scales from them). All plain values: this class holds no item. Q_INVOKABLE void beginDrag(const QString& paneId, int wsIndex, int tabIndex, qreal grabX, qreal grabY, qreal baseW, qreal baseH); // Claimed by the hot MissionDropZone on the falling edge of pressed. // Executes exactly one AppModel mutation, then resets. zone is // "left"|"right"|"up"|"down"|"center"|"append"|"newtab"; paneId is the // target pane ("" for append/newtab); tabIndex is -1 for newtab. Q_INVOKABLE void finishDrag(const QString& zone, const QString& paneId, int wsIndex, int tabIndex); Q_INVOKABLE void cancelDrag(); // Click claim. A MissionPaneTarget whose rect contains the press point // calls this on the falling edge of pressed when the gesture never // became a drag (dragArmed stayed false). The first claim wins (panes // tile the fit box, so at most one contains the press). The engine then // reveals that pane and asks the overlay to close; with no claim the // release was on empty background, which just closes. Routing the click // decision here (not in competing QML handlers) removes the ordering // race between "reveal the pane" and "close the overlay". Q_INVOKABLE void claimClick(const QString& paneId); // Would this drop be refused? Zones bind their highlight to it so an // illegal target never lights up and never claims the release. paneId // is the zone's neighbour pane ("" for append/newtab): a pane dropped // onto one of its OWN zones is a no-op the model refuses, so it must // not light. The source-shape guards mirror AppModel's mutators exactly // (movePaneBeside/movePaneToTab/movePaneToNewTab/swapPanes) so a lit // zone always mutates. Q_INVOKABLE bool legalDrop(const QString& zone, const QString& paneId, int wsIndex, int tabIndex) const; signals: void modelChanged(); void poolChanged(); void pointerChanged(); void pressChanged(); void pressedChanged(); void dragArmedChanged(); void dragActiveChanged(); // Emitted after finishDrag executed a model mutation, before the state // reset — QML may close/react. void dropExecuted(const QString& zone); // A click (pane or background) ended the gesture: the overlay closes. void closeRequested(); private: void setScene(qreal sceneX, qreal sceneY); void executeClaim(); // The deferred half of executeClaim: runs the AppModel mutation after a // render pass has processed the ghost unbind (see executeClaim). void executeQueuedDrop(); void resetDragState(); [[nodiscard]] bool isIllegalDrop(const QString& zone, const QString& paneId, int wsIndex, int tabIndex) const; AppModel* m_model = nullptr; PanePool* m_pool = nullptr; // Published pointer state. QPointF m_scene{0, 0}; QPointF m_press{0, 0}; bool m_pressed = false; bool m_dragArmed = false; // Drag session. bool m_dragActive = false; QString m_draggedPaneId; QPointF m_grab{0, 0}; QSizeF m_ghostBase{0, 0}; int m_sourceWs = -1; int m_sourceTab = -1; QPointer m_snapshotTarget; // The best drop claim recorded during the gesture, executed on release. // Zones claim synchronously inside pressedChanged(); specificity // ordering makes the winner independent of handler order. QString m_claimZone; QString m_claimPaneId; int m_claimWs = -1; int m_claimTab = -1; // The pane that claimed the click (a release that never armed a drag), // recorded synchronously inside pressedChanged() and resolved when the // release returns. Empty for a background click. QString m_clickPaneId; };