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 c6ac96a96489a..786a56f1c4791 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" @@ -39,6 +40,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 +583,8 @@ 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); }); @@ -1052,6 +1056,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 +1134,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 +3196,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 +3278,42 @@ 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); +} + +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 dab952921fb30..8b1ee2cc6cbf9 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,8 @@ 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); @@ -311,6 +316,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..dd66f7b4dbfd7 100644 --- a/src/notationscene/internal/notationcommandsregister.cpp +++ b/src/notationscene/internal/notationcommandsregister.cpp @@ -2914,6 +2914,20 @@ 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 { + 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/internal/notationcommandsstate.cpp b/src/notationscene/internal/notationcommandsstate.cpp index c0bcf25d0a7ed..55f2731b13398 100644 --- a/src/notationscene/internal/notationcommandsstate.cpp +++ b/src/notationscene/internal/notationcommandsstate.cpp @@ -349,6 +349,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); }); @@ -489,6 +493,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 4633aaf56a671..1b64744df1546 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..c037b6eecb828 100644 --- a/src/notationscene/notationcommands.h +++ b/src/notationscene/notationcommands.h @@ -484,6 +484,8 @@ 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"); +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/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 6e211d7282c8c..cba286b96ee90 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/iengravingconfiguration.h" // IWYU pragma: keep @@ -159,38 +161,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) @@ -395,7 +369,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; } @@ -908,7 +882,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; @@ -998,7 +972,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/notationcontextmenumodel.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp index 74d08710a537e..249136463b2ae 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp @@ -61,6 +61,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); } @@ -536,6 +542,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; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp new file mode 100644 index 0000000000000..e4a4129666161 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -0,0 +1,765 @@ +/* + * 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 "engraving/dom/tie.h" + +#include "notation/imasternotation.h" +#include "notation/inotation.h" +#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 + +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 (mu::engraving::Score* thisScore = score()) { + // TODO: More efficient if we only rebuild the affected staves/systems... + // 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, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } + 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, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + } + + if (notation->interaction()) { + notation->interaction()->selectionChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } + updateSelectionHighlight(); + }, 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, 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_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, 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, OverlaysMap& newOverlays) +{ + 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; + } + + // 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; + 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); + + for (Note* note : chord->notes()) { + 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.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); + } + } + } + + if (entries.empty()) { + return; + } + + 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 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].anchorNote->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 overlayCanvasRect(staffCanvasRect.x(), minY, staffCanvasRect.width(), maxY - minY); + + const std::vector selected = selectedNotes(); + + QVector rects; + rects.reserve(static_cast(entries.size())); + + for (size_t i = 0; i < entries.size(); ++i) { + const NoteEntry& entry = entries[i]; + 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; + } + + const int headStartTick = headChord->tick().ticks(); + const int tailEndTick = tailChord->tick().ticks() + tailChord->ticks().ticks(); + + // 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.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); + } + + if (rects.isEmpty()) { + return; + } + + const SysStaffKey key { system, staffIdx }; + for (int i = 0; i < static_cast(entries.size()); ++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; + 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 +{ + IF_ASSERT_FAILED(overlay) { + return; + } + + 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(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() +{ + 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 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; + data.overlay->updateRect(i, rect); + } + } + } +} + +void NotationNoteOffsetController::updateOverlaysGeometry() +{ + const bool visible = noteOffsets() && noteOffsets()->isEditModeEnabled(); + + for (const auto& [key, data] : m_overlaysByStaff) { + data.overlay->setVisible(visible); + if (!visible) { + continue; + } + + 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()); + } +} + +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 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 = data.notes.at(location.rectIndex); + 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 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()) { + return; + } + + // 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 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 = data.notes.at(rectIndex); + 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; + } + + const std::optional newTick = noteOffsetTickFromCanvasX(key.system, data.bandRect, newXN); + if (!newTick) { + return; + } + + // 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 = headNote->playbackStartOffset(); + int newDurationOffset = headNote->playbackDurationOffset(); + + if (isLeftEdge) { + 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 - 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 - headNote->playbackStartOffset()) + : (newDurationOffset - headNote->playbackDurationOffset()); + + std::vector affectedNotes { headNote }; + if (delta != 0 || !completed) { + const std::vector selected = selectedNotes(); + if (selected.size() > 1 && muse::contains(selected, headNote)) { + 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 == headNote) { + 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. + // 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); + } + } + 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..189170bbcd959 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h @@ -0,0 +1,137 @@ +/* + * 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 + { + // 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 staffIdx < k.staffIdx; + } + }; + + // 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* 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 + // every selected note's overlay entry, not just the one under the mouse. + struct NoteLocation { + SysStaffKey key; + int rectIndex = -1; + }; + + // 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, OverlaysMap& newOverlays); + void updateOverlaysGeometry(); + void updateSelectionHighlight(); + 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; + 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..46f31c9a88487 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -0,0 +1,238 @@ +/* + * 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::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; + 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; + update(); +} + +void NoteOffsetOverlay::setHandleColor(const QColor& color) +{ + m_handleColor = 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()) { + 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(rect.selected ? m_selectedFillColor : (rect.userModified ? m_modifiedFillColor : m_fillColor)); + painter->drawRoundedRect(bodyRect, cornerRadius, cornerRadius); + + painter->setPen(Qt::NoPen); + painter->setBrush(rect.selected ? m_selectedHandleColor : (rect.userModified ? m_modifiedHandleColor : m_handleColor)); + 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); + } + } +} + +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 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 (!hitLeft && !hitRight) { + continue; + } + + HitResult hit; + hit.rectIndex = i; + hit.isLeftEdge = hitLeft && (!hitRight || std::abs(posPx.x() - leftPx) <= std::abs(posPx.x() - rightPx)); + 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; +} + +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 new file mode 100644 index 0000000000000..6b5bd8c4bee5d --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -0,0 +1,113 @@ +/* + * 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; + 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); + + 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 setSelectedFillColor(const QColor& color); + 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; + + bool isDragging() const { return m_pressed; } + +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; + void mouseUngrabEvent() 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_selectedFillColor; + QColor m_modifiedFillColor; + QColor m_borderColor; + QColor m_handleColor; + QColor m_selectedHandleColor; + QColor m_modifiedHandleColor; + + 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); +} 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;