これは「ドワンゴ Advent Calendar 2020」の15日目の記事です。
ドッキングウインドウとは
Visual Studio や InteliJ IDEA のような IDE にはユーザがツールが表示されている子ウインドウをタイル上に自由にレイアウトできる機能があります。
参考: うまくドッキングしないウィンドウにサヨナラ! - @IT
こちらの機能、様々なアプリケーションで見かけ、また検索すると様々な言語で様々なライブラリが公開されています。プロプライエタリなものが多いようですが、フリーのものもいくつかあるようです。
- 例1: AvalonDock: 以前 WPF 利用したことがある
- 例2: Dock Spawn TS: オンラインのデモがある (画像参照)
このドッキングウインドウ、利用する立場からだと直感的でマニュアルも必要なく便利なのですが、いったいどうやったらこのような機構を実装できるのか、それほど自明ではないように思えます。
今回はこのドッキングウインドウの自作に挑戦したのでその軌跡を紹介します。開発言語には Elm を利用しました。Elm はまだバージョンが浅かった時代 (FRP推しだった時代) に触ったことがあるのですが、最近のバージョンはきちんと触ったことがないのでその学習も兼ねています。
Elm についてはオンラインのドキュメントが充実しいて、さらに Qiita 上にも多数記事があります。紹介記事としては以下のものがわかりやすかったです。
以降ちょくちょく Elm の話が出てきますが、ドッキングウインドウ自体はどのような言語・環境で作っても同じように作れそうです。未検証ですが特に React + Redux の組み合わせだと、ほとんど Elm と同じように作れるのではないかと思います。
作ったもの
本当はもっと機能が充実したものを作りたかったのですが思いのほか苦労しました。今日までに次の機能が実装できました。
- ペインの追加と削除ができる
- ペインを領域にドックしたり浮かせたりできる
- ペインの移動とリサイズができる
一般的なドッキングウインドウにあって今回の実装にない重要機能として、タブとピンがあります。またドキュメントとツールの区別とか細かい機能は一切ないです。しかし、ドッキングウインドウの自由にレイアウトできる機構は実装できたので満足です。
ドッキングウインドウ実現までの軌跡
いきなりドッキングウインドウを作ろうにも、何から手を付ければ良いか分からないので、できそうな部分から順番に作りました。以下でその軌跡を紹介します。
STEP1: 四角を表示してドラッグで移動できるようになる
まず最初のゴールとしてマウスでドラッグできる四角形を描画するプログラムを目指しました。四角をドラッグするプログラムを作るにはグラフィックの描画とマウスのイベントの扱い方が必要になります。まずグラフィックの表示ですが今回は SVG を利用しました。今回作った範囲では HTML でも作れるかと思います。
Elm で SVG は簡単に扱うことができ、公式サイトでもサンプルとして紹介されています。
Elm で SVG を扱うライブラリとしては公式で用意されている elm/svg があります。こちらのライブラリ、数値も含めてあらゆるものを String
型で表すのでそのあたりを改善したライブラリとして elm-community/typed-svg があるようです。今回は公式の elm/svg
を利用しました。
次にマウスイベントの扱いですが、以下のページにあるように、サブスクリプションとよばれる仕組みを使うようです。
このあたりを学習することで四角をドラッグするプログラムが作成できました。
Elm のプログラムはモデルとメッセージをどう作るかの部分が肝になります。今回の場合は次のように作りました。
type alias Model =
{ winSize : Size
, mousePos : Pos
, draggingStatus : DraggingStatus
, rect : Rect
}
全画面に表示するためにブラウザのサイズとマウスの位置をモデルに含めています。ブラウザのリサイズとマウスの移動はサブスクリプションにより通知されます。draggingStatus
はドラッグ中であるかそうでないかを表しています。Offset
でドラッグしたときに四角の中のどこをクリックしたかを保持しています。
type DraggingStatus
= NotDragging
| Dragging Offset
次にメッセージですが、次のようにしました。
type Msg
= BrowserResize Size
| MouseMove Pos
| MouseUp Pos
| StartDragging Offset
ブラウザのリサイズとマウスの移動とマウスを放したときのメッセージは次のサブスクリプションで発行されます。Decoder
の書き方は慣れるまで苦労しました。
subscriptions : Model -> Sub Msg
subscriptions _ =
let
resizeMsg : (Size -> a) -> Int -> Int -> a
resizeMsg f width height =
f { width = toFloat width, height = toFloat height }
mousePosDecoder : (Pos -> a) -> Decoder a
mousePosDecoder f =
Dec.map2 (\x y -> f { x = x, y = y })
(Dec.field "offsetX" Dec.float)
(Dec.field "offsetY" Dec.float)
in
Sub.batch
[ Browser.Events.onResize (resizeMsg BrowserResize)
, Browser.Events.onMouseMove (mousePosDecoder MouseMove)
, Browser.Events.onMouseUp (mousePosDecoder MouseUp)
]
このコードの resizeMsg
や mousePosDecoder
は必ずしも let
の中に書く必要はないのですが、他の関数からは使われていないということで let の中に入れてしまいました。このあたりどのように書けばスマートかはまだ自信がありません。トップレベル相当と言うことで resizeMsg
や mousePosDecoder
にも型を書いています。メッセージに応じてモデルを変更する update
関数は次のようになります。
細かな型や補助関数は ソースコード全体 にあります。
STEP2: 横方向にペインを追加するプログラム
STEP1のドラッグアンドドロップの段階で必要な技術はおおむね習得できたので、以降では何をどういう順番で作ったのかが重要となります。次に目標としたのは、ペインを横方向に追加していくプログラムです。以下の画像のようになります。
ここで重要なのは以下の2点です。
- レイアウトをどういうデータ構造で表現するか
- どのように追加するか
レイアウトをどのように表すかですがユーザが移動するウインドウをペイント呼びます。ペインは以下のように定義しました。重要なのは位置とサイズを持っている部分になります。
type alias Pane =
{ id : PaneId
, pos : Pos
, size : Size
, title : String
, status : PaneStatus
, content : PaneContent
}
status
にはペインの状態を入れます。状態にはどこかにドックされている状態と、ドックされていない状態(フローティング状態)があります。
type PaneStatus
= Docked DockingAreaId
| Floating
content
には背景色を入れています。背景色は常に白で実装したので実質意味のないフィールドとなっています。実質的に title
で区別することとしています。
type alias PaneContent =
{ backgroundColor : String
}
状態にかかわらずすべてのペインは Dict
型に格納して、PaneId (Intのalias) で識別しています。
type alias PaneDict =
Dict PaneId Pane
このペインを並べるデータ構造として、DockingArea
というのを作りました。
type alias DockingArea =
{ id : DockingAreaId
, pos : Pos
, size : Size
, orientation : Orientation
, items : List DockingItem
}
type Orientation
= Horizontal
| Vertical
DockingArea
自体も位置とサイズを持っており、items
としてペインをリストで保持しています。DockingItem
型は次のようになっています。単純なペインを格納している場合と、別の DockingArea
を格納している場合があります。各要素には factor
という値があり、親のサイズのうち自身が占めている割合を表示します。DockingArea
の items
のすべての factor
の値を合計すると 1.0
になるように値を割り振ります。
type alias DockingItem =
{ itemType : DockingItemType
, factor : Float
}
type DockingItemType
= PaneItem PaneId
| DockingAreaItem DockingAreaId
図で表現すると次のようになります。
ペイン同様すべての DockingArea
は Dict
型に格納して、DockingAreaId(Intのalias) で識別しています。図で表現すると次のようになります。ペインを追加する場合は、DockingArea
の先頭に追加することとします。DockingArea
の先頭のアイテムを取り出して、そのアイテムの factor
の 1/2
を追加するアイテムに割り当てるようにします。
いくつかのドッキングウインドウのレイアウトを触った結果、今回のようにリストで表現する実装と、リストではなくペアで表現している実装があるようでした。ペアで実装する場合は次の図のようになります。
こちらの場合は pane1
の左側の隙間をドラッグしたときに、そのサイズ変更が pane3
と pane2
両方に影響します。InteliJ IDEA は挙動をみるとペアで実装してるように見えます。
STEP3: ペインの幅をマウスで変更できるようにする
ドッキングウインドウではマウスでペイン間の幅を変更できるのですが、この変更できる部分は動的に変化します。この変更できる部分を今回はスプリッターと呼びます。例えば4つのペインが存在する場合、スプリッターは3つになります。今回のレイアウトの持ち方だと、スプリッターを移動することで、その両側にあるペインの factor
の値を適切に変更する必要があります。
Elm でこのようなプログラムを作るにはスプリッターを特定する方法が必要になります。今回は次のように、スプリッターが所属する DockingArea
とその領域内で何番目のスプリッターかを整数で特定しています。
type alias SplitterId =
{ areaId : DockingAreaId
, index : Int
}
スプリッターが特定できれば後は四角形のドラッグアンドドロップと同じ要領で指定した位置に移動させることができます。移動させた位置に応じて factor の値を変更すれば完了です。
STEP4: ペインを切り離してフローティング状態にできるようにする
ペインを並べることができたので次は分離します。
分離するにはペインのタイトルバーをドラッグすれば良いのですが、マウスをクリックした瞬間に分離すると使い勝手が悪いのでドラッグして一定の距離離れた時に分離するようにします。ペインの分離ができるようになると分離したペインの移動やリサイズを実装したくなります。移動やリサイズの実装は特に難しいところはありません。
STEP5: フローティング状態のペインをドックする
分離できるようになったら、再度 DockingArea
に戻したくなります。どのように操作してドックさせるかですが、今回は Visual Studio のようにアイコンを出して、そこにドラッグしたらドックする挙動とします。アイコンはペインの領域の上にマウスが移動したときに表示するようにします。
マウスを離すと、
ドック後の割合ですが以下図のように割り振りました。
- 右のペインの
factor
を2/3
にする。 - 左のペインの
factor
を2/3
にする。 - 左右のペインから残った量を新しいペインに割り振る。
図を見ると明らかですが、pane3
の右にドックする場合と、pane1
の左にドックする場合の挙動は同じになります。ドックする場合、常に左右にペインがあるとは限りません。ドッキングエリアの両端の場合は、1/2
にして割り振るように実装します。
さらにドッキングエリアが空の場合は特別なアイコンを表示して、ドックしたアイテムが全体を占めるようにします。
STEP6: 縦に分割できるようにする
横方向の分割が動くようになったので次は縦に分割します。
マウスを離すと、
縦の分割では以下の操作を行う必要があります。
- 縦方向にレイアウトする
DockingArea
を新しく作る - ドック先のペインを
DockingArea
に置き換える - 新しく作った
DockingArea
の items としてドック先のペイントドッキングするペインを並べる。
実装できたら縦方向にレイアウトしている DockingArea
の中にさらに横方向にレイアウトする DockingArea
を作成できるようにします。
レイアウトできたらさらに分離もできるように必要があります。分離をすると items
が空の DockingArea
が生まれます。この items
が空の DockingArea
が生じた場合にはレイアウトから取り除きます。具体例としては以下のようなレイアウトがあるとします。
このレイアウトから A と B を取り除くと空の DockingArea
ができるので取り除いて、C が全画面になるようにします。この空のレイアウトは再帰的に発生する (空の DockingArea
を取り除いた影響で、別の DockingArea
が連鎖的に空になる) ので実装時に注意が必要です。
STEP7: ルートの DockingArea のマーカーを作る
ここまでできればほぼ DockingArea
なのですが、例えば以下のようにDを挿入する操作はマーカーが表示されないためできません。
さらに次のようなルートの DockingArea
の方向が変わるような変更もできません。
このような変更に対応できるようにルート要素の周囲にもアイコンを出します。
マウスを離すと、
STEP8: 閉じるボタンを作る
ペインは閉じたいので閉じるボタンを作ります。クリックするとペインが消滅します。
閉じるは分離処理とほぼ同じなので特に難しくありませんでした。
今後のステップ
ドッキングウインドウのような挙動をするプログラムは書けました。この後に欲しい機能としてはやはりタブ機能があります。タブの他にはウインドウを自動で閉じる機能 (ピン機能) があります。
Elmの感想
ドッキングウインドウはそもそもの挙動が複雑なので、どのような言語だろうとあまり難易度に変化はなさそうではありますが、今回のように SVG をがっつり操作するプログラムには向いているように感じました。今回 Elm をがっつり触ったので初心者の感想をまとめておきます。
デバッガーが便利
Elm Debugger がとても便利です。モデルの内容を GUI で表示してくれて、さらにメッセージの履歴を閲覧して過去に状態を戻すことができるので、不具合を一瞬で発見できます。
型が通れば正しく動く
これは OCaml のような言語でも同様でとても感覚的なものになりますが、他のプログラミング言語だと、型を通した後に正しく動くかどうかを確認するフェーズがあると思うのですが、Elm だと型が通ればたいてい正しく動きます。もちろんモデルの作り方が適当だとこの恩恵は得られない訳ですが、型が通って正しく動かない場合は、モデルの作り方が悪いのではないかという発想になります。
例えば、今回の実装では Pane
のIDと DockingArea
のIDがどちらも INT になっていて、取り違えてもエラーになりません。仮にこれを別の型として定義すれば、このあたりのミスを検出できるようになりそうです。
コードの整理がやりやすい
今回かなり試行錯誤を繰り返しました。特にドッキングエリアの構造をどうやってモデルで表現すべきかは作りながら決めていきました。適当に作って後で直しても、型にがっちり守られているので安心して実装を整理できるのは Elm の利点だと感じました。(しただ今のコードはかなりひどいです)
まとめ
正直なところ、実際に作り始めるまでドッキングウインドウを作るのはそんなに難しくないと考えていましたが、実際に作ってみると意外と大変でした。しかもタブまで実装が終わりませんでした。会社で同じような分野をある程度長く続けていると、どうしても慣れてしまってあまり技術的な部分での苦労を感じなくなってしまいがちですが、たまには考え方の違う言語を触るのは大切だなと今年も思いを新たにしました。