🎵

MuseScore Studio のショートカットの細かい動作を改善する

2024/11/19に公開

はじめに

この記事は、東京大学工学部電子情報工学科・電気電子工学科の学生実験「大規模ソフトウェアを手探る」のレポートの一部として書かれたものです。
レポートの全記事をご覧になりたい方は下のリンクよりご覧ください。
https://zenn.dev/cmakescore/articles/6ca173cd570216

この記事について

この記事では、楽譜作成ソフト MuseScore Studio において、リハーサルマークを追加した際にデフォルトテキストを設定し、自動的に選択状態にする機能を実装した過程について解説します。さらに、テンポ記号にも同様の機能を拡張したことや、小節や範囲選択時にもショートカットでテキスト要素を追加できるようにした改良についても紹介します。

リハーサルマーク追加時のデフォルトテキスト設定と自動選択

MuseScore Studio では、パレットからリハーサルマークを適用した場合、スコア内にすでに存在するリハーサルマークの文字列に合わせて自動的にデフォルトのテキストが適用された状態で追加されます。一方、リハーサルマークをキーボードのショートカット Ctrl+M で追加した場合には、自動で文字列が設定されず、空のテキストボックスが生成されます。
ここでは、ショートカットからリハーサルマークを追加した場合でもデフォルトのテキストを設定し田上で、任意の文字列に変更したい場合にも操作上の不都合がないようにテキストを選択した状態にしておくようにしてみたいと思います。

パレットから追加した際の動作の観察

まず、パレットからリハーサルマークを追加する際の動作を分析しました。
とりあえず palette でファイル名を検索してみると、大量のファイルがヒットしました。どれが関連しているのかパッと見ただけではわかりませんでした。
パレットのGUIに表示されているリハーサルマークのデフォルト文字列「B1」をキーワードにコード検索を行った結果、src/palette/internal/palettecreator.cpp が見つかりましたが、これは MuseScore Studio を起動したときにパレットを構成するために使われているコードであり、追加動作とは無関係でした。

ファイル検索ではどうも関連コードを見つけられそうにないので、次に、スコアへの要素追加・削除時に出力されるログメッセージに注目しました。スコアに要素を追加や削除したとき、 Qt Creator のアプリケーション出力の欄には

14:28:31.185 | DEBUG | main_thread     | Score::endCmd   | Undo stack current macro child count: 1

のような出力が出ています。そこで、 Score::endCmd にブレークポイントを設置し、スタックを眺めてみたところ、パレットからの要素を追加する処理がsrc/notation/internal/notationinteraction.cppapplyPaletteElement に書かれていることが判明しました。

さらにデバッガでステップ実行を続けると、 src/engraving/dom/chordrest.cppdrop 関数で、以下のコードを発見しました。

EngravingItem* ChordRest::drop(EditData& data)
{
    EngravingItem* e       = data.dropElement;
    Measure* m       = measure();
    bool fromPalette = (e->track() == muse::nidx);
    switch (e->type()) {
    ...
    case ElementType::REHEARSAL_MARK:
    {
        e->setParent(segment());
        e->setTrack(trackZeroVoice(track()));
        if (e->isRehearsalMark()) {
            RehearsalMark* r = toRehearsalMark(e);
            if (fromPalette) {
                r->setXmlText(score()->createRehearsalMarkText(r));
            }
        }
        ...
        score()->undoAddElement(e);
        return e;
    }
    ...
    }
    ...
}

fromPalette が true の場合、つまりパレットから追加された場合に createRehearsalMarkText 関数でテキストが設定されていることが分かりました。

デフォルト文字列設定を実装

この動作を参考に、ショートカットでリハーサルマークを追加する際にもデフォルトテキストを設定するようにしてみましょう。

src/engraving/dom/edit.cpp

強弱記号ショートカット編でもいじった addText 関数です。他のテキスト要素をショートカットで追加したときのデフォルトテキストもこの関数の中で設定されているので、リハーサルマークの場合にもデフォルトテキストの設定をするように変更してみます。

 TextBase* Score::addText(TextStyleType type, EngravingItem* destinationElement)
 {
     ...
     switch (type) {
     ...
     case TextStyleType::REHEARSAL_MARK: {
         ChordRest* chordRest = chordOrRest(destinationElement);
         if (!chordRest) {
             break;
         }
-        textBox = Factory::createRehearsalMark(dummy()->segment());
+        textBox = Factory::createRehearsalMark(chordRest->segment());
+        textBox->setParent(chordRest->segment());
+        textBox->setTrack(0);
+        RehearsalMark* r = toRehearsalMark(textBox);
+        textBox->setXmlText(score()->createRehearsalMarkText(r));
         chordRest->undoAddAnnotation(textBox);
         break;
     }
     ...
     }
 }

デフォルト文字列を自動で選択するには

上記変更でショートカットで追加したリハーサルマークにデフォルト文字列が設定されるようになりましたが、カスタムの文字列を設定したい場合には一度文字を消してから打ち直す必要があり、不便です。デフォルトの文字列を最初から選択した状態にしておけば、すぐに文字列を入れ替えることができます。

この動作を実現するには、文字列の選択を行う方法を知る必要があります。まずはいつもの通り、二多機能の観察からです。
私の頭に浮かんだのは、歌詞入力の際に左右の歌詞に移動したときに元から入っている文字が選択される挙動でした。まずはこの動作を観察してみましょう。

歌詞を移動するショートカットは src/notation/internal/notationactioncontroller.cppnext-word で定義されています。ここから呼び出されている関数を Qt Creator のシンボルを参照という機能で辿っていくと、 src/notation/internal/notationinteraction.cpp に以下のコードを発見しました。

...
void NotationInteraction::navigateToLyrics(bool back, bool moveOnly, bool end)
{
    ...
    startEditText(nextLyrics, PointF());

    mu::engraving::TextCursor* cursor = nextLyrics->cursor();
    if (end) {
        nextLyrics->selectAll(cursor);
    } ...
    ...
}
...

startEditText の後に、テキストボックスの selectAll を呼ぶと文字列を選択可能であることがわかりました。

自動選択を実装

わかってしまえば、あとはかんたんです。テキストをショートカットで追加したときに startEditText が呼ばれているところを探して、その後に selectAll を呼ぶコードを追記します。

src/notation/internal/notationinteraction.cpp

 ...
 void NotationInteraction::addText(TextStyleType type, EngravingItem* item)
 {
     ...
     if (!text->isInstrumentChange()) {
         startEditText(text);
     }
+
+    if (text->isRehearsalMark()) {
+        text->selectAll(text->cursor());
+    }
 }
 ...

動作確認

ここまでの変更を確認してみると、ショートカットからリハーサルマークを追加した場合でもデフォルトのテキストが設定され、追加されると同時に文字が自動で選択された状態になっているという挙動が確認できました。

テンポ記号を追加したときもデフォルトで数字を選択状態にする

ここまでで実現した機能の応用で、テンポ記号をショートカットで追加したときにもデフォルトで数字の部分を選択しておき、すぐに書き換えられるようにするといいのではないかと思いついたので、これも実装してみます。
ただし、リハーサルマークの場合と異なり、テキストエリアの全選択をしてしまうと音符や=の記号も選択されてしまうので、実装を変更する必要がありそうです。

Qt Creator の機能を使って、リハーサルマークを全選択したときにお世話になった src/engraving/dom/textbase.cppselectAll 関数のシンボルの定義に飛び、周囲に使えそうな関数がないか探してみたところ、同じファイル内に TextCursor::selectWord() という、名前からしていかにもいま求めている動作をしてくれそうな関数があったので、これを使って試してみることにしましょう。

先ほど追記した src/notation/internal/notationinteraction.cppaddText 関数を以下のように変更してみます。

 ...
 void NotationInteraction::addText(TextStyleType type, EngravingItem* item)
 {
     ...
     if (!text->isInstrumentChange()) {
         startEditText(text);
     }

-    if (text->isRehearsalMark()) {
-        text->selectAll(text->cursor());
+    if (text->isRehearsalMark() || text->isTempoText()) {
+        text->cursor()->selectWord();
     }
 }
 ...

動作を確認してみると、たったのこれだけで予想通りの動作が実現できていました!
リハーサルマークのデフォルト文字列は1単語のみであるので、その自動選択機能は保ちつつ、テンポ記号の数字部分だけを選択する機能も追加することができました。

小節や範囲が選択されているときにショートカットでテキスト要素を追加可能にする

ここからはもう1つの機能の実装です。
パレットからクリックでテキスト要素を追加する場合には小節や範囲を選択していても要素が追加できましたが、ショートカットから追加しようとするとエラーとなっていました。この動作を修正し、パレットからもショートカットからも小節選択や範囲選択になっているときにテキスト要素を追加できるようにしていきます。

パレットの動作の観察

まずは先ほど発見した、パレットからの要素追加に関連するコード (src/notation/internal/notationinteraction.cppapplyPaletteElement) を眺めてみます。

少し長いですが、テキスト要素の追加に関連する部分は、以下のとおりでした。

bool NotationInteraction::applyPaletteElement(mu::engraving::EngravingItem* element, Qt::KeyboardModifiers modifiers)
{
    ...

    const mu::engraving::Selection sel = score->selection();   // make a copy of selection state before applying the operation.
    if (sel.isNone()) {
        return false;
    }

    startEdit();

    if (sel.isList()) {
        ...

        if (...) {
            ...
        } else if (...) {
            ...
        } else {
            for (EngravingItem* e : sel.elements()) {
                applyDropPaletteElement(score, e, element, modifiers);
            }
        }
    } else if (sel.isRange()) {
        if (...) {
            ...
        } else if (...) {
            ...
        } else if (element->isTextBase() && !element->isFingering() && !element->isSticking()) {
            mu::engraving::Segment* firstSegment = sel.startSegment();
            staff_idx_t firstStaffIndex = sel.staffStart();
            staff_idx_t lastStaffIndex = sel.staffEnd();

            // A text should only be added at the start of the selection
            // There shouldn't be a text at each element
            if (element->systemFlag()) {
                applyDropPaletteElement(score, firstSegment->firstElementForNavigation(0), element, modifiers);
            } else {
                for (staff_idx_t staff = firstStaffIndex; staff < lastStaffIndex; staff++) {
                    applyDropPaletteElement(score, firstSegment->firstElementForNavigation(staff), element, modifiers);
                }
            }
        } else if (...) {
            ...
        } else {
            ...
        }
    } else {
        LOGD("unknown selection state");
    }

    ...
}

これによると、選択の状態が List である場合 (個々の要素をクリック、Ctrl+クリックで選択した場合) と Range である場合 (小節をクリックしたり、shift+クリックで範囲を選択した場合) で異なる処理をしていることがわかります。後者の場合、選択範囲の最初のセグメントの最初の要素に対して要素の追加を行うようになっているみたいです。

変更すべき場所の特定

ショートカットからテキストを追加する場合に、テキストを適用する要素を取得している部分をデバッガで探しました。

src/notation/internal/notationactioncontroller.cppaddText 関数には次のように書かれており、contextItem から適用先の要素を取得していることが読み取れます。

...
void NotationActionController::addText(TextStyleType type)
{
    TRACEFUNC;

    auto interaction = currentNotationInteraction();
    if (!interaction) {
        return;
    }

    EngravingItem* item = contextItem(interaction);

    if (isVerticalBoxTextStyle(type)) {
        if (!item || !item->isVBox()) {
            interaction->addTextToTopFrame(type);
            return;
        }
    }

    Ret ret = interaction->canAddTextToItem(type, item);

    if (!ret) {
        if (configuration()->needToShowAddTextErrorMessage()) {
            IInteractive::Result result = showErrorMessage(ret.text());
            if (!result.showAgain()) {
                configuration()->setNeedToShowAddTextErrorMessage(false);
            }
        }

        return;
    }

    interaction->addTextToItem(type, item);
}
...

contextItem の実装は src/notation/internal/notationinteraction.cpp に書かれていました。選択が範囲選択ではなくリスト選択のときに、最後に選択した要素が返されるようになっているようです。

EngravingItem* contextItem(INotationInteractionPtr interaction)
{
    EngravingItem* item = interaction->selection()->element();
    if (item == nullptr) {
        return interaction->hitElementContext().element;
    }
    return item;
}

つまり、上述の動作を実現するためには、選択の状態が Range だった場合に contextItem を呼ぶ代わりに選択範囲の最初の要素を返してあげるコードを書けばよさそうです。

実際に書き換えてみる

パレットからの要素を追加しているコードと、 同ファイル内から score を取得している他のコードを参考に、やや苦し紛れ感あるコードになってしまいましたが、一旦実装してみます。

src/notation/internal/notationactioncontroller.cpp

void NotationActionController::addText(TextStyleType type)
{
    ...

    EngravingItem* item;
    if (interaction->selection()->isRange()) {
        const mu::engraving::Selection sel = currentNotationElements()->msScore()->selection();
        item = sel.startSegment()->firstElementForNavigation(sel.staffStart());
    } else {
        item = contextItem(interaction);
    }

    ...
}

動作確認

実際に実行して確認すると、これで選択の状態によらず、エラーを出さずにテキストを追加するという機能を実現することができました。
ただし、ここでは contextItem には手を加えずに実装しましたが、プルリクエストを提出したあとのコードレビューでは、この変更を contextItem の中に実装するなど、変更を行っています。

おわりに

このような手順で実装した機能を、 MuseScore Studio にマージしてもらうためにプルリクエストを作成しました。次の記事では、プルリクエストの作成と、その後に行ったことについて紹介していますので、そちらもぜひご覧ください。
https://zenn.dev/cmakescore/articles/04ffecc8af518c

Discussion