Node.jsでターミナルで動くテキスト編集可能なアプリケーションを作る


こんなの作ってます

何ができるのか?

  • BoostNote(v1)のデータの読み書き

どうやって作ったか?

使うもの

  • TypeScript
  • blessed
    • Node.jsからターミナルの表示を色々いじれるライブラリ
  • MobX
    • アプリケーションのベース部分
  • TextBuffer
    • テキストファイルへのアクセス補助

構成図

blessedの使い方

blessedは今風の宣言的UIライブラリではないので、結構泥臭い感じになります。(一応Reactでラップしたライブラリもありますが、今回は素のblessedを使いました。)

このアプリの場合は、画面内の各要素ごとに自前のコンポーネントを作成し、その中でblessedのパーツを描画する感じにしました。

import ...

type NoteListOptions = SetRequired<blessed.Widgets.ListOptions<any>, 'parent'>;

const kDefaultNoteListOption: Readonly<Partial<NoteListOptions>> = Object.freeze({
  keys: true,
  mouse: false,
  scrollbar: {
    ch: ' ',
    track: {
      bg: 'cyan',
    },
    style: {
      inverse: true,
    },
  },
  style: {
    item: {
      hover: {
        bg: 'blue',
      },
    },
    selected: {
      bg: 'blue',
      bold: true,
    },
  },
});

interface NoteList extends LoggableMixin, ReactableMixin {}

class NoteList {
  private noteList: blessed.Widgets.ListElement;

  constructor(options: NoteListOptions) {
    // blessedのパーツはコンポーネントで保持する
    this.noteList = blessed.list({
      ...kDefaultNoteListOption,
      ...options,
    });
    // コンポーネントのインスタンスメソッドを、パーツにイベントリスナーとして設定する
    this.noteList.key(['up'], this.onUpKeyPressed);
    this.noteList.key(['down'], this.onDownKeyPressed);
    this.noteList.key(['f'], this.onFolderKeyPressed);
    this.noteList.key(['c'], this.onCKeyPressed);
    this.noteList.on('select', this.onSelect);
    this.makeReactable();
  }

  focus() {
    this.noteList.focus();
  }

  @boundMethod
  onUpKeyPressed() {
    if (0 < appStore.currentShowDocumentIndex) {
      appStore.setCurrentShowDocumentIndex(appStore.currentShowDocumentIndex - 1);
    }
  }

  @boundMethod
  onDownKeyPressed() {
    if (appStore.currentShowDocumentIndex < appStore.currentFolderDocuments.length - 1) {
      appStore.setCurrentShowDocumentIndex(appStore.currentShowDocumentIndex + 1);
    }
  }

  ...
}

...

MobXの使い方

MobXは基本的にはReactと組み合わせて使うのが王道ですが、単体でも使えるということを自分なりにやってみたかったので使いました。

実装としてはシンプルに、MobXのobservableをクラスとして作り、そこにいろんな情報を入れて、先程の自前コンポーネントからsubscribeする、という感じです。

MobXのobservableの定義

class AppStore {

  ...

  /**
   * Folders
   */

  @observable
  private _folders: Folder[] = [];

  @computed
  get folders() {
    return this._folders;
  }

  @actionAsync
  async loadFolders() {
    this._folders = JSON.parse(await task(fs.readFile(config.folderFilePath, { encoding: 'utf8' }))).folders;
  }

  ...

}

observableを使うコンポーネント


class FolderList {
  private folderList: blessed.Widgets.ListElement;

  constructor(options: FolderListOptions) {
    this.folderList = blessed.list({
        ...
      },
      parent: options.parent,
    });
    this.folderList.on('select', this.onSelect);
    this.makeReactable();
  }

  ...

  // reactionMethodは、自分で作ったデコレータ
  // this.makeReactableを呼び出すと、MobXのreactionとして動くようになります
  @reactionMethod(() => appStore.isInitialized)
  reloadItems() {
    this.folderList.setItems(appStore.folders.map((folder) => folder.name));
    this.folderList.select(appStore.currentFolderIndex);
    this.folderList.screen.render();
  }
}

正直Reactみたいなわかりやすいデータフローでもなく、subscribeがいろんなところに散らばって見づらい感じはあるんですが、一応実装は出来ます。

TextBufferの使い方

TextBufferとはAtomで使われている、テキストファイルへのアクセスを簡単にするためのライブラリです。

yabaiでは、下記のような使い方をしています。

テキストファイルへのアクセスは全てTextBufferに任せ、テキストエディタコンポーネントはあくまで表示の処理に集中する、という使い方です。ちなみにこの実装のやり方は、blessedベースのテキストエディタである、slapを参考にして作りました。

テキストファイルの読み込み、書き込みなどの操作を全てTextBufferに任せられるので非常に楽です。

Tips

blessedが更新されていない

blessedは既に更新が止まっているようで(でも動く)、いろんな人がforkしてオレオレblessedを作っているようです。このアプリケーションでは、その中でももっとも更新の頻度が高そうなneo-blessedを選びました。

※さらに一部修正したい部分があったので、モノレポのパッケージの一つとしてリポジトリに入れてしまってます。

CoffeeScriptがWebpackでバンドルできない

BoostNoteのデータファイルは、csonベースなのでcsonを依存に含める必要があります。これが非常に悩みポイントでした。csonを依存に入れるということはcoffeescriptを依存に入れる、ということなのですが、coffeescriptはrequireが独自のものを使ってたりと色々癖があり非常に大変でした。

https://github.com/mk2/cson-parser
https://github.com/mk2/coffeescript

ここにWebpackでバンドル可能なcson-parser/coffesscriptを置いておきます。ちょっと手が滑ってコミット履歴など全て消し飛んでしまったのですが、修正点としては設定部分だけです。