TUIでリッチテキストの表示と編集を同時に扱う
はじめに
最近開発しているTUIのエディターがあるのですが、
これはマークダウン(の方言)をリッチテキストとしてレンダリングし、同時に編集も可能になるような設計をしています。
どういうことかというと、添付動画のような感じです。
こんな感じで、プレビューモードと編集モードを分けていません。
シャープを入力してスペースを挿入すると即座に見出しになり、シャープは見えなくなります。
アスタリスクで囲めば太字になるし、太字テキストを消すとそれを挟んでいたアスタリスクも一緒に消えます。
ちなみに、実用的なツールを作ってみたのでぜひ使ってください、といえるほどの完成度ではないです。
正直なところ実験的なプロジェクトの域を出ていませんが、
TUI上でリッチテキストを編集する設計に興味がある方には、何かしらのヒントになるかもしれません。
開発環境
Golang で開発しており、tcell というライブラリも利用しています。
tcell とは、curses みたいな感じでターミナルの任意のセルに自由に文字を出力できるライブラリです。
これをさらに便利にした tview というのもあるようですが、これは使っていません。
tcell
tview
仕様
どんなコンテンツでも格納でき、同時に編集も表示もできるエディターはロマンがあるのですが、
とはいえそれはあまりに難しすぎます。
私が開発しているエディターはいくつかの制約というか妥協のもとに開発しています。
(もしかしたらそのうちのいくつかは将来的に撤廃できるかもしれませんが)
- 横スクロールをサポートせずに、はみ出す行は常に折り返す
- ある行のリッチテキストがウィンドウ幅を超えるときは、プレーンテキストにフォールバックする
- 表示されるコンテンツは行単位で空間を占める
こう見ると結構しょぼいというか、逆にどこまでリッチテキストを扱えるの?
という感じですが、いまのところ以下のような感じです。
- 見出し
- 太字・イタリック・コード(インライン要素)
- コードブロック
- 折りたたみ
- テーブル
課題
リッチテキストの表示と編集を同時に扱うエディターでは、解決しなければならない明確な課題があります。
それがカーソル位置です。
なぜかというと、通常のエディターであれば普通に考えるとカーソル位置はテキストの中のインデックスです。
もちろん一文字単位を正確に区別しなければいけないので、それでも大変なのですが…
リッチテキストでは、飾り(シャープ, アスタリスク, バッククォート など)が 不可視の状態で存在します。
何も考えずに実装すると、カーソルが不可視の飾りに乗ってしまい、挿入や削除が直感とズレます。
どういうことかというと、先にもサンプルで見ていただいたように、
私のエディターではアスタリスクでテキストを囲むと即座に太字テキストに切り替わります。
このときアスタリスクは表示されなくなります。
この状態でカーソルを移動したとき、カーソルがユーザーにとって不可視なはずのアスタリスクの上に乗ってしまうわけです。
そしてこの状態で文字列を挿入すれば、
- ユーザーが意図しないところに文字列が挿入される
- 太字が解除される
こんな感じで手触りがよくありません。
ではどうあるべきかというと、カーソルは不可視な文字をまたいで移動するべきです。
飾りと一緒に表示するなら、飾りにはカーソルを置けないようにもするべきです。
ネストしたビューではさらに複雑です。
このあたりをケアするための設計がこの記事の中心となっているテーマです。
TUIエディタではレイアウトエンジンはどうするのか、どうやって一文字単位で挿入・削除するかなど話題がいろいろありますが、
そのあたりは特に触れません。
だいぶニッチな話題かもしれませんが…
基本的な設計
<この章の登場人物>
Document...文字列をバッファし、パースする
Element...パースした結果の構造を表現するツリー
いきなり本題に入る前に、まずは前提となる部分から説明します。
最初にも述べたように
これはマークダウン(の方言)をリッチテキストとしてレンダリングし、同時に編集も可能になるような設計をしています。
ある言語の構文に従ってリッチテキストをレンダリングする仕組みであるので、当然パースする必要があります。
もっと基本的なところでいうと、そもそも文字列を管理するバッファも必要です。
ただ、今回その実装はあまり重要ではないので、インターフェイスだけ説明します。
type Document interface {
Read(r Range) Segment
Render() []Element
InsertString(row int, bytePos int, s string)
Remove(row int, bytePos int, byteLen int)
ReplaceAll(r io.Reader)
Clear()
CreateTrack(row int, bytePos int) *Track
GetLineBytes(index int) int
GetLineCount() int
GetVersion() uint
}
こんな感じのインターフェイスを使います。
この Document 自体が文字列をなんらかの方式でバッファしており、Render()を呼び出すとそれをパースした結果の構造を返します。
そして、この Element というのは中間表現である点に注意が必要です。
つまり、私はいまマークダウン風の独自方言のレンダリングを実装しているわけですが、だからといってRender()でその言語専用のASTを返すよう設計してしまうと、
拡張性に乏しくなってしまう(将来別の言語に対応できなくなる)わけです。
なので、どんな言語であれ Element という中間表現に変換するプロセスが必要です。
そして、その Element の定義はシンプルです。
type Element interface {
GetRange(index int) Range
GetRangeCount() int
GetElement(index int) Element
GetElementCount() int
}
範囲のリストと子要素のリストを持つだけです。
範囲はその Element が表すコンテンツによりさまざまですが、0番目は常にそのコンテンツ全体を含む範囲というルールにしています。
1番目以降はないこともありますが、例えば HeadingElement (見出し)なら0番目がシャープを含むコンテンツ全体、1番目は見出し本文のみ、と使い分けることもできます。
リンク要素であれば1番目にAltテキスト、2番目にリンクを格納、なども考えられます。
正規表現のキャプチャグループみたいなイメージです。
この Element を使ってテキストを構造化されたデータとして表すことができます。
ここまではいわば”モデル”側の話であり、ここから”ビュー”についての話、そしてカーソル制御についての話をします。
ビューの設計
<この章の登場人物>
TextView...Elementと1:1で対応する形で存在しており、Elementの中身を描画し、カーソル移動を制御する
TextViewResolver...Elementを入力として対応するTextViewを解決する
TextLayout...あるElementを描画するための位置、サイズを保持する
ここからは、モデルである Element
を実際にレンダリングし、編集・カーソル操作を担う「ビュー層」について説明します。
ビューを構成するもっとも重要なインターフェイスがこれです。
type TextView interface {
Layout(ctx Context, textLayout *TextLayout, x, y, w, h int)
Draw(ctx Context, textLayout *TextLayout, renderer Renderer)
Measure(ctx Context, e model.Element, width int, height int) *TextLayout
MoveLength(ctx Context, textLayout *TextLayout) int
MoveUp(ctx Context, textLayout *TextLayout, viewLocalPos int) int
MoveDown(ctx Context, textLayout *TextLayout, viewLocalPos int) int
MoveLeft(ctx Context, textLayout *TextLayout, viewLocalPos int) int
MoveRight(ctx Context, textLayout *TextLayout, viewLocalPos int) int
ConvertPos(ctx Context, textLayout *TextLayout, viewLocalPos int) (ViewLocalX int, ViewLocalY int)
ConvertModel(ctx Context, textLayout *TextLayout, viewLocalPos int) CharacterReference
ConvertViewLocalPos(ctx Context, textLayout *TextLayout, bytePos model.Position) int
ShouldBeforeInsertionNewLineOnLineBegin(ctx Context, textLayout *TextLayout, viewLocalPos int) bool
ShouldRemoveWithLine(ctx Context, textLayout *TextLayout, viewLocalPos int) (int, bool)
ShouldRemoveWithSpecifiedColumnAfter(ctx Context, textLayout *TextLayout, viewLocalPos int) (model.Position, bool)
ShouldRemoveWithSpecifiedRangeLines(ctx Context, textLayout *TextLayout, viewLocalPos int) (model.Range, bool)
ShouldRemoveWithSpecifiedRangeColumns(ctx Context, textLayout *TextLayout, viewLocalPos int) (model.Range, bool)
ShouldRemoveLastCharacter(ctx Context, textLayout *TextLayout, viewLocalPos int) (model.Element, bool)
}
いきなり大量のメソッド宣言で少しギョッとするかもしれません。
コードブロック、見出し、インライン要素、などに対応するようにこの TextView の実装が存在しています。
ひとまず、いま重要なのは MoveXxx 系のメソッドと、ConvertXxx系のメソッドです。
MoveLength() というのはそのビューにおいてカーソルが移動可能な回数を返します。
プレーンテキストであれば単にクラスタ単位数を返せばよいことになります。
逆に bold などでは**を除いたクラスタ単位数を返す必要があります。
そして MoveUp, MoveDown, MoveLeft, MoveRight は引数で与えられたローカルビュー座標を、
指定の方向に移動したあとのローカルビュー座標に変換して返します。
ただし、移動した結果ビュー自身からはみ出すときは -1 を返す、というルールです。
さて、ローカルビュー座標という言葉が出てきましたが、このエディターではカーソルの位置をグローバルビュー座標で参照します。
これは単に0から全ての TextView の MoveLength() の和までの範囲の整数型の値です。(終端を含まない)
この値があれば、いま存在する全ての TextView について上から順に MoveLength() を足していけば現在カーソルが置かれている TextView が分かります。
そして、それまでのビューの MoveLength() の和を引けばその TextView におけるローカルビュー座標ももちろん分かります。
※「ローカルビュー座標」は1つのTextView内での相対位置を表し(0~MoveLength()で終端を含まない)、「グローバルビュー座標」はエディタ全体での位置を一意に表します。
二つのカーソル座標
ここで、重要な設計上の選択をしなければなりません。
- 文字列が挿入される場所(モデル座標)をカーソル位置として保持し、グローバルビュー座標に変換する
- グローバルビュー座標を保持し、挿入時にモデル座標に変換する
- 両方を保持し、片方が動いたらもう片方に変換し、常に両方が正しい状態にする
私が選択したのは 2. です。
なぜかというと、幽霊行(後述)のためにはビュー座標で保持する方がシンプルだったためです。
ビューの構造
少し本題から逸れるのですが、ビューの構造についても触れる必要があります。
ビューは、基本的に Element と同じ階層構造を持ちます。
なのですが、TextView は状態を持たないように実装されており、TextView 自体のインスタンスも階層化されません。
どういうことかというと、描画やカーソル移動のときにテンポラリな TextView のインスタンスを生成し、
必要に応じて子ビューに対して移譲するということです。
(というか、別に新しいインスタンスを作る必要さえなく、使いまわしでもよい。構造体のサイズがゼロなので今は特にキャッシュしてない)
では、TextView はどうやって子ビューのインスタンスを生成するの?というと、
引数の Context が以下インターフェイスを保持しています。
type TextViewResolver interface {
Resolve(e model.Element) TextView
}
このように、ある Element から TextView のインスタンスを解決できます。
※もし未対応の Element なら PlainTextView にフォールバックされます。
あとは、TextView がなんらかの方法で自分が描画しようとしている Element を参照できればよいわけです。
そこで TextView のメソッドの引数を見てみると、TextLayout というものが存在します。
type TextLayout struct {
Element model.Element
Children []*TextLayout
RelativeX int
RelativeY int
Width int
Height int
MinimumWidth int
MinimumHeight int
}
ある Element を表示するためのレイアウト情報も含まれていますが、
重要なのは Element を保持しており、そしてこの TextLayout 自体もまた階層構造を持っているということです。
基本的には、この TextLayout が Element の階層構造をマップする形で同じ階層を持っています。
TextView は引数の TextLayout.Element が表す範囲の文字列によってその内容を描画することができるし、
子ビューに描画を移譲したいなら TextLayout.Children[N] を子ビューの描画メソッドに渡せばよいです。
この TextLayout のツリーは、描画の一番最初のステップでDocument.Render()により得た各Elementに対して TextView を解決し、
そのそれぞれについて Measure() を呼び出すことで生成されます。
先述のとおり TextView 自体は状態を持たないので、戻り値として TextLayout を返すことで実現されています。
※ちなみに、ツリーと呼ぶとちょっとややこしいかもしれませんが、TextLayout のルートは一つではありません。
※Document.Render()が返す Element それぞれがルートです。つまり Element と TextLayout は同じ長さの配列で、その要素それぞれが子要素を階層的に持っている。
ビューは状態を持たない方がよい?
TextView が状態を持たないことについて、意図した選択ではないことを明確にしておきます。
なぜこうなったかというと、編集をまたいでビューの状態を維持するのは難しいと判断したためです。
編集をまたいで状態を維持するには、編集前のある要素が編集後のどの要素に対応するか解決できなければいけないはずです。
それに、ユーザーの編集がどれだけ大規模に行われるかも分かりません。
これにはきっと高度なインクリメンタルパーサーが必要なのではないでしょうか。
というわけで、編集をまたいでビューが状態を維持するのはやめたほうがよいと思いました。
カーソルの制御
ここからようやくカーソル制御の話に入っていきます。
さきにも述べたように、私の実装ではカーソル位置をグローバルビュー座標で保持しています。
この値から、現在の TextView と、それのローカルビュー座標を求めることができます。
でも重要なのは TextView それ自体ではなく、それが上から数えて何番目にあったか、です。
この番号は Document.Render() によって返ってくる Element の配列とそのまま対応するし、
現在保持している TextLayout の配列ともそのまま対応します。
イメージ:
def findCurrentView(globalViewPos):
total = 0
index = 0
for view in views:
moveLength = view.MoveLength()
start = total
end = total + moveLength
if globalViewPos >= start && globalViewPos < end:
return index
index += 1
total += moveLength
return -1
つまり、グローバルビュー座標から現在の TextLayout, Element, ローカルビュー座標 が分かります。
これらが揃えば MoveUp / Down / Left / Right を呼び出すことができます。
※ちゃんと読んでみると、Contextも必要なことが分かります。ただ、ここはテキストボックス内でグローバルな変数をつめておくだけなので特筆することはない。
あとは入力されたキーに応じて適切な方向のメソッドを呼び出して、-1じゃなければその変化量をグローバルビュー座標に反映すればよいし、
-1だったらキー入力の方向に応じて前のビューの最後を指すか、次のビューの先頭を指すようにグローバルビュー座標を変更すればよいです。
※私のエディターでは左、上方向にはみ出すときは常に前のビューの最後に戻し、右、下方向にはみ出すときは常に次のビューの先頭に進めています。
これが基本的なカーソル移動の方法です。
この方法の良いところは、ネストしたビューでも正しく動作する点です。
親ビューが子ビューの移動回数も含めた MoveLength() を返せばよいわけです。
逆に言うと、呼び出し側がツリーを潜って直接子要素に対して移動処理を呼び出すと正しく動作しません。
行ベースの移動
また少し横道にそれるのですが、上述のカーソル移動は完全ではありません。
上述の方法はさまざまなリッチコンテンツの上でカーソルを動かすための一般的な方法(他の便利な移動ができないときのフォールバック)ではありますが、
一般的なプレーンテキストのときは困る場合があります。
例えば、今以下のようなテキストがあるとします。
ABCDE
FGHIJ
K
そして、「C」にカーソルが置かれているとします。
このとき下キーを押下したら、「H」にカーソルが移動するべきでしょう。
さらにもう一度押下したら「K」の右隣りに移動するべきです。
これは上述のインターフェイスでは実現できません。
そこでこんなインターフェイスもあります。
type LinebaseTextView interface {
TextView
ConvertRelativeX(ctx Context, textLayout *TextLayout, viewLocalPos int) int
MoveFirstLine(ctx Context, textLayout *TextLayout, relX int) int
MoveLastLine(ctx Context, textLayout *TextLayout, relX int) int
}
これを実装している場合はローカルビュー座標をクラスタ単位の位置に変換し、
前/次の行のMoveFirstLine/MoveLastLineにその値を渡すことでそのビューにおけるそのクラスタ単位の位置を指すローカルビュー座標を得ます。
これで列位置を維持したまま行を移動することができます。
2D座標に変換する
グローバルビュー座標を移動させる方法は既に説明したとおりですが、
カーソルを描画するにはこれを2Dの座標に変換できる必要があります。
type TextView interface {
// 省略...
ConvertPos(ctx Context, textLayout *TextLayout, viewLocalPos int) (ViewLocalX int, ViewLocalY int)
}
それを行うのが ConvertPos です。ここで戻り値として、ビューの左上を原点とした2Dの相対座標を返します。
ここが相対座標になっているのが重要です。
ConvertPos もまたネストしたビューのときでも正しく座標を解決できる必要があります。
その場合、親ビューは子ビューの返す相対座標に対してその子ビュー自体がレイアウトされるオフセットを加算してやれば正しいカーソル位置を算出できます。
コードブロックのように飾りで囲まれたビューなら、飾りのオフセットもここで加えてやる必要があります。
現在の1文字に変換する
実は、カーソルが置かれているセルの文字を取得できないと、正しくカーソルを描画できません。(カーソルが指している文字を反転描画するため)
また、グローバルビュー座標から現在の1文字の場所が分からないと、当然カーソル位置への文字列の挿入ができません。
type TextView interface {
// 省略...
ConvertModel(ctx Context, textLayout *TextLayout, viewLocalPos int) CharacterReference
}
そこでビュー座標からその位置の1文字に変換するのが ConvertModel です。
これももちろんネストしたビューのときに正しく1文字を解決できる必要があります。
でも、これはシンプルです。グローバルビュー座標から現在の TextView とローカルビュー座標が計算で出せたように、
親ビューのローカルビュー座標が分かればその中にぶら下がっているどの子ビューが指されているか、そしてその子ビューにおけるローカルビュー座標も計算で出せます。
あとは子ビューに対して ConvertModel を移譲してやれば現在の1文字を解決できます。
編集位置から変換する
これまではグローバルビュー座標を主として考えてきましたが、逆に編集位置からグローバルビュー座標を求めたいこともあります。
編集位置とは、私のエディタにおいては次の3つで表されます。
- 行番号
- 指している文字の先頭バイトのインデックス
- 指している文字のバイト長
どんなときにこれが必要かというと、例えば文字列の挿入処理です。
まず現在のグローバルビュー座標から現在の1文字に変換し、挿入位置を得ます。
そこに文字列を挿入して、再パースを実行します。つまり、Document.Render()で挿入後の新しい Element や TextLayout を得ます。
次に、さきほど得た挿入位置を挿入した文字列のバイト分進めて、その位置から逆にビュー座標を得て、グローバルビュー座標として保持します。
これで挿入後の位置にカーソルが移動します。
type TextView interface {
// 省略...
ConvertViewLocalPos(ctx Context, textLayout *TextLayout, bytePos model.Position) int
}
この変換を担当するのが ConvertViewLocalPos です。
子Elementが保持している Range を見ればどの子ビューに対して ConvertViewLocalPos を呼べばよいか分かるので、
やはりネストしている場合にも正しく動作できます。
ShouldXxxメソッドはなに?
このあたりは説明してもあまり面白いものではありません。
なぜかというと、リッチテキストを編集するにあたり、一部のケースで直感的ではない結果になることがあり、
そのエッジケースをつぶすために仕方なく生やされたメソッドだからです。
(多様なリッチテキストを扱うにあたって一般的な編集操作を定義できない)
が、簡単にそのエッジケースのいくつかを説明します。
シャープから始まる見出し行が描画されているとき、シャープは不可視。でも行頭にカーソルを置いているとき、挿入位置はシャープのあと。
→行頭でENTERすると見出しがシャープと本文に分離してしまうのを防ぐ仕組みが必要。
コードブロックの中の最初の行の行頭でバックスペースを押下したとき、先頭行がバッククォートの右に移動して、言語名指定になってしまう。
→改行を残したままコードブロックの開始行にあるバッククォートを削るように。
太字テキストを囲んでいるアスタリスクは不可視。でも内側の太字テキストを消していくと突然アスタリスクが表れる。
→最後の太字テキストを消したら一緒にアスタリスクも消す。
幽霊行
ちょっとしたおまけ程度の内容ですが、幽霊行についても少しだけ触れます。
どういう呼び方がメジャーなのか分かりませんが、私のエディタでは幽霊行と呼んでいるものがあります。
例えば10行のテキストが書き込まれたテキストファイルを開いたとき、普通に考えると10行目までしかカーソルを置けないと思います。
が、実はこれだと困るケースがあるのです。
実装してみないとピンと来ないかもしれないのですが、最後の行にコードブロックが置かれているときに問題になります。
このとき、最後の行でいくら改行してもコードブロックが高くなるだけでコードブロックの向こう側にテキストを挿入できないのです。
もちろん他の方法でコードブロックを壊す(バッククォートを消すとか)ことができれば回避はできますが、
それよりももっと直感的にテキストを挿入したいと思い、実際のファイルには存在しない架空の行を末尾に表示しています。
ここで文字列を挿入すると、その位置までに存在する幽霊行の合計分改行を挿入し、そのあとにユーザーの入力文字が挿入されます。
で、少し前の方でカーソル位置としてグローバルビュー座標を保持することを選択した理由に幽霊行を挙げましたが、これについて。
モデル座標の方からビュー座標を導く場合を考えてみると、
幽霊行にカーソルが置かれているときにモデル座標がファイル内に存在しないバイト位置を指すことになります。
しかしそれはいかにもバグの原因になりそうで嫌だったのです。
という流れでカーソル位置としてグローバルビュー座標を選択したのでした。
最後に
TUIでリッチテキストの表示と編集を両立させる方法を模索してみましたが、
設計や概念的な話ばかりで、これで本当に動作するのか?と思われるかもしれません。
そこで、動くものをここに置いておきます。
でもまだ課題は山積みだし、v1.0.0ですらありません。
本当に快適に編集できるエディターにするにはまだ必要なものがあるはずです。
- スニペットの展開
- 特定のキーワードを入力してENTERでテーブルやコードブロックなどを一発挿入
- テーブルの編集
- テーブルの中で改行したら自動でセル数分のパイプを挿入してすぐに新しい列を追加
- インクリメンタルパーサー
- 今は編集のたびに全パースしてるけど、本当はよくないはず
そもそもTUIでやるべきなのか、という話もありますが…
Discussion