diff --git a/muse b/muse index 07c98aa2c85ba..836dbaff3de5d 160000 --- a/muse +++ b/muse @@ -1 +1 @@ -Subproject commit 07c98aa2c85baeb0a12ab9ba53cc47af8885e11f +Subproject commit 836dbaff3de5d7dba56d4f4e44cc5fb0f86c6fc7 diff --git a/src/engraving/api/v1/elements.h b/src/engraving/api/v1/elements.h index 4cc9966e8d8fd..24e51592302ca 100644 --- a/src/engraving/api/v1/elements.h +++ b/src/engraving/api/v1/elements.h @@ -1426,6 +1426,11 @@ class Note : public EngravingItem /// The tuning of this note, in cents. API_PROPERTY_T(qreal, tuning, TUNING) + /// The start time offset for playback, in ticks. + API_PROPERTY_T(int, playbackStartOffset, PLAYBACK_START_OFFSET) + /// The duration offset for playback, in ticks. + API_PROPERTY_T(int, playbackDurationOffset, PLAYBACK_DURATION_OFFSET) + /// For notes on non-tab staves: the line this note is on. API_PROPERTY_T(int, line, LINE) /// For notes on non-tab staves: Whether this note is diff --git a/src/engraving/dom/note.cpp b/src/engraving/dom/note.cpp index b9ce60983770c..edf61dbf612af 100644 --- a/src/engraving/dom/note.cpp +++ b/src/engraving/dom/note.cpp @@ -738,6 +738,8 @@ Note::Note(const Note& n, bool link) m_harmonic = n.m_harmonic; m_hasParens = n.m_hasParens; m_hideGeneratedParens = n.m_hideGeneratedParens; + m_playbackStartOffset = n.m_playbackStartOffset; + m_playbackDurationOffset = n.m_playbackDurationOffset; if (n.m_accidental) { add(new Accidental(*(n.m_accidental))); @@ -3134,6 +3136,10 @@ PropertyValue Note::getProperty(Pid propertyId) const return fixed(); case Pid::FIXED_LINE: return fixedLine(); + case Pid::PLAYBACK_START_OFFSET: + return m_playbackStartOffset; + case Pid::PLAYBACK_DURATION_OFFSET: + return m_playbackDurationOffset; case Pid::HAS_PARENTHESES: return m_hasParens ? ParenthesesMode::BOTH : ParenthesesMode::NONE; case Pid::HIDE_GENERATED_PARENTHESES: @@ -3242,6 +3248,12 @@ bool Note::setProperty(Pid propertyId, const PropertyValue& v) case Pid::FIXED_LINE: setFixedLine(v.toInt()); break; + case Pid::PLAYBACK_START_OFFSET: + setPlaybackStartOffset(v.toInt()); + break; + case Pid::PLAYBACK_DURATION_OFFSET: + setPlaybackDurationOffset(v.toInt()); + break; case Pid::HAS_PARENTHESES: if (v.value() != ParenthesesMode::BOTH && v.value() != ParenthesesMode::NONE) { ASSERT_X("Notes cannot set left & right parens individually"); @@ -3308,6 +3320,10 @@ PropertyValue Note::propertyDefault(Pid propertyId) const return 0; case Pid::TPC2: return getProperty(Pid::TPC1); + case Pid::PLAYBACK_START_OFFSET: + return 0; + case Pid::PLAYBACK_DURATION_OFFSET: + return 0; case Pid::PITCH: case Pid::TPC1: return PropertyValue(); diff --git a/src/engraving/dom/note.h b/src/engraving/dom/note.h index 7059529da3996..153ae8da42104 100644 --- a/src/engraving/dom/note.h +++ b/src/engraving/dom/note.h @@ -434,6 +434,12 @@ class Note final : public EngravingItem bool isTrillCueNote() const { return m_isTrillCueNote; } void setIsTrillCueNote(bool v); + int playbackStartOffset() const { return m_playbackStartOffset; } + void setPlaybackStartOffset(int offset) { m_playbackStartOffset = offset; } + + int playbackDurationOffset() const { return m_playbackDurationOffset; } + void setPlaybackDurationOffset(int offset) { m_playbackDurationOffset = offset; } + SymId noteHead() const; bool isNoteName() const; @@ -561,5 +567,8 @@ class Note final : public EngravingItem std::vector m_lineAttachPoints; TieJumpPointList m_jumpPoints { this }; + + int m_playbackStartOffset = 0; // offset in ticks to add to chord's tick for playback start + int m_playbackDurationOffset = 0; // offset in ticks to add to chord's ticks for playback duration }; } // namespace mu::engraving diff --git a/src/engraving/dom/property.cpp b/src/engraving/dom/property.cpp index e497f9ddc1a2e..880477a3899f6 100644 --- a/src/engraving/dom/property.cpp +++ b/src/engraving/dom/property.cpp @@ -519,6 +519,9 @@ static constexpr PropertyMetaData propertyList[] = { { Pid::SHARED_PART_ENABLED, P_TYPE::BOOL, PropertyGroup::NONE, false, "sharedPartEnabled", QT_TRANSLATE_NOOP("engraving/propertyName", "shared part enabled") }, + { Pid::PLAYBACK_START_OFFSET, P_TYPE::INT, PropertyGroup::APPEARANCE, false, "playbackStartOffset", QT_TRANSLATE_NOOP("engraving/propertyName", "playback start offset") }, + { Pid::PLAYBACK_DURATION_OFFSET, P_TYPE::INT, PropertyGroup::APPEARANCE, false, "playbackDurationOffset", QT_TRANSLATE_NOOP("engraving/propertyName", "playback duration offset") }, + { Pid::END, P_TYPE::INT, PropertyGroup::NONE, false, "++end++", nullptr } }; /* *INDENT-ON* */ diff --git a/src/engraving/dom/property.h b/src/engraving/dom/property.h index 60cbb2d00744f..0e4a5c6ea051d 100644 --- a/src/engraving/dom/property.h +++ b/src/engraving/dom/property.h @@ -528,6 +528,9 @@ enum class Pid : short { SHARED_PART_ENABLED, + PLAYBACK_START_OFFSET, + PLAYBACK_DURATION_OFFSET, + END }; diff --git a/src/engraving/playback/playbackmodel.cpp b/src/engraving/playback/playbackmodel.cpp index 97d6df4392b2a..a5683fdecbef0 100644 --- a/src/engraving/playback/playbackmodel.cpp +++ b/src/engraving/playback/playbackmodel.cpp @@ -394,6 +394,16 @@ muse::async::Channel PlaybackModel::trackRemoved() const return m_trackRemoved; } +dynamic_level_t PlaybackModel::appliableDynamicLevel(track_idx_t trackIdx, int tick) const +{ + if (!m_playbackCtx) { + return dynamicLevelFromType(muse::mpe::DynamicType::Natural); + } + + const int utick = repeatList().tick2utick(tick); + return m_playbackCtx->appliableDynamicLevel(trackIdx, utick); +} + void PlaybackModel::update(const int tickFrom, const int tickTo, const track_idx_t trackFrom, const track_idx_t trackTo, ChangedTrackIdSet* trackChanges) { diff --git a/src/engraving/playback/playbackmodel.h b/src/engraving/playback/playbackmodel.h index fd4afe6017c23..9aac710dfc8a7 100644 --- a/src/engraving/playback/playbackmodel.h +++ b/src/engraving/playback/playbackmodel.h @@ -93,6 +93,8 @@ class PlaybackModel : public muse::Contextable, public muse::async::Asyncable muse::async::Channel trackAdded() const; muse::async::Channel trackRemoved() const; + muse::mpe::dynamic_level_t appliableDynamicLevel(track_idx_t trackIdx, int tick) const; + private: static const InstrumentTrackId METRONOME_TRACK_ID; static const InstrumentTrackId CHORD_SYMBOLS_TRACK_ID; diff --git a/src/engraving/playback/renderers/noterenderer.cpp b/src/engraving/playback/renderers/noterenderer.cpp index e3bb30f0a93d3..036a1f04556af 100644 --- a/src/engraving/playback/renderers/noterenderer.cpp +++ b/src/engraving/playback/renderers/noterenderer.cpp @@ -128,6 +128,26 @@ void NoteRenderer::render(const Note* note, const RenderingContext& ctx, mpe::Pl return; } + if (note->playbackStartOffset() != 0 || note->playbackDurationOffset() != 0) { + // playbackStartOffset()/playbackDurationOffset() are ticks relative to the note's own + // nominal position - apply them on top of ctx's nominal tick range rather than + // recomputing from the chord's own tick()/ticks(). The chord's tick()/ticks() are its + // notated position/duration, which for a grace note or a note inside a repeated section + // is NOT the same as when/how long it actually plays - ctx.nominalPositionStartTick/ + // nominalPositionEndTick already reflect that (see GraceChordCtx::buildCtx), whereas + // chord->tick() would collapse a grace note's "before the beat" timing back to the + // principal note's tick. + const int nominalStartTick = ctx.nominalPositionStartTick; + const int nominalEndTick = ctx.nominalPositionEndTick + note->playbackDurationOffset(); + const int effectiveStartTick = std::max(0, nominalStartTick + note->playbackStartOffset()); + const int effectiveDurationTicks = std::max(1, nominalEndTick - effectiveStartTick); + + auto effectiveTnD = timestampAndDurationFromStartAndDurationTicks(ctx.score, effectiveStartTick, effectiveDurationTicks, + ctx.positionTickOffset); + noteCtx.timestamp = effectiveTnD.timestamp; + noteCtx.duration = effectiveTnD.duration; + } + const Tie* tieFor = note->tieFor(); if (tieFor && tieFor->playSpanner()) { if (tieFor->isPartialTie()) { diff --git a/src/engraving/rw/read500/tread.cpp b/src/engraving/rw/read500/tread.cpp index 95e43e82d1555..a3f86dd44931c 100644 --- a/src/engraving/rw/read500/tread.cpp +++ b/src/engraving/rw/read500/tread.cpp @@ -3470,6 +3470,8 @@ bool TRead::readProperties(Note* n, XmlReader& e, ReadContext& ctx) } else if (tag == "overrideBendVisibilityRules") { n->setOverrideBendVisibilityRules(e.readBool()); } else if (TRead::readProperty(n, tag, e, ctx, Pid::HIDE_GENERATED_PARENTHESES)) { + } else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_START_OFFSET)) { + } else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_DURATION_OFFSET)) { } else if (readItemProperties(n, e, ctx)) { } else { return false; diff --git a/src/engraving/rw/write/twrite.cpp b/src/engraving/rw/write/twrite.cpp index f3c704b6adf29..84d286394de69 100644 --- a/src/engraving/rw/write/twrite.cpp +++ b/src/engraving/rw/write/twrite.cpp @@ -2505,8 +2505,9 @@ void TWrite::write(const Note* item, XmlWriter& xml, WriteContext& ctx) xml.endElement(); } for (Pid id : { Pid::PITCH, Pid::CENT_OFFSET, Pid::TPC1, Pid::TPC2, Pid::SMALL, Pid::MIRROR_HEAD, Pid::DOT_POSITION, - Pid::HEAD_SCHEME, Pid::HEAD_GROUP, Pid::USER_VELOCITY, Pid::PLAY, Pid::TUNING, Pid::FRET, Pid::STRING, - Pid::GHOST, Pid::DEAD, Pid::HEAD_TYPE, Pid::FIXED, Pid::FIXED_LINE }) { + Pid::HEAD_SCHEME, Pid::HEAD_GROUP, Pid::VELO_TYPE, Pid::USER_VELOCITY, Pid::PLAY, Pid::TUNING, Pid::FRET, + Pid::STRING, Pid::GHOST, Pid::DEAD, Pid::HEAD_TYPE, Pid::FIXED, Pid::FIXED_LINE, + Pid::PLAYBACK_START_OFFSET, Pid::PLAYBACK_DURATION_OFFSET }) { writeProperty(item, xml, id); } diff --git a/src/engraving/tests/note_tests.cpp b/src/engraving/tests/note_tests.cpp index 99aed7a3410b0..1f42733412c0f 100644 --- a/src/engraving/tests/note_tests.cpp +++ b/src/engraving/tests/note_tests.cpp @@ -149,6 +149,14 @@ TEST_F(Engraving_NoteTests, note) EXPECT_EQ(n->userVelocity(), 71); delete n; + // playback start/duration offset + note->setPlaybackStartOffset(120); + note->setPlaybackDurationOffset(-60); + n = toNote(ScoreRW::writeReadElement(note)); + EXPECT_EQ(n->playbackStartOffset(), 120); + EXPECT_EQ(n->playbackDurationOffset(), -60); + delete n; + // tuning note->setTuning(1.3); n = toNote(ScoreRW::writeReadElement(note)); diff --git a/src/notation/CMakeLists.txt b/src/notation/CMakeLists.txt index d697fa3d44d7a..c60b68f41ea7b 100644 --- a/src/notation/CMakeLists.txt +++ b/src/notation/CMakeLists.txt @@ -36,6 +36,8 @@ target_sources(notation PRIVATE inotationselectionfilter.h inotationselectionrange.h inotationautomation.h + inotationnoteoffsets.h + inotationnotevelocity.h inotationinteraction.h inotationstyle.h inotationundostack.h @@ -83,6 +85,10 @@ target_sources(notation PRIVATE internal/notationcontextconfiguration.h internal/notationautomation.cpp internal/notationautomation.h + internal/notationnoteoffsets.cpp + internal/notationnoteoffsets.h + internal/notationnotevelocity.cpp + internal/notationnotevelocity.h internal/notationelements.cpp internal/notationelements.h internal/notationinteraction.cpp diff --git a/src/notation/imasternotation.h b/src/notation/imasternotation.h index a90c27dc17784..6d6f3e5edd5c3 100644 --- a/src/notation/imasternotation.h +++ b/src/notation/imasternotation.h @@ -72,6 +72,8 @@ class IMasterNotation virtual void initNotationSoloMuteState(const INotationPtr notation) = 0; virtual INotationAutomationPtr automation() const = 0; + virtual INotationNoteOffsetsPtr noteOffsets() const = 0; + virtual INotationNoteVelocityPtr noteVelocity() const = 0; }; using IMasterNotationPtr = std::shared_ptr; diff --git a/src/notation/inotation_fwd.h b/src/notation/inotation_fwd.h index 7dd484b61e270..1d092b2cb2b18 100644 --- a/src/notation/inotation_fwd.h +++ b/src/notation/inotation_fwd.h @@ -84,4 +84,10 @@ using INotationPlaybackPtr = std::shared_ptr; class INotationAutomation; using INotationAutomationPtr = std::shared_ptr; + +class INotationNoteOffsets; +using INotationNoteOffsetsPtr = std::shared_ptr; + +class INotationNoteVelocity; +using INotationNoteVelocityPtr = 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/inotationnotevelocity.h b/src/notation/inotationnotevelocity.h new file mode 100644 index 0000000000000..f38d3d42eef6b --- /dev/null +++ b/src/notation/inotationnotevelocity.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 INotationNoteVelocity +{ +public: + virtual ~INotationNoteVelocity() = default; + + virtual bool isEditModeEnabled() const = 0; + virtual void setEditModeEnabled(bool enabled) = 0; + virtual muse::async::Notification editModeEnabledChanged() const = 0; +}; + +using INotationNoteVelocityPtr = std::shared_ptr; +} diff --git a/src/notation/inotationplayback.h b/src/notation/inotationplayback.h index 99ddc32b4632f..6c448edf17ca8 100644 --- a/src/notation/inotationplayback.h +++ b/src/notation/inotationplayback.h @@ -67,6 +67,10 @@ class INotationPlayback virtual muse::async::Channel trackAdded() const = 0; virtual muse::async::Channel trackRemoved() const = 0; + // Dynamic level (marking/hairpin only, no per-note override) that would apply at this tick, + // for use by UI that needs a musically-coherent baseline (e.g. a velocity editor). + virtual muse::mpe::dynamic_level_t appliableDynamicLevel(engraving::track_idx_t trackIdx, int tick) const = 0; + virtual muse::audio::secs_t totalPlayTime() const = 0; virtual muse::async::Channel totalPlayTimeChanged() const = 0; diff --git a/src/notation/internal/masternotation.cpp b/src/notation/internal/masternotation.cpp index b14f6835d9a94..9a18d3b5592ac 100644 --- a/src/notation/internal/masternotation.cpp +++ b/src/notation/internal/masternotation.cpp @@ -51,6 +51,8 @@ #include "excerptnotation.h" #include "masternotationparts.h" #include "notationautomation.h" +#include "notationnoteoffsets.h" +#include "notationnotevelocity.h" #include "types/scorecreateoptions.h" #ifdef MUE_BUILD_ENGRAVING_PLAYBACK @@ -92,6 +94,8 @@ MasterNotation::MasterNotation(project::INotationProject* project, const muse::m #endif m_notationAutomation = std::make_shared(undoStack()); + m_notationNoteOffsets = std::make_shared(); + m_notationNoteVelocity = std::make_shared(); m_parts->partsChanged().onNotify(this, [this]() { notifyAboutNotationChanged(); @@ -766,6 +770,16 @@ INotationAutomationPtr MasterNotation::automation() const return m_notationAutomation; } +INotationNoteOffsetsPtr MasterNotation::noteOffsets() const +{ + return m_notationNoteOffsets; +} + +INotationNoteVelocityPtr MasterNotation::noteVelocity() const +{ + return m_notationNoteVelocity; +} + 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..e53af4ad45af2 100644 --- a/src/notation/internal/masternotation.h +++ b/src/notation/internal/masternotation.h @@ -74,6 +74,8 @@ class MasterNotation : public IMasterNotation, public Notation, public std::enab void initNotationSoloMuteState(const INotationPtr notation) override; INotationAutomationPtr automation() const override; + INotationNoteOffsetsPtr noteOffsets() const override; + INotationNoteVelocityPtr noteVelocity() const override; private: friend class project::NotationProject; @@ -102,6 +104,8 @@ 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; + INotationNoteVelocityPtr m_notationNoteVelocity = 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/notation/internal/notationnotevelocity.cpp b/src/notation/internal/notationnotevelocity.cpp new file mode 100644 index 0000000000000..4a8b4a7f4e860 --- /dev/null +++ b/src/notation/internal/notationnotevelocity.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 "notationnotevelocity.h" + +using namespace mu::notation; + +bool NotationNoteVelocity::isEditModeEnabled() const +{ + return m_isEditModeEnabled; +} + +void NotationNoteVelocity::setEditModeEnabled(bool enabled) +{ + if (m_isEditModeEnabled == enabled) { + return; + } + m_isEditModeEnabled = enabled; + m_editModeEnabledChanged.notify(); +} + +muse::async::Notification NotationNoteVelocity::editModeEnabledChanged() const +{ + return m_editModeEnabledChanged; +} diff --git a/src/notation/internal/notationnotevelocity.h b/src/notation/internal/notationnotevelocity.h new file mode 100644 index 0000000000000..beb9fff097da6 --- /dev/null +++ b/src/notation/internal/notationnotevelocity.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 "../inotationnotevelocity.h" + +#include "async/notification.h" + +namespace mu::notation { +class NotationNoteVelocity : public INotationNoteVelocity +{ +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/notation/internal/notationplayback.cpp b/src/notation/internal/notationplayback.cpp index c7d570b37c13d..bfa25e895b2b6 100644 --- a/src/notation/internal/notationplayback.cpp +++ b/src/notation/internal/notationplayback.cpp @@ -218,6 +218,11 @@ muse::async::Channel NotationPlayback::trackRemoved() const return m_playbackModel.trackRemoved(); } +muse::mpe::dynamic_level_t NotationPlayback::appliableDynamicLevel(track_idx_t trackIdx, int tick) const +{ + return m_playbackModel.appliableDynamicLevel(trackIdx, tick); +} + void NotationPlayback::updateLoopBoundaries() { LoopBoundaries newBoundaries; diff --git a/src/notation/internal/notationplayback.h b/src/notation/internal/notationplayback.h index 0ed987fd97752..0a519b6b6462a 100644 --- a/src/notation/internal/notationplayback.h +++ b/src/notation/internal/notationplayback.h @@ -66,6 +66,8 @@ class NotationPlayback : public INotationPlayback, public muse::async::Asyncable muse::async::Channel trackAdded() const override; muse::async::Channel trackRemoved() const override; + muse::mpe::dynamic_level_t appliableDynamicLevel(engraving::track_idx_t trackIdx, int tick) const override; + muse::audio::secs_t totalPlayTime() const override; muse::async::Channel totalPlayTimeChanged() const override; diff --git a/src/notation/internal/notationplaybackstub.cpp b/src/notation/internal/notationplaybackstub.cpp index 7ae42a325e466..17f37ba9f0974 100644 --- a/src/notation/internal/notationplaybackstub.cpp +++ b/src/notation/internal/notationplaybackstub.cpp @@ -106,6 +106,11 @@ muse::async::Channel NotationPlaybackStub::trackRemoved() con return muse::async::Channel(); } +muse::mpe::dynamic_level_t NotationPlaybackStub::appliableDynamicLevel(track_idx_t, int) const +{ + return muse::mpe::dynamicLevelFromType(muse::mpe::DynamicType::Natural); +} + muse::audio::secs_t NotationPlaybackStub::totalPlayTime() const { return muse::audio::secs_t(); diff --git a/src/notation/internal/notationplaybackstub.h b/src/notation/internal/notationplaybackstub.h index 60790427c966a..eb2cd5d3a932c 100644 --- a/src/notation/internal/notationplaybackstub.h +++ b/src/notation/internal/notationplaybackstub.h @@ -52,6 +52,8 @@ class NotationPlaybackStub : public INotationPlayback muse::async::Channel trackAdded() const override; muse::async::Channel trackRemoved() const override; + muse::mpe::dynamic_level_t appliableDynamicLevel(engraving::track_idx_t trackIdx, int tick) const override; + muse::audio::secs_t totalPlayTime() const override; muse::async::Channel totalPlayTimeChanged() const override; diff --git a/src/notationscene/inotationcommandscontroller.h b/src/notationscene/inotationcommandscontroller.h index 32048863bc533..5b3bb9e20794b 100644 --- a/src/notationscene/inotationcommandscontroller.h +++ b/src/notationscene/inotationcommandscontroller.h @@ -89,6 +89,12 @@ 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 isNoteVelocityEditModeEnabled() const = 0; + virtual muse::async::Notification noteVelocityEditModeEnabledChanged() 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..36f7199adae63 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,8 @@ #include "notation/imasternotation.h" #include "notation/inotation.h" #include "notation/inotationautomation.h" // IWYU pragma: keep +#include "notation/inotationnoteoffsets.h" // IWYU pragma: keep +#include "notation/inotationnotevelocity.h" // IWYU pragma: keep #include "notation/inotationelements.h" #include "notation/inotationmidiinput.h" #include "notation/inotationnoteinput.h" @@ -581,6 +584,10 @@ 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(TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND, &Controller::toggleNoteVelocityEditor); + registerCommand(RESET_NOTE_OFFSETS_COMMAND, &Controller::resetNoteOffsets); + registerCommand(RESET_NOTE_VELOCITIES_COMMAND, &Controller::resetNoteVelocities); // TAB registerCommand(SET_DURATION_WHOLE_TAB_COMMAND, [this]() { setDuration(DurationType::V_WHOLE); }); @@ -1052,6 +1059,8 @@ 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, {} }, + { "toggle-note-velocity-editor", TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND, {} }, { "string-up", GOTO_STRING_ABOVE_COMMAND, {} }, { "string-down", GOTO_STRING_BELOW_COMMAND, {} }, { "move-up", MOVE_UP_COMMAND, {} }, @@ -1129,6 +1138,14 @@ 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); + + masterNotation->noteVelocity()->editModeEnabledChanged().onNotify(this, [this]() { + m_noteVelocityEditModeEnabledChanged.notify(); + }, Asyncable::Mode::SetReplace); } } @@ -3187,6 +3204,26 @@ 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; +} + +bool NotationActionController::isNoteVelocityEditModeEnabled() const +{ + return currentMasterNotation() ? currentMasterNotation()->noteVelocity()->isEditModeEnabled() : false; +} + +muse::async::Notification NotationActionController::noteVelocityEditModeEnabledChanged() const +{ + return m_noteVelocityEditModeEnabledChanged; +} + muse::async::Notification NotationActionController::automationModeEnabledChanged() const { return m_automationModeEnabledChanged; @@ -3259,6 +3296,77 @@ 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::toggleNoteVelocityEditor() +{ + TRACEFUNC; + + IMasterNotationPtr masterNotation = currentMasterNotation(); + if (!masterNotation) { + return; + } + + const bool isEnabled = masterNotation->noteVelocity()->isEditModeEnabled(); + masterNotation->noteVelocity()->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(); +} + +void NotationActionController::resetNoteVelocities() +{ + 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 velocities")); + for (Note* note : notes) { + note->undoChangeProperty(Pid::USER_VELOCITY, 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..155955340f010 100644 --- a/src/notationscene/internal/notationactioncontroller.h +++ b/src/notationscene/internal/notationactioncontroller.h @@ -118,6 +118,12 @@ 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 isNoteVelocityEditModeEnabled() const override; + muse::async::Notification noteVelocityEditModeEnabledChanged() const override; + bool isDebuggingCommandEnabled(const muse::rcommand::Command& command) const override; muse::async::Notification debuggingOptionsChanged() const override; @@ -269,6 +275,10 @@ class NotationActionController : public INotationCommandsController, public muse void toggleAutomation(); muse::Ret selectAutomationType(const muse::rcommand::CommandQuery& query); + void toggleNoteOffsetEditor(); + void toggleNoteVelocityEditor(); + void resetNoteOffsets(); + void resetNoteVelocities(); // commands void registerCommand(const muse::rcommand::Command&, std::function); @@ -311,6 +321,8 @@ 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; + muse::async::Notification m_noteVelocityEditModeEnabledChanged; using IsActionEnabledFunc = std::function; std::map m_isEnabledMap; diff --git a/src/notationscene/internal/notationcommandsregister.cpp b/src/notationscene/internal/notationcommandsregister.cpp index c19a54d906caa..cfe177f12a766 100644 --- a/src/notationscene/internal/notationcommandsregister.cpp +++ b/src/notationscene/internal/notationcommandsregister.cpp @@ -2914,6 +2914,34 @@ 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 { + TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND, + TranslatableString("action", "Note velocities"), + TranslatableString("action", "Toggle note velocity editor"), + InputSchema(), + Decoration(IconCode::Code::DYNAMIC_FORTE, rcommand::Checkable::Yes) + }, + CommandInfo { + RESET_NOTE_OFFSETS_COMMAND, + TranslatableString("action", "Reset note offsets"), + TranslatableString("action", "Reset note offsets"), + InputSchema(), + Decoration() + }, + CommandInfo { + RESET_NOTE_VELOCITIES_COMMAND, + TranslatableString("action", "Reset note velocities"), + TranslatableString("action", "Reset note velocities"), + 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..2370b3349719d 100644 --- a/src/notationscene/internal/notationcommandsstate.cpp +++ b/src/notationscene/internal/notationcommandsstate.cpp @@ -349,6 +349,14 @@ void NotationCommandsState::init() updateCommandStates({ TOGGLE_AUTOMATION_COMMAND }); }); + controller()->noteOffsetEditModeEnabledChanged().onNotify(this, [this]() { + updateCommandStates({ TOGGLE_NOTE_OFFSET_EDITOR_COMMAND }); + }); + + controller()->noteVelocityEditModeEnabledChanged().onNotify(this, [this]() { + updateCommandStates({ TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND }); + }); + controller()->debuggingOptionsChanged().onNotify(this, [this]() { updateCommandStates(DEBUG_COMMANDS); }); @@ -489,6 +497,14 @@ 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 (command == TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND) { + return CommandState(true, controller()->isNoteVelocityEditModeEnabled()); + } + 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..bc4fa2d4c6aff 100644 --- a/src/notationscene/internal/notationuiactions.cpp +++ b/src/notationscene/internal/notationuiactions.cpp @@ -32,6 +32,8 @@ #include "notation/imasternotation.h" #include "notation/inotation.h" #include "notation/inotationautomation.h" // IWYU pragma: keep +#include "notation/inotationnoteoffsets.h" // IWYU pragma: keep +#include "notation/inotationnotevelocity.h" // IWYU pragma: keep #include "notation/inotationinteraction.h" #include "notation/inotationnoteinput.h" // IWYU pragma: keep #include "notation/inotationselection.h" // IWYU pragma: keep @@ -55,6 +57,8 @@ 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"); +static const ActionCode TOGGLE_NOTE_VELOCITY_EDITOR_CODE("toggle-note-velocity-editor"); // avoid translation duplication @@ -2700,6 +2704,22 @@ 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 + ), + UiAction(TOGGLE_NOTE_VELOCITY_EDITOR_CODE, + mu::context::UiCtxProjectOpened, + mu::context::CTX_NOTATION_OPENED, + TranslatableString("action", "Note velocities"), + TranslatableString("action", "Toggle note velocity editor"), + IconCode::Code::DYNAMIC_FORTE, + Checkable::Yes + ), }; const UiActionList NotationUiActions::s_scoreConfigActions = { @@ -2924,11 +2944,21 @@ void NotationUiActions::init() m_controller->currentMasterNotationChanged().onNotify(this, [this]() { m_actionCheckedChanged.send({ TOGGLE_AUTOMATION_CODE }); + m_actionCheckedChanged.send({ TOGGLE_NOTE_OFFSET_EDITOR_CODE }); + m_actionCheckedChanged.send({ TOGGLE_NOTE_VELOCITY_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); + + masterNotation->noteVelocity()->editModeEnabledChanged().onNotify(this, [this]() { + m_actionCheckedChanged.send({ TOGGLE_NOTE_VELOCITY_EDITOR_CODE }); + }, Asyncable::Mode::SetReplace); } }); @@ -3047,6 +3077,16 @@ 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 (act.code == TOGGLE_NOTE_VELOCITY_EDITOR_CODE) { + const IMasterNotationPtr masterNotation = m_controller->currentMasterNotation(); + return masterNotation ? masterNotation->noteVelocity()->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..45319f0cd757a 100644 --- a/src/notationscene/notationcommands.h +++ b/src/notationscene/notationcommands.h @@ -484,6 +484,10 @@ 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 TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND("command://notation/toggle-note-velocity-editor"); +inline static const muse::rcommand::Command RESET_NOTE_OFFSETS_COMMAND("command://notation/reset-note-offsets"); +inline static const muse::rcommand::Command RESET_NOTE_VELOCITIES_COMMAND("command://notation/reset-note-velocities"); // 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..1ee4a79ddab2e 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt +++ b/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt @@ -70,6 +70,10 @@ qt_add_qml_module(notationscene_qml notationcontextmenumodel.h notationnavigator.cpp notationnavigator.h + notationnoteoffsetcontroller.h + notationnoteoffsetcontroller.cpp + notationnotevelocitycontroller.h + notationnotevelocitycontroller.cpp notationpaintview.cpp notationpaintview.h notationruler.cpp @@ -88,6 +92,12 @@ qt_add_qml_module(notationscene_qml noteinputbarmodel.h noteinputcursor.cpp noteinputcursor.h + noteoffsetoverlay.cpp + noteoffsetoverlay.h + notevelocitygeometry.cpp + notevelocitygeometry.h + notevelocityoverlay.cpp + notevelocityoverlay.h paintedengravingitem.cpp paintedengravingitem.h partlistmodel.cpp @@ -109,6 +119,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..d8b2b8dfe8236 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp @@ -22,9 +22,11 @@ #include "abstractnotationpaintview.h" +#include #include #include #include +#include #include "async/async.h" #include "log.h" @@ -35,6 +37,8 @@ #include "notation/imasternotation.h" // IWYU pragma: keep #include "notation/inotationaccessibility.h" // IWYU pragma: keep #include "notation/inotationautomation.h" +#include "notation/inotationnoteoffsets.h" +#include "notation/inotationnotevelocity.h" #include "notation/inotationelements.h" #include "notation/inotationnoteinput.h" #include "notation/inotationpainting.h" // IWYU pragma: keep @@ -111,6 +115,34 @@ 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()); + + // Clip note velocity overlays to the view bounds + m_noteVelocityOverlayContainer = new QQuickItem(this); + m_noteVelocityOverlayContainer->setClip(true); + m_noteVelocityOverlayContainer->setWidth(width()); + m_noteVelocityOverlayContainer->setHeight(height()); + connect(this, &QQuickItem::widthChanged, m_noteVelocityOverlayContainer, [this]() { + m_noteVelocityOverlayContainer->setWidth(width()); + }); + connect(this, &QQuickItem::heightChanged, m_noteVelocityOverlayContainer, [this]() { + m_noteVelocityOverlayContainer->setHeight(height()); + }); + + m_notationNoteVelocityController = std::make_unique(m_noteVelocityOverlayContainer, iocContext()); m_playbackCursor = std::make_unique(iocContext()); m_playbackCursor->setVisible(false); m_noteInputCursor = std::make_unique(iocContext(), notationConfiguration()->thinNoteInputCursor()); @@ -375,6 +407,18 @@ void AbstractNotationPaintView::onLoadNotation(INotationPtr) emit automationModeChanged(); }); + // FIXME: only un-/re-subscribe when master notation changes + m_notationNoteOffsetController->init(); + notationNoteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { + scheduleRedraw(); + }); + + // FIXME: only un-/re-subscribe when master notation changes + m_notationNoteVelocityController->init(); + notationNoteVelocity()->editModeEnabledChanged().onNotify(this, [this]() { + scheduleRedraw(); + }); + if (isMainView()) { connect(this, &QQuickPaintedItem::focusChanged, this, [this](bool focused) { if (notation()) { @@ -427,6 +471,8 @@ void AbstractNotationPaintView::onUnloadNotation(INotationPtr) notationPlayback()->loopBoundariesChanged().disconnect(this); m_notation->viewModeChanged().disconnect(this); notationAutomation()->automationModeEnabledChanged().disconnect(this); + notationNoteOffsets()->editModeEnabledChanged().disconnect(this); + notationNoteVelocity()->editModeEnabledChanged().disconnect(this); if (isMainView()) { disconnect(this, &QQuickPaintedItem::focusChanged, this, nullptr); @@ -477,6 +523,14 @@ void AbstractNotationPaintView::onMatrixChanged(const Transform& oldMatrix, cons m_notationAutomationController->setViewMatrix(newMatrix); } + if (m_notationNoteOffsetController) { + m_notationNoteOffsetController->setViewMatrix(newMatrix); + } + + if (m_notationNoteVelocityController) { + m_notationNoteVelocityController->setViewMatrix(newMatrix); + } + scheduleRedraw(); emit horizontalScrollChanged(); @@ -602,6 +656,16 @@ INotationAutomationPtr AbstractNotationPaintView::notationAutomation() const return m_notation ? m_notation->masterNotation()->automation() : nullptr; } +INotationNoteOffsetsPtr AbstractNotationPaintView::notationNoteOffsets() const +{ + return m_notation ? m_notation->masterNotation()->noteOffsets() : nullptr; +} + +INotationNoteVelocityPtr AbstractNotationPaintView::notationNoteVelocity() const +{ + return m_notation ? m_notation->masterNotation()->noteVelocity() : nullptr; +} + void AbstractNotationPaintView::onNoteInputStateChanged() { TRACEFUNC; @@ -743,8 +807,11 @@ 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 INotationNoteVelocityPtr noteVelocity = notationNoteVelocity(); + const bool dimNotation = automationMode() || (noteOffsets && noteOffsets->isEditModeEnabled()) + || (noteVelocity && noteVelocity->isEditModeEnabled()); + notation()->painting()->paintView(painter, toLogical(rect), isPrinting, dimNotation); const INotationNoteInputPtr noteInput = notationNoteInput(); if (noteInput->isNoteInputMode() && !publishMode()) { @@ -1368,6 +1435,17 @@ bool AbstractNotationPaintView::shortcutOverride(QKeyEvent* event) void AbstractNotationPaintView::keyPressEvent(QKeyEvent* event) { + // Qt::Key_Control is Cmd on macOS, Ctrl on Windows/Linux (same swap as + // Qt::ControlModifier). Only *arms* here - the actual toggle only commits on a matching + // keyReleaseEvent() with nothing else having cancelled it in between (see event(), the single + // general choke point that does the cancelling). Committing on press instead would also fire + // as a side effect of every other Cmd/Ctrl shortcut in the app (copy, undo, Ctrl-click to + // extend a selection, Ctrl-wheel zoom, ...), which all necessarily start with this same + // physical key-down. + if (event->key() == Qt::Key_Control && !event->isAutoRepeat()) { + m_offsetOverlaysTogglePending = true; + } + if (isInited()) { m_inputController->keyPressEvent(event); } @@ -1382,6 +1460,32 @@ void AbstractNotationPaintView::keyPressEvent(QKeyEvent* event) void AbstractNotationPaintView::keyReleaseEvent(QKeyEvent* event) { + // See keyPressEvent(). Swaps which of the note-offset and note-velocity + // overlays paints - and is hit-tested - on top of the other, persisting until tapped again + // (not just while held). + if (event->key() == Qt::Key_Control && !event->isAutoRepeat() && m_offsetOverlaysTogglePending) { + m_offsetOverlaysTogglePending = false; + m_offsetOverlaysOnTop = !m_offsetOverlaysOnTop; + if (m_noteOffsetOverlayContainer && m_noteVelocityOverlayContainer) { + m_noteOffsetOverlayContainer->setZ(m_offsetOverlaysOnTop ? 1.0 : 0.0); + m_noteVelocityOverlayContainer->setZ(m_offsetOverlaysOnTop ? 0.0 : 1.0); + } + + // Which overlay's cursor is shown is only re-evaluated by Qt on the next hover event + // (see the cursor-priority comments in notevelocityoverlay.cpp/noteoffsetoverlay.cpp) - + // without this, a stationary mouse keeps showing whichever overlay's cursor was on top + // *before* the swap until it happens to move even a pixel, so a click there would already + // route to the new top overlay while the cursor still displays the old one. Synthesizing + // a button-less mouse-move at the current pointer position forces Qt Quick's normal + // hover-delivery path to run again immediately, the same as a real (zero-distance) move. + if (QQuickWindow* win = window()) { + const QPointF posInWindow = win->mapFromGlobal(QCursor::pos()); + QMouseEvent hoverRefresh(QEvent::MouseMove, posInWindow, posInWindow, QCursor::pos(), + Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QCoreApplication::sendEvent(win, &hoverRefresh); + } + } + if (isInited()) { m_inputController->keyReleaseEvent(event); } @@ -1396,6 +1500,33 @@ bool AbstractNotationPaintView::event(QEvent* event) QEvent::Type eventType = event->type(); auto keyEvent = dynamic_cast(event); + // See keyPressEvent()/keyReleaseEvent(). A single general choke point for + // cancelling the pending overlay-priority toggle, instead of reproducing this check in every + // individual event handler (key, mouse press, wheel, a future trackpad-gesture or tablet + // handler, ...): every one of those event types derives from QInputEvent and carries the live + // modifier state in modifiers(), and event() is the one dispatch point they all pass through + // before reaching their specific handler. Any of them carrying Control - other than the + // Control key's own press/release, which legitimately arms/commits the toggle itself - means + // Control is being used as a modifier for something else (a shortcut, Ctrl-click, Ctrl-wheel + // zoom, ...), so the tap in progress shouldn't also toggle the overlays on release. Note this + // still can't see a key combo a native OS-level menu resolves entirely outside Qt's event + // system (observed to not be an issue for Cmd-C/Cmd-V in practice, but not guaranteed for + // every shortcut). + if (m_offsetOverlaysTogglePending) { + const bool isControlKeyEventItself = keyEvent && keyEvent->key() == Qt::Key_Control; + // A QHoverEvent is passive mouse-position tracking, not a user action - it's still a + // QInputEvent and still carries whatever modifiers happen to be held, so without this + // exclusion the pending toggle would self-cancel just from the mouse sitting still over + // the canvas while Control is held (e.g. hoverMoveEvent() is enabled here whenever note + // input mode is active), making the tap silently do nothing in that mode. + const bool isPassiveHover = dynamic_cast(event) != nullptr; + if (auto* inputEvent = dynamic_cast(event)) { + if (!isPassiveHover && (inputEvent->modifiers() & Qt::ControlModifier) && !isControlKeyEventItself) { + m_offsetOverlaysTogglePending = false; + } + } + } + bool isContextMenuEvent = ((eventType == QEvent::ShortcutOverride && keyEvent->key() == Qt::Key_Menu) || eventType == QEvent::Type::ContextMenu) && hasFocus(); diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h index 425c742f238f0..e81e42882c79d 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h @@ -41,6 +41,8 @@ #include "notationscene/inotationsceneconfiguration.h" #include "notationviewinputcontroller.h" #include "notationautomationcontroller.h" +#include "notationnoteoffsetcontroller.h" +#include "notationnotevelocitycontroller.h" #include "noteinputcursor.h" #include "notationruler.h" #include "playbackcursor.h" @@ -218,6 +220,8 @@ protected slots: INotationStylePtr notationStyle() const; INotationSelectionPtr notationSelection() const; INotationAutomationPtr notationAutomation() const; + INotationNoteOffsetsPtr notationNoteOffsets() const; + INotationNoteVelocityPtr notationNoteVelocity() const; void clear(); void initBackground(); @@ -288,6 +292,22 @@ 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; + QQuickItem* m_noteVelocityOverlayContainer = nullptr; + std::unique_ptr m_notationNoteVelocityController; + + // Toggled by a standalone Cmd/Ctrl *tap* (pressed and released with nothing + // else happening in between - see keyPressEvent()/keyReleaseEvent()/event()), swaps which of + // the two containers paints (and is hit-tested) on top - lets a note-offset edge handle a + // velocity bar visually covers become both visible and reachable again, and vice versa. Only + // committing on release, and only if nothing else used Cmd/Ctrl as a modifier in the meantime + // (event() is the single choke point that cancels the pending toggle for that), keeps this + // from firing as a side effect of every other Cmd/Ctrl shortcut in the app (copy, undo, + // Ctrl-click to extend a selection, Ctrl-wheel zoom, ...), which all still start with the same + // physical key-down this feature would otherwise see first. + bool m_offsetOverlaysOnTop = false; + bool m_offsetOverlaysTogglePending = false; 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..d25eef1824d46 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp @@ -61,6 +61,18 @@ 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); + } + + const INotationNoteVelocityPtr noteVelocity = this->noteVelocity(); + if (noteVelocity && noteVelocity->isEditModeEnabled()) { + items << makeSeparator() + << makeMenuItem(RESET_NOTE_VELOCITIES_COMMAND); + } + setItems(items); } @@ -536,6 +548,18 @@ INotationAutomationPtr NotationContextMenuModel::automation() const return masterNotation ? masterNotation->automation() : nullptr; } +INotationNoteOffsetsPtr NotationContextMenuModel::noteOffsets() const +{ + IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + return masterNotation ? masterNotation->noteOffsets() : nullptr; +} + +INotationNoteVelocityPtr NotationContextMenuModel::noteVelocity() const +{ + IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + return masterNotation ? masterNotation->noteVelocity() : 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..7adea55346d6a 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h @@ -29,6 +29,8 @@ #include "notation/inotationinteraction.h" #include "notation/inotationautomation.h" +#include "notation/inotationnoteoffsets.h" +#include "notation/inotationnotevelocity.h" #include "notation/inotationconfiguration.h" namespace mu::notation { @@ -80,6 +82,8 @@ class NotationContextMenuModel : public muse::uicomponents::AbstractMenuModel INotationInteractionPtr interaction() const; INotationSelectionPtr selection() const; INotationAutomationPtr automation() const; + INotationNoteOffsetsPtr noteOffsets() const; + INotationNoteVelocityPtr noteVelocity() 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/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp new file mode 100644 index 0000000000000..60fd279f50e63 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -0,0 +1,755 @@ +/* + * 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 "notationnotevelocitycontroller.h" + +#include "notevelocityoverlay.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 "engraving/types/types.h" + +#include "mpe/mpetypes.h" + +#include "notation/imasternotation.h" +#include "notation/inotation.h" +#include "notation/inotationinteraction.h" +#include "notation/inotationnotevelocity.h" +#include "notation/inotationplayback.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; + +// Reserve velocity 0 for the model's own "no override, fall back to the dynamic marking" sentinel +// (Note::userVelocity() == 0) - the overlay itself always writes an explicit absolute value, so it +// never produces that sentinel by accident. +constexpr static int MIN_DRAGGABLE_VELOCITY = 1; +constexpr static int MAX_DRAGGABLE_VELOCITY = 127; + +// A mouse-move event fires far more often than the velocity value actually needs to be re-heard - +// without a minimum gap between auditions, a fast drag retriggers the sound almost every pixel of +// movement, which sounds like a machine gun rather than a musical preview. +constexpr static qint64 AUDITION_MIN_INTERVAL_MS = 200; + +constexpr static double BAR_HALF_WIDTH_SP = 0.45; +constexpr static double BAND_V_PADDING_SP = 0.3; + +NotationNoteVelocityController::NotationNoteVelocityController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx) + : muse::Contextable(iocCtx), m_overlaysParent(overlaysParent) +{ +} + +void NotationNoteVelocityController::init() +{ + IF_ASSERT_FAILED(noteVelocity() && currentNotation()) { + return; + } + + onCurrentNotationChanged(); + + noteVelocity()->editModeEnabledChanged().onNotify(this, [this]() { + if (noteVelocity()->isEditModeEnabled()) { + rebuildAllOverlays(); + } else { + updateOverlaysGeometry(); + } + }, Asyncable::Mode::SetReplace); + + globalContext()->currentNotationChanged().onNotify(this, [this]() { + onCurrentNotationChanged(); + }, Asyncable::Mode::SetReplace); +} + +void NotationNoteVelocityController::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(); + + 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 NotationNoteVelocityController::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 (noteVelocity() && noteVelocity()->isEditModeEnabled()) { + rebuildAllOverlays(); + } + }); +} + +void NotationNoteVelocityController::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 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 NotationNoteVelocityController::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; + } + + 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); + + std::vector chordNotes = chord->notes(); + // Highest pitch first - matches NoteVelocityOverlay's expected back-to-front paint + // order, so chord notes stack with the lowest-pitched note's bar fully in front. + std::sort(chordNotes.begin(), chordNotes.end(), [](const Note* a, const Note* b) { + return a->line() < b->line(); + }); + + for (Note* note : chordNotes) { + if (note->tieBack()) { + // Playback (NoteRenderer::shouldRender) skips tied-continuation notes + // entirely in most cases, so their own velocity would silently do nothing - + // don't offer a handle that can't actually affect anything. + continue; + } + + NoteEntry entry; + entry.note = note; + entry.leftX = note->canvasX() - BAR_HALF_WIDTH_SP * note->spatium(); + entry.rightX = note->canvasX() + BAR_HALF_WIDTH_SP * note->spatium(); + entry.yRange = noteVelocityYRange(note); + entries.push_back(entry); + } + } + } + + if (entries.empty()) { + return; + } + + const double vPadding = BAND_V_PADDING_SP * entries.front().note->spatium(); + const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); + + double minY = staffCanvasRect.top(); + double maxY = staffCanvasRect.bottom(); + for (const NoteEntry& entry : entries) { + minY = std::min({ minY, entry.yRange.y0, entry.yRange.y127 }); + maxY = std::max({ maxY, entry.yRange.y0, entry.yRange.y127 }); + } + minY -= vPadding; + maxY += vPadding; + + 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 (const NoteEntry& entry : entries) { + NoteVelocityOverlay::RectData rect; + rect.leftN = (entry.leftX - overlayCanvasRect.x()) / overlayCanvasRect.width(); + rect.rightN = (entry.rightX - overlayCanvasRect.x()) / overlayCanvasRect.width(); + rect.y0N = (entry.yRange.y0 - overlayCanvasRect.y()) / overlayCanvasRect.height(); + const int velocity = displayedVelocity(entry.note); + const double initialTopY = canvasYFromVelocity(entry.yRange, velocity); + rect.yTopN = (initialTopY - overlayCanvasRect.y()) / overlayCanvasRect.height(); + rect.selected = muse::contains(selected, entry.note); + rect.userModified = entry.note->userVelocity() != 0; + rect.velocity = velocity; + rects.push_back(rect); + } + + const SysStaffKey key { system, staffIdx }; + for (int i = 0; i < static_cast(entries.size()); ++i) { + m_noteLocations[entries[i].note] = NoteLocation { key, i }; + } + + NoteVelocityOverlay* 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 NoteVelocityOverlay(m_overlaysParent); + overlay->setRects(rects); + applyOverlayColors(overlay); + overlay->setVisible(false); + + QObject::connect(overlay, &NoteVelocityOverlay::barDragged, [this, key](int rectIndex, qreal deltaYN, bool completed) { + onBarDragged(key, rectIndex, deltaYN, completed); + }); + QObject::connect(overlay, &NoteVelocityOverlay::dragCancelled, [this, key](int rectIndex) { + onDragCancelled(key, rectIndex); + }); + } + + StaffOverlayData data; + data.overlay = overlay; + data.notes = std::move(entries); + data.bandRect = overlayCanvasRect; + newOverlays[key] = std::move(data); +} + +void NotationNoteVelocityController::applyOverlayColors(NoteVelocityOverlay* overlay) const +{ + IF_ASSERT_FAILED(overlay) { + return; + } + + overlay->setFillColor(QColor(90, 180, 140, 220)); + overlay->setSelectedFillColor(QColor(60, 160, 210, 235)); + overlay->setModifiedFillColor(QColor(235, 140, 40, 230)); + overlay->setBorderColor(QColor(50, 130, 100, 255)); + + // The value-label chip needs to stay legible against whatever the score's own background + // currently is (light/dark/high-contrast paper, or a user-customized color) - picking its + // colors from that background's luminance, rather than hardcoding per theme, keeps it correct + // even for a custom paper color that doesn't match either preset. + const QColor background = notationConfiguration() ? notationConfiguration()->backgroundColor() : QColor(Qt::white); + const double luminance = 0.299 * background.red() + 0.587 * background.green() + 0.114 * background.blue(); + if (luminance > 128.0) { + overlay->setValueLabelColors(QColor(40, 40, 40, 235), QColor(255, 255, 255)); + } else { + overlay->setValueLabelColors(QColor(235, 235, 235, 235), QColor(20, 20, 20)); + } +} + +void NotationNoteVelocityController::updateOverlaysGeometry() +{ + const bool visible = noteVelocity() && noteVelocity()->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 NotationNoteVelocityController::updateSelectionHighlight() +{ + if (!noteVelocity() || !noteVelocity()->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) { + NoteVelocityOverlay::RectData rect = rects.at(i); + rect.selected = isSelected; + data.overlay->updateRect(i, rect); + } + } + } +} + +void NotationNoteVelocityController::setViewMatrix(const muse::draw::Transform& viewMatrix) +{ + if (viewMatrix == m_viewMatrix) { + return; + } + m_viewMatrix = viewMatrix; + + if (noteVelocity() && noteVelocity()->isEditModeEnabled()) { + updateOverlaysGeometry(); + } +} + +std::vector NotationNoteVelocityController::selectedNotes() const +{ + const INotationPtr notation = currentNotation(); + if (!notation || !notation->interaction() || !notation->interaction()->selection()) { + return {}; + } + + return notation->interaction()->selection()->notes(); +} + +void NotationNoteVelocityController::previewBarHeight(const NoteLocation& location, int newVelocity) +{ + 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 double newTopY = canvasYFromVelocity(entry.yRange, newVelocity); + + 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. + NoteVelocityOverlay::RectData rect = rects.at(location.rectIndex); + rect.yTopN = (newTopY - data.bandRect.y()) / data.bandRect.height(); + rect.velocity = newVelocity; + data.overlay->updateRect(location.rectIndex, rect); +} + +void NotationNoteVelocityController::auditionNote(const Note* note, int velocity) +{ + IF_ASSERT_FAILED(note && note->chord()) { + return; + } + + // playNotes() always flushes the track's sound (all-notes-off, sustain/sostenuto reset) before + // playing - fine for a one-off preview, but retriggering that every ~200ms while real playback + // is running would audibly cut the actual transport playback instead of just previewing a + // value. Skip the audition rather than fight the transport for the track. + if (playbackController()->isPlaying()) { + return; + } + + // A throwaway NoteVal, never written to the real Note - playNotes() builds its own temporary + // Chord/Note from this to play, so the live drag value is heard without touching the score + // (or needing an undo entry) until the drag is actually committed. + NoteVal nval; + nval.pitch = note->pitch(); + nval.tpc1 = note->tpc1(); + nval.tpc2 = note->tpc2(); + nval.headGroup = note->headGroup(); + nval.velocityOverride = velocity; + + playbackController()->playNotes({ nval }, note->staffIdx(), note->chord()->segment()); +} + +bool NotationNoteVelocityController::auditionThrottleElapsed() const +{ + return !m_auditionThrottle.isValid() || m_auditionThrottle.elapsed() >= AUDITION_MIN_INTERVAL_MS; +} + +void NotationNoteVelocityController::markAudition(int velocity) +{ + m_lastAuditionedVelocity = velocity; + m_auditionThrottle.restart(); +} + +void NotationNoteVelocityController::resetAuditionThrottle() +{ + m_lastAuditionedVelocity = -1; + m_auditionThrottle.invalidate(); +} + +void NotationNoteVelocityController::onDragCancelled(const SysStaffKey& key, int rectIndex) +{ + resetAuditionThrottle(); + + 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; + } + + Note* draggedNote = dataIt->second.notes.at(rectIndex).note; + IF_ASSERT_FAILED(draggedNote) { + return; + } + + // previewBarHeight() calls during the drag mutate an overlay's rect directly, without ever + // touching the score - a grab stolen mid-drag (e.g. a popup opening) means no final + // barDragged(..., completed=true) ever arrives to settle those back to each note's real + // value, so without this the bar(s) would keep showing the live-preview height indefinitely, + // out of sync with the note's actual (untouched) velocity. If the dragged note was part of a + // multi-note selection, onBarDragged() would have live-previewed every selected note (and + // their forward tie chains) too - revert all of those the same way, not just the one bar that + // happened to own the mouse grab. + std::vector affectedNotes { draggedNote }; + const std::vector selected = selectedNotes(); + if (selected.size() > 1 && muse::contains(selected, draggedNote)) { + affectedNotes = selected; + } + + std::vector notesToRevert = affectedNotes; + for (Note* note : affectedNotes) { + for (Tie* tie = note->tieFor(); tie; tie = tie->endNote() ? tie->endNote()->tieFor() : nullptr) { + Note* tied = tie->endNote(); + if (!tied || muse::contains(notesToRevert, tied)) { + break; + } + notesToRevert.push_back(tied); + } + } + + for (Note* note : notesToRevert) { + const auto locIt = m_noteLocations.find(note); + if (locIt != m_noteLocations.end()) { + previewBarHeight(locIt->second, displayedVelocity(note)); + } + } +} + +void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int rectIndex, qreal deltaYN, 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* draggedNote = draggedEntry.note; + IF_ASSERT_FAILED(draggedNote) { + return; + } + + // The whole bar is a drag handle, wherever it was clicked - deltaYN is the mouse's own + // displacement since the press, never an absolute position, so this nudges the note's velocity + // by however far the mouse has moved rather than snapping it to whatever value the click + // position happens to correspond to. Computed directly from the y0-y127 span rather than via + // velocityFromCanvasY(), which clamps its result to [0, 127] - fine for an absolute position, + // but that clamp would floor every downward (negative) delta to 0 and make the bar impossible + // to drag back down. + const double deltaCanvasY = deltaYN * data.bandRect.height(); + const double span = draggedEntry.yRange.y127 - draggedEntry.yRange.y0; + const int deltaVelocity = std::abs(span) < 1e-9 ? 0 : static_cast(std::lround(deltaCanvasY / span * 127.0)); + const int startVelocity = displayedVelocity(draggedNote); + // A genuinely zero delta (a plain click landing back on the bar's own current position, or a + // drag that ends up exactly where it started) must leave the value untouched rather than run + // it through the [MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY] clamp - otherwise a note + // whose dynamics-derived velocity is legitimately 0 (e.g. under ppppppppp) gets silently + // floored to 1 by a no-op interaction, converting it from dynamics-following to an explicit + // user override it never asked for. + const int newVelocity = deltaVelocity == 0 + ? startVelocity + : std::clamp(startVelocity + deltaVelocity, MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); + + // Let the user hear the note at its live drag value before the change is committed - only the + // bar actually being dragged, and only when the (rounded) velocity has actually changed. While + // still dragging, also never more often than AUDITION_MIN_INTERVAL_MS - a mouse-move event + // fires far more often than that, so without the time gate a fast drag retriggers the sound + // almost every pixel of movement. On release, the throttle is bypassed rather than reset first + // - otherwise the exact value that ends up committed to the score could be one the user never + // actually heard, if it changed again within the last throttle window before release. + if (newVelocity != m_lastAuditionedVelocity && (completed || auditionThrottleElapsed())) { + auditionNote(draggedNote, newVelocity); + markAudition(newVelocity); + } + if (completed) { + resetAuditionThrottle(); + } + + // If the dragged note is part of a multi-note selection, apply the same velocity delta to + // every other selected note - including notes hidden behind others in the same chord's + // stack - each clamped independently. Only what's selected moves. + const int delta = newVelocity - startVelocity; + + 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 velocity = 0; + }; + std::vector changes; + changes.reserve(affectedNotes.size()); + + for (Note* note : affectedNotes) { + if (note == draggedNote) { + changes.push_back({ note, newVelocity }); + continue; + } + + // Same reasoning as newVelocity above - a zero delta must leave every co-selected note's + // own value untouched too, rather than floor a legitimately-0 one to 1. + const int otherStart = displayedVelocity(note); + const int otherVelocity = delta == 0 ? otherStart : std::clamp(otherStart + delta, MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); + changes.push_back({ note, otherVelocity }); + } + + // A tied-continuation note either produces no playback event of its own (its own velocity is + // then irrelevant) or, in some tie configurations (a tremolo spanning the tie, a partial tie + // across a repeat, a multi-note articulation, a trill ending on the tie's start chord), is + // rendered as its own independent event using its own velocity - which was otherwise never + // touched by this overlay (createOverlayForStaff() doesn't offer it a handle at all). Mirror + // every affected note's new value onto its whole forward tie chain so neither case is left + // with a stale value. + std::vector tiedChanges; + for (const PendingChange& change : changes) { + std::vector chain { change.note }; + for (Tie* tie = change.note->tieFor(); tie; tie = tie->endNote() ? tie->endNote()->tieFor() : nullptr) { + Note* tied = tie->endNote(); + if (!tied || muse::contains(chain, tied)) { + break; + } + chain.push_back(tied); + + const bool alreadyPending = muse::contains_if(changes, [tied](const PendingChange& c) { return c.note == tied; }) + || muse::contains_if(tiedChanges, [tied](const PendingChange& c) { return c.note == tied; }); + if (!alreadyPending) { + tiedChanges.push_back({ tied, change.velocity }); + } + } + } + changes.insert(changes.end(), tiedChanges.begin(), tiedChanges.end()); + + if (!completed) { + // Live drag preview - update every affected overlay's displayed bar height without + // touching the score. + for (const PendingChange& change : changes) { + const auto locIt = m_noteLocations.find(change.note); + if (locIt != m_noteLocations.end()) { + previewBarHeight(locIt->second, change.velocity); + } + } + return; + } + + // A note whose target velocity turned out identical to what it's already effectively playing + // at (the whole gesture net out to a zero delta - e.g. a plain click that lands back on the + // bar's own current position) has nothing to write - skip it rather than pin it to an + // explicit VeloType::USER_VAL it never asked for, and skip the whole undo entry if every + // affected note turns out this way (e.g. a click that amounts to just an audition). + std::vector realChanges; + for (const PendingChange& change : changes) { + if (change.velocity != displayedVelocity(change.note)) { + realChanges.push_back(change); + } + } + if (realChanges.empty()) { + return; + } + + const INotationPtr notation = currentNotation(); + const INotationUndoStackPtr undoStack = notation ? notation->undoStack() : nullptr; + IF_ASSERT_FAILED(undoStack) { + return; + } + + // Dragging sets an absolute target (this overlay is a fixed 0-127 viewport), so every + // affected note - including a VeloType::OFFSET_VAL one whose pre-drag effective value was + // already correctly resolved via displayedVelocity() above - ends up as an absolute + // USER_VAL. Its relative-to-the-dynamic-marking behavior is intentionally traded for "this is + // now the value I dragged it to" once the user has directly edited it through this UI. + undoStack->prepareChanges(muse::TranslatableString("undoableAction", "Change note velocity")); + for (const PendingChange& change : realChanges) { + if (change.note->getProperty(mu::engraving::Pid::VELO_TYPE).value() != VeloType::USER_VAL) { + change.note->undoChangeProperty(mu::engraving::Pid::VELO_TYPE, VeloType::USER_VAL, + mu::engraving::PropertyFlags::NOSTYLE); + } + change.note->undoChangeProperty(mu::engraving::Pid::USER_VELOCITY, change.velocity, mu::engraving::PropertyFlags::NOSTYLE); + } + undoStack->commitChanges(); +} + +int NotationNoteVelocityController::contextVelocity(const Note* note) const +{ + const IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + const INotationPlaybackPtr playback = masterNotation ? masterNotation->playback() : nullptr; + if (!playback) { + return 64; + } + + const muse::mpe::dynamic_level_t level = playback->appliableDynamicLevel(note->track(), note->tick().ticks()); + const double ratio = muse::mpe::dynamicLevelToVelocityRatio(level); + return std::clamp(static_cast(std::lround(ratio * 127.0)), 0, 127); +} + +int NotationNoteVelocityController::displayedVelocity(const Note* note) const +{ + const int userVelocity = note->userVelocity(); + if (userVelocity == 0) { + return contextVelocity(note); + } + + // Note::customizeVelocity(): VeloType::USER_VAL means userVelocity() IS the absolute value, + // but VeloType::OFFSET_VAL means it's a *percentage* nudge applied on top of the dynamic + // context (velo += velo * userVelocity() / 100) - treating it as absolute here would both + // show the wrong bar height and compute a wrong drag delta for these (rare, e.g. + // plugin-authored or imported) notes. + const VeloType veloType = note->getProperty(mu::engraving::Pid::VELO_TYPE).value(); + if (veloType == VeloType::USER_VAL) { + return userVelocity; + } + + const int context = contextVelocity(note); + const int offset = static_cast(std::lround(context * userVelocity / 100.0)); + return std::clamp(context + offset, 0, 127); +} + +INotationNoteVelocityPtr NotationNoteVelocityController::noteVelocity() const +{ + const IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + return masterNotation ? masterNotation->noteVelocity() : nullptr; +} + +INotationPtr NotationNoteVelocityController::currentNotation() const +{ + return globalContext()->currentNotation(); +} + +mu::engraving::Score* NotationNoteVelocityController::score() const +{ + return currentNotation() ? currentNotation()->elements()->msScore() : nullptr; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h new file mode 100644 index 0000000000000..e9583ecafa21b --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h @@ -0,0 +1,154 @@ +/* + * 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 "modularity/ioc.h" +#include "notation/inotationconfiguration.h" +#include "notation/notationtypes.h" +#include "playback/iplaybackcontroller.h" +#include "notevelocitygeometry.h" + +namespace mu::engraving { +struct ScoreChanges; +} + +namespace mu::notation { +class NoteVelocityOverlay; + +class NotationNoteVelocityController : public muse::Contextable, public muse::async::Asyncable +{ + muse::ContextInject globalContext = { this }; + muse::GlobalInject notationConfiguration; + muse::ContextInject playbackController = { this }; + +public: + NotationNoteVelocityController(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 entry per note. Entries belonging to the same chord are kept contiguous and sorted + // highest-pitch-first, matching NoteVelocityOverlay's expected back-to-front paint order. + struct NoteEntry { + mu::engraving::Note* note = nullptr; + double leftX = 0.0; + double rightX = 0.0; + NoteVelocityYRange yRange; + }; + + 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 { + NoteVelocityOverlay* 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(NoteVelocityOverlay* overlay) const; + + void onCurrentNotationChanged(); + void scheduleRebuild(); + void onBarDragged(const SysStaffKey& key, int rectIndex, qreal deltaYN, bool completed); + void onDragCancelled(const SysStaffKey& key, int rectIndex); + void previewBarHeight(const NoteLocation& location, int newVelocity); + void auditionNote(const mu::engraving::Note* note, int velocity); + bool auditionThrottleElapsed() const; + void markAudition(int velocity); + void resetAuditionThrottle(); + + std::vector selectedNotes() const; + + // What the dynamics-marking/hairpin context alone would produce at this note's tick, with no + // per-note override - used both as the displayed baseline for unedited notes and as the base + // that a VeloType::OFFSET_VAL note's percentage override applies on top of. + int contextVelocity(const mu::engraving::Note* note) const; + + // The velocity a note effectively plays at right now: its own explicit override if it has + // one, otherwise the dynamics-marking/hairpin level alone would produce at its tick - used as + // the displayed baseline for unedited notes, so nudging one starts from a musically coherent + // value instead of an arbitrary flat default. + int displayedVelocity(const mu::engraving::Note* note) const; + + INotationNoteVelocityPtr noteVelocity() 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; + + // Avoids re-triggering the audition sound on every single mouse-move event during a drag - + // only once per actually-distinct velocity value, and never faster than a fixed minimum + // interval (see AUDITION_MIN_INTERVAL_MS). + int m_lastAuditionedVelocity = -1; + QElapsedTimer m_auditionThrottle; +}; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp index 67b99af157f47..d01c4fb83afee 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp @@ -37,7 +37,9 @@ void NotationToolBarModel::load() muse::actions::ActionCodeList itemsCodes = { "parts", "toggle-mixer", - "toggle-automation" + "toggle-automation", + "toggle-note-offset-editor", + "toggle-note-velocity-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..d7369e0153f52 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -0,0 +1,234 @@ +/* + * 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); + + painter->setPen(QPen(m_borderColor, 1.0)); + painter->setBrush(rect.selected ? m_selectedFillColor : (rect.userModified ? m_modifiedFillColor : m_fillColor)); + painter->drawRect(bodyRect); + + 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/notevelocitygeometry.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.cpp new file mode 100644 index 0000000000000..f9828f9b824e1 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.cpp @@ -0,0 +1,76 @@ +/* + * 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 "notevelocitygeometry.h" + +#include +#include + +#include "engraving/dom/note.h" +#include "engraving/dom/stafftype.h" + +using namespace mu::notation; +using namespace mu::engraving; + +// A standard 5-line staff spans 8 half-line units (4 gaps x 2 half-line units per gap), using the +// same half-line-step convention as Note::updateRelLine()/Note::line(). Anchoring the "virtual 5th +// line" this many half-line units above the staff's real bottom line is what lets a 1-line +// percussion staff (or any staff with fewer than 5 lines) get the same velocity range as a normal +// 5-line staff, without needing to special-case the line count anywhere else. +constexpr static int STANDARD_STAFF_HALF_LINE_SPAN = 8; + +NoteVelocityYRange mu::notation::noteVelocityYRange(const Note* note) +{ + IF_ASSERT_FAILED(note && note->staffType()) { + return NoteVelocityYRange(); + } + + const StaffType* st = note->staffType(); + const double halfLineStepPx = note->spatium() * 0.5 * st->lineDistance().val(); + const double noteCanvasY = note->canvasPos().y(); + const int noteLine = note->line(); + + const int bottomLine = st->bottomLine(); + const int virtualTopLine = bottomLine - STANDARD_STAFF_HALF_LINE_SPAN; + + NoteVelocityYRange range; + range.y0 = noteCanvasY + (bottomLine - noteLine) * halfLineStepPx; + range.y127 = noteCanvasY + (virtualTopLine - noteLine) * halfLineStepPx; + return range; +} + +double mu::notation::canvasYFromVelocity(const NoteVelocityYRange& range, int velocity) +{ + const double v = std::clamp(velocity, 0, 127) / 127.0; + return range.y0 + (range.y127 - range.y0) * v; +} + +int mu::notation::velocityFromCanvasY(const NoteVelocityYRange& range, double canvasY) +{ + const double span = range.y127 - range.y0; + if (std::abs(span) < 1e-9) { + return 0; + } + + const double v = (canvasY - range.y0) / span; + return std::clamp(static_cast(std::lround(v * 127.0)), 0, 127); +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.h new file mode 100644 index 0000000000000..23f6665a07c4e --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.h @@ -0,0 +1,43 @@ +/* + * 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 + +namespace mu::engraving { +class Note; +} + +// Maps a note's velocity (0-127) to a canvas Y position between its staff's bottom line +// (velocity 0) and where a 5th staff line would sit if the staff had one (velocity 127), even on +// staves that don't actually have 5 lines (e.g. 1-line percussion staves). + +namespace mu::notation { +struct NoteVelocityYRange { + double y0 = 0.0; // canvas Y of the staff's actual bottom line (velocity 0) + double y127 = 0.0; // canvas Y of the (possibly virtual) 5th line from the bottom (velocity 127) +}; + +NoteVelocityYRange noteVelocityYRange(const mu::engraving::Note* note); + +double canvasYFromVelocity(const NoteVelocityYRange& range, int velocity); +int velocityFromCanvasY(const NoteVelocityYRange& range, double canvasY); +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp new file mode 100644 index 0000000000000..752e7ef1e13c0 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -0,0 +1,364 @@ +/* + * 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 "notevelocityoverlay.h" + +#include +#include + +#include +#include +#include + +using namespace mu::notation; + +constexpr static qreal EDGE_HIT_MARGIN_PX = 4.0; +constexpr static qreal BAR_HALF_WIDTH_MARGIN_PX = 1.0; // keeps adjacent chord bars from visually touching + +// Below this, a press+release is a plain click (jump straight to that position) rather than a +// drag (nudge relative to wherever the bar already was) - see mouseReleaseEvent(). +constexpr static qreal CLICK_MOVE_THRESHOLD_PX = 3.0; + +constexpr static qreal VALUE_LABEL_FONT_PX = 11.0; +constexpr static qreal VALUE_LABEL_GAP_PX = 4.0; // horizontal gap between the bar and the label chip +constexpr static qreal VALUE_LABEL_PADDING_X_PX = 4.0; +constexpr static qreal VALUE_LABEL_PADDING_Y_PX = 2.0; +constexpr static qreal VALUE_LABEL_CORNER_RADIUS_PX = 3.0; + +NoteVelocityOverlay::NoteVelocityOverlay(QQuickItem* parent) + : QQuickPaintedItem(parent) +{ + setAcceptedMouseButtons(Qt::LeftButton); + setAcceptHoverEvents(true); +} + +void NoteVelocityOverlay::setRects(const QVector& rects) +{ + m_rects = rects; + update(); +} + +const QVector& NoteVelocityOverlay::rects() const +{ + return m_rects; +} + +void NoteVelocityOverlay::updateRect(int index, const RectData& rect) +{ + if (index < 0 || index >= m_rects.size()) { + return; + } + + m_rects[index] = rect; + update(); +} + +void NoteVelocityOverlay::setFillColor(const QColor& color) +{ + m_fillColor = color; + update(); +} + +void NoteVelocityOverlay::setSelectedFillColor(const QColor& color) +{ + m_selectedFillColor = color; + update(); +} + +void NoteVelocityOverlay::setModifiedFillColor(const QColor& color) +{ + m_modifiedFillColor = color; + update(); +} + +void NoteVelocityOverlay::setBorderColor(const QColor& color) +{ + m_borderColor = color; + update(); +} + +void NoteVelocityOverlay::setValueLabelColors(const QColor& background, const QColor& text) +{ + m_valueLabelBgColor = background; + m_valueLabelTextColor = text; + update(); +} + +void NoteVelocityOverlay::paint(QPainter* painter) +{ + if (m_rects.isEmpty()) { + return; + } + + painter->setRenderHint(QPainter::Antialiasing); + painter->setPen(QPen(m_borderColor, 1.0)); + + const auto drawBar = [&](const RectData& rect) { + const qreal leftPx = rect.leftN * width() + BAR_HALF_WIDTH_MARGIN_PX; + const qreal rightPx = rect.rightN * width() - BAR_HALF_WIDTH_MARGIN_PX; + const qreal topPx = rect.yTopN * height(); + const qreal basePx = rect.y0N * height(); + + const QRectF barRect(leftPx, topPx, std::max(0.0, rightPx - leftPx), std::max(0.0, basePx - topPx)); + + painter->setBrush(rect.selected ? m_selectedFillColor : (rect.userModified ? m_modifiedFillColor : m_fillColor)); + painter->drawRect(barRect); + }; + + // Bars are stored in back-to-front paint order (see header comment) - simply painting each + // one's fully opaque body in order reproduces the stacked/overlapping look of a DAW velocity + // lane, with no extra bookkeeping needed here. + for (const RectData& rect : m_rects) { + if (!rect.selected) { + drawBar(rect); + } + } + + // A selected note's bar must stay fully visible (and, per hitTestPx(), clickable) no matter + // where it sits in the pitch-based stacking order - otherwise selecting a chord note that isn't + // the pitch-frontmost one leaves its bar hidden behind another note's, with no way to drag it. + // Redraw selected bars last so they always end up on top. + for (const RectData& rect : m_rects) { + if (rect.selected) { + drawBar(rect); + } + } + + // Only the bar actually being dragged gets a live numeric readout, to keep the staff + // uncluttered the rest of the time (matches Dorico's convention for its velocity lane). + if (m_pressed && m_activeRectIndex >= 0 && m_activeRectIndex < m_rects.size()) { + paintValueLabel(painter, m_rects.at(m_activeRectIndex)); + } +} + +void NoteVelocityOverlay::paintValueLabel(QPainter* painter, const RectData& rect) const +{ + const QString text = QString::number(rect.velocity); + + QFont font = painter->font(); + font.setPixelSize(static_cast(VALUE_LABEL_FONT_PX)); + painter->setFont(font); + + const QFontMetrics metrics(font); + const QSize textSize = metrics.size(Qt::TextSingleLine, text); + + const qreal chipWidth = textSize.width() + 2 * VALUE_LABEL_PADDING_X_PX; + const qreal chipHeight = textSize.height() + 2 * VALUE_LABEL_PADDING_Y_PX; + + const qreal leftPx = rect.leftN * width(); + const qreal rightPx = rect.rightN * width(); + const qreal topPx = rect.yTopN * height(); + + // Prefer sitting to the right of the bar; flip to the left if there isn't room, rather than + // letting the chip run off the edge of the overlay. + qreal chipLeft = rightPx + VALUE_LABEL_GAP_PX; + if (chipLeft + chipWidth > width()) { + chipLeft = leftPx - VALUE_LABEL_GAP_PX - chipWidth; + } + chipLeft = std::clamp(chipLeft, 0.0, std::max(0.0, width() - chipWidth)); + + const qreal chipTop = std::clamp(topPx - chipHeight / 2.0, 0.0, std::max(0.0, height() - chipHeight)); + + const QRectF chipRect(chipLeft, chipTop, chipWidth, chipHeight); + + painter->setPen(Qt::NoPen); + painter->setBrush(m_valueLabelBgColor); + painter->drawRoundedRect(chipRect, VALUE_LABEL_CORNER_RADIUS_PX, VALUE_LABEL_CORNER_RADIUS_PX); + + painter->setPen(m_valueLabelTextColor); + painter->drawText(chipRect, Qt::AlignCenter, text); +} + +int NoteVelocityOverlay::hitTestPx(const QPointF& posPx) const +{ + // Only bars whose horizontal span contains the click are candidates - chord columns never + // overlap in X, so this alone isolates the relevant column. + QVector candidates; + for (int i = 0; i < m_rects.size(); ++i) { + const RectData& r = m_rects.at(i); + const qreal leftPx = r.leftN * width(); + const qreal rightPx = r.rightN * width(); + if (posPx.x() >= leftPx && posPx.x() <= rightPx) { + candidates.push_back(i); + } + } + + if (candidates.isEmpty()) { + return -1; + } + + // A selected bar is always redrawn on top of every other bar in its column (see paint()), so + // it must win hit-testing too, regardless of pitch-based stacking order - otherwise a selected + // chord note that isn't the pitch-frontmost one would be visible but not draggable. Selected + // bars occlude everything below them, so account for all of them up front... + qreal minTopSoFarPx = std::numeric_limits::max(); + for (int idx : candidates) { + const RectData& r = m_rects.at(idx); + if (r.selected) { + minTopSoFarPx = std::min(minTopSoFarPx, r.yTopN * height()); + } + } + + // ...then let each selected bar claim any click within its own full body, ignoring occlusion + // from other selected bars (there's normally at most one per column anyway). + for (int idx : candidates) { + const RectData& r = m_rects.at(idx); + if (!r.selected) { + continue; + } + const qreal topPx = r.yTopN * height(); + const qreal basePx = r.y0N * height(); + if (posPx.y() >= topPx - EDGE_HIT_MARGIN_PX && posPx.y() <= basePx) { + return idx; + } + } + + // candidates preserve the original back-to-front order - scanning in reverse visits the + // frontmost (lowest-pitched) unselected bar first, exactly matching what's actually visible + // once any selected bar's on-top redraw (accounted for above) is factored in. + for (auto it = candidates.rbegin(); it != candidates.rend(); ++it) { + const RectData& r = m_rects.at(*it); + if (r.selected) { + continue; + } + const qreal topPx = r.yTopN * height(); + const qreal basePx = r.y0N * height(); + const qreal exposedBottomPx = std::min(basePx, minTopSoFarPx); + + if (posPx.y() >= topPx - EDGE_HIT_MARGIN_PX && posPx.y() <= exposedBottomPx) { + return *it; + } + + minTopSoFarPx = std::min(minTopSoFarPx, topPx); + } + + return -1; +} + +void NoteVelocityOverlay::hoverMoveEvent(QHoverEvent* e) +{ + // Which item's cursor actually gets displayed over an overlap is decided by QQuickWindow from + // each item's *declared* cursor (whichever topmost item has ever called setCursor()) - it has + // nothing to do with which item's hoverMoveEvent ignore()s the event. NoteOffsetOverlay + // unconditionally declares a cursor on every hover move, so unless this item declares (and + // un-declares) its own right here, Qt falls through to the offset overlay's stale declaration + // underneath even where a bar - painted on top, and already winning mouse presses via the same + // hit test - visually covers it. + const bool hoveringBar = hitTestPx(e->position()) >= 0; + if (hoveringBar == m_hoveringBar) { + return; + } + m_hoveringBar = hoveringBar; + + if (hoveringBar) { + setCursor(Qt::ArrowCursor); + } else { + unsetCursor(); + } +} + +void NoteVelocityOverlay::hoverLeaveEvent(QHoverEvent*) +{ + m_hoveringBar = false; + unsetCursor(); +} + +void NoteVelocityOverlay::mousePressEvent(QMouseEvent* e) +{ + const int hit = hitTestPx(e->position()); + if (hit < 0) { + e->ignore(); + return; + } + + m_pressed = true; + m_activeRectIndex = hit; + // Stored as a raw pixel position, not pre-divided by height() - the height a drag started at + // and the height read on a later move/release event aren't guaranteed to be the same value (a + // window resize or a view zoom/pan can call setHeight() on this item while the mouse is still + // held down), so normalizing each endpoint separately before subtracting could mix two + // different scales into one delta. Dividing the raw pixel delta by a single, current height() + // below keeps both ends of the subtraction on the same scale. + m_dragStartYPx = e->position().y(); + m_movedPastClickThreshold = false; + e->accept(); + + // A zero delta - the mouse hasn't moved yet - so the controller hears a plain click on a bar + // even if it never turns into an actual drag. + emit barDragged(m_activeRectIndex, 0.0, false); +} + +void NoteVelocityOverlay::mouseMoveEvent(QMouseEvent* e) +{ + if (!m_pressed) { + return; + } + + if (std::abs(e->position().y() - m_dragStartYPx) > CLICK_MOVE_THRESHOLD_PX) { + m_movedPastClickThreshold = true; + } + + // Not clamped to [0, 1] - unlike the drag-start position, which is always a valid in-bounds + // click on a bar, the mouse can (and, mid-drag, routinely does) move outside this item's own + // bounds while still grabbed; clamping here would flatten the delta near the edges instead of + // tracking the mouse's actual displacement all the way through. + const qreal deltaYN = (e->position().y() - m_dragStartYPx) / std::max(1.0, height()); + emit barDragged(m_activeRectIndex, deltaYN, false); +} + +void NoteVelocityOverlay::mouseReleaseEvent(QMouseEvent* e) +{ + if (!m_pressed) { + return; + } + + qreal deltaYN; + if (m_movedPastClickThreshold) { + // A real drag - unchanged relative behavior, nudging from wherever the bar already was. + deltaYN = (e->position().y() - m_dragStartYPx) / std::max(1.0, height()); + } else { + // A plain click, released without ever moving past the threshold - jump straight to the + // clicked position instead. barDragged()'s delta is always relative to the bar's *current* + // position (see its own doc comment) rather than an absolute target, so this is expressed + // as the delta from the bar's current top edge (yTopN) to the click position - the + // controller's linear canvasY -> velocity mapping means that delta alone, regardless of + // what it's measured from, resolves to exactly the velocity at the clicked position. + const qreal clickYN = e->position().y() / std::max(1.0, height()); + deltaYN = clickYN - m_rects.at(m_activeRectIndex).yTopN; + } + emit barDragged(m_activeRectIndex, deltaYN, true); + + m_pressed = false; + m_activeRectIndex = -1; +} + +void NoteVelocityOverlay::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. + if (m_pressed) { + emit dragCancelled(m_activeRectIndex); + } + m_pressed = false; + m_activeRectIndex = -1; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h new file mode 100644 index 0000000000000..735c7596fb278 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -0,0 +1,122 @@ +/* + * 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 + +class QHoverEvent; + +// NOTE: all rectangle coordinates are normalized [0, 1], relative to this item's own width/height, +// mirroring NoteOffsetOverlay's convention. +// +// Bars belonging to the same note column (i.e. sharing the same left/right X span - the notes of a +// chord) are expected to be stored in back-to-front paint order: the highest-pitched note's bar +// first (painted first, furthest back), the lowest-pitched note's bar last (painted last, frontmost +// and fully opaque). Painting each bar's opaque body in that order naturally makes a taller, +// further-back bar's tip peek out above a shorter, more-frontward one - exactly like an overlapping +// velocity lane in a DAW piano roll. hitTestPx() reconstructs the same front-to-back visibility +// order to find which bar is actually clickable at a given pixel. + +namespace mu::notation { +class NoteVelocityOverlay : public QQuickPaintedItem +{ + Q_OBJECT + +public: + struct RectData { + qreal leftN = 0.0; + qreal rightN = 0.0; + qreal y0N = 1.0; // velocity 0 (baseline) + qreal yTopN = 1.0; // current top edge, i.e. the note's velocity + bool selected = false; + bool userModified = false; // has an explicit user-set velocity, vs. the dynamics-derived default + int velocity = 0; // current velocity (0-127), shown next to the bar while it's being dragged + }; + + explicit NoteVelocityOverlay(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 setValueLabelColors(const QColor& background, const QColor& text); + + void paint(QPainter* painter) override; + + bool isDragging() const { return m_pressed; } + +signals: + // deltaYN is the mouse's own vertical displacement (normalized to this item's height) since + // the press that started this drag, not an absolute position - clicking anywhere on a bar acts + // as a drag handle for it, nudging its velocity relative to wherever it already was, rather + // than jumping the value to whatever the click position happens to correspond to. + void barDragged(int rectIndex, qreal deltaYN, bool completed); + + // Fired instead of a final barDragged() when a drag is cancelled by having its mouse grab + // stolen mid-gesture (e.g. a popup opening) - unlike barDragged(..., completed=true), this is + // NOT a commit signal (no score change should follow it); it exists so the controller can both + // reset its own live-drag-only state (e.g. audition throttling) and snap the bar's displayed + // height back to the note's actual (uncommitted) velocity - previewBarHeight() calls during + // the drag mutate the overlay's rect directly, so without this it would keep showing the + // live-preview height indefinitely, out of sync with the note's real value, until some + // unrelated rebuild happened to refresh it. + void dragCancelled(int rectIndex); + +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: + int hitTestPx(const QPointF& posPx) const; + void paintValueLabel(QPainter* painter, const RectData& rect) const; + + QVector m_rects; + + QColor m_fillColor; + QColor m_selectedFillColor; + QColor m_modifiedFillColor; + QColor m_borderColor; + QColor m_valueLabelBgColor; + QColor m_valueLabelTextColor; + + bool m_pressed = false; + int m_activeRectIndex = -1; + qreal m_dragStartYPx = 0.0; + bool m_movedPastClickThreshold = false; + bool m_hoveringBar = 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/NoteExpandableBlank.qml b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/NoteExpandableBlank.qml index 34d1b1a0bad74..220179d6dbb27 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/NoteExpandableBlank.qml +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/NoteExpandableBlank.qml @@ -42,7 +42,7 @@ ExpandableBlank { width: parent.width contentItemComponent: Column { - property int navigationRowEnd: tuningsSection.navigationRowEnd + property int navigationRowEnd: playbackDurationOffsetSection.navigationRowEnd spacing: 12 @@ -86,5 +86,48 @@ ExpandableBlank { propertyItem: root.model ? root.model.tuning : null } } + + Item { + height: childrenRect.height + width: root.width + + SpinBoxPropertyView { + id: playbackStartOffsetSection + anchors.left: parent.left + anchors.right: parent.horizontalCenter + anchors.rightMargin: 2 + + navigationName: "PlaybackStartOffset" + navigationPanel: root.navigation.panel + navigationRowStart: tuningsSection.navigationRowEnd + 1 + + titleText: qsTrc("propertiespanel", "Start offset") + propertyItem: root.model ? root.model.playbackStartOffset : null + + step: 1 + decimals: 0 + maxValue: 1920 + minValue: -1920 + } + + SpinBoxPropertyView { + id: playbackDurationOffsetSection + anchors.left: parent.horizontalCenter + anchors.leftMargin: 2 + anchors.right: parent.right + + navigationName: "PlaybackDurationOffset" + navigationPanel: root.navigation.panel + navigationRowStart: playbackStartOffsetSection.navigationRowEnd + 1 + + titleText: qsTrc("propertiespanel", "Duration offset") + propertyItem: root.model ? root.model.playbackDurationOffset : null + + step: 1 + decimals: 0 + maxValue: 1920 + minValue: -1920 + } + } } } 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 531d1e0985d19..648431feae7ad 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp @@ -21,9 +21,20 @@ */ #include "noteplaybackmodel.h" +#include +#include + #include "translation.h" #include "dataformatter.h" +#include "engraving/dom/note.h" +#include "engraving/types/types.h" + +#include "mpe/mpetypes.h" + +#include "notation/imasternotation.h" +#include "notation/inotationplayback.h" + using namespace mu::propertiespanel; NotePlaybackModel::NotePlaybackModel(QObject* parent, const muse::modularity::ContextPtr& iocCtx, IElementRepositoryService* repository) @@ -38,7 +49,58 @@ NotePlaybackModel::NotePlaybackModel(QObject* parent, const muse::modularity::Co void NotePlaybackModel::createProperties() { m_tuning = buildPropertyItem(mu::engraving::Pid::TUNING); - m_velocity = buildPropertyItem(mu::engraving::Pid::USER_VELOCITY); + + // Redirected to a dedicated callback instead of the default setPropertyValue() (which only + // ever writes the one Pid it's given) - dragging the on-canvas velocity bar always ends up as + // an absolute VeloType::USER_VAL (see NotationNoteVelocityController::onBarDragged()), and + // this mirrors that here too. Without it, typing a value into this spinbox for a + // VeloType::OFFSET_VAL note (userVelocity() is a *percentage* nudge on the dynamics-derived + // context for that type, not an absolute value) would leave VELO_TYPE untouched, silently + // reinterpreting the just-typed absolute number as a percentage the next time it's read. + auto onVelocityChanged = [this](const mu::engraving::Pid pid, const QVariant& newValue) { + if (m_elementList.empty()) { + return; + } + + beginCommand(muse::TranslatableString("undoableAction", "Change note velocity")); + + for (mu::engraving::EngravingItem* item : m_elementList) { + IF_ASSERT_FAILED(item) { + continue; + } + mu::engraving::Note* note = item->isNote() ? mu::engraving::toNote(item) : nullptr; + if (!note) { + continue; + } + + if (note->getProperty(mu::engraving::Pid::VELO_TYPE).value() + != mu::engraving::VeloType::USER_VAL) { + note->undoChangeProperty(mu::engraving::Pid::VELO_TYPE, mu::engraving::VeloType::USER_VAL, + mu::engraving::PropertyFlags::NOSTYLE); + } + + mu::engraving::PropertyFlags ps = item->propertyFlags(pid); + if (ps == mu::engraving::PropertyFlags::STYLED) { + ps = mu::engraving::PropertyFlags::UNSTYLED; + } + item->undoChangeProperty(pid, valueToElementUnits(pid, newValue, item), ps); + } + + updateNotation(); + endCommand(); + + loadProperties(); + }; + m_velocity = buildPropertyItem(mu::engraving::Pid::USER_VELOCITY, onVelocityChanged); + + // 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() @@ -49,10 +111,138 @@ void NotePlaybackModel::requestElements() void NotePlaybackModel::loadProperties() { loadPropertyItem(m_tuning, formatDoubleFunc); - loadPropertyItem(m_velocity, [](const QVariant& value) { - //! NOTE: display 64 instead of 0 in the Velocity field to avoid confusing the user - return value.toInt() == 0 ? 64 : value; - }); + loadVelocityProperty(); + loadPropertyItem(m_playbackStartOffset, headNoteElements()); + loadPropertyItem(m_playbackDurationOffset, headNoteElements()); +} + +void NotePlaybackModel::loadVelocityProperty() +{ + // loadPropertyItem()'s convertElementPropertyValueFunc only ever receives the already-read + // property value, with no way back to which element it came from - not enough to compute a + // per-note contextual fallback, so this walks m_elementList directly instead. + if (m_elementList.isEmpty()) { + m_velocity->setIsEnabled(false); + return; + } + + QVariant value; + bool isUndefined = false; + bool isModified = false; + + for (mu::engraving::EngravingItem* item : m_elementList) { + IF_ASSERT_FAILED(item) { + continue; + } + + mu::engraving::Note* note = item->isNote() ? mu::engraving::toNote(item) : nullptr; + if (!note) { + continue; + } + + const int elementValue = effectiveVelocity(note); + + if (!value.isValid()) { + value = elementValue; + } else if (!isUndefined && value.toInt() != elementValue) { + isUndefined = true; + } + + if (!isModified && note->userVelocity() != 0) { + isModified = true; + } + } + + // The displayed number alone can't distinguish "still following the dynamic context" from + // "just pinned explicitly to the same number that context happened to produce" - e.g. dragging + // a forte note's velocity bar to exactly 96 doesn't change what's displayed (96 both before and + // after), so the plain value-equality check in updateCurrentValue() would otherwise skip + // notifying entirely. Force the notification through whenever isModified is about to flip, so + // the spinbox never silently disagrees with the (always-correct) isModified-driven color. + const bool forceNotify = m_velocity->isModified() != isModified; + + m_velocity->setIsEnabled(value.isValid()); + m_velocity->updateCurrentValue(isUndefined ? QVariant() : value, forceNotify); + m_velocity->setIsModified(isModified); +} + +int NotePlaybackModel::contextVelocity(const mu::engraving::Note* note) const +{ + // What the dynamics-marking/hairpin context alone would produce at this note's tick, with no + // per-note override - falls back to a flat constant only when there's no playback available + // to ask (mirrors NotationNoteVelocityController::contextVelocity()). + const notation::IMasterNotationPtr masterNotation = context()->currentMasterNotation(); + const notation::INotationPlaybackPtr playback = masterNotation ? masterNotation->playback() : nullptr; + if (!playback) { + return 64; + } + + const muse::mpe::dynamic_level_t level = playback->appliableDynamicLevel(note->track(), note->tick().ticks()); + const double ratio = muse::mpe::dynamicLevelToVelocityRatio(level); + return std::clamp(static_cast(std::lround(ratio * 127.0)), 0, 127); +} + +int NotePlaybackModel::effectiveVelocity(const mu::engraving::Note* note) const +{ + if (!note) { + return 64; + } + + const int userVelocity = note->userVelocity(); + if (userVelocity == 0) { + // No explicit velocity set on this note - fall back to the same dynamics-derived value + // the on-canvas velocity-bar overlay already shows, instead of a flat constant that + // ignores whatever dynamic (piano, forte...) actually applies. + return contextVelocity(note); + } + + // Note::customizeVelocity(): VeloType::USER_VAL means userVelocity() IS the absolute value, + // but VeloType::OFFSET_VAL means it's a *percentage* nudge applied on top of the dynamic + // context (velo += velo * userVelocity() / 100) - treating it as absolute here would show a + // value with no relation to either the percentage or what actually plays, and disagree with + // NotationNoteVelocityController::displayedVelocity(), which this is meant to mirror. + const mu::engraving::VeloType veloType = note->getProperty(mu::engraving::Pid::VELO_TYPE).value(); + if (veloType == mu::engraving::VeloType::USER_VAL) { + return userVelocity; + } + + const int context = contextVelocity(note); + const int offset = static_cast(std::lround(context * userVelocity / 100.0)); + return std::clamp(context + offset, 0, 127); +} + +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 @@ -64,3 +254,13 @@ PropertyItem* NotePlaybackModel::velocity() const { return m_velocity; } + +PropertyItem* NotePlaybackModel::playbackStartOffset() const +{ + return m_playbackStartOffset; +} + +PropertyItem* NotePlaybackModel::playbackDurationOffset() const +{ + return m_playbackDurationOffset; +} 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 6753612c9ee3a..ee5b14911d411 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h @@ -34,20 +34,58 @@ class NotePlaybackModel : public PropertiesPanelAbstractModel Q_PROPERTY(mu::propertiespanel::PropertyItem * tuning READ tuning CONSTANT) Q_PROPERTY(mu::propertiespanel::PropertyItem * velocity READ velocity CONSTANT) + Q_PROPERTY(mu::propertiespanel::PropertyItem * playbackStartOffset READ playbackStartOffset CONSTANT) + Q_PROPERTY(mu::propertiespanel::PropertyItem * playbackDurationOffset READ playbackDurationOffset CONSTANT) public: explicit NotePlaybackModel(QObject* parent, const muse::modularity::ContextPtr& iocCtx, IElementRepositoryService* repository); PropertyItem* tuning() const; PropertyItem* velocity() const; + PropertyItem* playbackStartOffset() const; + PropertyItem* playbackDurationOffset() const; protected: 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; + + // loadPropertyItem()'s convertElementPropertyValueFunc only ever sees the already-read property + // value, not the element it came from - not enough to compute a per-note contextual fallback, so + // the velocity spinbox is loaded through this dedicated method instead of the generic one. + void loadVelocityProperty(); + + // What the dynamics-marking/hairpin context alone would produce at this note's tick, with no + // per-note override - mirrors NotationNoteVelocityController::contextVelocity(). + int contextVelocity(const mu::engraving::Note* note) const; + + // The velocity spinbox used to hardcode a flat 64 whenever a note had no explicit userVelocity() + // (0), completely ignoring any dynamic (piano, forte...) actually in effect at that note - unlike + // the on-canvas velocity-bar overlay, which already falls back to the real dynamics-derived value + // (NotationNoteVelocityController::displayedVelocity()/contextVelocity()). Mirrors that same + // fallback here so both surfaces agree - including displayedVelocity()'s VeloType::OFFSET_VAL + // handling (a percentage nudge on the context, not an absolute value). + int effectiveVelocity(const mu::engraving::Note* note) const; + PropertyItem* m_tuning = nullptr; PropertyItem* m_velocity = nullptr; + PropertyItem* m_playbackStartOffset = nullptr; + PropertyItem* m_playbackDurationOffset = nullptr; }; } diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.cpp b/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.cpp index 8ea43a5528df5..89e230a209289 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.cpp @@ -32,9 +32,9 @@ PropertyItem::PropertyItem(const mu::engraving::Pid propertyId, QObject* parent) m_propertyId = propertyId; } -void PropertyItem::updateCurrentValue(const QVariant& currentValue) +void PropertyItem::updateCurrentValue(const QVariant& currentValue, bool forceNotify) { - if (m_currentValue == currentValue) { + if (!forceNotify && m_currentValue == currentValue) { return; } diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.h b/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.h index 0f6f2f0f78695..73b1edac335b8 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.h +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.h @@ -46,7 +46,13 @@ class PropertyItem : public QObject public: explicit PropertyItem(const mu::engraving::Pid propertyId, QObject* parent = nullptr); - void updateCurrentValue(const QVariant& currentValue); + // forceNotify: emit valueChanged() even if currentValue equals the cached value. Needed by a + // property whose displayed number is a fallback computed from something other than the raw + // stored property (e.g. a note's contextual/dynamics-derived velocity when no explicit value + // is set) - the underlying state can genuinely change (unset -> explicit) while numerically + // landing on the same displayed number, which the plain equality check can't tell apart from + // "nothing changed". + void updateCurrentValue(const QVariant& currentValue, bool forceNotify = false); Q_INVOKABLE void resetToDefault(); Q_INVOKABLE void applyToStyle();