diff --git a/src/engraving/dom/mscore.cpp b/src/engraving/dom/mscore.cpp index 672aedd412e..21edae1052d 100644 --- a/src/engraving/dom/mscore.cpp +++ b/src/engraving/dom/mscore.cpp @@ -123,6 +123,8 @@ std::string MScore::errorToString(MsError err) case MsError::CANNOT_REPEAT_SELECTION: return "CANNOT_REPEAT_SELECTION"; case MsError::TRANSPOSE_NO_FRET_DIAGRAM: return "TRANSPOSE_NO_FRET_DIAGRAM"; case MsError::CANNOT_EXPLODE_IMPLODE_LOCAL_TIMESIG: return "CANNOT_EXPLODE_IMPLODE_LOCAL_TIMESIG"; + case MsError::NO_SPACE_FOR_TUPLET: return "NO_SPACE_FOR_TUPLET"; + case MsError::TUPLET_NOTE_VALUE_TOO_SHORT: return "TUPLET_NOTE_VALUE_TOO_SHORT"; } return {}; diff --git a/src/engraving/dom/mscore.h b/src/engraving/dom/mscore.h index 6ae45b4e9d3..c7899cbc82b 100644 --- a/src/engraving/dom/mscore.h +++ b/src/engraving/dom/mscore.h @@ -157,6 +157,8 @@ enum class MsError : unsigned char { CANNOT_REPEAT_SELECTION, TRANSPOSE_NO_FRET_DIAGRAM, CANNOT_EXPLODE_IMPLODE_LOCAL_TIMESIG, + NO_SPACE_FOR_TUPLET, + TUPLET_NOTE_VALUE_TOO_SHORT, }; /// \cond PLUGIN_API \private \endcond diff --git a/src/engraving/dom/score.h b/src/engraving/dom/score.h index 44a7e6ec993..56384fe44e9 100644 --- a/src/engraving/dom/score.h +++ b/src/engraving/dom/score.h @@ -421,6 +421,8 @@ class Score : public EngravingObject, public muse::Contextable Tuplet* addTuplet(ChordRest* destinationChordRest, const Fraction& ratio, const TDuration& baseLen, TupletNumberType numberType, TupletBracketType bracketType); bool canAddTuplet(const ChordRest* chordRest, const Fraction& ratio, const TDuration& baseLen) const; + bool canAddTuplet(const Segment* segment, track_idx_t track, const Fraction& ratio, const TDuration& baseLen) const; + Fraction tupletEndTick(const ChordRest* chordRest, const Fraction& ratio, const TDuration& baseLen) const; void selectNewTuplet(Tuplet* tuplet); ChordRest* addClone(ChordRest* cr, const Fraction& tick, const TDuration& d); diff --git a/src/engraving/editing/edit.cpp b/src/engraving/editing/edit.cpp index abd5ee89ba5..a8f4e94c0cd 100644 --- a/src/engraving/editing/edit.cpp +++ b/src/engraving/editing/edit.cpp @@ -279,6 +279,23 @@ Tuplet* Score::addTuplet(ChordRest* destinationChordRest, Fraction ratio, Tuplet return addTuplet(destinationChordRest, _ratio, TDuration(fr), numberType, bracketType); } +static bool isValidTuplet(const Fraction& ratio, const TDuration& baseLen) +{ + return baseLen.isValid() && !baseLen.isMeasure() && ratio.numerator() > 0 && ratio.denominator() > 0; +} + +static Fraction tupletEnd(const Staff* staff, const Fraction& tick, Tuplet* parent, const Fraction& ratio, const TDuration& baseLen) +{ + const Fraction ticks = baseLen.fraction() * ratio.denominator(); + return tick + actualTicks(ticks, parent, staff->timeStretch(tick)); +} + +// A tuplet must not cross a barline or the end of its parent tuplet +static Fraction tupletLimit(const Tuplet* parent, const Measure* measure) +{ + return parent ? parent->tick() + parent->actualTicks() : measure->endTick(); +} + //--------------------------------------------------------- // canAddTuplet // Whether addTuplet() can create a tuplet with this ratio @@ -287,7 +304,7 @@ Tuplet* Score::addTuplet(ChordRest* destinationChordRest, Fraction ratio, Tuplet bool Score::canAddTuplet(const ChordRest* chordRest, const Fraction& ratio, const TDuration& baseLen) const { - if (!chordRest || !baseLen.isValid() || baseLen.isMeasure() || ratio.numerator() <= 0 || ratio.denominator() <= 0) { + if (!chordRest || !isValidTuplet(ratio, baseLen)) { return false; } @@ -297,18 +314,52 @@ bool Score::canAddTuplet(const ChordRest* chordRest, const Fraction& ratio, cons } // changeCRlen() can't change the length of a measure repeat - const Fraction ticks = baseLen.fraction() * ratio.denominator(); - if (chordRest->isMeasureRepeat() && ticks != chordRest->ticks()) { + if (chordRest->isMeasureRepeat() && baseLen.fraction() * ratio.denominator() != chordRest->ticks()) { + return false; + } + + return tupletEndTick(chordRest, ratio, baseLen) <= tupletLimit(chordRest->tuplet(), measure); +} + +//--------------------------------------------------------- +// canAddTuplet +// The same at segment in track, where no chord/rest of +// the track may start: an empty voice, or inside a +// longer chord/rest +//--------------------------------------------------------- + +bool Score::canAddTuplet(const Segment* segment, track_idx_t track, const Fraction& ratio, const TDuration& baseLen) const +{ + if (!segment || track == muse::nidx) { + return false; + } + + if (const ChordRest* chordRest = segment->cr(track)) { + return canAddTuplet(chordRest, ratio, baseLen); + } + + const Measure* measure = segment->measure(); + const Staff* staff = this->staff(track2staff(track)); + if (!isValidTuplet(ratio, baseLen) || !measure || measure->isMMRest() || !staff) { return false; } - // the tuplet must not cross a barline or the end of a parent tuplet - Tuplet* parent = chordRest->tuplet(); - const Fraction timeStretch = chordRest->staff()->timeStretch(chordRest->tick()); - const Fraction end = chordRest->tick() + actualTicks(ticks, parent, timeStretch); - const Fraction limit = parent ? parent->tick() + parent->actualTicks() : measure->endTick(); + // inside a longer chord/rest, the tuplet would be in that chord/rest's tuplet + const ChordRest* previous = segment->nextChordRest(track, /*backwards*/ true, /*stopAtMeasureBoundary*/ true); + Tuplet* parent = previous && previous->endTick() > segment->tick() ? previous->tuplet() : nullptr; + + return tupletEnd(staff, segment->tick(), parent, ratio, baseLen) <= tupletLimit(parent, measure); +} + +//--------------------------------------------------------- +// tupletEndTick +// Where a tuplet of ratio.denominator() * baseLen that +// starts at chordRest ends +//--------------------------------------------------------- - return end <= limit; +Fraction Score::tupletEndTick(const ChordRest* chordRest, const Fraction& ratio, const TDuration& baseLen) const +{ + return tupletEnd(chordRest->staff(), chordRest->tick(), chordRest->tuplet(), ratio, baseLen); } //--------------------------------------------------------- diff --git a/src/engraving/editing/edittuplet.cpp b/src/engraving/editing/edittuplet.cpp index 196273da7e7..ec6c9ce8cc7 100644 --- a/src/engraving/editing/edittuplet.cpp +++ b/src/engraving/editing/edittuplet.cpp @@ -23,11 +23,15 @@ #include "edittuplet.h" #include +#include +#include #include #include "../dom/chord.h" #include "../dom/chordrest.h" +#include "../dom/durationtype.h" #include "../dom/input.h" +#include "../dom/mscore.h" #include "../dom/note.h" #include "../dom/score.h" #include "../dom/select.h" @@ -73,6 +77,27 @@ static std::vector selectedTupletChordRests(const Score* score) return chordRests; } +// The selected chords/rests that get a tuplet: one whose tuplet doesn't fit is left as it is (as is a measure rest, +// which has no duration to use as the subdivision), and one that the tuplet before it in its track will replace gets none +static std::vector tupletStarts(const Score* score, const Fraction& ratio) +{ + std::vector starts; + std::map tupletEndTicks; + for (ChordRest* chordRest : selectedTupletChordRests(score)) { + const auto previous = tupletEndTicks.find(chordRest->track()); + if (previous != tupletEndTicks.end() && chordRest->tick() < previous->second) { + continue; + } + if (!score->canAddTuplet(chordRest, ratio, chordRest->durationType())) { + continue; + } + tupletEndTicks[chordRest->track()] = score->tupletEndTick(chordRest, ratio, chordRest->durationType()); + starts.push_back(chordRest); + } + + return starts; +} + static void selectTupletStart(Score* score, const Tuplet* tuplet) { if (tuplet->elements().empty()) { @@ -94,9 +119,20 @@ static void selectTupletStart(Score* score, const Tuplet* tuplet) } } +bool EditTuplet::checkAddTupletsWithSelectedDurations(const Score* score, const Fraction& ratio) +{ + if (!selectedTupletChordRests(score).empty() && tupletStarts(score, ratio).empty()) { + MScore::setError(MsError::NO_SPACE_FOR_TUPLET); + return false; + } + + return true; +} + std::vector EditTuplet::addTuplets(Transaction&, Score* score, const Fraction& ratio, TupletNumberType numberType, - TupletBracketType bracketType, bool autoBaseLen) + TupletBracketType bracketType, bool autoBaseLen, TupletSelectedDuration selectedDurationAs) { + const bool asSubdivision = selectedDurationAs == TupletSelectedDuration::SUBDIVISION; Selection& selection = score->selection(); const bool wasRangeSelection = selection.isRange(); const Fraction startTick = selection.tickStart(); @@ -106,15 +142,37 @@ std::vector EditTuplet::addTuplets(Transaction&, Score* score, const Fr Box* startBox = selection.startBox(); Box* endBox = selection.endBox(); + // ticks taken before any tuplet is created + std::vector > starts; + for (ChordRest* chordRest : asSubdivision ? tupletStarts(score, ratio) : selectedTupletChordRests(score)) { + starts.emplace_back(chordRest, chordRest->tick()); + } + std::vector tuplets; - for (ChordRest* chordRest : selectedTupletChordRests(score)) { - if (Tuplet* tuplet = score->addTuplet(chordRest, dividingRatio(chordRest, ratio, autoBaseLen), numberType, bracketType)) { + // a subdivision tuplet can end after the range + Fraction rangeEndTick = endTick; + for (const auto& [chordRest, tick] : starts) { + // an earlier tuplet may have replaced it, e.g. on a linked staff + if (asSubdivision && score->findCR(tick, chordRest->track()) != chordRest) { + continue; + } + + Tuplet* tuplet = nullptr; + if (asSubdivision) { + tuplet = score->addTuplet(chordRest, ratio, chordRest->durationType(), numberType, bracketType); + if (tuplet) { + rangeEndTick = std::max(rangeEndTick, tuplet->endTick()); + } + } else { + tuplet = score->addTuplet(chordRest, dividingRatio(chordRest, ratio, autoBaseLen), numberType, bracketType); + } + if (tuplet) { tuplets.push_back(tuplet); } } if (wasRangeSelection) { - selection.setRangeTicks(startTick, endTick, startStaff, endStaff, startBox, endBox); + selection.setRangeTicks(startTick, rangeEndTick, startStaff, endStaff, startBox, endBox); selection.updateSelectedElements(); } else if (!tuplets.empty()) { selectTupletStart(score, tuplets.front()); diff --git a/src/engraving/editing/edittuplet.h b/src/engraving/editing/edittuplet.h index 91554150a13..5be01923bc2 100644 --- a/src/engraving/editing/edittuplet.h +++ b/src/engraving/editing/edittuplet.h @@ -34,9 +34,17 @@ class Transaction; class EditTuplet { public: + // Tuplets on the selected chords/rests that use their durations as the subdivision (3:2 on a quarter: 3 quarters + // in the time of 2). Tuplets that don't fit are left out, and so are measure rests; false (with + // MsError::NO_SPACE_FOR_TUPLET) if none does + static bool checkAddTupletsWithSelectedDurations(const Score* score, const Fraction& ratio); + // Divides each selected chord/rest into a tuplet (3:2 on a quarter: 3 eighths in the time of a quarter). Keeps a range // selection, otherwise selects the start of the first tuplet. Returns the tuplets in score order + // With TupletSelectedDuration::SUBDIVISION, call checkAddTupletsWithSelectedDurations() first, so that a message + // shows if no tuplet fits static std::vector addTuplets(Transaction& tx, Score* score, const Fraction& ratio, TupletNumberType numberType, - TupletBracketType bracketType, bool autoBaseLen); + TupletBracketType bracketType, bool autoBaseLen, + TupletSelectedDuration selectedDurationAs = TupletSelectedDuration::TUPLET_LENGTH); }; } diff --git a/src/engraving/tests/tuplet_tests.cpp b/src/engraving/tests/tuplet_tests.cpp index e2aa87c3d5c..2e06a6d68e6 100644 --- a/src/engraving/tests/tuplet_tests.cpp +++ b/src/engraving/tests/tuplet_tests.cpp @@ -884,6 +884,407 @@ TEST_F(Engraving_TupletTests, canAddTuplet) delete score; } +TEST_F(Engraving_TupletTests, canAddTupletInEmptyVoice) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + + // halfway through m1, where voice 2 is empty + const Segment* segment = chordRestAt(measureAt(score, 0), 4)->segment(); + ASSERT_FALSE(segment->cr(1)); + + // fits exactly up to the barline + EXPECT_TRUE(score->canAddTuplet(segment, 1, Fraction(3, 2), DurationType::V_QUARTER)); + // would cross the barline + EXPECT_FALSE(score->canAddTuplet(segment, 1, Fraction(3, 2), DurationType::V_HALF)); + // with a chord/rest in the track, the same as for that chord/rest: would cross the barline + EXPECT_FALSE(score->canAddTuplet(chordRestAt(measureAt(score, 3), 4)->segment(), 0, Fraction(3, 5), DurationType::V_EIGHTH)); + // invalid ratio + EXPECT_FALSE(score->canAddTuplet(segment, 1, Fraction(0, 2), DurationType::V_QUARTER)); + // no track + EXPECT_FALSE(score->canAddTuplet(segment, muse::nidx, Fraction(3, 2), DurationType::V_QUARTER)); + + delete score; +} + +TEST_F(Engraving_TupletTests, canAddTupletInEmptyVoiceDotted) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + + TDuration dottedEighth(DurationType::V_EIGHTH); + dottedEighth.setDots(1); + TDuration doubleDottedEighth(DurationType::V_EIGHTH); + doubleDottedEighth.setDots(2); + + // halfway through m1, where voice 2 is empty + const Segment* segment = chordRestAt(measureAt(score, 0), 4)->segment(); + // 3 dotted eighths in the time of 2: 3/8 fits + EXPECT_TRUE(score->canAddTuplet(segment, 1, Fraction(3, 2), dottedEighth)); + // 2 dotted eighths in the time of 3: 9/16 would cross the barline + EXPECT_FALSE(score->canAddTuplet(segment, 1, Fraction(2, 3), dottedEighth)); + // 3 double-dotted eighths in the time of 2: 7/16 fits + EXPECT_TRUE(score->canAddTuplet(segment, 1, Fraction(3, 2), doubleDottedEighth)); + // 2 double-dotted eighths in the time of 3: 21/32 would cross the barline + EXPECT_FALSE(score->canAddTuplet(segment, 1, Fraction(2, 3), doubleDottedEighth)); + + delete score; +} + +TEST_F(Engraving_TupletTests, canAddTupletInsideLongerChordRest) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + Measure* m1 = measureAt(score, 0); + + // voice 2 of m1: 3 quarter rests in the time of 2 over the first half + Segment* first = m1->first(SegmentType::ChordRest); + score->startCmd(TranslatableString::untranslatable("Engraving tuplet tests")); + score->expandVoice(first, 1); + score->endCmd(); + ASSERT_TRUE(first->cr(1)); + ASSERT_TRUE(addTuplet(first->cr(1), Fraction(3, 2), DurationType::V_QUARTER)); + + // the second eighth of m1, inside the first rest of that tuplet + const Segment* segment = m1->findSegment(SegmentType::ChordRest, Fraction(1, 8)); + ASSERT_TRUE(segment); + ASSERT_FALSE(segment->cr(1)); + + // 3 eighths in the time of 2 fit in the tuplet + EXPECT_TRUE(score->canAddTuplet(segment, 1, Fraction(3, 2), DurationType::V_EIGHTH)); + // 4 quarters in the time of 3 would cross the end of the tuplet, though not the barline + EXPECT_FALSE(score->canAddTuplet(segment, 1, Fraction(4, 3), DurationType::V_QUARTER)); + + delete score; +} + +TEST_F(Engraving_TupletTests, tupletEndTick) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + + const TDuration eighth(DurationType::V_EIGHTH); + + // 3 eighths in the time of 2, from the start of m1 + EXPECT_EQ(score->tupletEndTick(chordRestAt(measureAt(score, 0), 0), Fraction(3, 2), eighth), Fraction(1, 4)); + // 3 eighths in the time of 5, from halfway through m1: it ends after the barline + EXPECT_EQ(score->tupletEndTick(chordRestAt(measureAt(score, 0), 4), Fraction(3, 5), eighth), Fraction(9, 8)); + // 3 eighths in the time of 5 in the 9:8 tuplet of m3: it ends with the 9:8 tuplet + EXPECT_EQ(score->tupletEndTick(chordRestAt(measureAt(score, 2), 4), Fraction(3, 5), eighth), measureAt(score, 2)->endTick()); + + delete score; +} + +TEST_F(Engraving_TupletTests, tupletEndTickDotted) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + + TDuration dottedEighth(DurationType::V_EIGHTH); + dottedEighth.setDots(1); + TDuration doubleDottedEighth(DurationType::V_EIGHTH); + doubleDottedEighth.setDots(2); + + // from the start of m1 + ChordRest* first = chordRestAt(measureAt(score, 0), 0); + // 2 dotted eighths in the time of 3: 9/16 + EXPECT_EQ(score->tupletEndTick(first, Fraction(2, 3), dottedEighth), Fraction(9, 16)); + // 2 double-dotted eighths in the time of 3: 21/32 + EXPECT_EQ(score->tupletEndTick(first, Fraction(2, 3), doubleDottedEighth), Fraction(21, 32)); + + delete score; +} + +// Checks and adds tuplets that use the selected durations as the subdivision, as the tuplet commands do +static std::vector addTupletsWithSelectedDurations(Score* score, const Fraction& ratio) +{ + MScore::setError(MsError::MS_NO_ERROR); + if (!EditTuplet::checkAddTupletsWithSelectedDurations(score, ratio)) { + return {}; + } + + std::vector tuplets; + score->transactionManager()->transaction(TranslatableString::untranslatable("Engraving tuplet tests"), [&](Transaction& tx) { + tuplets = EditTuplet::addTuplets(tx, score, ratio, TupletNumberType::SHOW_NUMBER, TupletBracketType::AUTO_BRACKET, + /*autoBaseLen*/ true, TupletSelectedDuration::SUBDIVISION); + }); + return tuplets; +} + +TEST_F(Engraving_TupletTests, addTupletsWithSelectedDurations) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + Measure* m1 = measureAt(score, 0); + + // the first 3 eighths of m1: the first tuplet replaces the second eighth, so it gets no tuplet of its own + score->select(chordRestAt(m1, 0), SelectType::SINGLE); + score->select(chordRestAt(m1, 1), SelectType::ADD); + score->select(chordRestAt(m1, 2), SelectType::ADD); + EXPECT_EQ(addTupletsWithSelectedDurations(score, Fraction(3, 2)).size(), 2); + EXPECT_TRUE(score->sanityCheck()); + EXPECT_EQ(rhythm(m1), "3:2[8 r8 r8] 3:2[8 r8 r8] 8 8 8 8"); + + // the start of the first tuplet is selected + EXPECT_EQ(score->selection().element(), toChord(chordRestAt(m1, 0))->upNote()); + EXPECT_EQ(score->inputState().duration(), TDuration(DurationType::V_EIGHTH)); + + score->undoRedo(true, nullptr); + EXPECT_EQ(rhythm(m1), "8 8 8 8 8 8 8 8"); + + delete score; +} + +TEST_F(Engraving_TupletTests, addTupletsWithSelectedDurationsInRange) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + Measure* m1 = measureAt(score, 0); + + // a range of 1 eighth + score->select(chordRestAt(m1, 0), SelectType::RANGE); + EXPECT_EQ(addTupletsWithSelectedDurations(score, Fraction(3, 2)).size(), 1); + EXPECT_EQ(MScore::_error, MsError::MS_NO_ERROR); + EXPECT_EQ(rhythm(m1), "3:2[8 r8 r8] 8 8 8 8 8 8"); + + // the range grows to the end of the tuplet + EXPECT_TRUE(score->selection().isRange()); + EXPECT_EQ(score->selection().tickStart(), Fraction(0, 1)); + EXPECT_EQ(score->selection().tickEnd(), Fraction(1, 4)); + + score->undoRedo(true, nullptr); + EXPECT_EQ(rhythm(m1), "8 8 8 8 8 8 8 8"); + + // a range of 3 eighths: the first tuplet replaces the second eighth, and the third gets a tuplet that ends after + // the range + score->select(chordRestAt(m1, 0), SelectType::RANGE); + score->select(chordRestAt(m1, 2), SelectType::RANGE); + EXPECT_EQ(addTupletsWithSelectedDurations(score, Fraction(3, 2)).size(), 2); + EXPECT_TRUE(score->sanityCheck()); + EXPECT_EQ(rhythm(m1), "3:2[8 r8 r8] 3:2[8 r8 r8] 8 8 8 8"); + EXPECT_EQ(score->selection().tickEnd(), Fraction(1, 2)); + + score->undoRedo(true, nullptr); + EXPECT_EQ(rhythm(m1), "8 8 8 8 8 8 8 8"); + + // the whole measure: a tuplet on every other eighth + score->select(chordRestAt(m1, 0), SelectType::RANGE); + score->select(chordRestAt(m1, 7), SelectType::RANGE); + EXPECT_EQ(addTupletsWithSelectedDurations(score, Fraction(3, 2)).size(), 4); + EXPECT_TRUE(score->sanityCheck()); + EXPECT_EQ(rhythm(m1), "3:2[8 r8 r8] 3:2[8 r8 r8] 3:2[8 r8 r8] 3:2[8 r8 r8]"); + + delete score; +} + +TEST_F(Engraving_TupletTests, addTupletsWithSelectedDurationsNoSpace) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + Measure* m1 = measureAt(score, 0); + + // the last eighth's tuplet would cross the barline: nothing fits + score->select(chordRestAt(m1, 7), SelectType::SINGLE); + EXPECT_TRUE(addTupletsWithSelectedDurations(score, Fraction(3, 2)).empty()); + EXPECT_EQ(MScore::_error, MsError::NO_SPACE_FOR_TUPLET); + EXPECT_EQ(rhythm(m1), "8 8 8 8 8 8 8 8"); + + // the first one fits, the last one is left out + score->select(chordRestAt(m1, 0), SelectType::SINGLE); + score->select(chordRestAt(m1, 7), SelectType::ADD); + EXPECT_EQ(addTupletsWithSelectedDurations(score, Fraction(3, 2)).size(), 1); + EXPECT_EQ(MScore::_error, MsError::MS_NO_ERROR); + EXPECT_EQ(rhythm(m1), "3:2[8 r8 r8] 8 8 8 8 8 8"); + + score->undoRedo(true, nullptr); + + // would cross the end of the 9:8 tuplet of m3 + Measure* m3 = measureAt(score, 2); + score->select(chordRestAt(m3, 8), SelectType::SINGLE); + EXPECT_TRUE(addTupletsWithSelectedDurations(score, Fraction(3, 2)).empty()); + EXPECT_EQ(MScore::_error, MsError::NO_SPACE_FOR_TUPLET); + + delete score; +} + +TEST_F(Engraving_TupletTests, addTupletsWithSelectedDurationsInRangeNoSpace) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + Measure* m1 = measureAt(score, 0); + + TDuration dottedHalf(DurationType::V_HALF); + dottedHalf.setDots(1); + score->startCmd(TranslatableString::untranslatable("Engraving tuplet tests")); + score->changeCRlen(chordRestAt(m1, 0), dottedHalf); + score->endCmd(); + ASSERT_EQ(rhythm(m1), "2. 8 8"); + + // 3 dotted halves in the time of 2 don't fit, so they replace nothing: the next eighth gets its tuplet + score->select(chordRestAt(m1, 0), SelectType::RANGE); + score->select(chordRestAt(m1, 2), SelectType::RANGE); + EXPECT_EQ(addTupletsWithSelectedDurations(score, Fraction(3, 2)).size(), 1); + EXPECT_EQ(MScore::_error, MsError::MS_NO_ERROR); + EXPECT_TRUE(score->sanityCheck()); + EXPECT_EQ(rhythm(m1), "2. 3:2[8 r8 r8]"); + + score->undoRedo(true, nullptr); + EXPECT_EQ(rhythm(m1), "2. 8 8"); + + // a dotted half and a quarter: neither tuplet fits + score->startCmd(TranslatableString::untranslatable("Engraving tuplet tests")); + score->changeCRlen(chordRestAt(m1, 1), TDuration(DurationType::V_QUARTER)); + score->endCmd(); + ASSERT_EQ(rhythm(m1), "2. 4"); + score->select(chordRestAt(m1, 0), SelectType::RANGE); + score->select(chordRestAt(m1, 1), SelectType::RANGE); + EXPECT_TRUE(addTupletsWithSelectedDurations(score, Fraction(3, 2)).empty()); + EXPECT_EQ(MScore::_error, MsError::NO_SPACE_FOR_TUPLET); + EXPECT_EQ(rhythm(m1), "2. 4"); + + delete score; +} + +TEST_F(Engraving_TupletTests, addTupletsWithSelectedDurationsRangeAcrossStaves) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + addSecondStaff(score, /*linked*/ false); + Measure* m1 = measureAt(score, 0); + + // m1 on both staves: the eighths of the first staff get a tuplet on every other eighth, and the measure rest of the + // second staff is left as it is + score->selection().setRange(chordRestAt(m1, 0)->segment(), chordRestAt(measureAt(score, 1), 0)->segment(), 0, 2); + score->selection().updateSelectedElements(); + const std::vector tuplets = addTupletsWithSelectedDurations(score, Fraction(3, 2)); + EXPECT_EQ(MScore::_error, MsError::MS_NO_ERROR); + EXPECT_TRUE(score->sanityCheck()); + EXPECT_EQ(rhythm(m1), "3:2[8 r8 r8] 3:2[8 r8 r8] 3:2[8 r8 r8] 3:2[8 r8 r8]"); + EXPECT_TRUE(m1->first(SegmentType::ChordRest)->cr(VOICES)->durationType().isMeasure()); + + ASSERT_EQ(tuplets.size(), 4); + for (const Tuplet* tuplet : tuplets) { + EXPECT_EQ(tuplet->staffIdx(), 0); + } + + delete score; +} + +TEST_F(Engraving_TupletTests, addTupletsWithSelectedDurationsMeasureRest) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"empty.mscx"); + ASSERT_TRUE(score); + Measure* m1 = measureAt(score, 0); + + // a measure rest has no duration to use as the subdivision, so it gets no tuplet, alone or in a range + score->select(chordRestAt(m1, 0), SelectType::SINGLE); + EXPECT_TRUE(addTupletsWithSelectedDurations(score, Fraction(3, 2)).empty()); + EXPECT_EQ(MScore::_error, MsError::NO_SPACE_FOR_TUPLET); + + score->select(chordRestAt(m1, 0), SelectType::RANGE); + score->select(chordRestAt(measureAt(score, 1), 0), SelectType::RANGE); + EXPECT_TRUE(addTupletsWithSelectedDurations(score, Fraction(3, 2)).empty()); + EXPECT_EQ(MScore::_error, MsError::NO_SPACE_FOR_TUPLET); + EXPECT_EQ(rhythm(m1), "R"); + EXPECT_EQ(rhythm(measureAt(score, 1)), "R"); + + // with the selected duration as the tuplet length, each is divided + EXPECT_EQ(addTuplets(score, Fraction(3, 2)).size(), 2); + EXPECT_TRUE(score->sanityCheck()); + EXPECT_EQ(rhythm(m1), "3:2[r2 r2 r2]"); + EXPECT_EQ(rhythm(measureAt(score, 1)), "3:2[r2 r2 r2]"); + + delete score; +} + +TEST_F(Engraving_TupletTests, addTupletsWithSelectedDurationsLinkedStaff) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + addSecondStaff(score, /*linked*/ true); + Measure* m1 = measureAt(score, 0); + + // the first eighth of m1 on both staves: the tuplet on the first staff replaces the linked one + Segment* first = m1->first(SegmentType::ChordRest); + score->select(first->cr(0), SelectType::RANGE); + score->select(first->cr(VOICES), SelectType::RANGE); + ASSERT_EQ(score->getSelectedChordRests().size(), 2); + EXPECT_EQ(addTupletsWithSelectedDurations(score, Fraction(3, 2)).size(), 1); + EXPECT_TRUE(score->sanityCheck()); + EXPECT_EQ(rhythm(m1), "3:2[8 r8 r8] 8 8 8 8 8 8"); + + const Tuplet* linkedTuplet = first->cr(VOICES)->tuplet(); + ASSERT_TRUE(linkedTuplet); + EXPECT_EQ(linkedTuplet->ratio(), Fraction(3, 2)); + EXPECT_FALSE(linkedTuplet->tuplet()); + EXPECT_EQ(linkedTuplet->elements().size(), 3); + + delete score; +} + +TEST_F(Engraving_TupletTests, addTupletsWithSelectedDurationsDotted) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + Measure* m1 = measureAt(score, 0); + + TDuration dottedQuarter(DurationType::V_QUARTER); + dottedQuarter.setDots(1); + score->startCmd(TranslatableString::untranslatable("Engraving tuplet tests")); + score->changeCRlen(chordRestAt(m1, 0), dottedQuarter); + score->endCmd(); + ASSERT_EQ(rhythm(m1), "4. 8 8 8 8 8"); + + // 3 dotted quarters in the time of 2 end at 3/4: the eighth at 3/8 gets no tuplet of its own + score->select(chordRestAt(m1, 0), SelectType::SINGLE); + score->select(chordRestAt(m1, 1), SelectType::ADD); + score->select(chordRestAt(m1, 4), SelectType::ADD); + EXPECT_EQ(addTupletsWithSelectedDurations(score, Fraction(3, 2)).size(), 2); + EXPECT_TRUE(score->sanityCheck()); + EXPECT_EQ(rhythm(m1), "3:2[4. r4. r4.] 3:2[8 r8 r8]"); + + score->undoRedo(true, nullptr); + EXPECT_EQ(rhythm(m1), "4. 8 8 8 8 8"); + + // 2 dotted quarters in the time of 3: 9/8 would cross the barline + score->select(chordRestAt(m1, 0), SelectType::SINGLE); + EXPECT_TRUE(addTupletsWithSelectedDurations(score, Fraction(2, 3)).empty()); + EXPECT_EQ(MScore::_error, MsError::NO_SPACE_FOR_TUPLET); + + delete score; +} + +TEST_F(Engraving_TupletTests, addTupletsWithSelectedDurationsDoubleDotted) +{ + MasterScore* score = ScoreRW::readScore(TUPLET_DATA_DIR + u"addTuplet.mscx"); + ASSERT_TRUE(score); + Measure* m2 = measureAt(score, 1); + + TDuration doubleDottedQuarter(DurationType::V_QUARTER); + doubleDottedQuarter.setDots(2); + score->startCmd(TranslatableString::untranslatable("Engraving tuplet tests")); + score->changeCRlen(chordRestAt(m2, 0), doubleDottedQuarter); + score->endCmd(); + ASSERT_EQ(rhythm(m2), "4.. r16 r2"); + + // 3 double-dotted quarters in the time of 2 end at 7/8: the 16th rest at 7/16 gets no tuplet of its own + score->select(chordRestAt(m2, 0), SelectType::SINGLE); + score->select(chordRestAt(m2, 1), SelectType::ADD); + EXPECT_EQ(addTupletsWithSelectedDurations(score, Fraction(3, 2)).size(), 1); + EXPECT_TRUE(score->sanityCheck()); + EXPECT_EQ(rhythm(m2), "3:2[4.. r4.. r4..] r8"); + + score->undoRedo(true, nullptr); + EXPECT_EQ(rhythm(m2), "4.. r16 r2"); + + // 2 double-dotted quarters in the time of 3: 21/16 would cross the barline + score->select(chordRestAt(m2, 0), SelectType::SINGLE); + EXPECT_TRUE(addTupletsWithSelectedDurations(score, Fraction(2, 3)).empty()); + EXPECT_EQ(MScore::_error, MsError::NO_SPACE_FOR_TUPLET); + + delete score; +} + //----------------------------------------- // deleteTupletSelection // deleting a tuplet selects the rest that replaces it diff --git a/src/engraving/types/types.h b/src/engraving/types/types.h index ddde8b4b9b9..dd18cb5383d 100644 --- a/src/engraving/types/types.h +++ b/src/engraving/types/types.h @@ -1588,6 +1588,11 @@ enum class TupletNumberType : unsigned char { enum class TupletBracketType : unsigned char { AUTO_BRACKET, SHOW_BRACKET, SHOW_NO_BRACKET }; + +// what a tuplet command uses the selected duration as +enum class TupletSelectedDuration : unsigned char { + SUBDIVISION, TUPLET_LENGTH +}; } // mu::engraving template<> diff --git a/src/notation/internal/mscoreerrorscontroller.cpp b/src/notation/internal/mscoreerrorscontroller.cpp index f18357e68b7..59041091e32 100644 --- a/src/notation/internal/mscoreerrorscontroller.cpp +++ b/src/notation/internal/mscoreerrorscontroller.cpp @@ -183,6 +183,15 @@ void MScoreErrorsController::checkAndShowMScoreError() title = muse::trc("notation", "Can’t explode or implode"); message = muse::trc("notation", "Cannot explode or implode between different local time signatures"); break; + case MsError::NO_SPACE_FOR_TUPLET: + title = muse::trc("notation", "Cannot create tuplet"); + message = muse::trc("notation", "There isn’t enough space for the tuplet before the end of the measure " + "(or of the tuplet it would be nested in)."); + break; + case MsError::TUPLET_NOTE_VALUE_TOO_SHORT: + title = muse::trc("notation", "Cannot create tuplet"); + message = muse::trc("notation", "Note value is too short"); + break; } m_currentDialogError = err; diff --git a/src/notation/internal/notationinteraction.cpp b/src/notation/internal/notationinteraction.cpp index 36d0e15cbba..430af964cd9 100644 --- a/src/notation/internal/notationinteraction.cpp +++ b/src/notation/internal/notationinteraction.cpp @@ -5705,8 +5705,20 @@ void NotationInteraction::addTupletToSelectedChordRests(const TupletOptions& opt return; } + if (options.selectedDurationAs == TupletSelectedDuration::SUBDIVISION) { + if (!EditTuplet::checkAddTupletsWithSelectedDurations(score(), options.ratio)) { + checkAndShowError(); + return; + } + } else if (!canAddTupletToSelectedChordRests()) { + MScore::setError(MsError::TUPLET_NOTE_VALUE_TOO_SHORT); + checkAndShowError(); + return; + } + transaction(TranslatableString("undoableAction", "Add tuplet"), [&](mu::engraving::Transaction& tx) { - EditTuplet::addTuplets(tx, score(), options.ratio, options.numberType, options.bracketType, options.autoBaseLen); + EditTuplet::addTuplets(tx, score(), options.ratio, options.numberType, options.bracketType, options.autoBaseLen, + options.selectedDurationAs); }); notifyAboutNotationChanged(); } diff --git a/src/notation/internal/notationnoteinput.cpp b/src/notation/internal/notationnoteinput.cpp index 120b0790a92..b506d306023 100644 --- a/src/notation/internal/notationnoteinput.cpp +++ b/src/notation/internal/notationnoteinput.cpp @@ -746,16 +746,36 @@ void NotationNoteInput::addTuplet(const TupletOptions& options) const InputState& inputState = score()->inputState(); + const bool durationAsSubdivision = options.selectedDurationAs == TupletSelectedDuration::SUBDIVISION; + if (durationAsSubdivision + && !score()->canAddTuplet(inputState.segment(), inputState.track(), options.ratio, inputState.duration())) { + MScore::setError(MsError::NO_SPACE_FOR_TUPLET); + m_interaction->checkAndShowError(); + return; + } + + if (!durationAsSubdivision && !m_interaction->canAddTupletToSelectedChordRests()) { + MScore::setError(MsError::TUPLET_NOTE_VALUE_TOO_SHORT); + m_interaction->checkAndShowError(); + return; + } + startEdit(TranslatableString("undoableAction", "Add tuplet")); score()->expandVoice(); ChordRest* chordRest = inputState.cr(); if (chordRest) { - Fraction ratio = options.ratio; - if (options.autoBaseLen) { - ratio.setDenominator(Tuplet::computeTupletDenominator(ratio.numerator(), inputState.ticks())); + Tuplet* tuplet = nullptr; + if (durationAsSubdivision) { + tuplet = score()->addTuplet(chordRest, options.ratio, inputState.duration(), options.numberType, options.bracketType); + } else { + Fraction ratio = options.ratio; + if (options.autoBaseLen) { + ratio.setDenominator(Tuplet::computeTupletDenominator(ratio.numerator(), inputState.ticks())); + } + score()->changeCRlen(chordRest, inputState.duration()); + tuplet = score()->addTuplet(chordRest, ratio, options.numberType, options.bracketType); } - score()->changeCRlen(chordRest, inputState.duration()); - if (Tuplet* tuplet = score()->addTuplet(chordRest, ratio, options.numberType, options.bracketType)) { + if (tuplet) { score()->selectNewTuplet(tuplet); } } diff --git a/src/notation/types/noteinputtypes.h b/src/notation/types/noteinputtypes.h index 3c42935e3b0..40b65035113 100644 --- a/src/notation/types/noteinputtypes.h +++ b/src/notation/types/noteinputtypes.h @@ -50,5 +50,9 @@ struct TupletOptions engraving::TupletNumberType numberType = engraving::TupletNumberType::SHOW_NUMBER; engraving::TupletBracketType bracketType = engraving::TupletBracketType::AUTO_BRACKET; bool autoBaseLen = false; + + // SUBDIVISION: the selected duration is the subdivision (3:2 on an eighth: 3 eighths in the time of 2); + // TUPLET_LENGTH: the tuplet divides it + engraving::TupletSelectedDuration selectedDurationAs = engraving::TupletSelectedDuration::TUPLET_LENGTH; }; } diff --git a/src/notationscene/internal/notationactioncontroller.cpp b/src/notationscene/internal/notationactioncontroller.cpp index 241a3365c5b..3b4f95404c9 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -1510,6 +1510,7 @@ muse::Ret NotationActionController::putTuplet(const muse::rcommand::Params& para options.numberType = Contract::get(params); options.bracketType = Contract::get(params); options.autoBaseLen = params.at("auto-baselen", Val(false)).toBool(); + options.selectedDurationAs = Contract::get(params); putTuplet(options); return muse::make_ok(); @@ -1528,11 +1529,6 @@ void NotationActionController::putTuplet(const TupletOptions& options) return; } - if (!interaction->canAddTupletToSelectedChordRests()) { - interactive()->error(muse::trc("notation", "Cannot create tuplet"), muse::trc("notation", "Note value is too short")); - return; - } - if (noteInput->isNoteInputMode()) { noteInput->addTuplet(options); } else { diff --git a/src/notationscene/internal/notationcommandsregister.cpp b/src/notationscene/internal/notationcommandsregister.cpp index 2f10c6e3972..eb794493373 100644 --- a/src/notationscene/internal/notationcommandsregister.cpp +++ b/src/notationscene/internal/notationcommandsregister.cpp @@ -883,6 +883,7 @@ static const std::vector s_commandInfos = { { "number-type", Arg(DataType::String, u"Tuplet number type (number, relation, none)") }, { "bracket-type", Arg(DataType::String, u"Tuplet bracket type (auto, show, none)") }, { "auto-baselen", Arg(DataType::Bool, u"Auto base length") }, + { "selected-duration-as", Arg(DataType::String, u"Use selected duration as (subdivision, tuplet-length)") }, }), Decoration() }, diff --git a/src/notationscene/notationcommandsenums.h b/src/notationscene/notationcommandsenums.h index f2aa356f48e..41a79521f15 100644 --- a/src/notationscene/notationcommandsenums.h +++ b/src/notationscene/notationcommandsenums.h @@ -92,6 +92,15 @@ struct TupletBracketTypeContract { } }; }; +struct TupletSelectedDurationContract { + static constexpr std::string_view key = "selected-duration-as"; + static constexpr engraving::TupletSelectedDuration def = engraving::TupletSelectedDuration::TUPLET_LENGTH; + static constexpr Table table { { + { "subdivision", engraving::TupletSelectedDuration::SUBDIVISION, "subdivision" }, + { "tuplet-length", engraving::TupletSelectedDuration::TUPLET_LENGTH, "tuplet length" }, + } }; +}; + struct SelectionTargetContract { static constexpr std::string_view key = "target"; static constexpr SelectionTarget def = SelectionTarget::FirstItem; @@ -146,6 +155,7 @@ DECLARE_CONTRACT(mu::notation::NoteInputMethod, mu::notation::NoteInputMethodCon DECLARE_CONTRACT(mu::notation::NoteAddingMode, mu::notation::NoteAddingModeContract); DECLARE_CONTRACT(mu::engraving::TupletNumberType, mu::notation::TupletNumberTypeContract); DECLARE_CONTRACT(mu::engraving::TupletBracketType, mu::notation::TupletBracketTypeContract); +DECLARE_CONTRACT(mu::engraving::TupletSelectedDuration, mu::notation::TupletSelectedDurationContract); DECLARE_CONTRACT(mu::notation::SelectionTarget, mu::notation::SelectionTargetContract); DECLARE_CONTRACT(mu::notation::PlayMode, mu::notation::PlayModeContract); } diff --git a/src/notationscene/widgets/tupletdialog.cpp b/src/notationscene/widgets/tupletdialog.cpp index 9015bd82d2d..d95b4e1c726 100644 --- a/src/notationscene/widgets/tupletdialog.cpp +++ b/src/notationscene/widgets/tupletdialog.cpp @@ -54,6 +54,8 @@ void TupletDialog::componentComplete() numberGroupBox->setAccessibleName(formatGroupBox->title() + " " + numberGroupBox->title()); bracketGroupBox->setAccessibleName(formatGroupBox->title() + " " + bracketGroupBox->title()); + subdivision->setAccessibleName(useSelectedDurationAsLabel->text() + " " + subdivision->text()); + tupletLength->setAccessibleName(useSelectedDurationAsLabel->text() + " " + tupletLength->text()); //! NOTE: It is necessary for the correct start of navigation in the dialog setFocus(); @@ -103,6 +105,15 @@ TupletBracketType TupletDialog::bracketType() const return TupletBracketType::AUTO_BRACKET; } +TupletSelectedDuration TupletDialog::selectedDurationAs() const +{ + if (tupletLength->isChecked()) { + return TupletSelectedDuration::TUPLET_LENGTH; + } + + return TupletSelectedDuration::SUBDIVISION; +} + void TupletDialog::showEvent(QShowEvent* event) { WidgetStateStore::restoreGeometry(this); @@ -131,6 +142,7 @@ void TupletDialog::apply() { "ratio", Val(Fraction(actualNotes->value(), normalNotes->value()).toString().toStdString()) }, Contract::param(numberType()), Contract::param(bracketType()), + Contract::param(selectedDurationAs()), }; dispatcher()->dispatch(ADD_TUPLET_COMMAND, params); diff --git a/src/notationscene/widgets/tupletdialog.h b/src/notationscene/widgets/tupletdialog.h index 6629d82d637..2cedeac259c 100644 --- a/src/notationscene/widgets/tupletdialog.h +++ b/src/notationscene/widgets/tupletdialog.h @@ -32,6 +32,7 @@ namespace mu::engraving { enum class TupletNumberType : unsigned char; enum class TupletBracketType : unsigned char; +enum class TupletSelectedDuration : unsigned char; } namespace mu::notation { @@ -54,6 +55,7 @@ class TupletDialog : public muse::ui::WidgetDialog, private Ui::TupletDialog engraving::TupletNumberType numberType() const; engraving::TupletBracketType bracketType() const; + engraving::TupletSelectedDuration selectedDurationAs() const; INotationStylePtr style() const; INotationPtr notation() const; diff --git a/src/notationscene/widgets/tupletdialog.ui b/src/notationscene/widgets/tupletdialog.ui index 3bc4e298e57..3df81e34297 100644 --- a/src/notationscene/widgets/tupletdialog.ui +++ b/src/notationscene/widgets/tupletdialog.ui @@ -109,6 +109,30 @@ + + + + Use selected duration as: + + + + + + + Subdivision + + + true + + + + + + + Tuplet length + + +