CLIのGUIをCSSでOSSしてみた

[ちょっと何言ってるかわかんない](https://goo.gl/S881uC)と言われるであろうので補足すると、CLI **風の見た目** のGUIをCSSで **作ってみたので** OSS **として公開して使えるように** してみた、ということになります。車輪の再発明的な遊び。

## 成果物

UI : https://cli-gui.firebaseapp.com
レポジトリ : https://github.com/kyoyababa/cli-gui

![Screen Shot 2018-01-08 at 3.43.05 PM.png](https://qiita-image-store.s3.amazonaws.com/0/105252/4e64a319-eadc-697d-55e6-930b623ab021.png)

## できること

基本的な機能として以下だけ実装してみた。

* clear : ログのクリア
* help : コマンド一覧の出力
* version : アプリバージョンの出力

それだけだとつまらないので、引数をとるサンプルとして以下を追加した。

* cats : 猫のデータベースから品種を一覧して出力（品種国のフィルタと名前のソートつき）

## 技術仕様

ターミナル風のUIを作るために [Angular](https://angular.io/) を使いたかったので [Angular CLI](https://cli.angular.io/) で開発した。

## HTML

HTMLはシンプルで、以下のみ。あとはコントローラ側で制御する。

```html
<div id="jsi-cliLog" class="g-cli__wrapper" (click)="focus()">
  <pre [innerHTML]="cliLog"></pre>

  <form (submit)="addLog()" novalidate>
    <span>$</span>
    <input id="jsi-cliValue" type="text" name="cliValue" [(ngModel)]="cliValue" />
  </form>
</div>
```

## CSS

ターミナル風のUIは以下のCSSで再現した。以下に記載したのはおおまかな部分のみで細かい調整の部分は割愛。コントローラ側で生成したHTMLにスタイルを当てるためにAngularの `/deep/` 空間を使っている。 （全体は [こちら](https://github.com/kyoyababa/cli-gui/blob/master/src/app/cli/cli.component.scss) から）

```scss
:host /deep/ .g-cli__wrapper {
  overflow: auto;
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  background-color: #000000;
  font-family: monospace;
  color: #FFFFFF;

  > form {
    > input {
      width: calc(100vw - 1em * 3 - 0.5em);
      border: 0;
      outline: 0;
      background-color: inherit;
      color: inherit;
    }
  }
}
```

## `<input>` へのfocus

ターミナルライクにどこをクリックしても入力を開始できるように、 `<div>` 全体のどこをクリックしても `<input>` にfocusできるようにした。ログ上の文字列をドラッグして選択している場合はfocusしたくないので、 `window.getSelection()` をみて分岐。

```typescript
public focus(): void {
  if (window.getSelection().toString()) return;

  document.getElementById('jsi-cliValue').focus();
}
```

## 入力値の出力

`<input>` フィールドでEnterしたら、コントローラのモデル `this.cliValue` を `this.cliLog` に追加して、入力値を判定してコマンドになっていたら処理する。あとは、あとで上キーと下キーで過去の入力値を呼び出したいので `this.history` に登録して、モデルをクリアする。

```typescript
public addLog(): void {
  this.cliLog += `

<span>-(cli-gui)</span> : ${this.generateDateString()}
<span>$</span> ${this.cliValue}`;

  this.analyzeInput(this.cliValue);

  this.history.push(this.cliValue);
  this.lastAttachedHistoryNumber = null;
  
  this.initializeValue();
}
```

![Screen Shot 2018-01-08 at 4.02.33 PM.png](https://qiita-image-store.s3.amazonaws.com/0/105252/756da5c5-5a6e-3a5f-8ebf-d78e0132ee02.png)

## コマンド入力値の判定と処理

入力した値をスペースごとに分割して配列にし、 `cli-gui` で始まっていたらコマンドとして処理（ `this.do()` ）する。

```typescript
private analyzeInput(value: string): void {
  const commands = value.trim().split(' ').filter(val => val !== '');

  if (commands.length === 0) return;

  if (commands[0] === 'cli-gui' && commands.length > 0) {
    this.do(commands);
  } else {
    this.cliLog += `

&nbsp;&nbsp;<em>command ${commands[0]} is not found.</em>`;
  }
}
```

こんな感じ。いちどに複数のコマンドが入力されることは考慮しない（ことにした）ので、 `commands[1]` のみを見ている。

```typescript
private do(commands: Array<string>): void {
  switch (commands[1]) {
    case 'clear' {
      ...
    } break;

    case 'help': {
      ...
    } break;

    case 'cats': {
      ...
```

## コマンドの引数を見て処理を分岐

`猫のデータベースから品種を一覧して出力（品種国のフィルタと名前のソートつき）` については引数をとって処理を分岐したいので、少し実装を追加して、コマンドの引数とその引数の次に入力されている値を見るようにした。

```typescript
case 'cats':
case '-l': {
  let targetCats = this.cats;

  for (let i = 2; i < commands.length - 2; i++) {
    switch (commands[i]) {
      case '--country':
        if (!commands[i + 1]) {
          this.cliLog += `

&nbsp;&nbsp;<em>'country' filter must be supplied an argument.</em>`;
          return;
        }

        targetCats = targetCats.filter(cat => cat.country.toLowerCase() === commands[i + 1].toLowerCase());
        break;

      case ...
```

![Screen Shot 2018-01-08 at 4.15.55 PM.png](https://qiita-image-store.s3.amazonaws.com/0/105252/ffa66cb7-e998-8f02-91d5-8d81095ca108.png)

![Screen Shot 2018-01-08 at 4.16.29 PM.png](https://qiita-image-store.s3.amazonaws.com/0/105252/681222e0-318b-9610-49f9-269c50c97e69.png)

## 上スクロール方式にするための強制スクロール

CLIの場合、ログがたまっていっても常に最下部に入力フィールドが表示されていないといけない。かつ、ユーザーが過去のログを溯ろうとして上にスクロールすることもできる（なので、CSSのposition属性で強制的に画面下部に入力フィールドを固定することはできない）。

そこで、（あまりいい解決策ではないようにも思うが）ユーザーが文字列をドラッグしているのでない限り、常に画面を最下部にスクロールすることにした。以下のメソッドはつねにテンプレートから呼び出されている。

```typescript
public fetchingForScrolling(): void {
  if (window.getSelection().toString()) return;

  document.getElementById('jsi-cliLog').scrollTop = Number.MAX_SAFE_INTEGER;
}
```

## このアプリの使い道

正直おもいつかないけど、いつか使いたくなる日がくるはず。たぶん。

https://cli-gui.firebaseapp.com