Elmでドッキングウインドウを作ってみた

これは「[ドワンゴ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/dwango)」の15日目の記事です。

# ドッキングウインドウとは

Visual Studio や InteliJ IDEA のような IDE にはユーザがツールが表示されている子ウインドウをタイル上に自由にレイアウトできる機能があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/7d0159a3-1c35-de76-84f5-363a7fb008cd.png)

参考: [うまくドッキングしないウィンドウにサヨナラ！ - ＠IT](https://www.atmarkit.co.jp/fdotnet/vb2005/vb2005_13/vb2005_13_01.html)

こちらの機能、様々なアプリケーションで見かけ、また検索すると様々な言語で様々なライブラリが公開されています。プロプライエタリなものが多いようですが、フリーのものもいくつかあるようです。

- 例1: [AvalonDock](https://github.com/xceedsoftware/wpftoolkit/wiki/AvalonDock): 以前 WPF 利用したことがある
- 例2: [Dock Spawn TS](https://node-projects.github.io/dock-spawn-ts/): オンラインのデモがある (画像参照)

このドッキングウインドウ、利用する立場からだと直感的でマニュアルも必要なく便利なのですが、いったいどうやったらこのような機構を実装できるのか、それほど自明ではないように思えます。

今回はこのドッキングウインドウの自作に挑戦したのでその軌跡を紹介します。開発言語には [Elm](https://elm-lang.org/) を利用しました。Elm はまだバージョンが浅かった時代 (FRP推しだった時代) に触ったことがあるのですが、最近のバージョンはきちんと触ったことがないのでその学習も兼ねています。

Elm についてはオンラインのドキュメントが充実しいて、さらに Qiita 上にも多数記事があります。紹介記事としては以下のものがわかりやすかったです。

- [2020年 Elmをはじめる人が最初に読むページ](https://qiita.com/arowM/items/5ec5853298fc880353b7)

以降ちょくちょく Elm の話が出てきますが、ドッキングウインドウ自体はどのような言語・環境で作っても同じように作れそうです。未検証ですが特に React + Redux の組み合わせだと、ほとんど Elm と同じように作れるのではないかと思います。

# 作ったもの

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/1aecf804-680d-6245-81d4-aca757ff8c6f.png)

- [デモ (Docking Window Sysyte)](https://lambdataro.github.io/elm-docking-window/)
- [ソースコード](https://github.com/lambdataro/elm-docking-window)

本当はもっと機能が充実したものを作りたかったのですが思いのほか苦労しました。今日までに次の機能が実装できました。

- ペインの追加と削除ができる
- ペインを領域にドックしたり浮かせたりできる
- ペインの移動とリサイズができる

一般的なドッキングウインドウにあって今回の実装にない重要機能として、タブとピンがあります。またドキュメントとツールの区別とか細かい機能は一切ないです。しかし、ドッキングウインドウの自由にレイアウトできる機構は実装できたので満足です。




# ドッキングウインドウ実現までの軌跡

いきなりドッキングウインドウを作ろうにも、何から手を付ければ良いか分からないので、できそうな部分から順番に作りました。以下でその軌跡を紹介します。

## STEP1: 四角を表示してドラッグで移動できるようになる

まず最初のゴールとしてマウスでドラッグできる四角形を描画するプログラムを目指しました。四角をドラッグするプログラムを作るにはグラフィックの描画とマウスのイベントの扱い方が必要になります。まずグラフィックの表示ですが今回は SVG を利用しました。今回作った範囲では HTML でも作れるかと思います。

Elm で SVG は簡単に扱うことができ、公式サイトでもサンプルとして紹介されています。

- [shapes](https://elm-lang.org/examples/shapes)

Elm で SVG を扱うライブラリとしては公式で用意されている [elm/svg](https://package.elm-lang.org/packages/elm/svg/latest/) があります。こちらのライブラリ、数値も含めてあらゆるものを `String` 型で表すのでそのあたりを改善したライブラリとして [elm-community/typed-svg](https://package.elm-lang.org/packages/elm-community/typed-svg/latest/) があるようです。今回は公式の `elm/svg` を利用しました。 

次にマウスイベントの扱いですが、以下のページにあるように、サブスクリプションとよばれる仕組みを使うようです。

- [コマンドとサブスクリプション](https://guide.elm-lang.jp/effects/)

このあたりを学習することで四角をドラッグするプログラムが作成できました。

- [四角をドラングアンドドロップするプログラム](https://gist.github.com/lambdataro/0aedd8253be5222a036ca07200519625)

Elm のプログラムはモデルとメッセージをどう作るかの部分が肝になります。今回の場合は次のように作りました。

```elm
type alias Model =
    { winSize : Size
    , mousePos : Pos
    , draggingStatus : DraggingStatus
    , rect : Rect
    }
```

全画面に表示するためにブラウザのサイズとマウスの位置をモデルに含めています。ブラウザのリサイズとマウスの移動はサブスクリプションにより通知されます。`draggingStatus` はドラッグ中であるかそうでないかを表しています。`Offset` でドラッグしたときに四角の中のどこをクリックしたかを保持しています。

```
type DraggingStatus
    = NotDragging
    | Dragging Offset
```

次にメッセージですが、次のようにしました。

```elm
type Msg
    = BrowserResize Size
    | MouseMove Pos
    | MouseUp Pos
    | StartDragging Offset
```

ブラウザのリサイズとマウスの移動とマウスを放したときのメッセージは次のサブスクリプションで発行されます。`Decoder` の書き方は慣れるまで苦労しました。

```elm
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` 関数は次のようになります。

細かな型や補助関数は [ソースコード全体](https://gist.github.com/lambdataro/0aedd8253be5222a036ca07200519625) にあります。

## STEP2: 横方向にペインを追加するプログラム

STEP1のドラッグアンドドロップの段階で必要な技術はおおむね習得できたので、以降では何をどういう順番で作ったのかが重要となります。次に目標としたのは、ペインを横方向に追加していくプログラムです。以下の画像のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/3d9efa06-98a5-80d7-bf5b-03584adc56a6.png)

ここで重要なのは以下の2点です。

- レイアウトをどういうデータ構造で表現するか
- どのように追加するか

レイアウトをどのように表すかですがユーザが移動するウインドウをペイント呼びます。ペインは以下のように定義しました。重要なのは位置とサイズを持っている部分になります。

```elm
type alias Pane =
    { id : PaneId
    , pos : Pos
    , size : Size
    , title : String
    , status : PaneStatus
    , content : PaneContent
    }
```

`status` にはペインの状態を入れます。状態にはどこかにドックされている状態と、ドックされていない状態（フローティング状態）があります。

```elm
type PaneStatus
    = Docked DockingAreaId
    | Floating
```

`content` には背景色を入れています。背景色は常に白で実装したので実質意味のないフィールドとなっています。実質的に `title` で区別することとしています。

```elm
type alias PaneContent =
    { backgroundColor : String
    }
```

状態にかかわらずすべてのペインは `Dict` 型に格納して、PaneId (Intのalias) で識別しています。

```elm
type alias PaneDict =
    Dict PaneId Pane
```

このペインを並べるデータ構造として、`DockingArea` というのを作りました。

```elm
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` になるように値を割り振ります。

```elm
type alias DockingItem =
    { itemType : DockingItemType
    , factor : Float
    }

type DockingItemType
    = PaneItem PaneId
    | DockingAreaItem DockingAreaId
```

図で表現すると次のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/d96ecfe7-0b3b-964f-f11d-575ef18a268b.png)

ペイン同様すべての `DockingArea` は `Dict` 型に格納して、DockingAreaId(Intのalias) で識別しています。図で表現すると次のようになります。ペインを追加する場合は、`DockingArea` の先頭に追加することとします。`DockingArea` の先頭のアイテムを取り出して、そのアイテムの `factor` の `1/2` を追加するアイテムに割り当てるようにします。

いくつかのドッキングウインドウのレイアウトを触った結果、今回のようにリストで表現する実装と、リストではなくペアで表現している実装があるようでした。ペアで実装する場合は次の図のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/82bc9530-a811-6ec4-3f72-fe65757f6322.png)

こちらの場合は `pane1` の左側の隙間をドラッグしたときに、そのサイズ変更が `pane3` と `pane2` 両方に影響します。InteliJ IDEA は挙動をみるとペアで実装してるように見えます。

## STEP3: ペインの幅をマウスで変更できるようにする

ドッキングウインドウではマウスでペイン間の幅を変更できるのですが、この変更できる部分は動的に変化します。この変更できる部分を今回はスプリッターと呼びます。例えば4つのペインが存在する場合、スプリッターは3つになります。今回のレイアウトの持ち方だと、スプリッターを移動することで、その両側にあるペインの `factor` の値を適切に変更する必要があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/c7e58d20-f66d-d7c6-7488-0808e574a068.png)

Elm でこのようなプログラムを作るにはスプリッターを特定する方法が必要になります。今回は次のように、スプリッターが所属する `DockingArea` とその領域内で何番目のスプリッターかを整数で特定しています。

```elm
type alias SplitterId =
    { areaId : DockingAreaId
    , index : Int
    }
```

スプリッターが特定できれば後は四角形のドラッグアンドドロップと同じ要領で指定した位置に移動させることができます。移動させた位置に応じて factor の値を変更すれば完了です。

## STEP4: ペインを切り離してフローティング状態にできるようにする

ペインを並べることができたので次は分離します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/5a7b2f52-ca78-f989-4d95-3d9f63052744.png)

分離するにはペインのタイトルバーをドラッグすれば良いのですが、マウスをクリックした瞬間に分離すると使い勝手が悪いのでドラッグして一定の距離離れた時に分離するようにします。ペインの分離ができるようになると分離したペインの移動やリサイズを実装したくなります。移動やリサイズの実装は特に難しいところはありません。

## STEP5: フローティング状態のペインをドックする

分離できるようになったら、再度 `DockingArea` に戻したくなります。どのように操作してドックさせるかですが、今回は Visual Studio のようにアイコンを出して、そこにドラッグしたらドックする挙動とします。アイコンはペインの領域の上にマウスが移動したときに表示するようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/9499c19b-b6e5-5878-5b67-bc7063e5002f.png)

マウスを離すと、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/42ec6ff3-854e-beca-4328-c2b40908611d.png)

ドック後の割合ですが以下図のように割り振りました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/893e225f-22dc-2aa0-24f8-8b291a1fad5c.png)

- 右のペインの `factor` を `2/3` にする。
- 左のペインの `factor` を `2/3` にする。
- 左右のペインから残った量を新しいペインに割り振る。

図を見ると明らかですが、`pane3` の右にドックする場合と、`pane1` の左にドックする場合の挙動は同じになります。ドックする場合、常に左右にペインがあるとは限りません。ドッキングエリアの両端の場合は、`1/2` にして割り振るように実装します。

さらにドッキングエリアが空の場合は特別なアイコンを表示して、ドックしたアイテムが全体を占めるようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/e7909d61-ba55-74df-e7c2-140d680d7d64.png)


## STEP6: 縦に分割できるようにする

横方向の分割が動くようになったので次は縦に分割します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/ef1568bd-5350-6d09-eb27-5856433a2954.png)

マウスを離すと、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/5b677ca1-6efd-5c94-beb5-94dc7c7a19ed.png)

縦の分割では以下の操作を行う必要があります。

1. 縦方向にレイアウトする `DockingArea` を新しく作る
2. ドック先のペインを `DockingArea` に置き換える
3. 新しく作った `DockingArea` の items としてドック先のペイントドッキングするペインを並べる。

実装できたら縦方向にレイアウトしている `DockingArea` の中にさらに横方向にレイアウトする `DockingArea` を作成できるようにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/3bd10da0-55dd-b8a1-5e7e-699f06c8c4bd.png)

レイアウトできたらさらに分離もできるように必要があります。分離をすると `items` が空の `DockingArea` が生まれます。この `items` が空の `DockingArea` が生じた場合にはレイアウトから取り除きます。具体例としては以下のようなレイアウトがあるとします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/9f2940bc-1f54-de13-7257-326b65a2308d.png)

このレイアウトから A と B を取り除くと空の `DockingArea` ができるので取り除いて、C が全画面になるようにします。この空のレイアウトは再帰的に発生する (空の `DockingArea` を取り除いた影響で、別の `DockingArea` が連鎖的に空になる) ので実装時に注意が必要です。

## STEP7: ルートの DockingArea のマーカーを作る

ここまでできればほぼ `DockingArea` なのですが、例えば以下のようにDを挿入する操作はマーカーが表示されないためできません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/63dff3de-3523-8938-1de3-44ea1cf2a63f.png)

さらに次のようなルートの `DockingArea` の方向が変わるような変更もできません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/d8af658f-5080-338a-1888-c9dfa301a23e.png)

このような変更に対応できるようにルート要素の周囲にもアイコンを出します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/ac86ef55-ab88-65b2-0dd2-ec90d53b12b0.png)

マウスを離すと、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/0dbcfd9b-381c-beff-0b3b-129286978683.png)

## STEP8: 閉じるボタンを作る

ペインは閉じたいので閉じるボタンを作ります。クリックするとペインが消滅します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/9e6f3919-2841-5ffd-3d2f-d54a900a153d.png)

閉じるは分離処理とほぼ同じなので特に難しくありませんでした。

## 今後のステップ

ドッキングウインドウのような挙動をするプログラムは書けました。この後に欲しい機能としてはやはりタブ機能があります。タブの他にはウインドウを自動で閉じる機能 (ピン機能) があります。

# Elmの感想

ドッキングウインドウはそもそもの挙動が複雑なので、どのような言語だろうとあまり難易度に変化はなさそうではありますが、今回のように SVG をがっつり操作するプログラムには向いているように感じました。今回 Elm をがっつり触ったので初心者の感想をまとめておきます。

### デバッガーが便利

Elm Debugger がとても便利です。モデルの内容を GUI で表示してくれて、さらにメッセージの履歴を閲覧して過去に状態を戻すことができるので、不具合を一瞬で発見できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/70306/2d3d3f90-908f-a721-427a-f4b9d62fbbed.png)

### 型が通れば正しく動く

これは OCaml のような言語でも同様でとても感覚的なものになりますが、他のプログラミング言語だと、型を通した後に正しく動くかどうかを確認するフェーズがあると思うのですが、Elm だと型が通ればたいてい正しく動きます。もちろんモデルの作り方が適当だとこの恩恵は得られない訳ですが、型が通って正しく動かない場合は、モデルの作り方が悪いのではないかという発想になります。

例えば、今回の実装では `Pane` のIDと `DockingArea` のIDがどちらも INT になっていて、取り違えてもエラーになりません。仮にこれを別の型として定義すれば、このあたりのミスを検出できるようになりそうです。

### コードの整理がやりやすい

今回かなり試行錯誤を繰り返しました。特にドッキングエリアの構造をどうやってモデルで表現すべきかは作りながら決めていきました。適当に作って後で直しても、型にがっちり守られているので安心して実装を整理できるのは Elm の利点だと感じました。(しただ今のコードはかなりひどいです)

# まとめ

正直なところ、実際に作り始めるまでドッキングウインドウを作るのはそんなに難しくないと考えていましたが、実際に作ってみると意外と大変でした。しかもタブまで実装が終わりませんでした。会社で同じような分野をある程度長く続けていると、どうしても慣れてしまってあまり技術的な部分での苦労を感じなくなってしまいがちですが、たまには考え方の違う言語を触るのは大切だなと今年も思いを新たにしました。