From 55292ba8a324c66a2399cc7f854d14df953b499a Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Thu, 21 May 2026 15:17:07 -0400 Subject: [PATCH 1/7] nodal: add rubber-band box selection and Ctrl+click multi-select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeItem: respect Ctrl modifier on click — adds/removes from the current selection instead of always replacing it. Uses filterSelections to follow the standard GUI cumulation pattern. NodalIntervalView: replace left-drag-to-pan with rubber-band selection. Left button drag on empty space draws a dashed XOR selection rectangle and selects all node items whose scene bounding rect intersects it on release. With Ctrl held the new selection is merged into the existing one rather than replacing it. Clicking empty space without dragging deselects everything. Middle-button drag now pans the container, preserving the previous pan behaviour under that button. Nodal::View / Nodal::Presenter: same rubber-band logic for the Nodal-process layer view. The View emits areaSelectRequested(rect, cumulation) on mouse release; the Presenter collects every NodeItem whose scene rect intersects the view-local rubber-band rect and pushes the resulting Selection to the SelectionStack. After a box or Ctrl+click multi-selection, the existing copySelectedProcesses / pasteInCurrentInterval paths already serialise the selected Process::ProcessModel objects as {Processes, Cables} JSON, so Ctrl+C / Ctrl+V work without further changes. Fixes #1886 Co-Authored-By: Claude Sonnet 4.6 --- .../Process/Dataflow/NodeItem.cpp | 8 +- .../score-plugin-nodal/Nodal/Presenter.cpp | 17 ++++ .../score-plugin-nodal/Nodal/Presenter.hpp | 4 + src/plugins/score-plugin-nodal/Nodal/View.cpp | 65 +++++++++++++- src/plugins/score-plugin-nodal/Nodal/View.hpp | 19 ++++ .../Interval/FullView/NodalIntervalView.cpp | 88 +++++++++++++++---- .../Interval/FullView/NodalIntervalView.hpp | 9 ++ 7 files changed, 191 insertions(+), 19 deletions(-) diff --git a/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp b/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp index 4b8021b322..57ed210f80 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -963,7 +964,12 @@ void NodeItem::mousePressEvent(QGraphicsSceneMouseEvent* event) { m_context.focusDispatcher.focus(m_presenter); } - score::SelectionDispatcher{m_context.selectionStack}.select(m_model); + { + const bool cumulation = event->modifiers() & Qt::ControlModifier; + Selection sel = filterSelections( + &m_model, m_context.selectionStack.currentSelection(), cumulation); + score::SelectionDispatcher{m_context.selectionStack}.select(sel); + } event->accept(); } else diff --git a/src/plugins/score-plugin-nodal/Nodal/Presenter.cpp b/src/plugins/score-plugin-nodal/Nodal/Presenter.cpp index 40d7bf7e34..0135aa36fd 100644 --- a/src/plugins/score-plugin-nodal/Nodal/Presenter.cpp +++ b/src/plugins/score-plugin-nodal/Nodal/Presenter.cpp @@ -13,6 +13,9 @@ #include #include #include +#include +#include +#include #include #include @@ -31,6 +34,7 @@ Presenter::Presenter( bind(layer.nodes, *this); connect(view, &View::dropReceived, this, &Presenter::on_drop); + connect(view, &View::areaSelectRequested, this, &Presenter::on_areaSelect); if(auto itv = Scenario::closestParentInterval(m_model.parent())) { @@ -154,4 +158,17 @@ void Presenter::on_removing(const Process::ProcessModel& n) { m_nodes.erase(n.id()); } + +void Presenter::on_areaSelect(QRectF rect, bool cumulation) +{ + Selection sel; + for(Process::NodeItem& node : m_nodes) + { + const QRectF nodeRect = m_view->mapRectFromScene(node.sceneBoundingRect()); + if(rect.intersects(nodeRect)) + sel.append(node.model()); + } + sel = filterSelections(sel, m_context.context.selectionStack.currentSelection(), cumulation); + score::SelectionDispatcher{m_context.context.selectionStack}.select(sel); +} } diff --git a/src/plugins/score-plugin-nodal/Nodal/Presenter.hpp b/src/plugins/score-plugin-nodal/Nodal/Presenter.hpp index bd59692b87..292a5c011d 100644 --- a/src/plugins/score-plugin-nodal/Nodal/Presenter.hpp +++ b/src/plugins/score-plugin-nodal/Nodal/Presenter.hpp @@ -7,6 +7,9 @@ #include #include + +#include + namespace Nodal { class Model; @@ -33,6 +36,7 @@ class Presenter final void on_created(Process::ProcessModel& n); void on_removing(const Process::ProcessModel& n); void on_drop(const QPointF& pos, const QMimeData& mime); + void on_areaSelect(QRectF rect, bool cumulation); private: IdContainer m_nodes; diff --git a/src/plugins/score-plugin-nodal/Nodal/View.cpp b/src/plugins/score-plugin-nodal/Nodal/View.cpp index 27b289ac04..4242ff7e99 100644 --- a/src/plugins/score-plugin-nodal/Nodal/View.cpp +++ b/src/plugins/score-plugin-nodal/Nodal/View.cpp @@ -4,19 +4,80 @@ #include #include + +#include +W_OBJECT_IMPL(Nodal::View) + namespace Nodal { View::View(QGraphicsItem* parent) : LayerView{parent} { - setFlag(ItemHasNoContents, true); setFlag(ItemClipsChildrenToShape); } View::~View() { } -void View::paint_impl(QPainter* painter) const { } +void View::paint_impl(QPainter* painter) const +{ + if(m_rubberBanding && !m_rubberBandRect.isEmpty()) + { + painter->setRenderHint(QPainter::Antialiasing, false); + painter->setCompositionMode(QPainter::CompositionMode_Xor); + painter->setPen( + QPen{QColor{0, 0, 0, 127}, 2, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); + painter->setBrush(Qt::transparent); + painter->drawRect(m_rubberBandRect); + painter->setCompositionMode(QPainter::CompositionMode_SourceOver); + } +} + +void View::mousePressEvent(QGraphicsSceneMouseEvent* event) +{ + if(event->button() == Qt::LeftButton) + { + m_rubberBanding = true; + m_rubberBandOrigin = event->pos(); + m_rubberBandRect = QRectF{m_rubberBandOrigin, m_rubberBandOrigin}; + event->accept(); + } + else + { + LayerView::mousePressEvent(event); + } +} + +void View::mouseMoveEvent(QGraphicsSceneMouseEvent* event) +{ + if(m_rubberBanding && (event->buttons() & Qt::LeftButton)) + { + m_rubberBandRect = QRectF{m_rubberBandOrigin, event->pos()}.normalized(); + update(); + event->accept(); + } + else + { + LayerView::mouseMoveEvent(event); + } +} + +void View::mouseReleaseEvent(QGraphicsSceneMouseEvent* event) +{ + if(event->button() == Qt::LeftButton && m_rubberBanding) + { + m_rubberBanding = false; + const bool cumulation = event->modifiers() & Qt::ControlModifier; + areaSelectRequested(m_rubberBandRect, cumulation); + m_rubberBandRect = {}; + update(); + event->accept(); + } + else + { + LayerView::mouseReleaseEvent(event); + } +} void View::dragEnterEvent(QGraphicsSceneDragDropEvent* event) { } diff --git a/src/plugins/score-plugin-nodal/Nodal/View.hpp b/src/plugins/score-plugin-nodal/Nodal/View.hpp index 132f229df7..65a3ee11f4 100644 --- a/src/plugins/score-plugin-nodal/Nodal/View.hpp +++ b/src/plugins/score-plugin-nodal/Nodal/View.hpp @@ -1,20 +1,39 @@ #pragma once #include +#include +#include + +#include + +#include + namespace Nodal { class View final : public Process::LayerView { + W_OBJECT(View) public: explicit View(QGraphicsItem* parent); ~View() override; + void areaSelectRequested(QRectF area, bool cumulation) + E_SIGNAL(SCORE_PLUGIN_DEVICEEXPLORER_EXPORT, areaSelectRequested, area, cumulation) + private: void paint_impl(QPainter*) const override; + void mousePressEvent(QGraphicsSceneMouseEvent* event) override; + void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override; + void mouseReleaseEvent(QGraphicsSceneMouseEvent* event) override; + void dragEnterEvent(QGraphicsSceneDragDropEvent* event) override; void dragLeaveEvent(QGraphicsSceneDragDropEvent* event) override; void dragMoveEvent(QGraphicsSceneDragDropEvent* event) override; void dropEvent(QGraphicsSceneDragDropEvent* event) override; + + QPointF m_rubberBandOrigin{}; + QRectF m_rubberBandRect{}; + bool m_rubberBanding{false}; }; } diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp index a18a8be005..83ebcc0491 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,8 @@ #include #include +#include +#include #include #include @@ -316,35 +319,88 @@ void NodalIntervalView::dropEvent(QGraphicsSceneDragDropEvent* event) void NodalIntervalView::mousePressEvent(QGraphicsSceneMouseEvent* e) { - this->m_context.selectionStack.deselect(); - auto focus = Process::ProcessFocusManager::get(this->m_context); - focus->focusNothing(); - - m_pressedPos = e->scenePos(); + if(e->button() == Qt::LeftButton) + { + if(!(e->modifiers() & Qt::ControlModifier)) + { + this->m_context.selectionStack.deselect(); + auto focus = Process::ProcessFocusManager::get(this->m_context); + if(focus) + focus->focusNothing(); + } + m_rubberBanding = true; + m_rubberBandOrigin = e->pos(); + m_rubberBandRect = QRectF{m_rubberBandOrigin, m_rubberBandOrigin}; + } + else if(e->button() == Qt::MiddleButton) + { + m_pressedPos = e->scenePos(); + } e->accept(); - - score::SelectionDispatcher disp{m_context.selectionStack}; - disp.select(m_model); } void NodalIntervalView::mouseMoveEvent(QGraphicsSceneMouseEvent* e) { - const auto delta = e->scenePos() - m_pressedPos; - m_container->setPos(m_container->pos() + delta); - m_pressedPos = e->scenePos(); + if(m_rubberBanding && (e->buttons() & Qt::LeftButton)) + { + m_rubberBandRect = QRectF{m_rubberBandOrigin, e->pos()}.normalized(); + update(); + } + else if(e->buttons() & Qt::MiddleButton) + { + const auto delta = e->scenePos() - m_pressedPos; + m_container->setPos(m_container->pos() + delta); + m_pressedPos = e->scenePos(); + const_cast(m_model).setNodalOffset(m_model.nodalOffset() + delta); + } e->accept(); - const_cast(m_model).setNodalOffset(m_model.nodalOffset() + delta); } void NodalIntervalView::mouseReleaseEvent(QGraphicsSceneMouseEvent* e) { - const auto delta = e->scenePos() - m_pressedPos; + if(e->button() == Qt::LeftButton && m_rubberBanding) + { + m_rubberBanding = false; + + const bool cumulation = e->modifiers() & Qt::ControlModifier; + Selection sel; + for(auto* node : m_nodeItems) + { + const QRectF nodeRect = mapRectFromScene(node->sceneBoundingRect()); + if(m_rubberBandRect.intersects(nodeRect)) + sel.append(node->model()); + } + sel = filterSelections(sel, m_context.selectionStack.currentSelection(), cumulation); + score::SelectionDispatcher{m_context.selectionStack}.select(sel); - m_container->setPos(m_container->pos() + delta); - m_pressedPos = e->scenePos(); + m_rubberBandRect = {}; + update(); + } + else if(e->button() == Qt::MiddleButton) + { + const auto delta = e->scenePos() - m_pressedPos; + m_container->setPos(m_container->pos() + delta); + m_pressedPos = e->scenePos(); + const_cast(m_model).setNodalOffset(m_model.nodalOffset() + delta); + } e->accept(); +} + +void NodalIntervalView::paint( + QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) +{ + EmptyRectItem::paint(painter, option, widget); - const_cast(m_model).setNodalOffset(m_model.nodalOffset() + delta); + if(m_rubberBanding && !m_rubberBandRect.isEmpty()) + { + painter->setRenderHint(QPainter::Antialiasing, false); + painter->setCompositionMode(QPainter::CompositionMode_Xor); + painter->setPen( + QPen{QColor{0, 0, 0, 127}, 2, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); + painter->setBrush(Qt::transparent); + painter->drawRect(m_rubberBandRect); + painter->setCompositionMode(QPainter::CompositionMode_SourceOver); + } } void NodalIntervalView::contextMenuEvent(QGraphicsSceneContextMenuEvent* event) diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.hpp b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.hpp index 8a620a14d7..cf5e2b442e 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.hpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.hpp @@ -5,6 +5,8 @@ #include +#include + #include namespace Scenario @@ -58,6 +60,9 @@ class NodalIntervalView final void contextMenuEvent(QGraphicsSceneContextMenuEvent* event) override; void wheelEvent(QGraphicsSceneWheelEvent* event) override; + void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) + override; + const IntervalModel& m_model; const Process::Context& m_context; ItemsToShow m_itemsToShow{}; @@ -65,6 +70,10 @@ class NodalIntervalView final QGraphicsItem* m_container{}; QPointF m_pressedPos{}; double m_zoomLevel = 0; + + QPointF m_rubberBandOrigin{}; + QRectF m_rubberBandRect{}; + bool m_rubberBanding{false}; }; } From 2836ee4ff0c32274f92a7a796a49c61dbe2728e6 Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Thu, 21 May 2026 15:45:19 -0400 Subject: [PATCH 2/7] nodal: fix rubber-band visibility and require Ctrl to activate box selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a QGraphicsRectItem overlay (Z=1000) instead of painting in the item's own paint() — which was either skipped due to ItemHasNoContents or rendered beneath child nodes. Box selection now requires Ctrl+drag; plain left-drag restores pan behaviour in NodalIntervalView. Co-Authored-By: Claude Sonnet 4.6 --- src/plugins/score-plugin-nodal/Nodal/View.cpp | 31 ++++++----- src/plugins/score-plugin-nodal/Nodal/View.hpp | 3 ++ .../Interval/FullView/NodalIntervalView.cpp | 54 ++++++++----------- .../Interval/FullView/NodalIntervalView.hpp | 6 +-- 4 files changed, 43 insertions(+), 51 deletions(-) diff --git a/src/plugins/score-plugin-nodal/Nodal/View.cpp b/src/plugins/score-plugin-nodal/Nodal/View.cpp index 4242ff7e99..cec644cbb8 100644 --- a/src/plugins/score-plugin-nodal/Nodal/View.cpp +++ b/src/plugins/score-plugin-nodal/Nodal/View.cpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -15,31 +16,28 @@ View::View(QGraphicsItem* parent) : LayerView{parent} { setFlag(ItemClipsChildrenToShape); + + m_selectionRect = new QGraphicsRectItem(this); + m_selectionRect->setZValue(1000.); + m_selectionRect->setPen(QPen{QColor{0, 255, 255, 200}, 1, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); + m_selectionRect->setBrush(QColor{0, 255, 255, 20}); + m_selectionRect->setAcceptedMouseButtons(Qt::NoButton); + m_selectionRect->setVisible(false); } View::~View() { } -void View::paint_impl(QPainter* painter) const -{ - if(m_rubberBanding && !m_rubberBandRect.isEmpty()) - { - painter->setRenderHint(QPainter::Antialiasing, false); - painter->setCompositionMode(QPainter::CompositionMode_Xor); - painter->setPen( - QPen{QColor{0, 0, 0, 127}, 2, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); - painter->setBrush(Qt::transparent); - painter->drawRect(m_rubberBandRect); - painter->setCompositionMode(QPainter::CompositionMode_SourceOver); - } -} +void View::paint_impl(QPainter* painter) const { } void View::mousePressEvent(QGraphicsSceneMouseEvent* event) { - if(event->button() == Qt::LeftButton) + if(event->button() == Qt::LeftButton && (event->modifiers() & Qt::ControlModifier)) { m_rubberBanding = true; m_rubberBandOrigin = event->pos(); m_rubberBandRect = QRectF{m_rubberBandOrigin, m_rubberBandOrigin}; + m_selectionRect->setVisible(true); + m_selectionRect->setRect(QRectF{}); event->accept(); } else @@ -53,7 +51,7 @@ void View::mouseMoveEvent(QGraphicsSceneMouseEvent* event) if(m_rubberBanding && (event->buttons() & Qt::LeftButton)) { m_rubberBandRect = QRectF{m_rubberBandOrigin, event->pos()}.normalized(); - update(); + m_selectionRect->setRect(m_rubberBandRect); event->accept(); } else @@ -67,10 +65,11 @@ void View::mouseReleaseEvent(QGraphicsSceneMouseEvent* event) if(event->button() == Qt::LeftButton && m_rubberBanding) { m_rubberBanding = false; + m_selectionRect->setVisible(false); + m_selectionRect->setRect(QRectF{}); const bool cumulation = event->modifiers() & Qt::ControlModifier; areaSelectRequested(m_rubberBandRect, cumulation); m_rubberBandRect = {}; - update(); event->accept(); } else diff --git a/src/plugins/score-plugin-nodal/Nodal/View.hpp b/src/plugins/score-plugin-nodal/Nodal/View.hpp index 65a3ee11f4..60eae81f17 100644 --- a/src/plugins/score-plugin-nodal/Nodal/View.hpp +++ b/src/plugins/score-plugin-nodal/Nodal/View.hpp @@ -4,6 +4,8 @@ #include #include +class QGraphicsRectItem; + #include #include @@ -32,6 +34,7 @@ class View final : public Process::LayerView void dragMoveEvent(QGraphicsSceneDragDropEvent* event) override; void dropEvent(QGraphicsSceneDragDropEvent* event) override; + QGraphicsRectItem* m_selectionRect{}; QPointF m_rubberBandOrigin{}; QRectF m_rubberBandRect{}; bool m_rubberBanding{false}; diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp index 83ebcc0491..3a679b7a02 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp @@ -20,10 +20,10 @@ #include +#include #include #include #include -#include #include #include @@ -115,6 +115,13 @@ NodalIntervalView::NodalIntervalView( } QTimer::singleShot(1, this, &NodalIntervalView::recenterRelativeToView); + + m_selectionRect = new QGraphicsRectItem(this); + m_selectionRect->setZValue(1000.); + m_selectionRect->setPen(QPen{QColor{0, 255, 255, 200}, 1, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); + m_selectionRect->setBrush(QColor{0, 255, 255, 20}); + m_selectionRect->setAcceptedMouseButtons(Qt::NoButton); + m_selectionRect->setVisible(false); } void NodalIntervalView::zoomPlus() @@ -321,16 +328,22 @@ void NodalIntervalView::mousePressEvent(QGraphicsSceneMouseEvent* e) { if(e->button() == Qt::LeftButton) { - if(!(e->modifiers() & Qt::ControlModifier)) + if(e->modifiers() & Qt::ControlModifier) + { + m_rubberBanding = true; + m_rubberBandOrigin = e->pos(); + m_rubberBandRect = QRectF{m_rubberBandOrigin, m_rubberBandOrigin}; + m_selectionRect->setVisible(true); + m_selectionRect->setRect(QRectF{}); + } + else { this->m_context.selectionStack.deselect(); auto focus = Process::ProcessFocusManager::get(this->m_context); if(focus) focus->focusNothing(); + m_pressedPos = e->scenePos(); } - m_rubberBanding = true; - m_rubberBandOrigin = e->pos(); - m_rubberBandRect = QRectF{m_rubberBandOrigin, m_rubberBandOrigin}; } else if(e->button() == Qt::MiddleButton) { @@ -344,9 +357,9 @@ void NodalIntervalView::mouseMoveEvent(QGraphicsSceneMouseEvent* e) if(m_rubberBanding && (e->buttons() & Qt::LeftButton)) { m_rubberBandRect = QRectF{m_rubberBandOrigin, e->pos()}.normalized(); - update(); + m_selectionRect->setRect(m_rubberBandRect); } - else if(e->buttons() & Qt::MiddleButton) + else if(e->buttons() & (Qt::LeftButton | Qt::MiddleButton)) { const auto delta = e->scenePos() - m_pressedPos; m_container->setPos(m_container->pos() + delta); @@ -361,6 +374,8 @@ void NodalIntervalView::mouseReleaseEvent(QGraphicsSceneMouseEvent* e) if(e->button() == Qt::LeftButton && m_rubberBanding) { m_rubberBanding = false; + m_selectionRect->setVisible(false); + m_selectionRect->setRect(QRectF{}); const bool cumulation = e->modifiers() & Qt::ControlModifier; Selection sel; @@ -372,36 +387,11 @@ void NodalIntervalView::mouseReleaseEvent(QGraphicsSceneMouseEvent* e) } sel = filterSelections(sel, m_context.selectionStack.currentSelection(), cumulation); score::SelectionDispatcher{m_context.selectionStack}.select(sel); - m_rubberBandRect = {}; - update(); - } - else if(e->button() == Qt::MiddleButton) - { - const auto delta = e->scenePos() - m_pressedPos; - m_container->setPos(m_container->pos() + delta); - m_pressedPos = e->scenePos(); - const_cast(m_model).setNodalOffset(m_model.nodalOffset() + delta); } e->accept(); } -void NodalIntervalView::paint( - QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) -{ - EmptyRectItem::paint(painter, option, widget); - - if(m_rubberBanding && !m_rubberBandRect.isEmpty()) - { - painter->setRenderHint(QPainter::Antialiasing, false); - painter->setCompositionMode(QPainter::CompositionMode_Xor); - painter->setPen( - QPen{QColor{0, 0, 0, 127}, 2, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); - painter->setBrush(Qt::transparent); - painter->drawRect(m_rubberBandRect); - painter->setCompositionMode(QPainter::CompositionMode_SourceOver); - } -} void NodalIntervalView::contextMenuEvent(QGraphicsSceneContextMenuEvent* event) { diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.hpp b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.hpp index cf5e2b442e..3f87fe1cfe 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.hpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.hpp @@ -7,6 +7,8 @@ #include +class QGraphicsRectItem; + #include namespace Scenario @@ -60,14 +62,12 @@ class NodalIntervalView final void contextMenuEvent(QGraphicsSceneContextMenuEvent* event) override; void wheelEvent(QGraphicsSceneWheelEvent* event) override; - void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) - override; - const IntervalModel& m_model; const Process::Context& m_context; ItemsToShow m_itemsToShow{}; std::vector m_nodeItems; QGraphicsItem* m_container{}; + QGraphicsRectItem* m_selectionRect{}; QPointF m_pressedPos{}; double m_zoomLevel = 0; From b575b435e2a16dc5b3b0b34e3f9ecde94f97bfcc Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Thu, 21 May 2026 15:56:24 -0400 Subject: [PATCH 3/7] nodal: match rubber-band selection color to selected-node color (Base2.darker) Co-Authored-By: Claude Sonnet 4.6 --- src/plugins/score-plugin-nodal/Nodal/View.cpp | 17 +++++++++++------ .../Interval/FullView/NodalIntervalView.cpp | 18 ++++++++++++------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/plugins/score-plugin-nodal/Nodal/View.cpp b/src/plugins/score-plugin-nodal/Nodal/View.cpp index cec644cbb8..fe021f13e6 100644 --- a/src/plugins/score-plugin-nodal/Nodal/View.cpp +++ b/src/plugins/score-plugin-nodal/Nodal/View.cpp @@ -17,12 +17,17 @@ View::View(QGraphicsItem* parent) { setFlag(ItemClipsChildrenToShape); - m_selectionRect = new QGraphicsRectItem(this); - m_selectionRect->setZValue(1000.); - m_selectionRect->setPen(QPen{QColor{0, 255, 255, 200}, 1, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); - m_selectionRect->setBrush(QColor{0, 255, 255, 20}); - m_selectionRect->setAcceptedMouseButtons(Qt::NoButton); - m_selectionRect->setVisible(false); + { + auto selColor = score::Skin::instance().Base2.darker.brush.color(); + auto selFill = selColor; + selFill.setAlpha(40); + m_selectionRect = new QGraphicsRectItem(this); + m_selectionRect->setZValue(1000.); + m_selectionRect->setPen(QPen{selColor, 1, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); + m_selectionRect->setBrush(selFill); + m_selectionRect->setAcceptedMouseButtons(Qt::NoButton); + m_selectionRect->setVisible(false); + } } View::~View() { } diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp index 3a679b7a02..f0fa83c676 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/Interval/FullView/NodalIntervalView.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -116,12 +117,17 @@ NodalIntervalView::NodalIntervalView( QTimer::singleShot(1, this, &NodalIntervalView::recenterRelativeToView); - m_selectionRect = new QGraphicsRectItem(this); - m_selectionRect->setZValue(1000.); - m_selectionRect->setPen(QPen{QColor{0, 255, 255, 200}, 1, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); - m_selectionRect->setBrush(QColor{0, 255, 255, 20}); - m_selectionRect->setAcceptedMouseButtons(Qt::NoButton); - m_selectionRect->setVisible(false); + { + auto selColor = score::Skin::instance().Base2.darker.brush.color(); + auto selFill = selColor; + selFill.setAlpha(40); + m_selectionRect = new QGraphicsRectItem(this); + m_selectionRect->setZValue(1000.); + m_selectionRect->setPen(QPen{selColor, 1, Qt::DashLine, Qt::SquareCap, Qt::BevelJoin}); + m_selectionRect->setBrush(selFill); + m_selectionRect->setAcceptedMouseButtons(Qt::NoButton); + m_selectionRect->setVisible(false); + } } void NodalIntervalView::zoomPlus() From 1f80987b50e2b8090acc66175ca148e243380855 Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Fri, 22 May 2026 23:04:26 -0400 Subject: [PATCH 4/7] nodal: fix export macro on View signal to use SCORE_PLUGIN_NODAL_EXPORT Using SCORE_PLUGIN_DEVICEEXPLORER_EXPORT on a symbol defined in score_plugin_nodal caused an undefined symbol linker error on platforms that build with dynamic plugins (macOS, Flatpak). Co-Authored-By: Claude Sonnet 4.6 --- src/plugins/score-plugin-nodal/Nodal/View.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/score-plugin-nodal/Nodal/View.hpp b/src/plugins/score-plugin-nodal/Nodal/View.hpp index 60eae81f17..eee72fc716 100644 --- a/src/plugins/score-plugin-nodal/Nodal/View.hpp +++ b/src/plugins/score-plugin-nodal/Nodal/View.hpp @@ -6,7 +6,7 @@ class QGraphicsRectItem; -#include +#include #include @@ -20,7 +20,7 @@ class View final : public Process::LayerView ~View() override; void areaSelectRequested(QRectF area, bool cumulation) - E_SIGNAL(SCORE_PLUGIN_DEVICEEXPLORER_EXPORT, areaSelectRequested, area, cumulation) + E_SIGNAL(SCORE_PLUGIN_NODAL_EXPORT, areaSelectRequested, area, cumulation) private: void paint_impl(QPainter*) const override; From 038ecfba85269a9d91371c4d9a678f6822b5b65e Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Mon, 25 May 2026 09:43:02 -0400 Subject: [PATCH 5/7] scenario: fix crash and silent failure when pasting multiple processes into a scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When pasting processes copied from a nodal view into a clean scenario, the old code called loadProcessInSlot() (which assigns new IDs via getStrongId) but then fed cable paths that still referenced the original process IDs — causing an assertion crash in restoreCables(). Fix: track the old→new process ID mapping built during loadProcessInSlot calls, then remap cable endpoint paths before passing them to loadCables(). This keeps loadProcessInSlot (so processes appear as visible timeline slots) while fixing the ID mismatch that caused the crash. Co-Authored-By: Claude Sonnet 4.6 --- .../Scenario/Document/ScenarioEditor.cpp | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/ScenarioEditor.cpp b/src/plugins/score-plugin-scenario/Scenario/Document/ScenarioEditor.cpp index 3409e5ec6d..87353a829b 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/ScenarioEditor.cpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/ScenarioEditor.cpp @@ -26,6 +26,8 @@ #include #include +#include +#include #include #include @@ -117,35 +119,46 @@ static bool pasteInScenario( auto& interval = m.createBox(sm, origin.date, TimeVal(origin.date.impl + t.impl), origin.y); + // Load each process into a slot, collecting old→new ID mapping for cable remapping. + // loadProcessInSlot always creates a view slot so processes are visible in the timeline. + std::vector> proc_id_map; for(auto& proc : processes) { - if(proc.IsObject()) - { - if(proc.HasMember(score::StringConstant().uuid)) - { - m.loadProcessInSlot(interval, proc); - } - } + if(!proc.IsObject() || !proc.HasMember(score::StringConstant().uuid)) + continue; + auto id_it = proc.FindMember("id"); + if(id_it == proc.MemberEnd()) + continue; + int32_t old_id = id_it->value.GetInt(); + if(auto* new_proc = m.loadProcessInSlot(interval, proc)) + proc_id_map.emplace_back(old_id, new_proc->id().val()); } + // Remap cable endpoints from old process IDs to new ones, then load. { - auto new_path = score::IDocument::path(interval).unsafePath(); - - // !!! FIXME this looks like it's not valid, use - // serializedCablesFromCableJson instead, no ? auto cables = JsonValue{cables_it->value}.to(); - - for(auto& cable : cables) - { - qDebug() << cable.second.source.unsafePath().toString(); - qDebug() << cable.second.sink.unsafePath().toString(); - } + auto new_path = score::IDocument::path(interval).unsafePath(); auto& document = score::IDocument::get(ctx.document); - for(auto& c : cables) + for(auto& [cable_id, cable_data] : cables) { - c.first = getStrongId(document.cables); + auto remap = [&](ObjectPath& path) { + auto& vec = path.vec(); + if(vec.empty()) + return; + for(auto& [old_id, new_id] : proc_id_map) + { + if(vec.front().id() == old_id) + { + vec.front() = ObjectIdentifier{vec.front().objectName(), new_id}; + break; + } + } + }; + remap(cable_data.source.unsafePath()); + remap(cable_data.sink.unsafePath()); + cable_id = getStrongId(document.cables); } m.loadCables(new_path, cables); } From 4cc6b3502fcd2d97d9036cbe489f477e3fd93383 Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Thu, 25 Jun 2026 17:06:51 -0400 Subject: [PATCH 6/7] nodal: defer node selection change to release so multi-selection can be dragged Pressing on an already-selected node previously ran filterSelections immediately on press: with Ctrl held (required for box selection) this toggled the clicked node off, so dragging moved every selected node except the clicked one; without a modifier it collapsed the selection to the single clicked node, making it impossible to drag a multi-selection at all. Defer the selection change to the release event when the pressed node is already selected. A drag then moves the whole selection untouched, while a click without drag still applies the standard GUI behaviour (collapse to the clicked node, or toggle it off when Ctrl is held). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Process/Dataflow/NodeItem.cpp | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp b/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp index 57ed210f80..2c56a2eb42 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp @@ -942,6 +942,12 @@ enum Interaction } nodeItemInteraction{}; QSizeF origNodeSize{}; bool nodeDidMove{}; +// When pressing on an already-selected node, the selection change is deferred +// to the release event so that the whole selection can be dragged. The +// toggle/collapse only happens on release if no drag occurred. The modifier +// state at press time is captured for that deferred handling. +bool nodeSelectOnRelease{}; +bool nodeSelectCumulation{}; } void NodeItem::mousePressEvent(QGraphicsSceneMouseEvent* event) @@ -966,9 +972,23 @@ void NodeItem::mousePressEvent(QGraphicsSceneMouseEvent* event) } { const bool cumulation = event->modifiers() & Qt::ControlModifier; - Selection sel = filterSelections( - &m_model, m_context.selectionStack.currentSelection(), cumulation); - score::SelectionDispatcher{m_context.selectionStack}.select(sel); + // If the node is already part of the (possibly multi-) selection, defer + // the selection change to the release event. This way, pressing on an + // already-selected node and dragging moves the whole selection instead + // of collapsing it to (or toggling off) this single node. The + // toggle/collapse still happens on a click without drag (see release). + if(m_model.selection.get()) + { + nodeSelectOnRelease = true; + nodeSelectCumulation = cumulation; + } + else + { + nodeSelectOnRelease = false; + Selection sel = filterSelections( + &m_model, m_context.selectionStack.currentSelection(), cumulation); + score::SelectionDispatcher{m_context.selectionStack}.select(sel); + } } event->accept(); } @@ -1034,7 +1054,18 @@ void NodeItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* event) mouseMoveEvent(event); if(nodeDidMove) + { m_dispatcher.commit(); + } + else if(nodeSelectOnRelease) + { + // Click (no drag) on an already-selected node: apply the standard GUI + // selection logic now (collapse to this node, or toggle it off if Ctrl). + Selection sel = filterSelections( + &m_model, m_context.selectionStack.currentSelection(), nodeSelectCumulation); + score::SelectionDispatcher{m_context.selectionStack}.select(sel); + } + nodeSelectOnRelease = false; nodeDidMove = false; event->accept(); } From a0683cb2eb21eed26dcdca76169c038d2160e171 Mon Sep 17 00:00:00 2001 From: Edu Meneses Date: Thu, 25 Jun 2026 17:20:10 -0400 Subject: [PATCH 7/7] scenario: support deleting a multi-selection of processes ScenarioEditor::remove only handled the single-element case for processes, so deleting a nodal box-selection of several processes did nothing (or only removed one). Add a branch that, when the whole selection is made of processes living in intervals, removes each of them in a single macro via the new RemoveMultipleProcessesFromInterval aggregate command. Also drop the explanatory comments added with the deferred-selection fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Process/Dataflow/NodeItem.cpp | 11 ------- .../Interval/RemoveProcessFromInterval.hpp | 6 ++++ .../Scenario/Document/ScenarioEditor.cpp | 33 +++++++++++++++++++ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp b/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp index 2c56a2eb42..85f1e3b276 100644 --- a/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp +++ b/src/plugins/score-lib-process/Process/Dataflow/NodeItem.cpp @@ -942,10 +942,6 @@ enum Interaction } nodeItemInteraction{}; QSizeF origNodeSize{}; bool nodeDidMove{}; -// When pressing on an already-selected node, the selection change is deferred -// to the release event so that the whole selection can be dragged. The -// toggle/collapse only happens on release if no drag occurred. The modifier -// state at press time is captured for that deferred handling. bool nodeSelectOnRelease{}; bool nodeSelectCumulation{}; } @@ -972,11 +968,6 @@ void NodeItem::mousePressEvent(QGraphicsSceneMouseEvent* event) } { const bool cumulation = event->modifiers() & Qt::ControlModifier; - // If the node is already part of the (possibly multi-) selection, defer - // the selection change to the release event. This way, pressing on an - // already-selected node and dragging moves the whole selection instead - // of collapsing it to (or toggling off) this single node. The - // toggle/collapse still happens on a click without drag (see release). if(m_model.selection.get()) { nodeSelectOnRelease = true; @@ -1059,8 +1050,6 @@ void NodeItem::mouseReleaseEvent(QGraphicsSceneMouseEvent* event) } else if(nodeSelectOnRelease) { - // Click (no drag) on an already-selected node: apply the standard GUI - // selection logic now (collapse to this node, or toggle it off if Ctrl). Selection sel = filterSelections( &m_model, m_context.selectionStack.currentSelection(), nodeSelectCumulation); score::SelectionDispatcher{m_context.selectionStack}.select(sel); diff --git a/src/plugins/score-plugin-scenario/Scenario/Commands/Interval/RemoveProcessFromInterval.hpp b/src/plugins/score-plugin-scenario/Scenario/Commands/Interval/RemoveProcessFromInterval.hpp index 27606b5aa9..01ebf50c2b 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Commands/Interval/RemoveProcessFromInterval.hpp +++ b/src/plugins/score-plugin-scenario/Scenario/Commands/Interval/RemoveProcessFromInterval.hpp @@ -51,5 +51,11 @@ class RemoveProcessAndKeepLinked final : public score::AggregateCommand SCORE_COMMAND_DECL( CommandFactoryName(), RemoveProcessAndKeepLinked, "Remove process from chain") }; + +class RemoveMultipleProcessesFromInterval final : public score::AggregateCommand +{ + SCORE_COMMAND_DECL( + CommandFactoryName(), RemoveMultipleProcessesFromInterval, "Remove processes") +}; } } diff --git a/src/plugins/score-plugin-scenario/Scenario/Document/ScenarioEditor.cpp b/src/plugins/score-plugin-scenario/Scenario/Document/ScenarioEditor.cpp index 87353a829b..eca460f624 100644 --- a/src/plugins/score-plugin-scenario/Scenario/Document/ScenarioEditor.cpp +++ b/src/plugins/score-plugin-scenario/Scenario/Document/ScenarioEditor.cpp @@ -327,6 +327,39 @@ bool ScenarioEditor::paste( bool ScenarioEditor::remove(const Selection& s, const score::DocumentContext& ctx) { + if(s.size() > 1) + { + std::vector> procs; + procs.reserve(s.size()); + bool only_interval_processes = true; + for(auto& elt : s) + { + auto proc = qobject_cast(elt.data()); + if(!proc) + { + only_interval_processes = false; + break; + } + auto itv = qobject_cast(proc->parent()); + if(!itv) + { + only_interval_processes = false; + break; + } + procs.push_back({itv, proc}); + } + + if(only_interval_processes && !procs.empty()) + { + Scenario::Command::Macro m{ + new Scenario::Command::RemoveMultipleProcessesFromInterval, ctx}; + for(auto& [itv, proc] : procs) + m.removeProcess(*itv, proc->id()); + m.commit(); + return true; + } + } + if(s.size() == 1) { auto first = s.begin()->data();