#include "MissionControlController.h" #include "AppModel.h" #include "PanePool.h" #include "SplitTree.h" #include "Workspace.h" using namespace Qt::Literals::StringLiterals; namespace { // Pixels of movement before a press becomes a drag. Below it, release is // a click. constexpr qreal kDragThreshold = 8.0; // Drop specificity. The most specific claim for a release wins, which // makes the result independent of QML signal-handler ordering: a pane's // zone beats the card's append backdrop, which beats the row's newtab. int zoneRank(const QString& zone) { if (zone == u"newtab"_s) { return 1; } if (zone == u"append"_s) { return 2; } return 3; } int paneCountOf(const XenoTab* tab) { if (tab == nullptr || tab->treeRoot() == nullptr) { return 0; } return static_cast(paneRectsNormalized(*tab->treeRoot()).size()); } } // namespace MissionControlController::MissionControlController(QObject* parent) : QObject(parent) { } AppModel* MissionControlController::model() const { return m_model; } void MissionControlController::setModel(AppModel* model) { if (m_model == model) { return; } m_model = model; emit modelChanged(); } PanePool* MissionControlController::pool() const { return m_pool; } void MissionControlController::setPool(PanePool* pool) { if (m_pool == pool) { return; } m_pool = pool; emit poolChanged(); } // --- pointer ingestion ------------------------------------------------------- void MissionControlController::setScene(const qreal sceneX, const qreal sceneY) { if (m_scene == QPointF{sceneX, sceneY}) { return; } m_scene = {sceneX, sceneY}; emit pointerChanged(); } void MissionControlController::pointerPressed(const qreal sceneX, const qreal sceneY) { setScene(sceneX, sceneY); m_press = {sceneX, sceneY}; m_clickPaneId = QString(); emit pressChanged(); // A fresh press clears the previous gesture's armed flag, so a release // handler can read dragArmed as "this press became a drag". if (m_dragArmed) { m_dragArmed = false; emit dragArmedChanged(); } if (!m_pressed) { m_pressed = true; emit pressedChanged(); } } void MissionControlController::pointerMoved(const qreal sceneX, const qreal sceneY) { setScene(sceneX, sceneY); if (!m_pressed || m_dragArmed) { return; } const qreal dx = sceneX - m_press.x(); const qreal dy = sceneY - m_press.y(); if ((dx * dx) + (dy * dy) < (kDragThreshold * kDragThreshold)) { return; } m_dragArmed = true; emit dragArmedChanged(); } void MissionControlController::pointerReleased(const qreal sceneX, const qreal sceneY) { // Publish the release position first so every containment binding // re-evaluates before anyone claims. setScene(sceneX, sceneY); m_pressed = false; // QML signal delivery is synchronous: by the time this returns, every // hot MissionDropZone has called finishDrag() and any clicked pane has // called claimClick(). emit pressedChanged(); if (m_dragActive) { if (m_claimZone.isEmpty()) { // Nothing legal under the pointer: the drag is abandoned. cancelDrag(); } else { executeClaim(); } return; } // Not a drag: a click. Reveal the clicked pane (if any), then close. // The engine — not competing QML handlers — owns this so "reveal the // pane" can never race "close the overlay". if (!m_clickPaneId.isEmpty() && m_model != nullptr) { m_model->revealPane(m_clickPaneId); } m_clickPaneId = QString(); emit closeRequested(); } void MissionControlController::claimClick(const QString& paneId) { // Only a press that never armed a drag is a click; the first pane to // claim wins (panes tile the fit box, so at most one contains it). if (m_pressed || m_dragArmed || m_dragActive) { return; } if (m_clickPaneId.isEmpty()) { m_clickPaneId = paneId; } } // --- drag engine ------------------------------------------------------------- void MissionControlController::beginDrag(const QString& paneId, const int wsIndex, const int tabIndex, const qreal grabX, const qreal grabY, const qreal baseW, const qreal baseH) { // The first pane to claim the press owns the drag. if (m_dragActive || paneId.isEmpty()) { return; } m_dragActive = true; m_draggedPaneId = paneId; m_grab = {grabX, grabY}; m_ghostBase = {baseW, baseH}; m_sourceWs = wsIndex; m_sourceTab = tabIndex; m_claimZone = QString(); m_claimPaneId = QString(); m_claimWs = -1; m_claimTab = -1; // Resolve the ghost's snapshot target and publish the drag-active state // BEFORE capturing. The overlay watches dragActiveChanged (Connections // below) and performs the capture itself: the grab must be masked // (hide the opaque backdrop for the synchronous full-window grab), and // masking is scene-graph work that never belongs in this class. m_snapshotTarget = nullptr; if (m_pool != nullptr) { m_snapshotTarget = m_pool->item(paneId); } emit dragActiveChanged(); } void MissionControlController::finishDrag(const QString& zone, const QString& paneId, const int wsIndex, const int tabIndex) { if (!m_dragActive || zone.isEmpty()) { return; } // Highest specificity wins; ties keep the first claim. if (!m_claimZone.isEmpty() && zoneRank(m_claimZone) >= zoneRank(zone)) { return; } m_claimZone = zone; m_claimPaneId = paneId; m_claimWs = wsIndex; m_claimTab = tabIndex; } void MissionControlController::executeClaim() { const QString zone = m_claimZone; if (m_model == nullptr || isIllegalDrop(zone, m_claimPaneId, m_claimWs, m_claimTab)) { resetDragState(); return; } // Defer the mutation one event-loop turn. The QML ghost is a live // ShaderEffectSource over the dragged pane's TerminalQuickItem; the drop // re-parents / re-adopts that item, and sampling a reparenting RHI item // wedges its render layer — the pane lands black in its new split (the // "void"). Release the sample (dragActiveChanged → the QML SES source // unbinds) and let one render pass process the unbind BEFORE the model // mutates. An emit alone is not enough: a synchronous emit only marks the // QML binding dirty; the scene graph still holds the stale source pointer // until the next sync, which would otherwise run after the reparent. m_snapshotTarget = nullptr; m_dragActive = false; emit dragActiveChanged(); QMetaObject::invokeMethod(this, &MissionControlController::executeQueuedDrop, Qt::QueuedConnection); } // The deferred half of executeClaim. Claim fields and draggedPaneId are // still set (only dragActive/snapshotTarget were released); finishDrag and // beginDrag both guard on dragActive, so nothing can mutate the claim // during the gap. void MissionControlController::executeQueuedDrop() { const QString zone = m_claimZone; if (m_model == nullptr || m_draggedPaneId.isEmpty() || zone.isEmpty()) { resetDragState(); return; } if (zone == u"center"_s) { m_model->swapPanes(m_draggedPaneId, m_claimPaneId); } else if (zone == u"append"_s) { m_model->movePaneToTab(m_draggedPaneId, m_claimWs, m_claimTab); } else if (zone == u"newtab"_s) { m_model->movePaneToNewTab(m_draggedPaneId, m_claimWs); } else { m_model->movePaneBeside(m_draggedPaneId, m_claimPaneId, zone); } emit dropExecuted(zone); resetDragState(); } void MissionControlController::cancelDrag() { resetDragState(); } bool MissionControlController::legalDrop(const QString& zone, const QString& paneId, const int wsIndex, const int tabIndex) const { if (!m_dragActive || zone.isEmpty()) { return false; } return !isIllegalDrop(zone, paneId, wsIndex, tabIndex); } // Mirrors AppModel's four pane-move guards exactly, so a lit zone always // mutates and a refused drop never lights. Source facts: // movePaneBeside (edge zones): refuses a self-drop; same-tab needs >=2 // source panes; cross-tab refuses only the workspace's last pane of // its last tab. // swapPanes (center): refuses only a self-swap. // movePaneToTab (append): refuses srcTree<2; cross-tab out of a // single-tab source workspace. // movePaneToNewTab (newtab): refuses cross-workspace when the source is // one tab of one pane (it would delete the source workspace). bool MissionControlController::isIllegalDrop(const QString& zone, const QString& paneId, const int wsIndex, const int tabIndex) const { if (m_model == nullptr || m_sourceWs < 0 || m_sourceTab < 0) { return false; } const QList workspaces = m_model->workspaces(); if (m_sourceWs >= workspaces.size()) { return false; } const QList srcTabs = workspaces.at(m_sourceWs)->tabs(); const int srcTabCount = static_cast(srcTabs.size()); if (m_sourceTab >= srcTabCount) { return false; } const int srcPanes = paneCountOf(srcTabs.at(m_sourceTab)); const bool sameTab = (wsIndex == m_sourceWs && tabIndex == m_sourceTab); // The source workspace's only tab holding only the dragged pane: moving // it out would silently delete the workspace. const bool onlyPaneOfOnlyTab = (srcTabCount <= 1 && srcPanes <= 1); const bool self = (!paneId.isEmpty() && paneId == m_draggedPaneId); if (zone == u"center"_s) { return self; } if (zone == u"newtab"_s) { return (wsIndex != m_sourceWs) && onlyPaneOfOnlyTab; } if (zone == u"append"_s) { return srcPanes < 2 || (!sameTab && srcTabCount <= 1); } // Edge zones (left/right/up/down) -> movePaneBeside. if (self) { return true; } if (sameTab) { return srcPanes < 2; } return onlyPaneOfOnlyTab; } void MissionControlController::resetDragState() { const bool wasActive = m_dragActive; m_dragActive = false; m_draggedPaneId = QString(); m_grab = {0, 0}; m_ghostBase = {0, 0}; m_sourceWs = -1; m_sourceTab = -1; m_claimZone = QString(); m_claimPaneId = QString(); m_claimWs = -1; m_claimTab = -1; m_snapshotTarget = nullptr; if (wasActive) { emit dragActiveChanged(); } }