※ この記事は Zenn との同時投稿です。
■ 1. はじめに
Markdownでドキュメントを作成している際、下図のようなクラス図の構造やUIのモックアップを、テキストベースの罫線を使ってサクッと描きたいと思うことはないだろうか? 筆者が開発中のMarkdownエディタ「viMarkdown」において、こうした罫線図解を手軽に描けるよう、直感的な罫線描画機能を実装した。

だがしかし、ここで一つの大きな問題に直面した。せっかく綺麗に描いた図の中にテキストを入力しようとすると、文字を挿入した分だけ右側の罫線が押し出され、レイアウトが崩れてしまうのだ。図を維持するために入力のたびにDelete・Backspaceキーで空白を調整する必要がある。逆に、文字を削除すると右側の罫線が左に移動してしまうので、その分の空白を罫線の前に挿入する必要がある。これらは非常にストレスフルな作業だ。
この問題を解決するためには、文字を入力しても図の枠組みを維持する「罫線保護機能」が不可欠だ。本稿では、QPlainTextEdit 派生クラスという制約の中で、いかにして「壊れない罫線」を実現したか。viMarkdownに実装した「罫線保護アルゴリズム」の仕組みについて解説する。
■ 2. 罫線保護アルゴリズム
2.1 罫線保護アルゴリズム
罫線がずれないようにするには、単純に上書きモードで文字挿入を行えばよいように思われるかもしれない。しかし、日本語環境では半角文字と全角文字が混在するため、単純な上書き処理では表示幅の不整合が発生してしまう。
もしエディタをゼロから実装しているのであれば、描画やレイアウトを含めて完全に制御することも可能である。しかし viMarkdown のエディタは QPlainTextEdit の派生クラスとして実装しているため、低レイヤの編集処理を全面的に制御することはできない。そのため、Qt が提供する標準的なテキスト編集の枠組みの中で解決策を見つける必要があった。
そこで注目したのが、QTextDocument::contentsChange(int position, int charsRemoved, int charsAdded) シグナルだ。
contentsChange() はドキュメントの内容が変更されるたびに発行されるシグナルであり、
- 変更位置(position)
- 削除された文字数(charsRemoved)
- 追加された文字数(charsAdded)
が引数として渡される。
これを利用することで、Qt標準の編集処理を壊さずに「編集差分」を検知できる。
具体的な処理の流れとしては、まず変更時に削除された文字列を取得し、現在のカラム位置(表示上の幅)を正確に算出する。その計算結果に基づき、挿入位置の右側にある罫線直前の空白を動的に削除、あるいは挿入することで、全体のレイアウトを維持する仕組みだ。
2.2 文字列挿入時の処理
まず、contentsChange() シグナルを処理関数へ接続する。
connect(document(), &QTextDocument::contentsChange, this, &Editor::onContentsChanged);
挿入が発生した場合の基本処理は次の通りである。
- 現在ブロック(行)のテキストを取得
- 挿入位置を算出
- 挿入された文字列の表示カラム幅を計算
- 直後の罫線直前に存在する空白を、そのカラム幅分だけ削除
※ 削除可能な空白が存在しない場合は何もしない。その場合は仕様として、罫線が右へずれることを許容する。
void Editor::onContentsChanged(int position, int charsRemoved, int charsAdded) {
QTextCursor cursor = this->textCursor(); // 現在行カーソル
const QString &text = cursor.block().text(); // 編集後ブロックテキスト
if( charsAdded > 0 ) { // 文字挿入された場合
ix = cursor.position() - cursor.block().position(); // ブロック先頭からのオフセット
int wd = columnWidth(text, ix, charsAdded); // 挿入文字列のカラム幅を計算
直後罫線文字直前のwd 個の半角空白を削除
}
}
2.3 文字列削除時の処理
文字削除の場合、charsRemoved によって削除された「文字数」は分かるが、contentsChange が発行された時点ですでに文字は消えているため、削除された「文字列そのもの」を取得することができない。そのため、削除された文字列の正確な表示幅を計算できない。
この問題を解決するため、カーソル移動のたびに現在行のテキストを保存しておき、削除発生時にはその保存値を参照するようにした。
void Editor::onCursorPosChanged() {
QTextCursor cursor = this->textCursor();
m_lastCurBlockText = cursor.block().text(); // カーソル行テキスト保存
....
}
削除時の処理は以下の通り。
- 削除前テキスト(保存済み)を参照
- 削除位置を算出
- 削除された文字列の表示カラム幅を計算
- 罫線直前にその幅分の半角空白を挿入
void Editor::onContentsChanged(int position, int charsRemoved, int charsAdded) {
.....
if( charsRemoved > 0 ) { // 文字列削除の場合
ix = cursor.position() - cursor.block().position(); // ブロック先頭からのオフセット
int wd = columnWidth(m_lastCurBlockText, ix, charsRemoved); // 削除された文字列のカラム幅を計算
直後罫線文字直前にwd個の半角空白を挿入
}
}
2.4 カラム幅の算出
表示上のカラム幅は、QFontMetrics を用いてピクセル単位の幅を計算し、それを基準となる文字幅で除算することで求めている。
int Editor::columnWidth(const QString &text, int ix, int length) {
QFontMetrics fm(font());
int halfWidth = fm.horizontalAdvance(u'9'); // 半角一文字幅
int fullWidth = fm.horizontalAdvance(text.mid(ix, length)); // カラム数計算文字列幅
return fullWidth / halfWidth; // 文字列幅を半角文字幅で除算することで、カラム幅計算
}
2.5 IME入力対応
これまでの実装で、半角・全角が混在する環境でも文字の挿入・削除に追従できるようになった。しかし、実際に日本語を入力しようとすると、新たな問題に直面する。「IME(日本語入力)による未確定文字列の処理」だ。
IMEで文字を入力・変換している最中も contentsChange() シグナルは容赦なく発行される。しかし、このシグナルをそのまま処理してしまうと、罫線保護アルゴリズムは破綻する。その理由は、Qt 内部における IME の挙動にある。
IMEで未確定の文字列を入力している間、エディタ(Qt)は入力中の文字列を表示するためのスペースを確保するため、カーソル位置より後ろにある文字列を一時的に「本当に右へずらす(ドキュメント内に仮の文字列を挿入する)」処理を行う。この時も当然シグナルが発行されてしまう。
そして、IMEの変換が「確定」した瞬間、ずらしていた一時的な文字列を削除して元の位置に戻し、最終的な確定文字列を挿入し直すのだ。
この複雑な内部処理の結果、IME確定時に発行される contentsChange() では、charsRemoved と charsAdded の値が、ユーザーが実際に確定した文字数よりも不自然に大きくなってしまうという現象が発生する。
- IME入力した場合の具体例:
abc(改行)
上記「abc]の先頭に「あい」を入力した場合、charsRemoved = 4, charsAdded = 6 で onContentsChanged() がコールされる。
この問題を解決するため、以下の「2段構え」の対策を実装した。
対策1:IME変換中は処理をスキップ
まず、IMEで変換中(未確定状態)の間は、罫線の動的補正を一切行わないようにする。
inputMethodEvent をオーバーライドし、未確定文字列(preeditString)が存在するかどうかで「変換中フラグ(m_isComposing)」を管理する。
void MarkdownEditor::inputMethodEvent(QInputMethodEvent *event) {
m_isComposing = !event->preeditString().isEmpty(); // IME変換中フラグ
MarkdownBaseEdit::inputMethodEvent(event);
}
そして、メインの処理関数ではこのフラグを見て、変換中であれば即座にリターン(処理をスキップ)させる。
void MarkdownEditor::onContentsChanged(int position, int charsRemoved, int charsAdded) {
if (m_isComposing) return; // IME変換中は処理を行わない
.....
}
対策2:確定時の余分な追加・削除数を相殺する
IMEが確定し m_isComposing が false になった直後にもシグナルが発行されるが、前述の通り charsRemoved と charsAdded には「一時文字列の巻き戻し分」が過剰に含まれている。
そこで、追加された文字列(strAdded)と削除された文字列(strRemoved)を後方から比較し、「末尾の共通部分(Qtが内部的に動かしただけの変化していない部分)」をカウントして、変動文字数から減算する。これにより、純粋に「ユーザーが新たに入力・削除した文字数」だけを抽出できる。
void MarkdownEditor::onContentsChanged(int position, int charsRemoved, int charsAdded) {
.....
const QString strAdded = position 以降に挿入された文字列(block.text() 参照)
const QString strRemoved = position 以降で削除された文字列(m_lastCurBlockText 参照)
int c = 0;
while(charsAdded-c-1 > 0 && charsRemoved-c-1 > 0 &&
strAdded[charsAdded-c-1] == strRemoved[charsRemoved-c-1] )
{
++c; // 末尾共通部分
}
charsRemoved -= c;
charsAdded -= c;
.....
}
3. おわりに
3.1 まとめ
全角半角混在の日本語環境において「上書きモード」だけで罫線を守ることは不可能だが、以下の要素を組み合わせることで解決した。
- contentsChange() をフックすることで差分処理が可能
- 削除幅算出のために事前テキストを保持
- QFontMetrics により表示カラム幅を計算
- IME変換中は処理を行わない
- IME確定時は余分な charsRemoved, charsAdded を減少させる
これらの工夫により、Qt の標準的なテキスト編集機構を活かしつつ、最小限の補正処理で罫線を保護する、軽量で堅牢な設計を実現している。
3.2 今後の課題
- 1回のundoで文字入力・削除前の状態に戻す(現状は、文字入力・削除と罫線保護処理の2つに分かれている)
- 文字入力により罫線を右にずらさす必要がある場合:次の行に文字列を送る or 罫線枠を拡張
これらの課題は、ver 0.3 での「QPlainTextEdit 派生ではない独自エディタ実装」以降に hopefully 解決する予定だ。
3.3 宣伝
ついに viMarkdown ver 0.1.201 beta-1 をリリース(現状は Windows版バイナリのみ)しました!
「構造化文書の作成がもっと楽にならないか?」という思いを込めて、C++とQtで一から作り込んでいるエディタです。
Windowsをお使いの方は、ぜひダウンロードし、本稿で紹介した「壊れない罫線」の感触を試していただけると嬉しいです。
- Github > Release: https://github.com/vivisuke/viMarkdown/releases
感想やバグ報告 、お待ちしております!