From d9a6e65e3749aed2b610063b0cc8f7cf71f3bc5a Mon Sep 17 00:00:00 2001 From: David Stephen Grant Date: Thu, 1 Oct 2026 22:19:21 +0200 Subject: [PATCH 1/3] Let tuplets use the selected duration as their subdivision The tuplet commands divided the selected duration by the ratio: 3:2 with a quarter gave 3 eighths in the time of a quarter. Add TupletOptions::selectedDurationAs, with the new TupletSelectedDuration: SUBDIVISION uses the selected duration as the tuplet's subdivision: 3:2 with a quarter gives 3 quarters in the time of 2; TUPLET_LENGTH (the default) divides it as before. The selected chords/rests are taken in score order, per track: one that an earlier tuplet replaces gets none, and one whose tuplet would cross a barline or the end of an enclosing tuplet is left as it is, as is a measure rest, which has no duration to use as the subdivision. If no tuplet fits, nothing changes and a message shows, as other editing commands do (a new MsError value). A range grows to the end of the tuplets. Nothing sets the option yet. EditTuplet::addTuplets() takes the option, and the new EditTuplet::checkAddTupletsWithSelectedDurations() checks the selection first. Add Score::tupletEndTick() and a Score::canAddTuplet() overload that takes a segment and track (for an empty voice in note input); both use the existing canAddTuplet()'s calculation. Assisted-by: Claude Code 2.1.290 (Claude Opus 5.5) --- src/engraving/dom/mscore.cpp | 1 + src/engraving/dom/mscore.h | 1 + src/engraving/dom/score.h | 2 + src/engraving/editing/edit.cpp | 69 ++- src/engraving/editing/edittuplet.cpp | 66 ++- src/engraving/editing/edittuplet.h | 10 +- src/engraving/tests/tuplet_tests.cpp | 401 ++++++++++++++++++ src/engraving/types/types.h | 5 + .../internal/mscoreerrorscontroller.cpp | 5 + src/notation/internal/notationinteraction.cpp | 9 +- src/notation/internal/notationnoteinput.cpp | 24 +- src/notation/types/noteinputtypes.h | 4 + .../internal/notationactioncontroller.cpp | 3 +- 13 files changed, 579 insertions(+), 21 deletions(-) diff --git a/src/engraving/dom/mscore.cpp b/src/engraving/dom/mscore.cpp index 672aedd412ea..eeaa2d9cade7 100644 --- a/src/engraving/dom/mscore.cpp +++ b/src/engraving/dom/mscore.cpp @@ -123,6 +123,7 @@ 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"; } return {}; diff --git a/src/engraving/dom/mscore.h b/src/engraving/dom/mscore.h index 6ae45b4e9d30..c4cf247334b5 100644 --- a/src/engraving/dom/mscore.h +++ b/src/engraving/dom/mscore.h @@ -157,6 +157,7 @@ enum class MsError : unsigned char { CANNOT_REPEAT_SELECTION, TRANSPOSE_NO_FRET_DIAGRAM, CANNOT_EXPLODE_IMPLODE_LOCAL_TIMESIG, + NO_SPACE_FOR_TUPLET, }; /// \cond PLUGIN_API \private \endcond diff --git a/src/engraving/dom/score.h b/src/engraving/dom/score.h index 44a7e6ec993e..56384fe44e94 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 abd5ee89ba5c..a8f4e94c0cdc 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 196273da7e7d..ec6c9ce8cc73 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 91554150a13f..5be01923bc2b 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 e2aa87c3d5c6..2e06a6d68e62 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 ddde8b4b9b96..dd18cb5383d3 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 f18357e68b7e..3902539bf355 100644 --- a/src/notation/internal/mscoreerrorscontroller.cpp +++ b/src/notation/internal/mscoreerrorscontroller.cpp @@ -183,6 +183,11 @@ 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; } m_currentDialogError = err; diff --git a/src/notation/internal/notationinteraction.cpp b/src/notation/internal/notationinteraction.cpp index 36d0e15cbba6..1961d83a57ed 100644 --- a/src/notation/internal/notationinteraction.cpp +++ b/src/notation/internal/notationinteraction.cpp @@ -5705,8 +5705,15 @@ void NotationInteraction::addTupletToSelectedChordRests(const TupletOptions& opt return; } + if (options.selectedDurationAs == TupletSelectedDuration::SUBDIVISION + && !EditTuplet::checkAddTupletsWithSelectedDurations(score(), options.ratio)) { + 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 120b0790a924..e8b5ede100b5 100644 --- a/src/notation/internal/notationnoteinput.cpp +++ b/src/notation/internal/notationnoteinput.cpp @@ -746,16 +746,30 @@ 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; + } + 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 3c42935e3b0a..40b65035113f 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 241a3365c5b0..5fe537e0c495 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -1528,7 +1528,8 @@ void NotationActionController::putTuplet(const TupletOptions& options) return; } - if (!interaction->canAddTupletToSelectedChordRests()) { + // subdivision tuplets are checked when they're added + if (options.selectedDurationAs != TupletSelectedDuration::SUBDIVISION && !interaction->canAddTupletToSelectedChordRests()) { interactive()->error(muse::trc("notation", "Cannot create tuplet"), muse::trc("notation", "Note value is too short")); return; } From a5ca4b373973493f87e0c75514ac2e236a39c55b Mon Sep 17 00:00:00 2001 From: David Stephen Grant Date: Sat, 3 Oct 2026 12:16:11 +0200 Subject: [PATCH 2/3] Fix #23216: Let the custom tuplet dialog use the selected duration as the subdivision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add > Tuplets > Other… always divided the selected duration by the ratio, so tuplets such as 3 quarters in the time of 5 couldn't be created from it. Add "Use selected duration as" to the dialog, with "Subdivision" (each note of the tuplet has the selected duration) and "Tuplet length" (the old behaviour). The dialog opens with Subdivision. It passes the choice to the add-tuplet command in a new selected-duration-as argument (subdivision or tuplet-length); without it, the command divides as before. Assisted-by: Claude Code 2.1.287 (Claude Opus 5.5) --- .../internal/notationactioncontroller.cpp | 1 + .../internal/notationcommandsregister.cpp | 1 + src/notationscene/notationcommandsenums.h | 10 ++++++++ src/notationscene/widgets/tupletdialog.cpp | 12 ++++++++++ src/notationscene/widgets/tupletdialog.h | 2 ++ src/notationscene/widgets/tupletdialog.ui | 24 +++++++++++++++++++ 6 files changed, 50 insertions(+) diff --git a/src/notationscene/internal/notationactioncontroller.cpp b/src/notationscene/internal/notationactioncontroller.cpp index 5fe537e0c495..1a7f45be8dde 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(); diff --git a/src/notationscene/internal/notationcommandsregister.cpp b/src/notationscene/internal/notationcommandsregister.cpp index 2f10c6e3972d..eb7944933738 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 f2aa356f48ed..41a79521f155 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 9015bd82d2d2..d95b4e1c7269 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 6629d82d6373..2cedeac259c5 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 3bc4e298e578..3df81e342977 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 + + + From 7ba3e0fd8f9b716da049790f8004e2c0fb289394 Mon Sep 17 00:00:00 2001 From: David Stephen Grant Date: Sat, 24 Oct 2026 13:56:19 +0200 Subject: [PATCH 3/3] Show the tuplet "Note value is too short" message as an editing message The tuplet commands showed "Note value is too short" in an error box from NotationActionController, while the other "Cannot create tuplet" messages are editing messages (MsError). Make it MsError::TUPLET_NOTE_VALUE_TOO_SHORT, checked where the tuplet is added, for the selection and in note input. It now shows like the others, with "Don't show this again". Assisted-by: Claude Code 2.1.290 (Claude Opus 5.5) --- src/engraving/dom/mscore.cpp | 1 + src/engraving/dom/mscore.h | 1 + src/notation/internal/mscoreerrorscontroller.cpp | 4 ++++ src/notation/internal/notationinteraction.cpp | 9 +++++++-- src/notation/internal/notationnoteinput.cpp | 6 ++++++ src/notationscene/internal/notationactioncontroller.cpp | 6 ------ 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/engraving/dom/mscore.cpp b/src/engraving/dom/mscore.cpp index eeaa2d9cade7..21edae1052dc 100644 --- a/src/engraving/dom/mscore.cpp +++ b/src/engraving/dom/mscore.cpp @@ -124,6 +124,7 @@ std::string MScore::errorToString(MsError err) 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 c4cf247334b5..c7899cbc82bc 100644 --- a/src/engraving/dom/mscore.h +++ b/src/engraving/dom/mscore.h @@ -158,6 +158,7 @@ enum class MsError : unsigned char { 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/notation/internal/mscoreerrorscontroller.cpp b/src/notation/internal/mscoreerrorscontroller.cpp index 3902539bf355..59041091e32c 100644 --- a/src/notation/internal/mscoreerrorscontroller.cpp +++ b/src/notation/internal/mscoreerrorscontroller.cpp @@ -188,6 +188,10 @@ void MScoreErrorsController::checkAndShowMScoreError() 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 1961d83a57ed..430af964cd99 100644 --- a/src/notation/internal/notationinteraction.cpp +++ b/src/notation/internal/notationinteraction.cpp @@ -5705,8 +5705,13 @@ void NotationInteraction::addTupletToSelectedChordRests(const TupletOptions& opt return; } - if (options.selectedDurationAs == TupletSelectedDuration::SUBDIVISION - && !EditTuplet::checkAddTupletsWithSelectedDurations(score(), options.ratio)) { + 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; } diff --git a/src/notation/internal/notationnoteinput.cpp b/src/notation/internal/notationnoteinput.cpp index e8b5ede100b5..b506d3060239 100644 --- a/src/notation/internal/notationnoteinput.cpp +++ b/src/notation/internal/notationnoteinput.cpp @@ -754,6 +754,12 @@ void NotationNoteInput::addTuplet(const TupletOptions& options) 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(); diff --git a/src/notationscene/internal/notationactioncontroller.cpp b/src/notationscene/internal/notationactioncontroller.cpp index 1a7f45be8dde..3b4f95404c9e 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -1529,12 +1529,6 @@ void NotationActionController::putTuplet(const TupletOptions& options) return; } - // subdivision tuplets are checked when they're added - if (options.selectedDurationAs != TupletSelectedDuration::SUBDIVISION && !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 {