From 89906035252f33ffd23a4c37a112bc7f5d57164e Mon Sep 17 00:00:00 2001 From: sfer Date: Thu, 13 Aug 2026 12:38:42 +0200 Subject: [PATCH 01/10] Add drag-handle overlay to edit per-note playback start/duration offsets Adds an on-canvas alternative to the Properties panel spinboxes for editing the playbackStartOffset/playbackDurationOffset introduced in "Add per-note playback start/duration offset properties": a toggleable overlay ("Note offsets" toolbar action, alongside Automation) draws a small rounded rectangle above each note, anchored on the note's own position. Dragging the rectangle's left/right edge adjusts the start/duration offset respectively, with a live preview during the drag and a single undo/redo step on release. Dragging a note that is part of a multi-note selection applies the same tick delta to every selected note. The notation dims while the mode is active, matching the existing Automation overlay's behavior. New files: - INotationNoteOffsets / NotationNoteOffsets: edit-mode toggle state, mirroring INotationAutomation. - NotationNoteOffsetController: builds/positions one overlay per staff from the actual note layout (not a fixed lane), converts drag input to ticks via segment interpolation, and commits offset changes through the existing undoChangeProperty/Pid machinery (no new UndoableCommand needed). - NoteOffsetOverlay: QQuickPaintedItem drawing the rectangles/handles and doing its own hit-testing, independent of the Grip/NotationInteraction edit path - the same pattern PolylinePlot uses for automation. - segmentcanvasinterpolation.{h,cpp}: canvasX<->tick interpolation shared between the automation and note-offset controllers (previously duplicated). Wiring follows the existing "toggle-automation" action end to end (notationuiactions.cpp, notationactioncontroller.cpp, notationcommandsregister.cpp, notationcommandsstate.cpp, notationtoolbarmodel.cpp) and reuses AbstractNotationPaintView's existing per-view-matrix redraw/dim hooks. --- src/notation/CMakeLists.txt | 3 + src/notation/imasternotation.h | 1 + src/notation/inotation_fwd.h | 3 + src/notation/inotationnoteoffsets.h | 39 ++ src/notation/internal/masternotation.cpp | 7 + src/notation/internal/masternotation.h | 2 + src/notation/internal/notationnoteoffsets.cpp | 44 ++ src/notation/internal/notationnoteoffsets.h | 40 ++ .../inotationcommandscontroller.h | 3 + .../internal/notationactioncontroller.cpp | 30 + .../internal/notationactioncontroller.h | 5 + .../internal/notationcommandsregister.cpp | 7 + .../internal/notationcommandsstate.cpp | 8 + .../internal/notationuiactions.cpp | 20 + src/notationscene/notationcommands.h | 1 + .../MuseScore/NotationScene/CMakeLists.txt | 6 + .../abstractnotationpaintview.cpp | 36 +- .../NotationScene/abstractnotationpaintview.h | 4 + .../notationautomationcontroller.cpp | 40 +- .../notationnoteoffsetcontroller.cpp | 550 ++++++++++++++++++ .../notationnoteoffsetcontroller.h | 115 ++++ .../NotationScene/notationtoolbarmodel.cpp | 3 +- .../NotationScene/noteoffsetoverlay.cpp | 191 ++++++ .../NotationScene/noteoffsetoverlay.h | 89 +++ .../segmentcanvasinterpolation.cpp | 93 +++ .../segmentcanvasinterpolation.h | 38 ++ 26 files changed, 1342 insertions(+), 36 deletions(-) create mode 100644 src/notation/inotationnoteoffsets.h create mode 100644 src/notation/internal/notationnoteoffsets.cpp create mode 100644 src/notation/internal/notationnoteoffsets.h create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h create mode 100644 src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp create mode 100644 src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h create mode 100644 src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.cpp create mode 100644 src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.h diff --git a/src/notation/CMakeLists.txt b/src/notation/CMakeLists.txt index d697fa3d44d7a..8505fa6c06256 100644 --- a/src/notation/CMakeLists.txt +++ b/src/notation/CMakeLists.txt @@ -36,6 +36,7 @@ target_sources(notation PRIVATE inotationselectionfilter.h inotationselectionrange.h inotationautomation.h + inotationnoteoffsets.h inotationinteraction.h inotationstyle.h inotationundostack.h @@ -83,6 +84,8 @@ target_sources(notation PRIVATE internal/notationcontextconfiguration.h internal/notationautomation.cpp internal/notationautomation.h + internal/notationnoteoffsets.cpp + internal/notationnoteoffsets.h internal/notationelements.cpp internal/notationelements.h internal/notationinteraction.cpp diff --git a/src/notation/imasternotation.h b/src/notation/imasternotation.h index a90c27dc17784..36413d0060014 100644 --- a/src/notation/imasternotation.h +++ b/src/notation/imasternotation.h @@ -72,6 +72,7 @@ class IMasterNotation virtual void initNotationSoloMuteState(const INotationPtr notation) = 0; virtual INotationAutomationPtr automation() const = 0; + virtual INotationNoteOffsetsPtr noteOffsets() const = 0; }; using IMasterNotationPtr = std::shared_ptr; diff --git a/src/notation/inotation_fwd.h b/src/notation/inotation_fwd.h index 7dd484b61e270..02a0a182e888f 100644 --- a/src/notation/inotation_fwd.h +++ b/src/notation/inotation_fwd.h @@ -84,4 +84,7 @@ using INotationPlaybackPtr = std::shared_ptr; class INotationAutomation; using INotationAutomationPtr = std::shared_ptr; + +class INotationNoteOffsets; +using INotationNoteOffsetsPtr = std::shared_ptr; } diff --git a/src/notation/inotationnoteoffsets.h b/src/notation/inotationnoteoffsets.h new file mode 100644 index 0000000000000..3f72866af235c --- /dev/null +++ b/src/notation/inotationnoteoffsets.h @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "async/notification.h" + +namespace mu::notation { +class INotationNoteOffsets +{ +public: + virtual ~INotationNoteOffsets() = default; + + virtual bool isEditModeEnabled() const = 0; + virtual void setEditModeEnabled(bool enabled) = 0; + virtual muse::async::Notification editModeEnabledChanged() const = 0; +}; + +using INotationNoteOffsetsPtr = std::shared_ptr; +} diff --git a/src/notation/internal/masternotation.cpp b/src/notation/internal/masternotation.cpp index b14f6835d9a94..232ae03b3257c 100644 --- a/src/notation/internal/masternotation.cpp +++ b/src/notation/internal/masternotation.cpp @@ -51,6 +51,7 @@ #include "excerptnotation.h" #include "masternotationparts.h" #include "notationautomation.h" +#include "notationnoteoffsets.h" #include "types/scorecreateoptions.h" #ifdef MUE_BUILD_ENGRAVING_PLAYBACK @@ -92,6 +93,7 @@ MasterNotation::MasterNotation(project::INotationProject* project, const muse::m #endif m_notationAutomation = std::make_shared(undoStack()); + m_notationNoteOffsets = std::make_shared(); m_parts->partsChanged().onNotify(this, [this]() { notifyAboutNotationChanged(); @@ -766,6 +768,11 @@ INotationAutomationPtr MasterNotation::automation() const return m_notationAutomation; } +INotationNoteOffsetsPtr MasterNotation::noteOffsets() const +{ + return m_notationNoteOffsets; +} + void MasterNotation::initNotationSoloMuteState(const INotationPtr notation) { IF_ASSERT_FAILED(notation) { diff --git a/src/notation/internal/masternotation.h b/src/notation/internal/masternotation.h index 586e73b0a66cf..8c9aeb5977be4 100644 --- a/src/notation/internal/masternotation.h +++ b/src/notation/internal/masternotation.h @@ -74,6 +74,7 @@ class MasterNotation : public IMasterNotation, public Notation, public std::enab void initNotationSoloMuteState(const INotationPtr notation) override; INotationAutomationPtr automation() const override; + INotationNoteOffsetsPtr noteOffsets() const override; private: friend class project::NotationProject; @@ -102,6 +103,7 @@ class MasterNotation : public IMasterNotation, public Notation, public std::enab muse::async::Notification m_excerptsChanged; INotationPlaybackPtr m_notationPlayback = nullptr; INotationAutomationPtr m_notationAutomation = nullptr; + INotationNoteOffsetsPtr m_notationNoteOffsets = nullptr; muse::async::Notification m_hasPartsChanged; mutable ExcerptNotationList m_potentialExcerpts; diff --git a/src/notation/internal/notationnoteoffsets.cpp b/src/notation/internal/notationnoteoffsets.cpp new file mode 100644 index 0000000000000..72aa563956959 --- /dev/null +++ b/src/notation/internal/notationnoteoffsets.cpp @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "notationnoteoffsets.h" + +using namespace mu::notation; + +bool NotationNoteOffsets::isEditModeEnabled() const +{ + return m_isEditModeEnabled; +} + +void NotationNoteOffsets::setEditModeEnabled(bool enabled) +{ + if (m_isEditModeEnabled == enabled) { + return; + } + m_isEditModeEnabled = enabled; + m_editModeEnabledChanged.notify(); +} + +muse::async::Notification NotationNoteOffsets::editModeEnabledChanged() const +{ + return m_editModeEnabledChanged; +} diff --git a/src/notation/internal/notationnoteoffsets.h b/src/notation/internal/notationnoteoffsets.h new file mode 100644 index 0000000000000..f719537e33673 --- /dev/null +++ b/src/notation/internal/notationnoteoffsets.h @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include "../inotationnoteoffsets.h" + +#include "async/notification.h" + +namespace mu::notation { +class NotationNoteOffsets : public INotationNoteOffsets +{ +public: + bool isEditModeEnabled() const override; + void setEditModeEnabled(bool enabled) override; + muse::async::Notification editModeEnabledChanged() const override; + +private: + bool m_isEditModeEnabled = false; + muse::async::Notification m_editModeEnabledChanged; +}; +} diff --git a/src/notationscene/inotationcommandscontroller.h b/src/notationscene/inotationcommandscontroller.h index 32048863bc533..a20c720a76d95 100644 --- a/src/notationscene/inotationcommandscontroller.h +++ b/src/notationscene/inotationcommandscontroller.h @@ -89,6 +89,9 @@ class INotationCommandsController : MODULE_CONTEXT_INTERFACE virtual bool isAutomationModeEnabled() const = 0; virtual muse::async::Notification automationModeEnabledChanged() const = 0; + virtual bool isNoteOffsetEditModeEnabled() const = 0; + virtual muse::async::Notification noteOffsetEditModeEnabledChanged() const = 0; + virtual bool isDebuggingCommandEnabled(const muse::rcommand::Command& command) const = 0; virtual muse::async::Notification debuggingOptionsChanged() const = 0; }; diff --git a/src/notationscene/internal/notationactioncontroller.cpp b/src/notationscene/internal/notationactioncontroller.cpp index e5eaa0373bbff..9ad23b1817332 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -39,6 +39,7 @@ #include "notation/imasternotation.h" #include "notation/inotation.h" #include "notation/inotationautomation.h" // IWYU pragma: keep +#include "notation/inotationnoteoffsets.h" // IWYU pragma: keep #include "notation/inotationelements.h" #include "notation/inotationmidiinput.h" #include "notation/inotationnoteinput.h" @@ -581,6 +582,7 @@ void NotationActionController::init() registerCommand(TOGGLE_AUTOMATION_COMMAND, &Controller::toggleAutomation); registerQueryCommand(SELECT_AUTOMATION_TYPE_COMMAND, &Controller::selectAutomationType); + registerCommand(TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, &Controller::toggleNoteOffsetEditor); // TAB registerCommand(SET_DURATION_WHOLE_TAB_COMMAND, [this]() { setDuration(DurationType::V_WHOLE); }); @@ -1052,6 +1054,7 @@ void NotationActionController::init() { "scoop", ADD_SCOOP_COMMAND, {} }, { "hammer-on-pull-off", ADD_HAMMER_ON_PULL_OFF_COMMAND, {} }, { "toggle-automation", TOGGLE_AUTOMATION_COMMAND, {} }, + { "toggle-note-offset-editor", TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, {} }, { "string-up", GOTO_STRING_ABOVE_COMMAND, {} }, { "string-down", GOTO_STRING_BELOW_COMMAND, {} }, { "move-up", MOVE_UP_COMMAND, {} }, @@ -1129,6 +1132,10 @@ void NotationActionController::init() masterNotation->automation()->automationModeEnabledChanged().onNotify(this, [this]() { m_automationModeEnabledChanged.notify(); }, Asyncable::Mode::SetReplace); + + masterNotation->noteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { + m_noteOffsetEditModeEnabledChanged.notify(); + }, Asyncable::Mode::SetReplace); } } @@ -3187,6 +3194,16 @@ bool NotationActionController::isAutomationModeEnabled() const return currentMasterNotation() ? currentMasterNotation()->automation()->isAutomationModeEnabled() : false; } +bool NotationActionController::isNoteOffsetEditModeEnabled() const +{ + return currentMasterNotation() ? currentMasterNotation()->noteOffsets()->isEditModeEnabled() : false; +} + +muse::async::Notification NotationActionController::noteOffsetEditModeEnabledChanged() const +{ + return m_noteOffsetEditModeEnabledChanged; +} + muse::async::Notification NotationActionController::automationModeEnabledChanged() const { return m_automationModeEnabledChanged; @@ -3259,6 +3276,19 @@ void NotationActionController::toggleAutomation() masterNotation->automation()->setAutomationModeEnabled(!isEnabled); } +void NotationActionController::toggleNoteOffsetEditor() +{ + TRACEFUNC; + + IMasterNotationPtr masterNotation = currentMasterNotation(); + if (!masterNotation) { + return; + } + + const bool isEnabled = masterNotation->noteOffsets()->isEditModeEnabled(); + masterNotation->noteOffsets()->setEditModeEnabled(!isEnabled); +} + muse::Ret NotationActionController::selectAutomationType(const muse::rcommand::CommandQuery& query) { const std::string type = query.param("type").toString(); diff --git a/src/notationscene/internal/notationactioncontroller.h b/src/notationscene/internal/notationactioncontroller.h index dab952921fb30..efd04fc578fca 100644 --- a/src/notationscene/internal/notationactioncontroller.h +++ b/src/notationscene/internal/notationactioncontroller.h @@ -118,6 +118,9 @@ class NotationActionController : public INotationCommandsController, public muse bool isAutomationModeEnabled() const override; muse::async::Notification automationModeEnabledChanged() const override; + bool isNoteOffsetEditModeEnabled() const override; + muse::async::Notification noteOffsetEditModeEnabledChanged() const override; + bool isDebuggingCommandEnabled(const muse::rcommand::Command& command) const override; muse::async::Notification debuggingOptionsChanged() const override; @@ -269,6 +272,7 @@ class NotationActionController : public INotationCommandsController, public muse void toggleAutomation(); muse::Ret selectAutomationType(const muse::rcommand::CommandQuery& query); + void toggleNoteOffsetEditor(); // commands void registerCommand(const muse::rcommand::Command&, std::function); @@ -311,6 +315,7 @@ class NotationActionController : public INotationCommandsController, public muse muse::async::Channel m_scoreConfigChanged; muse::async::Notification m_currentNotationStyleChanged; muse::async::Notification m_automationModeEnabledChanged; + muse::async::Notification m_noteOffsetEditModeEnabledChanged; using IsActionEnabledFunc = std::function; std::map m_isEnabledMap; diff --git a/src/notationscene/internal/notationcommandsregister.cpp b/src/notationscene/internal/notationcommandsregister.cpp index c19a54d906caa..40abd65d81c6a 100644 --- a/src/notationscene/internal/notationcommandsregister.cpp +++ b/src/notationscene/internal/notationcommandsregister.cpp @@ -2914,6 +2914,13 @@ static const std::vector s_commandInfos = { InputSchema(), Decoration(IconCode::Code::AUTOMATION, rcommand::Checkable::Yes) }, + CommandInfo { + TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, + TranslatableString("action", "Note offsets"), + TranslatableString("action", "Toggle note offset editor"), + InputSchema(), + Decoration(IconCode::Code::CLOCK, rcommand::Checkable::Yes) + }, CommandInfo { SELECT_AUTOMATION_TYPE_COMMAND, TranslatableString::untranslatable("Automation type"), diff --git a/src/notationscene/internal/notationcommandsstate.cpp b/src/notationscene/internal/notationcommandsstate.cpp index 7c53a0d1beaa6..b72bbc1f1645c 100644 --- a/src/notationscene/internal/notationcommandsstate.cpp +++ b/src/notationscene/internal/notationcommandsstate.cpp @@ -347,6 +347,10 @@ void NotationCommandsState::init() updateCommandStates({ TOGGLE_AUTOMATION_COMMAND }); }); + controller()->noteOffsetEditModeEnabledChanged().onNotify(this, [this]() { + updateCommandStates({ TOGGLE_NOTE_OFFSET_EDITOR_COMMAND }); + }); + controller()->debuggingOptionsChanged().onNotify(this, [this]() { updateCommandStates(DEBUG_COMMANDS); }); @@ -485,6 +489,10 @@ CommandState NotationCommandsState::doCommandState(const Command& command) const return CommandState(true, controller()->isAutomationModeEnabled()); } + if (command == TOGGLE_NOTE_OFFSET_EDITOR_COMMAND) { + return CommandState(true, controller()->isNoteOffsetEditModeEnabled()); + } + if (muse::contains(DEBUG_COMMANDS, command)) { return CommandState(true, controller()->isDebuggingCommandEnabled(command)); } diff --git a/src/notationscene/internal/notationuiactions.cpp b/src/notationscene/internal/notationuiactions.cpp index 63e04057d19b1..12ccd084a8ddb 100644 --- a/src/notationscene/internal/notationuiactions.cpp +++ b/src/notationscene/internal/notationuiactions.cpp @@ -32,6 +32,7 @@ #include "notation/imasternotation.h" #include "notation/inotation.h" #include "notation/inotationautomation.h" // IWYU pragma: keep +#include "notation/inotationnoteoffsets.h" // IWYU pragma: keep #include "notation/inotationinteraction.h" #include "notation/inotationnoteinput.h" // IWYU pragma: keep #include "notation/inotationselection.h" // IWYU pragma: keep @@ -55,6 +56,7 @@ static const ActionCode SHOW_IRREGULAR_CODE("show-irregular"); static const ActionCode TOGGLE_CONCERT_PITCH_CODE("concert-pitch"); static const ActionCode TOGGLE_AUTOMATION_CODE("toggle-automation"); +static const ActionCode TOGGLE_NOTE_OFFSET_EDITOR_CODE("toggle-note-offset-editor"); // avoid translation duplication @@ -2700,6 +2702,14 @@ const UiActionList NotationUiActions::s_actions = { IconCode::Code::AUTOMATION, Checkable::Yes ), + UiAction(TOGGLE_NOTE_OFFSET_EDITOR_CODE, + mu::context::UiCtxProjectOpened, + mu::context::CTX_NOTATION_OPENED, + TranslatableString("action", "Note offsets"), + TranslatableString("action", "Toggle note offset editor"), + IconCode::Code::CLOCK, + Checkable::Yes + ), }; const UiActionList NotationUiActions::s_scoreConfigActions = { @@ -2924,11 +2934,16 @@ void NotationUiActions::init() m_controller->currentMasterNotationChanged().onNotify(this, [this]() { m_actionCheckedChanged.send({ TOGGLE_AUTOMATION_CODE }); + m_actionCheckedChanged.send({ TOGGLE_NOTE_OFFSET_EDITOR_CODE }); if (const IMasterNotationPtr masterNotation = m_controller->currentMasterNotation()) { masterNotation->automation()->automationModeEnabledChanged().onNotify(this, [this]() { m_actionCheckedChanged.send({ TOGGLE_AUTOMATION_CODE }); }, Asyncable::Mode::SetReplace); + + masterNotation->noteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { + m_actionCheckedChanged.send({ TOGGLE_NOTE_OFFSET_EDITOR_CODE }); + }, Asyncable::Mode::SetReplace); } }); @@ -3047,6 +3062,11 @@ bool NotationUiActions::actionChecked(const UiAction& act) const return masterNotation ? masterNotation->automation()->isAutomationModeEnabled() : false; } + if (act.code == TOGGLE_NOTE_OFFSET_EDITOR_CODE) { + const IMasterNotationPtr masterNotation = m_controller->currentMasterNotation(); + return masterNotation ? masterNotation->noteOffsets()->isEditModeEnabled() : false; + } + if (isScoreConfigAction(act.code)) { auto interaction = m_controller->currentNotationInteraction(); if (interaction) { diff --git a/src/notationscene/notationcommands.h b/src/notationscene/notationcommands.h index 8544e5313e6df..2d3d5f53d3af8 100644 --- a/src/notationscene/notationcommands.h +++ b/src/notationscene/notationcommands.h @@ -484,6 +484,7 @@ inline static const muse::rcommand::Command VOICE_ASSIGNMENT_ALL_IN_INSTR_COMMAN inline static const muse::rcommand::Command VOICE_ASSIGNMENT_ALL_IN_STAFF_COMMAND("command://notation/voice-assignment-all-in-staff"); inline static const muse::rcommand::Command TOGGLE_AUTOMATION_COMMAND("command://notation/toggle-automation"); inline static const muse::rcommand::Command SELECT_AUTOMATION_TYPE_COMMAND("command://notation/select-automation-type"); // with params +inline static const muse::rcommand::Command TOGGLE_NOTE_OFFSET_EDITOR_COMMAND("command://notation/toggle-note-offset-editor"); // TAB commands inline static const muse::rcommand::Command SET_DURATION_WHOLE_TAB_COMMAND("command://notation/set-duration-whole-tab"); diff --git a/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt b/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt index 247446b260609..713fada176449 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt +++ b/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt @@ -70,6 +70,8 @@ qt_add_qml_module(notationscene_qml notationcontextmenumodel.h notationnavigator.cpp notationnavigator.h + notationnoteoffsetcontroller.h + notationnoteoffsetcontroller.cpp notationpaintview.cpp notationpaintview.h notationruler.cpp @@ -88,6 +90,8 @@ qt_add_qml_module(notationscene_qml noteinputbarmodel.h noteinputcursor.cpp noteinputcursor.h + noteoffsetoverlay.cpp + noteoffsetoverlay.h paintedengravingitem.cpp paintedengravingitem.h partlistmodel.cpp @@ -109,6 +113,8 @@ qt_add_qml_module(notationscene_qml playbackcursor.h searchpopupmodel.cpp searchpopupmodel.h + segmentcanvasinterpolation.cpp + segmentcanvasinterpolation.h selectionfilter/abstractselectionfiltermodel.cpp selectionfilter/abstractselectionfiltermodel.h selectionfilter/elementsselectionfiltermodel.cpp diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp index 5f68e6953ed19..b5b3fef3e921b 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp @@ -35,6 +35,7 @@ #include "notation/imasternotation.h" // IWYU pragma: keep #include "notation/inotationaccessibility.h" // IWYU pragma: keep #include "notation/inotationautomation.h" +#include "notation/inotationnoteoffsets.h" #include "notation/inotationelements.h" #include "notation/inotationnoteinput.h" #include "notation/inotationpainting.h" // IWYU pragma: keep @@ -111,6 +112,20 @@ void AbstractNotationPaintView::load() }); m_notationAutomationController = std::make_unique(m_automationLinesContainer, iocContext()); + + // Clip note offset overlays to the view bounds + m_noteOffsetOverlayContainer = new QQuickItem(this); + m_noteOffsetOverlayContainer->setClip(true); + m_noteOffsetOverlayContainer->setWidth(width()); + m_noteOffsetOverlayContainer->setHeight(height()); + connect(this, &QQuickItem::widthChanged, m_noteOffsetOverlayContainer, [this]() { + m_noteOffsetOverlayContainer->setWidth(width()); + }); + connect(this, &QQuickItem::heightChanged, m_noteOffsetOverlayContainer, [this]() { + m_noteOffsetOverlayContainer->setHeight(height()); + }); + + m_notationNoteOffsetController = std::make_unique(m_noteOffsetOverlayContainer, iocContext()); m_playbackCursor = std::make_unique(iocContext()); m_playbackCursor->setVisible(false); m_noteInputCursor = std::make_unique(iocContext(), notationConfiguration()->thinNoteInputCursor()); @@ -375,6 +390,12 @@ void AbstractNotationPaintView::onLoadNotation(INotationPtr) emit automationModeChanged(); }); + // FIXME: only un-/re-subscribe when master notation changes + m_notationNoteOffsetController->init(); + notationNoteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { + scheduleRedraw(); + }); + if (isMainView()) { connect(this, &QQuickPaintedItem::focusChanged, this, [this](bool focused) { if (notation()) { @@ -427,6 +448,7 @@ void AbstractNotationPaintView::onUnloadNotation(INotationPtr) notationPlayback()->loopBoundariesChanged().disconnect(this); m_notation->viewModeChanged().disconnect(this); notationAutomation()->automationModeEnabledChanged().disconnect(this); + notationNoteOffsets()->editModeEnabledChanged().disconnect(this); if (isMainView()) { disconnect(this, &QQuickPaintedItem::focusChanged, this, nullptr); @@ -477,6 +499,10 @@ void AbstractNotationPaintView::onMatrixChanged(const Transform& oldMatrix, cons m_notationAutomationController->setViewMatrix(newMatrix); } + if (m_notationNoteOffsetController) { + m_notationNoteOffsetController->setViewMatrix(newMatrix); + } + scheduleRedraw(); emit horizontalScrollChanged(); @@ -602,6 +628,11 @@ INotationAutomationPtr AbstractNotationPaintView::notationAutomation() const return m_notation ? m_notation->masterNotation()->automation() : nullptr; } +INotationNoteOffsetsPtr AbstractNotationPaintView::notationNoteOffsets() const +{ + return m_notation ? m_notation->masterNotation()->noteOffsets() : nullptr; +} + void AbstractNotationPaintView::onNoteInputStateChanged() { TRACEFUNC; @@ -743,8 +774,9 @@ void AbstractNotationPaintView::paint(QPainter* qp) painter->setWorldTransform(m_matrix * guiScalingCompensation); const bool isPrinting = publishMode() || m_inputController->readonly(); - const bool isAutomation = automationMode(); - notation()->painting()->paintView(painter, toLogical(rect), isPrinting, isAutomation); + const INotationNoteOffsetsPtr noteOffsets = notationNoteOffsets(); + const bool dimNotation = automationMode() || (noteOffsets && noteOffsets->isEditModeEnabled()); + notation()->painting()->paintView(painter, toLogical(rect), isPrinting, dimNotation); const INotationNoteInputPtr noteInput = notationNoteInput(); if (noteInput->isNoteInputMode() && !publishMode()) { diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h index 425c742f238f0..ee46ae40c3475 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h @@ -41,6 +41,7 @@ #include "notationscene/inotationsceneconfiguration.h" #include "notationviewinputcontroller.h" #include "notationautomationcontroller.h" +#include "notationnoteoffsetcontroller.h" #include "noteinputcursor.h" #include "notationruler.h" #include "playbackcursor.h" @@ -218,6 +219,7 @@ protected slots: INotationStylePtr notationStyle() const; INotationSelectionPtr notationSelection() const; INotationAutomationPtr notationAutomation() const; + INotationNoteOffsetsPtr notationNoteOffsets() const; void clear(); void initBackground(); @@ -288,6 +290,8 @@ protected slots: std::unique_ptr m_inputController; QQuickItem* m_automationLinesContainer = nullptr; std::unique_ptr m_notationAutomationController; + QQuickItem* m_noteOffsetOverlayContainer = nullptr; + std::unique_ptr m_notationNoteOffsetController; std::unique_ptr m_playbackCursor; std::unique_ptr m_noteInputCursor; std::unique_ptr m_ruler; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationautomationcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationautomationcontroller.cpp index 9a2808ec3cc49..607c87fd60d28 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationautomationcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationautomationcontroller.cpp @@ -29,6 +29,8 @@ #include "async/async.h" +#include "segmentcanvasinterpolation.h" + #include "uicomponents/qml/Muse/UiComponents/polylineplot.h" #include "engraving/automation/automationdata.h" @@ -158,38 +160,10 @@ static const Segment* lastSegmentOfSystem(const System* system) } // Maps an x position to a tick via linear interpolation between the nearest Duration/barline segments on either side of it -static std::optional tickFromCanvasX(const System* system, const muse::RectF& staffCanvasRect, qreal x) +static std::optional automationTickFromCanvasX(const System* system, const muse::RectF& staffCanvasRect, qreal x) { - IF_ASSERT_FAILED(system) { - return std::nullopt; - } - const double pointCanvasX = staffCanvasRect.x() + x * staffCanvasRect.width(); - const mu::engraving::SegmentType type = mu::engraving::SegmentType::Duration | mu::engraving::SegmentType::BarLineTypes; - - const Segment* prevSeg = nullptr; - const Segment* nextSeg = nullptr; - for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(type) : nullptr; - seg && seg->system() == system; seg = seg->next1(type)) { - if (seg->canvasX() <= pointCanvasX) { - prevSeg = seg; - } else { - nextSeg = seg; - break; - } - } - - if (!prevSeg) { - return nextSeg ? std::make_optional(nextSeg->tick().ticks()) : std::nullopt; - } - - // No next segment - use prevSeg's own end as a virtual next point - const double nextCanvasX = nextSeg ? nextSeg->canvasX() : prevSeg->canvasX() + prevSeg->width(); - const int nextTick = nextSeg ? nextSeg->tick().ticks() : prevSeg->tick().ticks() + prevSeg->ticks().ticks(); - const double canvasSpan = nextCanvasX - prevSeg->canvasX(); - const double ratio = canvasSpan > 0.0 ? (pointCanvasX - prevSeg->canvasX()) / canvasSpan : 0.0; - - return prevSeg->tick().ticks() + static_cast(ratio * (nextTick - prevSeg->tick().ticks())); + return tickFromCanvasX(system, pointCanvasX); } static AutomationCurveKey curveKeyFor(AutomationType type, const Staff* staff) @@ -394,7 +368,7 @@ muse::uicomponents::PolylinePlot* NotationAutomationController::createPolylineFo return; } - const std::optional tick = tickFromCanvasX(system, staffCanvasRect, x); + const std::optional tick = automationTickFromCanvasX(system, staffCanvasRect, x); if (!tick) { return; } @@ -907,7 +881,7 @@ bool NotationAutomationController::requestEditPoint(const PointData& oldPointDat // STEP 2 - Determine the new tick value based on the x parameter... const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); - const std::optional newTickOpt = tickFromCanvasX(system, staffCanvasRect, x); + const std::optional newTickOpt = automationTickFromCanvasX(system, staffCanvasRect, x); const int newTick = newTickOpt.value_or(oldPointData.tick); const bool tickChanged = newTick != oldPointData.tick; @@ -997,7 +971,7 @@ bool NotationAutomationController::requestAddPoint(const SysStaffKey& key, qreal } const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); - const std::optional newTick = tickFromCanvasX(system, staffCanvasRect, x); + const std::optional newTick = automationTickFromCanvasX(system, staffCanvasRect, x); if (!newTick) { return false; } diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp new file mode 100644 index 0000000000000..a142818a8ed81 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -0,0 +1,550 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "notationnoteoffsetcontroller.h" + +#include "noteoffsetoverlay.h" +#include "segmentcanvasinterpolation.h" + +#include +#include + +#include "async/async.h" +#include "global/containers.h" + +#include "engraving/dom/chord.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/mscore.h" +#include "engraving/dom/note.h" +#include "engraving/dom/property.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/system.h" + +#include "notation/imasternotation.h" +#include "notation/inotation.h" +#include "notation/inotationinteraction.h" +#include "notation/inotationnoteoffsets.h" +#include "notation/inotationselection.h" +#include "notation/inotationundostack.h" +#include "notation/inotationelements.h" // IWYU pragma: keep + +using namespace mu::notation; +using namespace mu::engraving; + +// Each rectangle is anchored on its own note's vertical position, not on a fixed lane above the +// staff - this way a rectangle always sits right above its notehead, and chord notes naturally +// stack in the same order as their pitches instead of needing an artificial row index. +constexpr static double RECT_TOP_MARGIN_SP = 0.45; // gap between the notehead center and the rectangle's top edge +constexpr static double RECT_BOTTOM_OVERLAP_SP = 0.4; // how far below the notehead center the rectangle's bottom edge extends + +constexpr static int MAX_OFFSET_TICKS = 1920; // matches the Properties panel spinbox range +constexpr static int MIN_EFFECTIVE_TICKS = 1; + +static std::optional noteOffsetTickFromCanvasX(const System* system, const muse::RectF& bandCanvasRect, qreal xN) +{ + const double pointCanvasX = bandCanvasRect.x() + xN * bandCanvasRect.width(); + return mu::notation::tickFromCanvasX(system, pointCanvasX); +} + +// Pixel shift corresponding to a tick offset away from baseTick, using the same segment +// interpolation as canvasXFromTick/noteOffsetTickFromCanvasX so it round-trips exactly with how +// the mouse position was interpreted. Falls back to a locally-derived ratio only if the note +// sits right at a system boundary where interpolation has nothing to anchor to. +static double pixelDeltaForTickOffset(const System* system, int baseTick, int tickOffset, double fallbackPxPerTick) +{ + if (tickOffset == 0) { + return 0.0; + } + + const std::optional basePx = mu::notation::canvasXFromTick(system, baseTick); + const std::optional offsetPx = mu::notation::canvasXFromTick(system, baseTick + tickOffset); + if (basePx && offsetPx) { + return *offsetPx - *basePx; + } + + return tickOffset * fallbackPxPerTick; +} + +NotationNoteOffsetController::NotationNoteOffsetController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx) + : muse::Contextable(iocCtx), m_overlaysParent(overlaysParent) +{ +} + +void NotationNoteOffsetController::init() +{ + IF_ASSERT_FAILED(noteOffsets() && currentNotation()) { + return; + } + + onCurrentNotationChanged(); + + noteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { + if (noteOffsets()->isEditModeEnabled()) { + rebuildAllOverlays(); + } else { + updateOverlaysGeometry(); + } + }, Asyncable::Mode::SetReplace); + + globalContext()->currentNotationChanged().onNotify(this, [this]() { + onCurrentNotationChanged(); + }, Asyncable::Mode::SetReplace); +} + +void NotationNoteOffsetController::onCurrentNotationChanged() +{ + rebuildAllOverlays(); + + if (score()) { + // TODO: More efficient if we only rebuild the affected staves/systems... + score()->changesChannel().onReceive(this, [this](const mu::engraving::ScoreChanges&) { + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + } + + const INotationPtr notation = currentNotation(); + if (notation) { + // Switching between Page/Continuous/Continuous vertical view completely re-flows the + // systems - the overlays' cached positions need to be rebuilt from scratch, not just + // repositioned via the view matrix. + notation->viewModeChanged().onNotify(this, [this]() { + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + } +} + +void NotationNoteOffsetController::scheduleRebuild() +{ + if (m_rebuildScheduled) { + return; + } + m_rebuildScheduled = true; + + // Defer to the next event loop iteration - the score may still be mid-layout at the + // point the changesChannel notification fires, so rebuilding synchronously here (which + // reads System/Segment/Chord layout data) is not safe. + muse::async::Async::call(this, [this]() { + m_rebuildScheduled = false; + if (noteOffsets() && noteOffsets()->isEditModeEnabled()) { + rebuildAllOverlays(); + } + }); +} + +void NotationNoteOffsetController::rebuildAllOverlays() +{ + for (const auto& [key, overlay] : m_overlaysByStaff) { + delete overlay; + } + m_overlaysByStaff.clear(); + m_notesByStaff.clear(); + m_bandRectByStaff.clear(); + m_noteLocations.clear(); + + if (!score()) { + // Happens on close... + return; + } + + for (const System* system : score()->systems()) { + staff_idx_t staffIdx = system->firstVisibleStaff(); + while (staffIdx != muse::nidx) { + createOverlayForStaff(system, staffIdx); + staffIdx = system->nextVisibleStaff(staffIdx); + } + } + + updateOverlaysGeometry(); +} + +void NotationNoteOffsetController::createOverlayForStaff(const System* system, staff_idx_t staffIdx) +{ + IF_ASSERT_FAILED(system && m_overlaysParent && score()) { + return; + } + + const Staff* staff = score()->staff(staffIdx); + const SysStaff* sysStaff = system->staff(staffIdx); + if (!staff || !sysStaff || !staff->isPrimaryStaff()) { + return; + } + + std::vector entries; + + const track_idx_t strack = staffIdx * VOICES; + const track_idx_t etrack = strack + VOICES; + + for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(SegmentType::ChordRest) : nullptr; + seg && seg->system() == system; seg = seg->next1(SegmentType::ChordRest)) { + for (track_idx_t track = strack; track < etrack; ++track) { + EngravingItem* item = seg->element(track); + if (!item || !item->isChord()) { + continue; + } + const Chord* chord = toChord(item); + + // The nominal (zero-offset) span is anchored on the note/segment's own real layout + // position, not on a tick->x interpolation - this guarantees the rectangle sits + // exactly on the notehead when there's no offset yet. + const Segment* nextSeg = seg->next1(SegmentType::ChordRest); + const double nominalRightX = (nextSeg && nextSeg->system() == system) + ? nextSeg->canvasX() : (seg->canvasX() + seg->width()); + + for (Note* note : chord->notes()) { + NoteEntry entry; + entry.note = note; + entry.nominalLeftX = note->canvasX(); + entry.nominalRightX = nominalRightX; + entries.push_back(entry); + } + } + } + + if (entries.empty()) { + return; + } + + const double spatium = entries.front().note->spatium(); + const double topMargin = RECT_TOP_MARGIN_SP * spatium; + const double bottomOverlap = RECT_BOTTOM_OVERLAP_SP * spatium; + const double rectHeight = topMargin + bottomOverlap; + const double vPadding = 0.3 * spatium; + + // Anchored on each note's own vertical position, so the rectangle sits right above its + // notehead (and chord notes stack in pitch order without needing an artificial row index) + std::vector centerY; + centerY.reserve(entries.size()); + double minY = 0.0; + double maxY = 0.0; + for (size_t i = 0; i < entries.size(); ++i) { + const double noteY = entries[i].note->canvasPos().y(); + const double y = noteY - topMargin + rectHeight / 2.0; + centerY.push_back(y); + if (i == 0) { + minY = noteY - topMargin; + maxY = noteY + bottomOverlap; + } else { + minY = std::min(minY, noteY - topMargin); + maxY = std::max(maxY, noteY + bottomOverlap); + } + } + minY -= vPadding; + maxY += vPadding; + + // The overlay's vertical bounds are derived from the actual note positions rather than a + // fixed margin around the staff - this way it always contains every rectangle regardless of + // how far above/below the staff a note sits (ledger lines, etc.) + const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); + const muse::RectF overlayCanvasRect(staffCanvasRect.x(), minY, staffCanvasRect.width(), maxY - minY); + + QVector rects; + rects.reserve(static_cast(entries.size())); + + for (size_t i = 0; i < entries.size(); ++i) { + const NoteEntry& entry = entries[i]; + const Note* note = entry.note; + const Chord* chord = note->chord(); + IF_ASSERT_FAILED(chord) { + continue; + } + + // Fallback local px-per-tick rate, only used if a note's offset pushes it right at a + // system boundary where segment interpolation has nothing to anchor to. + const int chordTicks = chord->ticks().ticks(); + const double fallbackPxPerTick = chordTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / chordTicks : 0.0; + + const int chordStartTick = chord->tick().ticks(); + const int chordEndTick = chordStartTick + chordTicks; + const double leftPx = entry.nominalLeftX + + pixelDeltaForTickOffset(system, chordStartTick, note->playbackStartOffset(), fallbackPxPerTick); + const double rightPx = entry.nominalRightX + + pixelDeltaForTickOffset(system, chordEndTick, note->playbackDurationOffset(), fallbackPxPerTick); + + NoteOffsetOverlay::RectData rect; + rect.leftN = (leftPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); + rect.rightN = (rightPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); + rect.centerYN = (centerY[i] - overlayCanvasRect.y()) / overlayCanvasRect.height(); + rect.heightYN = rectHeight / overlayCanvasRect.height(); + rects.push_back(rect); + } + + if (rects.isEmpty()) { + return; + } + + const SysStaffKey key { system, staffIdx }; + for (int i = 0; i < static_cast(entries.size()); ++i) { + m_noteLocations[entries[i].note] = NoteLocation { key, i }; + } + m_notesByStaff[key] = std::move(entries); + m_bandRectByStaff[key] = overlayCanvasRect; + + NoteOffsetOverlay* overlay = new NoteOffsetOverlay(m_overlaysParent); + overlay->setRects(rects); + applyOverlayColors(overlay); + overlay->setVisible(false); + + QObject::connect(overlay, &NoteOffsetOverlay::edgeDragged, [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { + onEdgeDragged(key, rectIndex, isLeftEdge, newXN, completed); + }); + + m_overlaysByStaff[key] = overlay; +} + +void NotationNoteOffsetController::applyOverlayColors(NoteOffsetOverlay* overlay) const +{ + IF_ASSERT_FAILED(overlay) { + return; + } + + overlay->setFillColor(QColor(100, 150, 220, 60)); + overlay->setBorderColor(QColor(80, 130, 200, 200)); + overlay->setHandleColor(QColor(60, 110, 190, 230)); +} + +void NotationNoteOffsetController::updateOverlaysGeometry() +{ + const bool visible = noteOffsets() && noteOffsets()->isEditModeEnabled(); + + for (const auto& [key, overlay] : m_overlaysByStaff) { + overlay->setVisible(visible); + if (!visible) { + continue; + } + + const auto bandRectIt = m_bandRectByStaff.find(key); + IF_ASSERT_FAILED(bandRectIt != m_bandRectByStaff.end()) { + continue; + } + + const muse::RectF screenRect = m_viewMatrix.map(bandRectIt->second); + overlay->setWidth(screenRect.width()); + overlay->setHeight(screenRect.height()); + overlay->setX(screenRect.x()); + overlay->setY(screenRect.y()); + } +} + +void NotationNoteOffsetController::setViewMatrix(const muse::draw::Transform& viewMatrix) +{ + if (viewMatrix == m_viewMatrix) { + return; + } + m_viewMatrix = viewMatrix; + + if (noteOffsets() && noteOffsets()->isEditModeEnabled()) { + updateOverlaysGeometry(); + } +} + +std::vector NotationNoteOffsetController::selectedNotes() const +{ + const INotationPtr notation = currentNotation(); + if (!notation || !notation->interaction() || !notation->interaction()->selection()) { + return {}; + } + + return notation->interaction()->selection()->notes(); +} + +void NotationNoteOffsetController::previewNoteRect(const NoteLocation& location, int newStartOffset, int newDurationOffset) +{ + const auto notesIt = m_notesByStaff.find(location.key); + const auto bandRectIt = m_bandRectByStaff.find(location.key); + const auto overlayIt = m_overlaysByStaff.find(location.key); + IF_ASSERT_FAILED(notesIt != m_notesByStaff.end() && bandRectIt != m_bandRectByStaff.end() + && overlayIt != m_overlaysByStaff.end() && location.rectIndex >= 0 + && static_cast(location.rectIndex) < notesIt->second.size()) { + return; + } + + const NoteEntry& entry = notesIt->second.at(location.rectIndex); + const Chord* chord = entry.note ? entry.note->chord() : nullptr; + IF_ASSERT_FAILED(chord) { + return; + } + + const int chordTicks = chord->ticks().ticks(); + const double fallbackPxPerTick = chordTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / chordTicks : 0.0; + const int chordStartTick = chord->tick().ticks(); + const int chordEndTick = chordStartTick + chordTicks; + const double leftPx = entry.nominalLeftX + + pixelDeltaForTickOffset(location.key.system, chordStartTick, newStartOffset, fallbackPxPerTick); + const double rightPx = entry.nominalRightX + + pixelDeltaForTickOffset(location.key.system, chordEndTick, newDurationOffset, fallbackPxPerTick); + + QVector rects = overlayIt->second->rects(); + if (location.rectIndex >= rects.size()) { + return; + } + + NoteOffsetOverlay::RectData& rect = rects[location.rectIndex]; + rect.leftN = (leftPx - bandRectIt->second.x()) / bandRectIt->second.width(); + rect.rightN = (rightPx - bandRectIt->second.x()) / bandRectIt->second.width(); + overlayIt->second->setRects(rects); +} + +void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rectIndex, bool isLeftEdge, qreal newXN, bool completed) +{ + const auto notesIt = m_notesByStaff.find(key); + const auto bandRectIt = m_bandRectByStaff.find(key); + IF_ASSERT_FAILED(key.isValid() && notesIt != m_notesByStaff.end() && bandRectIt != m_bandRectByStaff.end() + && rectIndex >= 0 && static_cast(rectIndex) < notesIt->second.size()) { + return; + } + + const NoteEntry& draggedEntry = notesIt->second.at(rectIndex); + Note* draggedNote = draggedEntry.note; + Chord* draggedChord = draggedNote ? draggedNote->chord() : nullptr; + IF_ASSERT_FAILED(draggedNote && draggedChord) { + return; + } + + const std::optional newTick = noteOffsetTickFromCanvasX(key.system, bandRectIt->second, newXN); + if (!newTick) { + return; + } + + const int draggedChordStartTick = draggedChord->tick().ticks(); + const int draggedChordEndTick = draggedChordStartTick + draggedChord->ticks().ticks(); + + int newStartOffset = draggedNote->playbackStartOffset(); + int newDurationOffset = draggedNote->playbackDurationOffset(); + + if (isLeftEdge) { + newStartOffset = std::clamp(*newTick - draggedChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + const int effEnd = draggedChordEndTick + newDurationOffset; + if (effEnd - (draggedChordStartTick + newStartOffset) < MIN_EFFECTIVE_TICKS) { + newStartOffset = std::clamp(effEnd - MIN_EFFECTIVE_TICKS - draggedChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + } + } else { + newDurationOffset = std::clamp(*newTick - draggedChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + const int effStart = draggedChordStartTick + newStartOffset; + if ((draggedChordEndTick + newDurationOffset) - effStart < MIN_EFFECTIVE_TICKS) { + newDurationOffset = std::clamp(effStart + MIN_EFFECTIVE_TICKS - draggedChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + } + } + + // If the dragged note is part of a multi-note selection, apply the same tick delta to every + // other selected note's corresponding offset, each clamped independently. + const int delta = isLeftEdge ? (newStartOffset - draggedNote->playbackStartOffset()) + : (newDurationOffset - draggedNote->playbackDurationOffset()); + + std::vector affectedNotes { draggedNote }; + if (delta != 0 || !completed) { + const std::vector selected = selectedNotes(); + if (selected.size() > 1 && muse::contains(selected, draggedNote)) { + affectedNotes = selected; + } + } + + struct PendingChange { + Note* note = nullptr; + int startOffset = 0; + int durationOffset = 0; + }; + std::vector changes; + changes.reserve(affectedNotes.size()); + + for (Note* note : affectedNotes) { + if (note == draggedNote) { + changes.push_back({ note, newStartOffset, newDurationOffset }); + continue; + } + + const Chord* chord = note->chord(); + if (!chord) { + continue; + } + + int otherStartOffset = note->playbackStartOffset(); + int otherDurationOffset = note->playbackDurationOffset(); + + if (isLeftEdge) { + otherStartOffset = std::clamp(otherStartOffset + delta, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + const int chordEndTick = chord->tick().ticks() + chord->ticks().ticks(); + const int effEnd = chordEndTick + otherDurationOffset; + if (effEnd - (chord->tick().ticks() + otherStartOffset) < MIN_EFFECTIVE_TICKS) { + otherStartOffset = std::clamp(effEnd - MIN_EFFECTIVE_TICKS - chord->tick().ticks(), + -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + } + } else { + otherDurationOffset = std::clamp(otherDurationOffset + delta, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + const int chordStartTick = chord->tick().ticks(); + const int chordEndTick = chordStartTick + chord->ticks().ticks(); + const int effStart = chordStartTick + otherStartOffset; + if ((chordEndTick + otherDurationOffset) - effStart < MIN_EFFECTIVE_TICKS) { + otherDurationOffset = std::clamp(effStart + MIN_EFFECTIVE_TICKS - chordEndTick, + -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + } + } + + changes.push_back({ note, otherStartOffset, otherDurationOffset }); + } + + if (!completed) { + // Live drag preview - update every affected overlay's displayed rect without touching + // the score, anchored on the same nominal note positions used when overlays were built + for (const PendingChange& change : changes) { + const auto locIt = m_noteLocations.find(change.note); + if (locIt != m_noteLocations.end()) { + previewNoteRect(locIt->second, change.startOffset, change.durationOffset); + } + } + return; + } + + const INotationPtr notation = currentNotation(); + const INotationUndoStackPtr undoStack = notation ? notation->undoStack() : nullptr; + IF_ASSERT_FAILED(undoStack) { + return; + } + + undoStack->prepareChanges(muse::TranslatableString("undoableAction", "Change note playback offset")); + for (const PendingChange& change : changes) { + if (isLeftEdge) { + change.note->undoChangeProperty(mu::engraving::Pid::PLAYBACK_START_OFFSET, change.startOffset, + mu::engraving::PropertyFlags::NOSTYLE); + } else { + change.note->undoChangeProperty(mu::engraving::Pid::PLAYBACK_DURATION_OFFSET, change.durationOffset, + mu::engraving::PropertyFlags::NOSTYLE); + } + } + undoStack->commitChanges(); +} + +INotationNoteOffsetsPtr NotationNoteOffsetController::noteOffsets() const +{ + const IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + return masterNotation ? masterNotation->noteOffsets() : nullptr; +} + +INotationPtr NotationNoteOffsetController::currentNotation() const +{ + return globalContext()->currentNotation(); +} + +mu::engraving::Score* NotationNoteOffsetController::score() const +{ + return currentNotation() ? currentNotation()->elements()->msScore() : nullptr; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h new file mode 100644 index 0000000000000..6687dfbe8de03 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include "context/iglobalcontext.h" +#include "async/asyncable.h" +#include "notation/notationtypes.h" + +namespace mu::engraving { +struct ScoreChanges; +} + +namespace mu::notation { +class NoteOffsetOverlay; + +class NotationNoteOffsetController : public muse::Contextable, public muse::async::Asyncable +{ + muse::ContextInject globalContext = { this }; + +public: + NotationNoteOffsetController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx); + + void init(); + void setViewMatrix(const muse::draw::Transform& viewMatrix); + +private: + // Necessary since SysStaff doesn't hold a reference to its system, which is needed + // for calculating a SysStaff's relative position... + struct SysStaffKey { + const System* system = nullptr; + staff_idx_t staffIdx = muse::nidx; + + bool isValid() const + { + return system && !system->measures().empty() && staffIdx != muse::nidx; + } + + bool operator<(const SysStaffKey& k) const + { + if (system == k.system) { + return staffIdx < k.staffIdx; + } + return system->first()->index() < k.system->first()->index(); + } + }; + + // Nominal (zero-offset) canvas X positions, taken directly from the note's own layout - + // anchors the rectangle exactly on the notehead rather than relying on tick interpolation. + struct NoteEntry { + mu::engraving::Note* note = nullptr; + double nominalLeftX = 0.0; + double nominalRightX = 0.0; + }; + + // Where a given note's rectangle lives, so a drag on a multi-note selection can update/commit + // every selected note's overlay entry, not just the one under the mouse. + struct NoteLocation { + SysStaffKey key; + int rectIndex = -1; + }; + + using OverlaysMap = std::map; + using NotesByStaffMap = std::map >; + using BandRectByStaffMap = std::map; + using NoteLocationMap = std::map; + + void rebuildAllOverlays(); + void createOverlayForStaff(const System* system, staff_idx_t staffIdx); + void updateOverlaysGeometry(); + void applyOverlayColors(NoteOffsetOverlay* overlay) const; + + void onCurrentNotationChanged(); + void scheduleRebuild(); + void onEdgeDragged(const SysStaffKey& key, int rectIndex, bool isLeftEdge, qreal newXN, bool completed); + void previewNoteRect(const NoteLocation& location, int newStartOffset, int newDurationOffset); + + std::vector selectedNotes() const; + + INotationNoteOffsetsPtr noteOffsets() const; + INotationPtr currentNotation() const; + mu::engraving::Score* score() const; + + QQuickItem* m_overlaysParent = nullptr; + OverlaysMap m_overlaysByStaff; + NotesByStaffMap m_notesByStaff; + BandRectByStaffMap m_bandRectByStaff; + NoteLocationMap m_noteLocations; + muse::draw::Transform m_viewMatrix; + bool m_rebuildScheduled = false; +}; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp index 67b99af157f47..9789b0d08f4be 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp @@ -37,7 +37,8 @@ void NotationToolBarModel::load() muse::actions::ActionCodeList itemsCodes = { "parts", "toggle-mixer", - "toggle-automation" + "toggle-automation", + "toggle-note-offset-editor" }; ToolBarItemList items; diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp new file mode 100644 index 0000000000000..3df795f32e99b --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -0,0 +1,191 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "noteoffsetoverlay.h" + +#include +#include + +#include +#include +#include +#include + +using namespace mu::notation; + +constexpr static qreal EDGE_HANDLE_HIT_MARGIN_PX = 6.0; +constexpr static qreal EDGE_HANDLE_WIDTH_PX = 4.0; + +NoteOffsetOverlay::NoteOffsetOverlay(QQuickItem* parent) + : QQuickPaintedItem(parent) +{ + setAcceptHoverEvents(true); + setAcceptedMouseButtons(Qt::LeftButton); +} + +void NoteOffsetOverlay::setRects(const QVector& rects) +{ + m_rects = rects; + update(); +} + +const QVector& NoteOffsetOverlay::rects() const +{ + return m_rects; +} + +void NoteOffsetOverlay::setFillColor(const QColor& color) +{ + m_fillColor = color; + update(); +} + +void NoteOffsetOverlay::setBorderColor(const QColor& color) +{ + m_borderColor = color; + update(); +} + +void NoteOffsetOverlay::setHandleColor(const QColor& color) +{ + m_handleColor = color; + update(); +} + +void NoteOffsetOverlay::paint(QPainter* painter) +{ + if (m_rects.isEmpty()) { + return; + } + + painter->setRenderHint(QPainter::Antialiasing); + + for (const RectData& rect : m_rects) { + const qreal leftPx = rect.leftN * width(); + const qreal rightPx = rect.rightN * width(); + const qreal centerYPx = rect.centerYN * height(); + const qreal halfHeightPx = (rect.heightYN * height()) / 2.0; + + const QRectF bodyRect(leftPx, centerYPx - halfHeightPx, rightPx - leftPx, halfHeightPx * 2.0); + + // Fully-rounded "pill" ends - radius tied to the rectangle's own height so it stays + // consistent at any zoom level or rectangle size, rather than a fixed pixel amount. + const qreal cornerRadius = std::min(halfHeightPx, bodyRect.width() / 2.0); + + painter->setPen(QPen(m_borderColor, 1.0)); + painter->setBrush(m_fillColor); + painter->drawRoundedRect(bodyRect, cornerRadius, cornerRadius); + + painter->setPen(Qt::NoPen); + painter->setBrush(m_handleColor); + painter->drawRoundedRect(QRectF(leftPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), + EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + painter->drawRoundedRect(QRectF(rightPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), + EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + } +} + +NoteOffsetOverlay::HitResult NoteOffsetOverlay::hitTestPx(const QPointF& posPx) const +{ + for (int i = 0; i < m_rects.size(); ++i) { + const RectData& rect = m_rects.at(i); + const qreal centerYPx = rect.centerYN * height(); + const qreal halfHeightPx = (rect.heightYN * height()) / 2.0 + EDGE_HANDLE_HIT_MARGIN_PX; + if (posPx.y() < centerYPx - halfHeightPx || posPx.y() > centerYPx + halfHeightPx) { + continue; + } + + const qreal leftPx = rect.leftN * width(); + const qreal rightPx = rect.rightN * width(); + + const qreal distToLeft = std::abs(posPx.x() - leftPx); + const qreal distToRight = std::abs(posPx.x() - rightPx); + + if (distToLeft > EDGE_HANDLE_HIT_MARGIN_PX && distToRight > EDGE_HANDLE_HIT_MARGIN_PX) { + continue; + } + + HitResult hit; + hit.rectIndex = i; + hit.isLeftEdge = distToLeft <= distToRight; + return hit; + } + + return HitResult(); +} + +void NoteOffsetOverlay::updateCursor(bool hoveringEdge) +{ + if (hoveringEdge == m_hoveringEdge) { + return; + } + m_hoveringEdge = hoveringEdge; + setCursor(hoveringEdge ? Qt::SizeHorCursor : Qt::ArrowCursor); +} + +void NoteOffsetOverlay::hoverMoveEvent(QHoverEvent* e) +{ + const HitResult hit = hitTestPx(e->position()); + updateCursor(hit.isValid()); +} + +void NoteOffsetOverlay::hoverLeaveEvent(QHoverEvent*) +{ + updateCursor(false); +} + +void NoteOffsetOverlay::mousePressEvent(QMouseEvent* e) +{ + const HitResult hit = hitTestPx(e->position()); + if (!hit.isValid()) { + e->ignore(); + return; + } + + m_pressed = true; + m_activeRectIndex = hit.rectIndex; + m_activeIsLeftEdge = hit.isLeftEdge; + e->accept(); +} + +void NoteOffsetOverlay::mouseMoveEvent(QMouseEvent* e) +{ + if (!m_pressed) { + return; + } + + const qreal xN = std::clamp(e->position().x() / std::max(1.0, width()), 0.0, 1.0); + emit edgeDragged(m_activeRectIndex, m_activeIsLeftEdge, xN, false); +} + +void NoteOffsetOverlay::mouseReleaseEvent(QMouseEvent* e) +{ + if (!m_pressed) { + return; + } + + const qreal xN = std::clamp(e->position().x() / std::max(1.0, width()), 0.0, 1.0); + emit edgeDragged(m_activeRectIndex, m_activeIsLeftEdge, xN, true); + + m_pressed = false; + m_activeRectIndex = -1; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h new file mode 100644 index 0000000000000..01dc65b53e160 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +// NOTE: all rectangle coordinates are normalized [0, 1], relative to this item's own width/height, +// mirroring the approach used by muse::uicomponents::PolylinePlot for the automation overlay - this +// keeps stored positions valid regardless of the live view/zoom transform applied to the item itself. + +namespace mu::notation { +class NoteOffsetOverlay : public QQuickPaintedItem +{ + Q_OBJECT + +public: + struct RectData { + qreal leftN = 0.0; + qreal rightN = 0.0; + qreal centerYN = 0.5; + qreal heightYN = 1.0; + }; + + explicit NoteOffsetOverlay(QQuickItem* parent); + + void setRects(const QVector& rects); + const QVector& rects() const; + + void setFillColor(const QColor& color); + void setBorderColor(const QColor& color); + void setHandleColor(const QColor& color); + + void paint(QPainter* painter) override; + +signals: + void edgeDragged(int rectIndex, bool isLeftEdge, qreal newXN, bool completed); + +protected: + void hoverMoveEvent(QHoverEvent* e) override; + void hoverLeaveEvent(QHoverEvent* e) override; + void mousePressEvent(QMouseEvent* e) override; + void mouseMoveEvent(QMouseEvent* e) override; + void mouseReleaseEvent(QMouseEvent* e) override; + +private: + struct HitResult { + int rectIndex = -1; + bool isLeftEdge = false; + + bool isValid() const { return rectIndex >= 0; } + }; + + HitResult hitTestPx(const QPointF& posPx) const; + void updateCursor(bool hoveringEdge); + + QVector m_rects; + + QColor m_fillColor; + QColor m_borderColor; + QColor m_handleColor; + + bool m_pressed = false; + int m_activeRectIndex = -1; + bool m_activeIsLeftEdge = false; + bool m_hoveringEdge = false; +}; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.cpp b/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.cpp new file mode 100644 index 0000000000000..0bff67dadf0c9 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.cpp @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "segmentcanvasinterpolation.h" + +#include "engraving/dom/segment.h" +#include "engraving/dom/system.h" + +using namespace mu::notation; +using namespace mu::engraving; + +std::optional mu::notation::tickFromCanvasX(const System* system, double canvasX) +{ + IF_ASSERT_FAILED(system) { + return std::nullopt; + } + + const SegmentType type = SegmentType::Duration | SegmentType::BarLineTypes; + + const Segment* prevSeg = nullptr; + const Segment* nextSeg = nullptr; + for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(type) : nullptr; + seg && seg->system() == system; seg = seg->next1(type)) { + if (seg->canvasX() <= canvasX) { + prevSeg = seg; + } else { + nextSeg = seg; + break; + } + } + + if (!prevSeg) { + return nextSeg ? std::make_optional(nextSeg->tick().ticks()) : std::nullopt; + } + + const double nextCanvasX = nextSeg ? nextSeg->canvasX() : prevSeg->canvasX() + prevSeg->width(); + const int nextTick = nextSeg ? nextSeg->tick().ticks() : prevSeg->tick().ticks() + prevSeg->ticks().ticks(); + const double canvasSpan = nextCanvasX - prevSeg->canvasX(); + const double ratio = canvasSpan > 0.0 ? (canvasX - prevSeg->canvasX()) / canvasSpan : 0.0; + + return prevSeg->tick().ticks() + static_cast(ratio * (nextTick - prevSeg->tick().ticks())); +} + +std::optional mu::notation::canvasXFromTick(const System* system, int tick) +{ + IF_ASSERT_FAILED(system) { + return std::nullopt; + } + + const SegmentType type = SegmentType::Duration | SegmentType::BarLineTypes; + + const Segment* prevSeg = nullptr; + const Segment* nextSeg = nullptr; + for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(type) : nullptr; + seg && seg->system() == system; seg = seg->next1(type)) { + if (seg->tick().ticks() <= tick) { + prevSeg = seg; + } else { + nextSeg = seg; + break; + } + } + + if (!prevSeg) { + return nextSeg ? std::make_optional(nextSeg->canvasX()) : std::nullopt; + } + + const int nextTick = nextSeg ? nextSeg->tick().ticks() : prevSeg->tick().ticks() + prevSeg->ticks().ticks(); + const double nextCanvasX = nextSeg ? nextSeg->canvasX() : prevSeg->canvasX() + prevSeg->width(); + const int tickSpan = nextTick - prevSeg->tick().ticks(); + const double ratio = tickSpan > 0 ? static_cast(tick - prevSeg->tick().ticks()) / tickSpan : 0.0; + + return prevSeg->canvasX() + ratio * (nextCanvasX - prevSeg->canvasX()); +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.h b/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.h new file mode 100644 index 0000000000000..8e26fae378922 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.h @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +#include "notation/notationtypes.h" + +// Shared canvasX<->tick interpolation used by overlay controllers (automation, note offsets) to +// translate between a mouse/canvas X position and a musical tick, and back. Both directions +// interpolate linearly between the nearest Duration/barline segments on either side of the point, +// so a caller that uses one direction to interpret input and the other to render output gets +// values that round-trip exactly. + +namespace mu::notation { +std::optional tickFromCanvasX(const System* system, double canvasX); +std::optional canvasXFromTick(const System* system, int tick); +} From b84570b517d6e77b45a2698e8b43f627028b625b Mon Sep 17 00:00:00 2001 From: sfer Date: Thu, 13 Aug 2026 17:58:24 +0200 Subject: [PATCH 02/10] Rebuild note-offset overlay geometry on style changes Live-dragging a style value (e.g. "Staff space (sp)" in Page Settings) relayouts the score without going through changesChannel(), so the overlay's cached note positions went stale and stopped tracking the rescaled notation in real time. --- .../NotationScene/notationnoteoffsetcontroller.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index a142818a8ed81..ed417a217b625 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -45,6 +45,7 @@ #include "notation/inotationinteraction.h" #include "notation/inotationnoteoffsets.h" #include "notation/inotationselection.h" +#include "notation/inotationstyle.h" #include "notation/inotationundostack.h" #include "notation/inotationelements.h" // IWYU pragma: keep @@ -130,6 +131,15 @@ void NotationNoteOffsetController::onCurrentNotationChanged() notation->viewModeChanged().onNotify(this, [this]() { scheduleRebuild(); }, Asyncable::Mode::SetReplace); + + if (notation->style()) { + // Style edits (e.g. live-dragging "Staff space (sp)" in Page Settings) relayout the + // score without necessarily going through changesChannel() - without this, the + // overlay's cached note positions go stale and stop tracking the rescaled notation. + notation->style()->styleChanged().onNotify(this, [this]() { + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + } } } From b74429ca39a7303c33e37945b8e827c57bf4360a Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:07:20 +0200 Subject: [PATCH 03/10] Fix crash and lifecycle bugs in the note-offset drag-handle overlay - Compare SysStaffKey's System pointer by address only, never dereferencing it - a stale key still held from a previous rebuild had a dangling System* once Page/Continuous view mode switching destroyed and recreated every System, crashing on MeasureBase::index(). - Skip tied-continuation notes when building overlay handles: their own playback offset is ignored by NoteRenderer::shouldRender() in most cases, so a handle there could never actually do anything. - Disconnect from a document's viewModeChanged/styleChanged the moment a different document becomes current, instead of leaking one subscription per document ever opened in the session. - Don't delete an overlay that's mid-drag (holding the mouse grab) when a rebuild is triggered; defer until the drag finishes. - Handle mouseUngrabEvent so a stolen mouse grab mid-drag doesn't leave the overlay stuck thinking a drag is still in progress. - Consolidate the three parallel per-staff maps (overlay/notes/band rect) into one map to a per-staff struct, and reuse an existing overlay item in place instead of destroying and recreating every overlay on every rebuild. - Mutate a single rect in place during a drag instead of copying the whole staff's rect vector out and back on every mouse-move. --- .../notationnoteoffsetcontroller.cpp | 157 ++++++++++++------ .../notationnoteoffsetcontroller.h | 29 +++- .../NotationScene/noteoffsetoverlay.cpp | 19 +++ .../NotationScene/noteoffsetoverlay.h | 8 + 4 files changed, 151 insertions(+), 62 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index ed417a217b625..1801b6a5382ef 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -116,19 +116,31 @@ void NotationNoteOffsetController::onCurrentNotationChanged() { rebuildAllOverlays(); - if (score()) { + if (mu::engraving::Score* thisScore = score()) { // TODO: More efficient if we only rebuild the affected staves/systems... - score()->changesChannel().onReceive(this, [this](const mu::engraving::ScoreChanges&) { + // SetReplace only dedupes a subscription against the exact same Score/Notation instance - + // switching documents subscribes to a brand new instance each time, so guard the callback + // itself against firing for a document that's no longer current, rather than leaking one + // live subscription per every document ever opened this session. + score()->changesChannel().onReceive(this, [this, thisScore](const mu::engraving::ScoreChanges&) { + if (thisScore != score()) { + return; + } scheduleRebuild(); }, Asyncable::Mode::SetReplace); } const INotationPtr notation = currentNotation(); if (notation) { + mu::notation::INotation* thisNotation = notation.get(); + // Switching between Page/Continuous/Continuous vertical view completely re-flows the // systems - the overlays' cached positions need to be rebuilt from scratch, not just // repositioned via the view matrix. - notation->viewModeChanged().onNotify(this, [this]() { + notation->viewModeChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } scheduleRebuild(); }, Asyncable::Mode::SetReplace); @@ -136,7 +148,10 @@ void NotationNoteOffsetController::onCurrentNotationChanged() // Style edits (e.g. live-dragging "Staff space (sp)" in Page Settings) relayout the // score without necessarily going through changesChannel() - without this, the // overlay's cached note positions go stale and stop tracking the rescaled notation. - notation->style()->styleChanged().onNotify(this, [this]() { + notation->style()->styleChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } scheduleRebuild(); }, Asyncable::Mode::SetReplace); } @@ -163,31 +178,52 @@ void NotationNoteOffsetController::scheduleRebuild() void NotationNoteOffsetController::rebuildAllOverlays() { - for (const auto& [key, overlay] : m_overlaysByStaff) { - delete overlay; + for (const auto& [key, data] : m_overlaysByStaff) { + if (data.overlay->isDragging()) { + // Deleting an overlay that currently holds the mouse grab (mid-drag) would drop the + // in-progress edit and risk delivering the next mouse event to a freed item - wait + // for the drag to finish instead of rebuilding out from under it. + scheduleRebuild(); + return; + } } - m_overlaysByStaff.clear(); - m_notesByStaff.clear(); - m_bandRectByStaff.clear(); + m_noteLocations.clear(); if (!score()) { // Happens on close... + for (const auto& [key, data] : m_overlaysByStaff) { + delete data.overlay; + } + m_overlaysByStaff.clear(); return; } + // createOverlayForStaff reuses an existing overlay item in place (just updating its rects) + // when a staff already had one, instead of destroying and recreating every overlay QQuickItem + // on every edit - it consumes matching entries out of m_overlaysByStaff as it goes, so + // whatever is left there afterwards belongs to a staff that's no longer visible/primary/has + // no offsettable notes anymore, and can be deleted. + OverlaysMap newOverlays; + for (const System* system : score()->systems()) { staff_idx_t staffIdx = system->firstVisibleStaff(); while (staffIdx != muse::nidx) { - createOverlayForStaff(system, staffIdx); + createOverlayForStaff(system, staffIdx, newOverlays); staffIdx = system->nextVisibleStaff(staffIdx); } } + for (const auto& [key, data] : m_overlaysByStaff) { + delete data.overlay; + } + + m_overlaysByStaff = std::move(newOverlays); + updateOverlaysGeometry(); } -void NotationNoteOffsetController::createOverlayForStaff(const System* system, staff_idx_t staffIdx) +void NotationNoteOffsetController::createOverlayForStaff(const System* system, staff_idx_t staffIdx, OverlaysMap& newOverlays) { IF_ASSERT_FAILED(system && m_overlaysParent && score()) { return; @@ -221,6 +257,13 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s ? nextSeg->canvasX() : (seg->canvasX() + seg->width()); for (Note* note : chord->notes()) { + if (note->tieBack()) { + // Playback (NoteRenderer::shouldRender) skips tied-continuation notes + // entirely in most cases, so their own offset would silently do nothing - + // don't offer a handle that can't actually affect anything. + continue; + } + NoteEntry entry; entry.note = note; entry.nominalLeftX = note->canvasX(); @@ -306,19 +349,32 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s for (int i = 0; i < static_cast(entries.size()); ++i) { m_noteLocations[entries[i].note] = NoteLocation { key, i }; } - m_notesByStaff[key] = std::move(entries); - m_bandRectByStaff[key] = overlayCanvasRect; - - NoteOffsetOverlay* overlay = new NoteOffsetOverlay(m_overlaysParent); - overlay->setRects(rects); - applyOverlayColors(overlay); - overlay->setVisible(false); - - QObject::connect(overlay, &NoteOffsetOverlay::edgeDragged, [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { - onEdgeDragged(key, rectIndex, isLeftEdge, newXN, completed); - }); - m_overlaysByStaff[key] = overlay; + NoteOffsetOverlay* overlay = nullptr; + const auto oldIt = m_overlaysByStaff.find(key); + if (oldIt != m_overlaysByStaff.end()) { + // Reuse the existing overlay item in place rather than destroying and recreating it - + // its drag-signal connection (bound to this same key) is still valid. + overlay = oldIt->second.overlay; + overlay->setRects(rects); + m_overlaysByStaff.erase(oldIt); + } else { + overlay = new NoteOffsetOverlay(m_overlaysParent); + overlay->setRects(rects); + applyOverlayColors(overlay); + overlay->setVisible(false); + + QObject::connect(overlay, &NoteOffsetOverlay::edgeDragged, + [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { + onEdgeDragged(key, rectIndex, isLeftEdge, newXN, completed); + }); + } + + StaffOverlayData data; + data.overlay = overlay; + data.notes = std::move(entries); + data.bandRect = overlayCanvasRect; + newOverlays[key] = std::move(data); } void NotationNoteOffsetController::applyOverlayColors(NoteOffsetOverlay* overlay) const @@ -336,22 +392,17 @@ void NotationNoteOffsetController::updateOverlaysGeometry() { const bool visible = noteOffsets() && noteOffsets()->isEditModeEnabled(); - for (const auto& [key, overlay] : m_overlaysByStaff) { - overlay->setVisible(visible); + for (const auto& [key, data] : m_overlaysByStaff) { + data.overlay->setVisible(visible); if (!visible) { continue; } - const auto bandRectIt = m_bandRectByStaff.find(key); - IF_ASSERT_FAILED(bandRectIt != m_bandRectByStaff.end()) { - continue; - } - - const muse::RectF screenRect = m_viewMatrix.map(bandRectIt->second); - overlay->setWidth(screenRect.width()); - overlay->setHeight(screenRect.height()); - overlay->setX(screenRect.x()); - overlay->setY(screenRect.y()); + const muse::RectF screenRect = m_viewMatrix.map(data.bandRect); + data.overlay->setWidth(screenRect.width()); + data.overlay->setHeight(screenRect.height()); + data.overlay->setX(screenRect.x()); + data.overlay->setY(screenRect.y()); } } @@ -379,16 +430,14 @@ std::vector NotationNoteOffsetController::selectedNotes() void NotationNoteOffsetController::previewNoteRect(const NoteLocation& location, int newStartOffset, int newDurationOffset) { - const auto notesIt = m_notesByStaff.find(location.key); - const auto bandRectIt = m_bandRectByStaff.find(location.key); - const auto overlayIt = m_overlaysByStaff.find(location.key); - IF_ASSERT_FAILED(notesIt != m_notesByStaff.end() && bandRectIt != m_bandRectByStaff.end() - && overlayIt != m_overlaysByStaff.end() && location.rectIndex >= 0 - && static_cast(location.rectIndex) < notesIt->second.size()) { + const auto dataIt = m_overlaysByStaff.find(location.key); + IF_ASSERT_FAILED(dataIt != m_overlaysByStaff.end() && location.rectIndex >= 0 + && static_cast(location.rectIndex) < dataIt->second.notes.size()) { return; } + const StaffOverlayData& data = dataIt->second; - const NoteEntry& entry = notesIt->second.at(location.rectIndex); + const NoteEntry& entry = data.notes.at(location.rectIndex); const Chord* chord = entry.note ? entry.note->chord() : nullptr; IF_ASSERT_FAILED(chord) { return; @@ -403,34 +452,36 @@ void NotationNoteOffsetController::previewNoteRect(const NoteLocation& location, const double rightPx = entry.nominalRightX + pixelDeltaForTickOffset(location.key.system, chordEndTick, newDurationOffset, fallbackPxPerTick); - QVector rects = overlayIt->second->rects(); + const QVector& rects = data.overlay->rects(); if (location.rectIndex >= rects.size()) { return; } - NoteOffsetOverlay::RectData& rect = rects[location.rectIndex]; - rect.leftN = (leftPx - bandRectIt->second.x()) / bandRectIt->second.width(); - rect.rightN = (rightPx - bandRectIt->second.x()) / bandRectIt->second.width(); - overlayIt->second->setRects(rects); + // Single-struct copy plus an in-place update, instead of copying the whole staff's rect + // vector out and back on every mouse-move during a drag. + NoteOffsetOverlay::RectData rect = rects.at(location.rectIndex); + rect.leftN = (leftPx - data.bandRect.x()) / data.bandRect.width(); + rect.rightN = (rightPx - data.bandRect.x()) / data.bandRect.width(); + data.overlay->updateRect(location.rectIndex, rect); } void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { - const auto notesIt = m_notesByStaff.find(key); - const auto bandRectIt = m_bandRectByStaff.find(key); - IF_ASSERT_FAILED(key.isValid() && notesIt != m_notesByStaff.end() && bandRectIt != m_bandRectByStaff.end() - && rectIndex >= 0 && static_cast(rectIndex) < notesIt->second.size()) { + const auto dataIt = m_overlaysByStaff.find(key); + IF_ASSERT_FAILED(key.isValid() && dataIt != m_overlaysByStaff.end() + && rectIndex >= 0 && static_cast(rectIndex) < dataIt->second.notes.size()) { return; } + const StaffOverlayData& data = dataIt->second; - const NoteEntry& draggedEntry = notesIt->second.at(rectIndex); + const NoteEntry& draggedEntry = data.notes.at(rectIndex); Note* draggedNote = draggedEntry.note; Chord* draggedChord = draggedNote ? draggedNote->chord() : nullptr; IF_ASSERT_FAILED(draggedNote && draggedChord) { return; } - const std::optional newTick = noteOffsetTickFromCanvasX(key.system, bandRectIt->second, newXN); + const std::optional newTick = noteOffsetTickFromCanvasX(key.system, data.bandRect, newXN); if (!newTick) { return; } diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h index 6687dfbe8de03..9ce43dcc789ea 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h @@ -61,10 +61,16 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn bool operator<(const SysStaffKey& k) const { - if (system == k.system) { - return staffIdx < k.staffIdx; + // Compare the System pointer by address only - never dereference it here. This key + // is looked up against entries left over from a previous rebuild (to reuse an + // existing overlay item instead of recreating it), and a view mode switch + // (Page <-> Continuous) destroys and recreates every System, so a stale key still + // sitting in the map at that point has a dangling `system` - dereferencing it (as + // `system->first()->index()` used to) is a use-after-free/crash. + if (system != k.system) { + return system < k.system; } - return system->first()->index() < k.system->first()->index(); + return staffIdx < k.staffIdx; } }; @@ -83,13 +89,20 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn int rectIndex = -1; }; - using OverlaysMap = std::map; - using NotesByStaffMap = std::map >; - using BandRectByStaffMap = std::map; + // The overlay item, its notes and its canvas-space band rect were previously three separate + // maps kept in lockstep by every add/remove/clear - a single map to this struct removes the + // risk of them silently desyncing for a staff. + struct StaffOverlayData { + NoteOffsetOverlay* overlay = nullptr; + std::vector notes; + muse::RectF bandRect; + }; + + using OverlaysMap = std::map; using NoteLocationMap = std::map; void rebuildAllOverlays(); - void createOverlayForStaff(const System* system, staff_idx_t staffIdx); + void createOverlayForStaff(const System* system, staff_idx_t staffIdx, OverlaysMap& newOverlays); void updateOverlaysGeometry(); void applyOverlayColors(NoteOffsetOverlay* overlay) const; @@ -106,8 +119,6 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn QQuickItem* m_overlaysParent = nullptr; OverlaysMap m_overlaysByStaff; - NotesByStaffMap m_notesByStaff; - BandRectByStaffMap m_bandRectByStaff; NoteLocationMap m_noteLocations; muse::draw::Transform m_viewMatrix; bool m_rebuildScheduled = false; diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp index 3df795f32e99b..7c18ecc62f743 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -53,6 +53,16 @@ const QVector& NoteOffsetOverlay::rects() const return m_rects; } +void NoteOffsetOverlay::updateRect(int index, const RectData& rect) +{ + if (index < 0 || index >= m_rects.size()) { + return; + } + + m_rects[index] = rect; + update(); +} + void NoteOffsetOverlay::setFillColor(const QColor& color) { m_fillColor = color; @@ -189,3 +199,12 @@ void NoteOffsetOverlay::mouseReleaseEvent(QMouseEvent* e) m_pressed = false; m_activeRectIndex = -1; } + +void NoteOffsetOverlay::mouseUngrabEvent() +{ + // The mouse grab taken in mousePressEvent can be stolen mid-drag (e.g. a popup opening) - + // without this, mouseReleaseEvent never fires and this item is left thinking a drag is still + // active. Treat it as a cancel rather than guessing a commit at an unknown final position. + m_pressed = false; + m_activeRectIndex = -1; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h index 01dc65b53e160..0d1ddfeca7ca2 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -48,12 +48,19 @@ class NoteOffsetOverlay : public QQuickPaintedItem void setRects(const QVector& rects); const QVector& rects() const; + // Mutates a single rect in place, avoiding a full-vector copy-out/copy-back - used for live + // preview during a drag and for selection-highlight updates, both of which only ever touch a + // handful of rects at a time even on a staff with many notes. + void updateRect(int index, const RectData& rect); + void setFillColor(const QColor& color); void setBorderColor(const QColor& color); void setHandleColor(const QColor& color); void paint(QPainter* painter) override; + bool isDragging() const { return m_pressed; } + signals: void edgeDragged(int rectIndex, bool isLeftEdge, qreal newXN, bool completed); @@ -63,6 +70,7 @@ class NoteOffsetOverlay : public QQuickPaintedItem void mousePressEvent(QMouseEvent* e) override; void mouseMoveEvent(QMouseEvent* e) override; void mouseReleaseEvent(QMouseEvent* e) override; + void mouseUngrabEvent() override; private: struct HitResult { From 52bd18330eec7e4b46cec6441b667cb77c69bb46 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:57:27 +0200 Subject: [PATCH 04/10] Fix codestyle (uncrustify) violation in NotationNoteOffsetController Continuation-line indentation was off by one space, flagged by the codestyle CI check. --- .../MuseScore/NotationScene/notationnoteoffsetcontroller.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index 1801b6a5382ef..8d314776dd4b3 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -365,7 +365,7 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s overlay->setVisible(false); QObject::connect(overlay, &NoteOffsetOverlay::edgeDragged, - [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { + [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { onEdgeDragged(key, rectIndex, isLeftEdge, newXN, completed); }); } From 142dde7e49231070ee3aeb0a86dec3baa0b3c0d9 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 22:40:02 +0200 Subject: [PATCH 05/10] Fix note-offset rectangle width when another voice has a shorter note createOverlayForStaff() anchored the nominal (zero-offset) right edge on "the next ChordRest segment", but that segment is shared across every voice/track on the staff. If a different voice had a shorter simultaneous note (e.g. an eighth note under a quarter note), its segment became the "next" one for every voice, cutting the longer note's rectangle down to the shorter note's end tick instead of its own. Anchor the right edge on this chord's own end tick via canvasXFromTick instead, which already correctly interpolates/snaps to the real segment at that tick regardless of which voice created it. --- .../NotationScene/notationnoteoffsetcontroller.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index 8d314776dd4b3..dcdbb80545181 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -249,12 +249,13 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s } const Chord* chord = toChord(item); - // The nominal (zero-offset) span is anchored on the note/segment's own real layout - // position, not on a tick->x interpolation - this guarantees the rectangle sits - // exactly on the notehead when there's no offset yet. - const Segment* nextSeg = seg->next1(SegmentType::ChordRest); - const double nominalRightX = (nextSeg && nextSeg->system() == system) - ? nextSeg->canvasX() : (seg->canvasX() + seg->width()); + // The nominal (zero-offset) right edge is anchored on this chord's own end tick, not + // on "the next ChordRest segment" - that segment is shared across every voice/track at + // this staff, so a shorter simultaneous note in another voice would otherwise cut this + // chord's rectangle down to the shorter note's end tick instead of its own. + const int chordEndTick = chord->tick().ticks() + chord->ticks().ticks(); + const std::optional interpolatedRightX = mu::notation::canvasXFromTick(system, chordEndTick); + const double nominalRightX = interpolatedRightX ? *interpolatedRightX : (seg->canvasX() + seg->width()); for (Note* note : chord->notes()) { if (note->tieBack()) { From 8816db7f0950cfb7b26c23e33cd6861eb925306d Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 15:20:34 +0200 Subject: [PATCH 06/10] Color-code note-offset rectangles by modification/selection state Match the note-velocity overlay's convention: green when both playback offsets are at their default (0), orange as soon as either the start or duration offset has been user-modified, blue when the note is selected (taking priority over the modified color, same as velocity). Both offsets already round-trip correctly through save/reload, so the color - computed live from those persisted values on every rebuild - does too. --- .../notationnoteoffsetcontroller.cpp | 49 +++++++++++++++++-- .../notationnoteoffsetcontroller.h | 1 + .../NotationScene/noteoffsetoverlay.cpp | 14 +++++- .../NotationScene/noteoffsetoverlay.h | 6 +++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index dcdbb80545181..8b71de233e8d2 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -155,6 +155,15 @@ void NotationNoteOffsetController::onCurrentNotationChanged() scheduleRebuild(); }, Asyncable::Mode::SetReplace); } + + if (notation->interaction()) { + notation->interaction()->selectionChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } + updateSelectionHighlight(); + }, Asyncable::Mode::SetReplace); + } } } @@ -311,6 +320,8 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); const muse::RectF overlayCanvasRect(staffCanvasRect.x(), minY, staffCanvasRect.width(), maxY - minY); + const std::vector selected = selectedNotes(); + QVector rects; rects.reserve(static_cast(entries.size())); @@ -339,6 +350,8 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s rect.rightN = (rightPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); rect.centerYN = (centerY[i] - overlayCanvasRect.y()) / overlayCanvasRect.height(); rect.heightYN = rectHeight / overlayCanvasRect.height(); + rect.selected = muse::contains(selected, entry.note); + rect.userModified = note->playbackStartOffset() != 0 || note->playbackDurationOffset() != 0; rects.push_back(rect); } @@ -384,9 +397,39 @@ void NotationNoteOffsetController::applyOverlayColors(NoteOffsetOverlay* overlay return; } - overlay->setFillColor(QColor(100, 150, 220, 60)); - overlay->setBorderColor(QColor(80, 130, 200, 200)); - overlay->setHandleColor(QColor(60, 110, 190, 230)); + overlay->setFillColor(QColor(90, 180, 140, 60)); + overlay->setSelectedFillColor(QColor(60, 160, 210, 90)); + overlay->setModifiedFillColor(QColor(235, 140, 40, 90)); + overlay->setBorderColor(QColor(50, 130, 100, 200)); + overlay->setHandleColor(QColor(70, 70, 70, 230)); +} + +void NotationNoteOffsetController::updateSelectionHighlight() +{ + if (!noteOffsets() || !noteOffsets()->isEditModeEnabled()) { + return; + } + + const std::vector selected = selectedNotes(); + + for (const auto& [key, data] : m_overlaysByStaff) { + const QVector& rects = data.overlay->rects(); + if (rects.size() != static_cast(data.notes.size())) { + continue; + } + + // Only a handful of notes typically change selection at once, even on a staff with many + // notes - update just those rects in place instead of copying the whole vector out and + // back regardless of how many actually changed. + for (int i = 0; i < rects.size(); ++i) { + const bool isSelected = muse::contains(selected, data.notes.at(i).note); + if (rects.at(i).selected != isSelected) { + NoteOffsetOverlay::RectData rect = rects.at(i); + rect.selected = isSelected; + data.overlay->updateRect(i, rect); + } + } + } } void NotationNoteOffsetController::updateOverlaysGeometry() diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h index 9ce43dcc789ea..b62ab090eb5b9 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h @@ -104,6 +104,7 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn void rebuildAllOverlays(); void createOverlayForStaff(const System* system, staff_idx_t staffIdx, OverlaysMap& newOverlays); void updateOverlaysGeometry(); + void updateSelectionHighlight(); void applyOverlayColors(NoteOffsetOverlay* overlay) const; void onCurrentNotationChanged(); diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp index 7c18ecc62f743..032902c2e28d6 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -69,6 +69,18 @@ void NoteOffsetOverlay::setFillColor(const QColor& color) update(); } +void NoteOffsetOverlay::setSelectedFillColor(const QColor& color) +{ + m_selectedFillColor = color; + update(); +} + +void NoteOffsetOverlay::setModifiedFillColor(const QColor& color) +{ + m_modifiedFillColor = color; + update(); +} + void NoteOffsetOverlay::setBorderColor(const QColor& color) { m_borderColor = color; @@ -102,7 +114,7 @@ void NoteOffsetOverlay::paint(QPainter* painter) const qreal cornerRadius = std::min(halfHeightPx, bodyRect.width() / 2.0); painter->setPen(QPen(m_borderColor, 1.0)); - painter->setBrush(m_fillColor); + painter->setBrush(rect.selected ? m_selectedFillColor : (rect.userModified ? m_modifiedFillColor : m_fillColor)); painter->drawRoundedRect(bodyRect, cornerRadius, cornerRadius); painter->setPen(Qt::NoPen); diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h index 0d1ddfeca7ca2..f6fbe7540d673 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -41,6 +41,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem qreal rightN = 0.0; qreal centerYN = 0.5; qreal heightYN = 1.0; + bool selected = false; + bool userModified = false; // either playback offset is non-zero }; explicit NoteOffsetOverlay(QQuickItem* parent); @@ -54,6 +56,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem void updateRect(int index, const RectData& rect); void setFillColor(const QColor& color); + void setSelectedFillColor(const QColor& color); + void setModifiedFillColor(const QColor& color); void setBorderColor(const QColor& color); void setHandleColor(const QColor& color); @@ -86,6 +90,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem QVector m_rects; QColor m_fillColor; + QColor m_selectedFillColor; + QColor m_modifiedFillColor; QColor m_borderColor; QColor m_handleColor; From 76ac7a2b130a7bff2aca40c8bf9f4b534b6c8d59 Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 15:41:12 +0200 Subject: [PATCH 07/10] Add "Reset note offsets" context-menu item Mirrors the existing Automation context-menu entry: only appears when note-offset edit mode is enabled (NotationContextMenuModel::loadItems), and resets both playback offsets to 0 for every selected note via the undo stack, so notes revert to the system-default (unmodified) offset. --- .../internal/notationactioncontroller.cpp | 25 +++++++++++++++++++ .../internal/notationactioncontroller.h | 1 + .../internal/notationcommandsregister.cpp | 7 ++++++ src/notationscene/notationcommands.h | 1 + .../notationcontextmenumodel.cpp | 12 +++++++++ .../NotationScene/notationcontextmenumodel.h | 2 ++ 6 files changed, 48 insertions(+) diff --git a/src/notationscene/internal/notationactioncontroller.cpp b/src/notationscene/internal/notationactioncontroller.cpp index 9ad23b1817332..42a532f79cfef 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -31,6 +31,7 @@ #include "engraving/dom/harmony.h" #include "engraving/dom/masterscore.h" #include "engraving/dom/note.h" +#include "engraving/dom/property.h" #include "engraving/dom/chord.h" #include "engraving/dom/text.h" #include "engraving/dom/sig.h" @@ -583,6 +584,7 @@ void NotationActionController::init() registerCommand(TOGGLE_AUTOMATION_COMMAND, &Controller::toggleAutomation); registerQueryCommand(SELECT_AUTOMATION_TYPE_COMMAND, &Controller::selectAutomationType); registerCommand(TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, &Controller::toggleNoteOffsetEditor); + registerCommand(RESET_NOTE_OFFSETS_COMMAND, &Controller::resetNoteOffsets); // TAB registerCommand(SET_DURATION_WHOLE_TAB_COMMAND, [this]() { setDuration(DurationType::V_WHOLE); }); @@ -3289,6 +3291,29 @@ void NotationActionController::toggleNoteOffsetEditor() masterNotation->noteOffsets()->setEditModeEnabled(!isEnabled); } +void NotationActionController::resetNoteOffsets() +{ + TRACEFUNC; + + INotationSelectionPtr selection = currentNotationSelection(); + std::vector notes = selection ? selection->notes() : std::vector(); + if (notes.empty()) { + return; + } + + INotationUndoStackPtr undoStack = currentNotationUndoStack(); + if (!undoStack) { + return; + } + + undoStack->prepareChanges(TranslatableString("undoableAction", "Reset note offsets")); + for (Note* note : notes) { + note->undoChangeProperty(Pid::PLAYBACK_START_OFFSET, 0, mu::engraving::PropertyFlags::NOSTYLE); + note->undoChangeProperty(Pid::PLAYBACK_DURATION_OFFSET, 0, mu::engraving::PropertyFlags::NOSTYLE); + } + undoStack->commitChanges(); +} + muse::Ret NotationActionController::selectAutomationType(const muse::rcommand::CommandQuery& query) { const std::string type = query.param("type").toString(); diff --git a/src/notationscene/internal/notationactioncontroller.h b/src/notationscene/internal/notationactioncontroller.h index efd04fc578fca..8b1ee2cc6cbf9 100644 --- a/src/notationscene/internal/notationactioncontroller.h +++ b/src/notationscene/internal/notationactioncontroller.h @@ -273,6 +273,7 @@ class NotationActionController : public INotationCommandsController, public muse void toggleAutomation(); muse::Ret selectAutomationType(const muse::rcommand::CommandQuery& query); void toggleNoteOffsetEditor(); + void resetNoteOffsets(); // commands void registerCommand(const muse::rcommand::Command&, std::function); diff --git a/src/notationscene/internal/notationcommandsregister.cpp b/src/notationscene/internal/notationcommandsregister.cpp index 40abd65d81c6a..dd66f7b4dbfd7 100644 --- a/src/notationscene/internal/notationcommandsregister.cpp +++ b/src/notationscene/internal/notationcommandsregister.cpp @@ -2921,6 +2921,13 @@ static const std::vector s_commandInfos = { InputSchema(), Decoration(IconCode::Code::CLOCK, rcommand::Checkable::Yes) }, + CommandInfo { + RESET_NOTE_OFFSETS_COMMAND, + TranslatableString("action", "Reset note offsets"), + TranslatableString("action", "Reset note offsets"), + InputSchema(), + Decoration() + }, CommandInfo { SELECT_AUTOMATION_TYPE_COMMAND, TranslatableString::untranslatable("Automation type"), diff --git a/src/notationscene/notationcommands.h b/src/notationscene/notationcommands.h index 2d3d5f53d3af8..c037b6eecb828 100644 --- a/src/notationscene/notationcommands.h +++ b/src/notationscene/notationcommands.h @@ -485,6 +485,7 @@ inline static const muse::rcommand::Command VOICE_ASSIGNMENT_ALL_IN_STAFF_COMMAN inline static const muse::rcommand::Command TOGGLE_AUTOMATION_COMMAND("command://notation/toggle-automation"); inline static const muse::rcommand::Command SELECT_AUTOMATION_TYPE_COMMAND("command://notation/select-automation-type"); // with params inline static const muse::rcommand::Command TOGGLE_NOTE_OFFSET_EDITOR_COMMAND("command://notation/toggle-note-offset-editor"); +inline static const muse::rcommand::Command RESET_NOTE_OFFSETS_COMMAND("command://notation/reset-note-offsets"); // TAB commands inline static const muse::rcommand::Command SET_DURATION_WHOLE_TAB_COMMAND("command://notation/set-duration-whole-tab"); diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp index e004fe50b0553..b6ecaeea67136 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp @@ -57,6 +57,12 @@ void NotationContextMenuModel::loadItems(int elementType) << makeMenu(TranslatableString::untranslatable("Automation type"), makeAutomationTypeItems()); } + const INotationNoteOffsetsPtr noteOffsets = this->noteOffsets(); + if (noteOffsets && noteOffsets->isEditModeEnabled()) { + items << makeSeparator() + << makeMenuItem(RESET_NOTE_OFFSETS_COMMAND); + } + setItems(items); } @@ -537,6 +543,12 @@ INotationAutomationPtr NotationContextMenuModel::automation() const return masterNotation ? masterNotation->automation() : nullptr; } +INotationNoteOffsetsPtr NotationContextMenuModel::noteOffsets() const +{ + IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + return masterNotation ? masterNotation->noteOffsets() : nullptr; +} + const EngravingItem* NotationContextMenuModel::currentElement() const { const EngravingItem* element = hitElementContext().element; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h index 114fa8b33e57b..cc6ff97f0b528 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h @@ -29,6 +29,7 @@ #include "notation/inotationinteraction.h" #include "notation/inotationautomation.h" +#include "notation/inotationnoteoffsets.h" #include "notation/inotationconfiguration.h" namespace mu::notation { @@ -80,6 +81,7 @@ class NotationContextMenuModel : public muse::uicomponents::AbstractMenuModel INotationInteractionPtr interaction() const; INotationSelectionPtr selection() const; INotationAutomationPtr automation() const; + INotationNoteOffsetsPtr noteOffsets() const; const engraving::EngravingItem* currentElement() const; From 4dc153a0274a3b64c1573d40ae32f810ecd0d275 Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 22:36:49 +0200 Subject: [PATCH 08/10] Tint note-offset drag handles by the rectangle's own state color The edge-drag handle nubs were always a fixed gray regardless of whether the pill body was showing default/modified/selected color, making them visually disconnected from the rectangle they belong to. Handles now use a darker shade of the same state color (green/orange/ blue) as the pill body they're attached to. --- .../NotationScene/notationnoteoffsetcontroller.cpp | 4 +++- .../MuseScore/NotationScene/noteoffsetoverlay.cpp | 14 +++++++++++++- .../MuseScore/NotationScene/noteoffsetoverlay.h | 4 ++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index 8b71de233e8d2..52a8c43d5a2dc 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -401,7 +401,9 @@ void NotationNoteOffsetController::applyOverlayColors(NoteOffsetOverlay* overlay overlay->setSelectedFillColor(QColor(60, 160, 210, 90)); overlay->setModifiedFillColor(QColor(235, 140, 40, 90)); overlay->setBorderColor(QColor(50, 130, 100, 200)); - overlay->setHandleColor(QColor(70, 70, 70, 230)); + overlay->setHandleColor(QColor(90, 180, 140, 230).darker(160)); + overlay->setSelectedHandleColor(QColor(60, 160, 210, 230).darker(140)); + overlay->setModifiedHandleColor(QColor(235, 140, 40, 230).darker(140)); } void NotationNoteOffsetController::updateSelectionHighlight() diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp index 032902c2e28d6..d7d792e96b1d6 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -93,6 +93,18 @@ void NoteOffsetOverlay::setHandleColor(const QColor& color) update(); } +void NoteOffsetOverlay::setSelectedHandleColor(const QColor& color) +{ + m_selectedHandleColor = color; + update(); +} + +void NoteOffsetOverlay::setModifiedHandleColor(const QColor& color) +{ + m_modifiedHandleColor = color; + update(); +} + void NoteOffsetOverlay::paint(QPainter* painter) { if (m_rects.isEmpty()) { @@ -118,7 +130,7 @@ void NoteOffsetOverlay::paint(QPainter* painter) painter->drawRoundedRect(bodyRect, cornerRadius, cornerRadius); painter->setPen(Qt::NoPen); - painter->setBrush(m_handleColor); + painter->setBrush(rect.selected ? m_selectedHandleColor : (rect.userModified ? m_modifiedHandleColor : m_handleColor)); painter->drawRoundedRect(QRectF(leftPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); painter->drawRoundedRect(QRectF(rightPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h index f6fbe7540d673..998259762e35d 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -60,6 +60,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem void setModifiedFillColor(const QColor& color); void setBorderColor(const QColor& color); void setHandleColor(const QColor& color); + void setSelectedHandleColor(const QColor& color); + void setModifiedHandleColor(const QColor& color); void paint(QPainter* painter) override; @@ -94,6 +96,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem QColor m_modifiedFillColor; QColor m_borderColor; QColor m_handleColor; + QColor m_selectedHandleColor; + QColor m_modifiedHandleColor; bool m_pressed = false; int m_activeRectIndex = -1; From 5efb9d596e9bdf01579a630cbb6470ff5c017b20 Mon Sep 17 00:00:00 2001 From: sfer Date: Tue, 18 Aug 2026 10:35:47 +0200 Subject: [PATCH 09/10] Extend note-offset overlay rectangles across full tie chains Previously a tied-into note (note->tieBack()) got no overlay entry at all, so the rectangle stopped at the first note's own duration and left a visual gap over the rest of the tied chain, even though playback already correctly sounds through the whole chain. NoteEntry now tracks headNote/tailNote/anchorNote instead of a single note. A chain-head's rectangle extends to the tail's own end tick (walked via Note::lastTiedNote(), playback-accurate to match NoteRenderer::renderNormalTie()), with the right/duration handle shown only if the tail resolves within the same System. A new branch handles a tied-continuation note whose *predecessor* lives in a different System: it gets its own continuation fragment starting at that System's own left edge, which is what actually closes the gap for a tie crossing a system or page break - each System still only knows its own coordinate space, so a single rectangle can't literally span two of them, but a fragment per System reads as continuous. Only the chain's first note owns the left/start handle and only its last note owns the right/duration handle; everything in between has neither, matching the fact that dragging duration from the middle of a tie chain (or start from past its own end) has no sensible meaning. Property writes always target the chain's head note regardless of which fragment/handle was actually dragged, since only the head's own offset is ever honored during playback. Also added two one-sided drag clamps, both only active for a real tie (tailNote != headNote): the duration handle can't shrink the total span to end before the tail note's own start, and the start handle can't push the start past the head note's own end. --- .../notationnoteoffsetcontroller.cpp | 244 +++++++++++++----- .../notationnoteoffsetcontroller.h | 16 +- .../NotationScene/noteoffsetoverlay.cpp | 20 +- .../NotationScene/noteoffsetoverlay.h | 6 + 4 files changed, 207 insertions(+), 79 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index 52a8c43d5a2dc..e4a4129666161 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -39,6 +39,7 @@ #include "engraving/dom/segment.h" #include "engraving/dom/staff.h" #include "engraving/dom/system.h" +#include "engraving/dom/tie.h" #include "notation/imasternotation.h" #include "notation/inotation.h" @@ -244,6 +245,12 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s return; } + // Computed up front (not just for the final overlay geometry, below) - a tie chain that + // enters this system from a previous one, or continues past it into the next, has nothing of + // its own tick to anchor a rectangle edge on within this system, so that edge is clamped to + // the system's own visual bounds for this staff instead. + const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); + std::vector entries; const track_idx_t strack = staffIdx * VOICES; @@ -258,26 +265,77 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s } const Chord* chord = toChord(item); - // The nominal (zero-offset) right edge is anchored on this chord's own end tick, not - // on "the next ChordRest segment" - that segment is shared across every voice/track at - // this staff, so a shorter simultaneous note in another voice would otherwise cut this - // chord's rectangle down to the shorter note's end tick instead of its own. - const int chordEndTick = chord->tick().ticks() + chord->ticks().ticks(); - const std::optional interpolatedRightX = mu::notation::canvasXFromTick(system, chordEndTick); - const double nominalRightX = interpolatedRightX ? *interpolatedRightX : (seg->canvasX() + seg->width()); - for (Note* note : chord->notes()) { - if (note->tieBack()) { - // Playback (NoteRenderer::shouldRender) skips tied-continuation notes - // entirely in most cases, so their own offset would silently do nothing - - // don't offer a handle that can't actually affect anything. + const Tie* backTie = note->tieBack(); + + if (!backTie) { + // Chain head (or an untied note) - walk forward to where the tie chain + // actually ends (mirroring the tick range NoteRenderer::renderNormalTie() + // already applies to playback), so the rectangle covers the whole chain + // instead of stopping at this note's own duration. + Note* tailNote = note->lastTiedNote(/*ignorePlayback*/ false); + const Chord* tailChord = tailNote->chord(); + const bool tailInSameSystem = tailChord && tailChord->segment()->system() == system; + + NoteEntry entry; + entry.headNote = note; + entry.tailNote = tailNote; + entry.anchorNote = note; + entry.nominalLeftX = note->canvasX(); + entry.hasLeftHandle = true; + entry.hasRightHandle = tailInSameSystem; + + if (tailInSameSystem) { + const int tailEndTick = tailChord->tick().ticks() + tailChord->ticks().ticks(); + const std::optional rx = mu::notation::canvasXFromTick(system, tailEndTick); + entry.nominalRightX = rx ? *rx : (staffCanvasRect.x() + staffCanvasRect.width()); + } else { + // The chain continues past this system - stop at the system's own right + // edge instead of interpolating a tick that lies entirely outside it. The + // rest of the chain gets its own fragment wherever its later systems are + // processed (see the tieBack() branch below). + entry.nominalRightX = staffCanvasRect.x() + staffCanvasRect.width(); + } + + entries.push_back(entry); + continue; + } + + // A tied-continuation note. Playback (NoteRenderer::shouldRender) skips these + // entirely - only the chain's head note's own offset is ever honored - so it never + // gets an independent handle of its own. It only needs a fragment here if the + // previous note in the chain lives in a *different* system: that's the one case + // the head's own fragment (built above, in the head's own system) can't reach, + // since each system's overlay only has coordinate data for itself. A continuation + // note whose predecessor is in this same system is already fully covered by that + // fragment's extended right edge. + const Note* prevNote = backTie->startNote(); + const Chord* prevChord = prevNote ? prevNote->chord() : nullptr; + if (!prevChord || prevChord->segment()->system() == system) { continue; } + Note* headNote = note->firstTiedNote(/*ignorePlayback*/ false); + Note* tailNote = note->lastTiedNote(/*ignorePlayback*/ false); + const Chord* tailChord = tailNote->chord(); + const bool tailInSameSystem = tailChord && tailChord->segment()->system() == system; + NoteEntry entry; - entry.note = note; - entry.nominalLeftX = note->canvasX(); - entry.nominalRightX = nominalRightX; + entry.headNote = headNote; + entry.tailNote = tailNote; + entry.anchorNote = note; + entry.nominalLeftX = staffCanvasRect.x(); + entry.hasLeftHandle = false; + entry.hasRightHandle = tailInSameSystem; + + if (tailInSameSystem) { + const int tailEndTick = tailChord->tick().ticks() + tailChord->ticks().ticks(); + const std::optional rx = mu::notation::canvasXFromTick(system, tailEndTick); + entry.nominalRightX = rx ? *rx : (staffCanvasRect.x() + staffCanvasRect.width()); + } else { + entry.nominalRightX = staffCanvasRect.x() + staffCanvasRect.width(); + } + entries.push_back(entry); } } @@ -287,20 +345,21 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s return; } - const double spatium = entries.front().note->spatium(); + const double spatium = entries.front().anchorNote->spatium(); const double topMargin = RECT_TOP_MARGIN_SP * spatium; const double bottomOverlap = RECT_BOTTOM_OVERLAP_SP * spatium; const double rectHeight = topMargin + bottomOverlap; const double vPadding = 0.3 * spatium; - // Anchored on each note's own vertical position, so the rectangle sits right above its - // notehead (and chord notes stack in pitch order without needing an artificial row index) + // Anchored on each fragment's own anchor note's vertical position (the note actually laid + // out in this system), so the rectangle sits right above its notehead (and chord notes stack + // in pitch order without needing an artificial row index) std::vector centerY; centerY.reserve(entries.size()); double minY = 0.0; double maxY = 0.0; for (size_t i = 0; i < entries.size(); ++i) { - const double noteY = entries[i].note->canvasPos().y(); + const double noteY = entries[i].anchorNote->canvasPos().y(); const double y = noteY - topMargin + rectHeight / 2.0; centerY.push_back(y); if (i == 0) { @@ -317,7 +376,6 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s // The overlay's vertical bounds are derived from the actual note positions rather than a // fixed margin around the staff - this way it always contains every rectangle regardless of // how far above/below the staff a note sits (ledger lines, etc.) - const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); const muse::RectF overlayCanvasRect(staffCanvasRect.x(), minY, staffCanvasRect.width(), maxY - minY); const std::vector selected = selectedNotes(); @@ -327,31 +385,44 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s for (size_t i = 0; i < entries.size(); ++i) { const NoteEntry& entry = entries[i]; - const Note* note = entry.note; - const Chord* chord = note->chord(); - IF_ASSERT_FAILED(chord) { + const Note* headNote = entry.headNote; + const Note* tailNote = entry.tailNote; + const Chord* headChord = headNote ? headNote->chord() : nullptr; + const Chord* tailChord = tailNote ? tailNote->chord() : nullptr; + IF_ASSERT_FAILED(headChord && tailChord) { continue; } - // Fallback local px-per-tick rate, only used if a note's offset pushes it right at a - // system boundary where segment interpolation has nothing to anchor to. - const int chordTicks = chord->ticks().ticks(); - const double fallbackPxPerTick = chordTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / chordTicks : 0.0; + const int headStartTick = headChord->tick().ticks(); + const int tailEndTick = tailChord->tick().ticks() + tailChord->ticks().ticks(); - const int chordStartTick = chord->tick().ticks(); - const int chordEndTick = chordStartTick + chordTicks; - const double leftPx = entry.nominalLeftX - + pixelDeltaForTickOffset(system, chordStartTick, note->playbackStartOffset(), fallbackPxPerTick); - const double rightPx = entry.nominalRightX - + pixelDeltaForTickOffset(system, chordEndTick, note->playbackDurationOffset(), fallbackPxPerTick); + // Fallback local px-per-tick rate, only used if an offset pushes an edge right at a + // system boundary where segment interpolation has nothing to anchor to. + const int totalTicks = tailEndTick - headStartTick; + const double fallbackPxPerTick = totalTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / totalTicks : 0.0; + + // A fragment without a given handle doesn't own that edge (it belongs to a fragment in a + // different system) - its position stays pinned to the system boundary it was clamped to, + // rather than tracking an offset that isn't actually about this fragment's own edge. + const double leftPx = entry.hasLeftHandle + ? entry.nominalLeftX + + pixelDeltaForTickOffset(system, headStartTick, headNote->playbackStartOffset(), fallbackPxPerTick) + : entry.nominalLeftX; + const double rightPx = entry.hasRightHandle + ? entry.nominalRightX + + pixelDeltaForTickOffset(system, tailEndTick, headNote->playbackDurationOffset(), fallbackPxPerTick) + : entry.nominalRightX; NoteOffsetOverlay::RectData rect; rect.leftN = (leftPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); rect.rightN = (rightPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); rect.centerYN = (centerY[i] - overlayCanvasRect.y()) / overlayCanvasRect.height(); rect.heightYN = rectHeight / overlayCanvasRect.height(); - rect.selected = muse::contains(selected, entry.note); - rect.userModified = note->playbackStartOffset() != 0 || note->playbackDurationOffset() != 0; + rect.hasLeftHandle = entry.hasLeftHandle; + rect.hasRightHandle = entry.hasRightHandle; + rect.selected = muse::contains(selected, entry.headNote) || muse::contains(selected, entry.tailNote) + || muse::contains(selected, entry.anchorNote); + rect.userModified = headNote->playbackStartOffset() != 0 || headNote->playbackDurationOffset() != 0; rects.push_back(rect); } @@ -361,7 +432,11 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s const SysStaffKey key { system, staffIdx }; for (int i = 0; i < static_cast(entries.size()); ++i) { - m_noteLocations[entries[i].note] = NoteLocation { key, i }; + const NoteEntry& entry = entries[i]; + m_noteLocations[entry.anchorNote] = NoteLocation { key, i }; + if (entry.hasRightHandle && entry.tailNote != entry.anchorNote) { + m_noteLocations[entry.tailNote] = NoteLocation { key, i }; + } } NoteOffsetOverlay* overlay = nullptr; @@ -424,7 +499,9 @@ void NotationNoteOffsetController::updateSelectionHighlight() // notes - update just those rects in place instead of copying the whole vector out and // back regardless of how many actually changed. for (int i = 0; i < rects.size(); ++i) { - const bool isSelected = muse::contains(selected, data.notes.at(i).note); + const NoteEntry& entry = data.notes.at(i); + const bool isSelected = muse::contains(selected, entry.headNote) || muse::contains(selected, entry.tailNote) + || muse::contains(selected, entry.anchorNote); if (rects.at(i).selected != isSelected) { NoteOffsetOverlay::RectData rect = rects.at(i); rect.selected = isSelected; @@ -484,19 +561,25 @@ void NotationNoteOffsetController::previewNoteRect(const NoteLocation& location, const StaffOverlayData& data = dataIt->second; const NoteEntry& entry = data.notes.at(location.rectIndex); - const Chord* chord = entry.note ? entry.note->chord() : nullptr; - IF_ASSERT_FAILED(chord) { + const Chord* headChord = entry.headNote ? entry.headNote->chord() : nullptr; + const Chord* tailChord = entry.tailNote ? entry.tailNote->chord() : nullptr; + IF_ASSERT_FAILED(headChord && tailChord) { return; } - const int chordTicks = chord->ticks().ticks(); - const double fallbackPxPerTick = chordTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / chordTicks : 0.0; - const int chordStartTick = chord->tick().ticks(); - const int chordEndTick = chordStartTick + chordTicks; - const double leftPx = entry.nominalLeftX - + pixelDeltaForTickOffset(location.key.system, chordStartTick, newStartOffset, fallbackPxPerTick); - const double rightPx = entry.nominalRightX - + pixelDeltaForTickOffset(location.key.system, chordEndTick, newDurationOffset, fallbackPxPerTick); + const int headStartTick = headChord->tick().ticks(); + const int tailEndTick = tailChord->tick().ticks() + tailChord->ticks().ticks(); + const int totalTicks = tailEndTick - headStartTick; + const double fallbackPxPerTick = totalTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / totalTicks : 0.0; + + const double leftPx = entry.hasLeftHandle + ? entry.nominalLeftX + + pixelDeltaForTickOffset(location.key.system, headStartTick, newStartOffset, fallbackPxPerTick) + : entry.nominalLeftX; + const double rightPx = entry.hasRightHandle + ? entry.nominalRightX + + pixelDeltaForTickOffset(location.key.system, tailEndTick, newDurationOffset, fallbackPxPerTick) + : entry.nominalRightX; const QVector& rects = data.overlay->rects(); if (location.rectIndex >= rects.size()) { @@ -521,9 +604,11 @@ void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rec const StaffOverlayData& data = dataIt->second; const NoteEntry& draggedEntry = data.notes.at(rectIndex); - Note* draggedNote = draggedEntry.note; - Chord* draggedChord = draggedNote ? draggedNote->chord() : nullptr; - IF_ASSERT_FAILED(draggedNote && draggedChord) { + Note* headNote = draggedEntry.headNote; + Note* tailNote = draggedEntry.tailNote; + Chord* headChord = headNote ? headNote->chord() : nullptr; + Chord* tailChord = tailNote ? tailNote->chord() : nullptr; + IF_ASSERT_FAILED(headNote && tailNote && headChord && tailChord) { return; } @@ -532,35 +617,49 @@ void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rec return; } - const int draggedChordStartTick = draggedChord->tick().ticks(); - const int draggedChordEndTick = draggedChordStartTick + draggedChord->ticks().ticks(); + // Only the chain's head note's own offset is ever honored during playback (see the tieBack() + // skip in createOverlayForStaff), so it's always the target here regardless of which + // fragment/handle - possibly on the chain's last note, in a different system - was dragged. + const int headChordStartTick = headChord->tick().ticks(); + const int headChordEndTick = headChordStartTick + headChord->ticks().ticks(); + const int tailChordStartTick = tailChord->tick().ticks(); + const int tailChordEndTick = tailChordStartTick + tailChord->ticks().ticks(); - int newStartOffset = draggedNote->playbackStartOffset(); - int newDurationOffset = draggedNote->playbackDurationOffset(); + int newStartOffset = headNote->playbackStartOffset(); + int newDurationOffset = headNote->playbackDurationOffset(); if (isLeftEdge) { - newStartOffset = std::clamp(*newTick - draggedChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); - const int effEnd = draggedChordEndTick + newDurationOffset; - if (effEnd - (draggedChordStartTick + newStartOffset) < MIN_EFFECTIVE_TICKS) { - newStartOffset = std::clamp(effEnd - MIN_EFFECTIVE_TICKS - draggedChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + newStartOffset = std::clamp(*newTick - headChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + // Never let the start creep past the end of the *first* tied note's own span - dragging + // the start into (or past) a later tied note has no sensible meaning either, mirroring + // the floor applied to the duration handle below. For an untied note tailNote == headNote, + // so this reduces to the original same-note bound (can't cross wherever the duration + // handle currently puts the note's own effective end). + const int ceilingTick = (tailNote == headNote) ? (tailChordEndTick + newDurationOffset) : headChordEndTick; + if (ceilingTick - (headChordStartTick + newStartOffset) < MIN_EFFECTIVE_TICKS) { + newStartOffset = std::clamp(ceilingTick - MIN_EFFECTIVE_TICKS - headChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); } } else { - newDurationOffset = std::clamp(*newTick - draggedChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); - const int effStart = draggedChordStartTick + newStartOffset; - if ((draggedChordEndTick + newDurationOffset) - effStart < MIN_EFFECTIVE_TICKS) { - newDurationOffset = std::clamp(effStart + MIN_EFFECTIVE_TICKS - draggedChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + newDurationOffset = std::clamp(*newTick - tailChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + // Never let the total duration shrink to end before the *last* tied note's own start - + // dragging into the middle of the tie chain has no sensible meaning (there's no tick at + // which "the note" could be said to end while a tied continuation is still sounding). + // For an untied note tailNote == headNote, so this reduces to the original same-note bound. + const int floorTick = (tailNote == headNote) ? (headChordStartTick + newStartOffset) : tailChordStartTick; + if ((tailChordEndTick + newDurationOffset) - floorTick < MIN_EFFECTIVE_TICKS) { + newDurationOffset = std::clamp(floorTick + MIN_EFFECTIVE_TICKS - tailChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); } } // If the dragged note is part of a multi-note selection, apply the same tick delta to every // other selected note's corresponding offset, each clamped independently. - const int delta = isLeftEdge ? (newStartOffset - draggedNote->playbackStartOffset()) - : (newDurationOffset - draggedNote->playbackDurationOffset()); + const int delta = isLeftEdge ? (newStartOffset - headNote->playbackStartOffset()) + : (newDurationOffset - headNote->playbackDurationOffset()); - std::vector affectedNotes { draggedNote }; + std::vector affectedNotes { headNote }; if (delta != 0 || !completed) { const std::vector selected = selectedNotes(); - if (selected.size() > 1 && muse::contains(selected, draggedNote)) { + if (selected.size() > 1 && muse::contains(selected, headNote)) { affectedNotes = selected; } } @@ -574,7 +673,7 @@ void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rec changes.reserve(affectedNotes.size()); for (Note* note : affectedNotes) { - if (note == draggedNote) { + if (note == headNote) { changes.push_back({ note, newStartOffset, newDurationOffset }); continue; } @@ -611,8 +710,17 @@ void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rec if (!completed) { // Live drag preview - update every affected overlay's displayed rect without touching - // the score, anchored on the same nominal note positions used when overlays were built + // the score, anchored on the same nominal note positions used when overlays were built. + // The actually-dragged fragment is addressed directly by its own (key, rectIndex) rather + // than via m_noteLocations, since that map resolves headNote back to *its own* fragment - + // which, when dragging the duration handle on a different system's tail fragment, is not + // the same fragment the mouse is over. + const NoteLocation draggedLocation { key, rectIndex }; for (const PendingChange& change : changes) { + if (change.note == headNote) { + previewNoteRect(draggedLocation, change.startOffset, change.durationOffset); + continue; + } const auto locIt = m_noteLocations.find(change.note); if (locIt != m_noteLocations.end()) { previewNoteRect(locIt->second, change.startOffset, change.durationOffset); diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h index b62ab090eb5b9..189170bbcd959 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h @@ -74,12 +74,22 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn } }; - // Nominal (zero-offset) canvas X positions, taken directly from the note's own layout - - // anchors the rectangle exactly on the notehead rather than relying on tick interpolation. + // One rectangle fragment, possibly covering only part of a tie chain (a chain that crosses a + // System boundary is drawn as one fragment per System it touches). headNote is always the + // chain's first note - the only one whose playbackStartOffset/playbackDurationOffset are ever + // honored during playback, so it's the sole target for property writes regardless of which + // fragment/handle was actually dragged. tailNote is the chain's last note, used as the tick + // reference for the duration handle. anchorNote is whichever note is physically laid out in + // this fragment's own System (equal to headNote unless this fragment is a continuation + // picked up from a previous System) - used for vertical positioning and note-selection lookup. struct NoteEntry { - mu::engraving::Note* note = nullptr; + mu::engraving::Note* headNote = nullptr; + mu::engraving::Note* tailNote = nullptr; + mu::engraving::Note* anchorNote = nullptr; double nominalLeftX = 0.0; double nominalRightX = 0.0; + bool hasLeftHandle = true; + bool hasRightHandle = true; }; // Where a given note's rectangle lives, so a drag on a multi-note selection can update/commit diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp index d7d792e96b1d6..46f31c9a88487 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -131,10 +131,14 @@ void NoteOffsetOverlay::paint(QPainter* painter) painter->setPen(Qt::NoPen); painter->setBrush(rect.selected ? m_selectedHandleColor : (rect.userModified ? m_modifiedHandleColor : m_handleColor)); - painter->drawRoundedRect(QRectF(leftPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), - EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); - painter->drawRoundedRect(QRectF(rightPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), - EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + if (rect.hasLeftHandle) { + painter->drawRoundedRect(QRectF(leftPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), + EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + } + if (rect.hasRightHandle) { + painter->drawRoundedRect(QRectF(rightPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), + EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + } } } @@ -151,16 +155,16 @@ NoteOffsetOverlay::HitResult NoteOffsetOverlay::hitTestPx(const QPointF& posPx) const qreal leftPx = rect.leftN * width(); const qreal rightPx = rect.rightN * width(); - const qreal distToLeft = std::abs(posPx.x() - leftPx); - const qreal distToRight = std::abs(posPx.x() - rightPx); + const bool hitLeft = rect.hasLeftHandle && std::abs(posPx.x() - leftPx) <= EDGE_HANDLE_HIT_MARGIN_PX; + const bool hitRight = rect.hasRightHandle && std::abs(posPx.x() - rightPx) <= EDGE_HANDLE_HIT_MARGIN_PX; - if (distToLeft > EDGE_HANDLE_HIT_MARGIN_PX && distToRight > EDGE_HANDLE_HIT_MARGIN_PX) { + if (!hitLeft && !hitRight) { continue; } HitResult hit; hit.rectIndex = i; - hit.isLeftEdge = distToLeft <= distToRight; + hit.isLeftEdge = hitLeft && (!hitRight || std::abs(posPx.x() - leftPx) <= std::abs(posPx.x() - rightPx)); return hit; } diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h index 998259762e35d..6b5bd8c4bee5d 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -43,6 +43,12 @@ class NoteOffsetOverlay : public QQuickPaintedItem qreal heightYN = 1.0; bool selected = false; bool userModified = false; // either playback offset is non-zero + + // A tie-chain fragment only offers the handle for the edge it actually owns: the start + // handle on the chain's first note, the duration handle on its last - an intermediate + // fragment (or one whose own chain-end lives in a different system) has neither. + bool hasLeftHandle = true; + bool hasRightHandle = true; }; explicit NoteOffsetOverlay(QQuickItem* parent); From 71efc9952818fed332146e336f16f4b83c4916aa Mon Sep 17 00:00:00 2001 From: sfer Date: Tue, 18 Aug 2026 10:35:47 +0200 Subject: [PATCH 10/10] Make note-offset Properties panel spinboxes tie-aware The "Start offset"/"Duration offset" spinboxes read and wrote Pid::PLAYBACK_START_OFFSET/DURATION_OFFSET on the exact selected note with no tie-awareness at all: selecting a tied-continuation note let you type a value that was silently ignored during playback (only the chain's head note's own offset is ever honored) and, now that the on-canvas overlay is tie-aware too, disagreed with what it shows for that same chain. NotePlaybackModel::headNoteElements() now remaps each selected note to its firstTiedNote(false) for both directions, via a custom onPropertyChangedCallBack passed to buildPropertyItem - ties into an extension point PropertiesPanelAbstractModel already supported, no base-class changes needed. A note that is neither its chain's head nor its tail (a middle link in a 3+-note chain) owns no handle at all in the overlay, so it's dropped from the list entirely rather than redirected, leaving both spinboxes disabled for it instead of quietly editing a value it has no visual handle for. Also fixed a separate, pre-existing refresh bug found while testing this: committing a property change (e.g. releasing a drag) never live-updated these spinboxes without leaving and re-entering the Properties tab. GeneralSettingsModel::onNotationChanged() only ever reloaded its own four Pids and never forwarded the notification down into m_playbackProxyModel (unlike onCurrentNotationChanged() right below it, which does forward to both nested models), and NotePlaybackModel never overrode onNotationChanged() to react even if it had been forwarded. Both gaps are now closed for NotePlaybackModel; PlaybackProxyModel's other sibling models (arpeggio/fermata/breath/ glissando/gradual tempo change) have the same gap and are left as a known, separate follow-up. --- .../general/generalsettingsmodel.cpp | 9 +++- .../playback/internal/noteplaybackmodel.cpp | 51 +++++++++++++++++-- .../playback/internal/noteplaybackmodel.h | 15 ++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/generalsettingsmodel.cpp b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/generalsettingsmodel.cpp index 2c8c5a231e9c4..bcee4014a7af1 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/generalsettingsmodel.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/generalsettingsmodel.cpp @@ -118,9 +118,16 @@ void GeneralSettingsModel::loadProperties() updateAreGeneralPropertiesAvailable(); } -void GeneralSettingsModel::onNotationChanged(const PropertyIdSet& changedPropertyIdSet, const StyleIdSet&) +void GeneralSettingsModel::onNotationChanged(const PropertyIdSet& changedPropertyIdSet, const StyleIdSet& changedStyleIdSet) { loadProperties(changedPropertyIdSet); + + // Forwarded here rather than relying on PropertiesPanelListModel to reach these nested models + // directly - only top-level section models are in its own list (see onCurrentNotationChanged() + // just below, which forwards for the same reason). Without this, an external score change (e.g. + // committing a note-offset drag, or an undo/redo) never reaches m_playbackProxyModel's nested + // models, which then only ever refresh via the unrelated elementsUpdated()/reselection path. + m_playbackProxyModel->onNotationChanged(changedPropertyIdSet, changedStyleIdSet); } void GeneralSettingsModel::loadProperties(const mu::engraving::PropertyIdSet& propertyIdSet) diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp index 025447c322841..c4a486a4232b6 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp @@ -24,6 +24,8 @@ #include "translation.h" #include "dataformatter.h" +#include "engraving/dom/note.h" + using namespace mu::propertiespanel; NotePlaybackModel::NotePlaybackModel(QObject* parent, const muse::modularity::ContextPtr& iocCtx, IElementRepositoryService* repository) @@ -39,8 +41,15 @@ void NotePlaybackModel::createProperties() { m_tuning = buildPropertyItem(mu::engraving::Pid::TUNING); m_velocity = buildPropertyItem(mu::engraving::Pid::USER_VELOCITY); - m_playbackStartOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_START_OFFSET); - m_playbackDurationOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_DURATION_OFFSET); + + // Redirected to each note's own chain head (see headNoteElements()) instead of the default + // callback, which would write to the exact selected note. + auto onOffsetChanged = [this](const mu::engraving::Pid pid, const QVariant& newValue) { + setPropertyValue(headNoteElements(), pid, newValue); + loadProperties(); + }; + m_playbackStartOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_START_OFFSET, onOffsetChanged); + m_playbackDurationOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_DURATION_OFFSET, onOffsetChanged); } void NotePlaybackModel::requestElements() @@ -55,8 +64,42 @@ void NotePlaybackModel::loadProperties() //! NOTE: display 64 instead of 0 in the Velocity field to avoid confusing the user return value.toInt() == 0 ? 64 : value; }); - loadPropertyItem(m_playbackStartOffset); - loadPropertyItem(m_playbackDurationOffset); + loadPropertyItem(m_playbackStartOffset, headNoteElements()); + loadPropertyItem(m_playbackDurationOffset, headNoteElements()); +} + +void NotePlaybackModel::onNotationChanged(const mu::engraving::PropertyIdSet&, const mu::engraving::StyleIdSet&) +{ + loadProperties(); +} + +QList NotePlaybackModel::headNoteElements() const +{ + QList result; + result.reserve(m_elementList.size()); + + for (mu::engraving::EngravingItem* item : m_elementList) { + mu::engraving::Note* note = item && item->isNote() ? mu::engraving::toNote(item) : nullptr; + if (!note) { + result.push_back(item); + continue; + } + + mu::engraving::Note* head = note->firstTiedNote(/*ignorePlayback*/ false); + mu::engraving::Note* tail = note->lastTiedNote(/*ignorePlayback*/ false); + + // A note buried in the middle of a longer tie chain (neither the chain's head nor its + // tail) owns neither edge of the overlay's rectangle for that chain - it's excluded here + // entirely, rather than merely redirected, so both spinboxes read as disabled instead of + // silently editing a value this note has no visual handle for. + if (note != head && note != tail) { + continue; + } + + result.push_back(head); + } + + return result; } PropertyItem* NotePlaybackModel::tuning() const diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h index aaf6c6b02dc9a..12ceb1e340d70 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h @@ -49,8 +49,23 @@ class NotePlaybackModel : public PropertiesPanelAbstractModel void createProperties() override; void requestElements() override; void loadProperties() override; + void onNotationChanged(const mu::engraving::PropertyIdSet& changedPropertyIdSet, + const mu::engraving::StyleIdSet& changedStyleIdSet) override; private: + // Playback start/duration offset are only ever honored on a tie chain's first note - a + // tied-continuation note is skipped entirely during rendering (see NoteRenderer::shouldRender() + // and the matching tieBack() skip in NotationNoteOffsetController::createOverlayForStaff()). + // Reading/writing these two properties on the exact selected note would silently affect + // nothing whenever that note is a tied continuation, and would disagree with what the + // on-canvas drag-handle overlay shows for the same chain - so both directions are redirected + // to each note's own chain head, regardless of which note in the chain is selected. A note + // that is neither its chain's head nor its tail (a middle link in a 3+-note chain) owns no + // handle at all in that overlay, so it's dropped from the returned list entirely rather than + // redirected - loadPropertyItem()/setPropertyValue() then treat it as no selection at all, + // leaving both spinboxes disabled instead of silently editing a value it has no handle for. + QList headNoteElements() const; + PropertyItem* m_tuning = nullptr; PropertyItem* m_velocity = nullptr; PropertyItem* m_playbackStartOffset = nullptr;