Hyperloopというフレームワークがあります。
OpalReactをラップしたフレームワークとなっております。
つまりRubyでWebのフロントエンドを作るためのフレームワークですね。

ところで、WebフロントエンドがあればElectronアプリ化ができます。
つまりHyperloop(生Opalでも良いけど) + Electronで、Rubyを使ったマルチプラットフォーム・デスクトップアプリの開発が可能では?

という事で、やってみました。

対象読者

  • Rubyを使ったことがある
  • Reactを使ったことがある

Hyperloopアプリ

まず普通にHyperloopアプリを作ってみましょう。
いつもは足し算アプリを作るのですが、今回は気分を変えてTODOアプリにしてみました。JSフレームワークのサンプルっぽいですよね。

Hyperloopの導入

Opalアプリの環境はRubyと同じくBundlerで整える事ができます。
まず前提としてRubyが入っていないといけません。
もしRubyが入っていなければ、何らかの手段でRubyを入れてください。直に入れても、rvmやrbenvを使ってもDockerを使っても良いと思います(個人的にはrbenvかDockerをおすすめします)。

$ ruby -v
ruby 2.4.2p198 (2017-09-14 revision 59899) [x86_64-linux]

適当なディレクトリを切り、その中にさらにディレクトリを作りましょう。
外側がElectron用、内側がHyperloop用です。

$ mkdir -p todo-app/hyperloop
$ cd $_

Gemfileを書きます。

# frozen_string_literal: true
source "https://rubygems.org"

gem "opal"
gem "hyperloop"

そしてbundle install

$ bundle install --path vendor/bundle

これで土台ができました。一般的なRubyのアプリケーションと同じように作れることがわかりますね。

次はJavaScript部分の準備です。
まず、Nodeとnpmが使える必要があります。これも、もし無ければ何らかの手段で用意してください。

$ node -v                              
v9.3.0
$ npm -v                              
5.6.0

Hyperloopを使うには、React、jQuery、そしてOpalのライブラリがブラウザから読み込めなければなりません。
単なるWebアプリであればCDNとかを利用しても良いのですが、今回は将来的にデスクトップアプリにする予定なので、JavaScriptのソースをローカルに置くことにします。パッケージ管理は、単にファイルを取ってきて手元に置きたいだけなのでbowerを使ってみました(npmとかでもいけると思います)。

$ npm install -g bower

また、CSSフレームワークとしてspectreを使うことにします。これもbowerで入れます。

bower.json
{
  "main": "dist/bundle.js",
  "private": true,
  "ignore": [
    "**/.*",
    "node_modules",
    "bower_components",
    "test",
    "tests"
  ],
  "dependencies": {
    "react": "^15.6.0",
    "jquery": "^3.2.0",
    "hyperloop-js": "git://github.com/ruby-hyperloop/hyperloop-js.git",
    "spectre.css": "^0.4.5"
  }
}

bower.jsonを置いたら、必要なライブラリを入れましょう。

$ bower install

フロントエンド・アプリケーション

では、まず単体で動くTODOアプリを作っていきます。
最初にアプリが動くページを作ります。

index.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>TODO List</title>

  <!-- React and JQuery -->
  <script type="text/javascript" src="bower_components/react/react.min.js"></script>
  <script type="text/javascript" src="bower_components/react/react-dom.min.js"></script>
  <script type="text/javascript" src="bower_components/jquery/dist/jquery.min.js"></script>

  <!-- Opal and Hyperloop -->
  <script type="text/javascript" src="bower_components/hyperloop-js/opal-compiler.min.js"></script>
  <script type="text/javascript" src="bower_components/hyperloop-js/hyperloop.min.js"></script>

  <link rel="stylesheet" type="text/css" href="bower_components/spectre.css/docs/dist/spectre.min.css">
</head>
<body>
  <div data-hyperloop-mount="Root" />
  <script src="dist/bundle.js"></script>
</body>
</htm>

アプリそのものはSPAとして作り、全てこのHTML上で動かします。
必要なファイルをそれぞれ参照しておきましょう。

ここで注目していただきたいのは、bodyタグ内の

<body>
  <div data-hyperloop-mount="Root" />
  <script src="dist/bundle.js"></script>
</body>

の部分です。Hyperloopでは、 data-hyperloop-mount という属性で、マウントするクラス、つまりコンポーネントを指定します。
なのでこの後、Rootという名前のコンポーネントを作ることになります。

Hyperloopアプリケーション

Hyperloopディレクトリの下に、さらにsrcというディレクトリを掘って、その中に、Ruby(実際にはOpalとして処理されるけど)のソースコードを置いていきましょう。

hyperloop
├ src
│ ├ app.rb
│ ├ todo_list.rb
│ └ todo.rb
└ index.html

(上の図には入っていませんが、実際にはGemfileとかbower.jsonとかもあるはずです。)

トップダウンに作っていきます。
まずルートとなるapp.rbファイルを書きましょう。

app.rb
require_relative "todo_list"

class Root
  include Hyperloop::Component::Mixin

  def render
    DIV(class: "container") do
      DIV(class: "columns") do
        DIV(class: "column col-8 col-mx-auto") do
          H1 { "Todo List" }
          TodoList()
        end
      end
    end
  end
end

先述したことを覚えているでしょうか。Rootクラスを作りました。
これが、index.htmlのdiv要素にマウントされるコンポーネントです。

Hyperloopでコンポーネントを作る方法は、継承とmixinの2通りがあります。
個人的に継承よりmixinが好きなので、そちらを使いました。Hyperloop::Component::Mixinモジュールをincludeします。

さて、Reactのコンポーネントにはrender関数が必要です。
renderクラスメソッドを使うか、単にrenderメソッドを作りましょう。

Reactでは普通、JSX記法でHTMLを記述しますが、HyperloopではRubyのDSLで記述します。どのような記法かは、コードを見て頂けると何となく分かるかと思います。

  def render
    DIV(class: "container") do
      DIV(class: "columns") do
        DIV(class: "column col-8 col-mx-auto") do
          H1 { "Todo List" }
          TodoList()
        end
      end
    end
  end

ここで、 TodoList() は別のコンポーネントを利用している部分です。

さて、これだけだと何もわからないので、TodoListコンポーネントを見てみましょう。

todo_list.rb
require_relative "todo"

class TodoList
  include Hyperloop::Component::Mixin

  state todos: []
  state desc: false

  def add_todo
    if @desc && !@desc.empty?
      mutate.todos << Todo.new(@desc)
      mutate.desc(true)
    end
  end

  def complete_todo(i)
    mutate.todos[i] = state.todos[i].toggle
  end

  def delete_todo(i)
    mutate.todos.delete_at i
  end

  def render
    TABLE(class: "table table-striped table-hover") do
      THEAD do
        TR do
          TH { "#" }
          TH do
            attrs = {
              class: "form-input",
              style: { width: "100%" },
              type: "text"
            }
            if state.desc
              attrs[:value] = ""
              mutate.desc(false)
            end
            INPUT(attrs).on(:input) { |e|
              e.prevent_default
              @desc = e.target.value
            }
          end
          TH do
            BUTTON(class: "btn") { ">>" }.on(:click) { add_todo }
          end
        end
      end
      TBODY do
        state.todos.each.with_index do |todo, i|
          TR do
            TD do
              SPAN { "#{(i+1).to_s} / " }
              INPUT(
                class: "form-checkbox",
                type: :checkbox,
                checked: todo.status
              ).on(:change) {
                complete_todo i
              }
            end
            TD do
              if todo.status
                DEL { todo.desc }
              else
                todo.desc
              end
            end
            TD do
              BUTTON(class: "btn") { "×" }.on(:click) { delete_todo i }
            end
          end
        end
      end
    end
  end
end

このコンポーネントがTODOリストのメインですね。
Hyperloop::Component::Mixinモジュールをincludeしているのは先と同じです。

  state todos: []
  state desc: false

stateを作っている部分です。Hyperloopではstateクラスメソッドでstateと初期値とを設定します。ここではtodosとdescという2つのstateを作っています。

  def add_todo
    if @desc && !@desc.empty?
      mutate.todos << Todo.new(@desc)
      mutate.desc(true)
    end
  end

stateを使う時は statemutate を介します。
state.hogehoge というstateを取り出し、また muteta.hoge(value) で新しい値と入れ替えます。その他、mutateで取り出した値を破壊的に変更する事でも、値を書き換える事ができます。
stateが書き換えられると、再レンダリングされます。

            INPUT(attrs).on(:input) { |e|
              e.prevent_default
              @desc = e.target.value
            }

要素にイベントを引っ掛けるにはonメソッドを使います。これも、見ての通りですね。

TODOリスト

さて、TODOリストアプリでできることは何でしょう。

  • TODOを追加する
  • TODOの状態を完了、あるいは未完了にする
  • TODOを削除する

この3つができれば、最低限TODOアプリとして使えそうですね。
(保存する、とかもちゃんとしたアプリとして使うならば当然必要だと思うのですが、今回はそれは置いておきます。)

上記したコードで、それらの処理を行っている箇所を見てみましょう。

  def add_todo
    if @desc && !@desc.empty?
      mutate.todos << Todo.new(@desc)
      mutate.desc(true)
    end
  end

  def complete_todo(i)
    mutate.todos[i] = state.todos[i].toggle
  end

  def delete_todo(i)
    mutate.todos.delete_at i
  end

この3つのメソッドが、それぞれの処理、つまりTODOの追加、状態変更、削除に対応しています。
mutateの説明は先章で解説しましたね。

まず、TODOの追加の処理を追います。

  def add_todo
    if @desc && !@desc.empty?
      mutate.todos << Todo.new(@desc)
      mutate.desc(true)
    end
  end

ifで囲んであるのは、空文字列を追加してしまわないようにです。

Todoというクラスのインスタンスを作っています。
このクラスのコードを見てみましょう。

todo.rb
class Todo
  attr_reader :desc, :status

  def initialize(desc, status = false)
    @desc = desc
    @status = status
  end

  def toggle
    self.class.new(@desc, !@status)
  end
end

このクラスは、 コンポーネントではありません 。単なるRubyのクラスです。
いわゆる不変データを扱う為の構造で、初期化時にのみ設定可能な、読み出し専用のインスタンス変数を持ちます。descとstatusという2つのインスタンス変数は、それぞれTODOの説明と、状態(完了、未完了)を表します。

不変データなので、完了・未完了を変更する場合は、インスタンスの状態を変更するのではなく、新しいクラスを作ってそれを返しています(toggleメソッドがそれ)。

さて、Todoクラスがどういうものかは分かりました。
TODOの追加処理に戻ります。

  def add_todo
    if @desc && !@desc.empty?
      mutate.todos << Todo.new(@desc)
      mutate.desc(true)
    end
  end

mutate.todos で取り出した値に対するメソッド呼び出しは、値をくるむObservableでフックされ、再レンダリングを引き起こします。なので、 << メソッドを使って配列に値を追加すると、状態が新しい配列に自動更新されるわけですね。

その下では、 mutate.desc(true) の呼び出しでstateの値を入れ替えています。これは、TODOの入力欄を空にする為の処理ですね。

  def complete_todo(i)
    mutate.todos[i] = state.todos[i].toggle
  end

  def delete_todo(i)
    mutate.todos.delete_at i
  end

これはTODOの状態を切り替えるメソッドと削除するメソッドです。
complete_todoは、メソッド名に反して、完了状態のTODOを未完了状態に戻すこともやります。
引数にはTODOのインデックスを取ります(このコンポーネント内では、TODOの判別は全てインデックスで行います)。

先述した通り、 mutate で呼び出した状態に対するメソッド呼び出しは、フックされて、再レンダリングを引き起こします。

これらのメソッドを、render内の任意のイベントコールバックの中から呼び出して、TODOの追加、変更、削除を行います。

SPAとして動かしてみる

ここまでで、ブラウザ上で動くSPAとしては完成しています。
試しに動かしてみましょう。

まず、RubyのソースコードをOpalとしてJavaScriptにトランスパイルします。

$ mkdir dist
$ bundle exec opal -I. -c src/app.rb > dist/bundle.js

これで、 dist/bundle.js にバンドルされたJavaScriptファイルが書き出されます。

そして、同じディレクトリで1行サーバを立てましょう。何を使っても良いのですが、Rubyだと以下のコマンドで8080ポートにバインドされたサーバが立ち上がります。

$ ruby -run -e httpd . -p 8080

さて、ブラウザで localhost:8080 にアクセスして確認してみましょう。
無事、アプリが表示され、動いているでしょうか? TODOの追加、状態変更、削除を一通り使ってみましょう。

ここまでで、単にWebアプリでHyperloopを使う方法については、一通り身についたかと思います。

Electronアプリ

この記事のタイトルは「HyperloopとElectronでアプリを作ってみる」なので、上で作ったWebアプリをElectronアプリにしてみましょう。

とはいえ、そう難しいものではないです。

準備

まず、electronが無ければ入れておきます。

$ npm i -g electron

Electronアプリのルートディレクトリ(今回だとHyperloopアプリの1つ上のディレクトリ)に移動し、package.jsonとmain.jsとを設置します。

package.json
{
  "name": "hyperloop-electron",
  "version": "0.1.0",
  "main": "main.js"
}
main.js
const { app, BrowserWindow, Menu } = require('electron')
const path = require('path')
const url = require('url')

let win

function createWindow () {
  win = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: { nodeIntegration: false }
  })

  win.loadURL(url.format({
    pathname: path.join(__dirname, 'hyperloop/index.html'),
    protocol: 'file:',
    slashes: true
  }))

  win.webContents.openDevTools()

  win.on('closed', () => {
    win = null
  })
}

app.on('ready', createWindow)

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (win === null) {
    createWindow()
  }
})

動かしてみる

それから徐にelectronコマンドを叩きます。

$ electron .

これで、ElectronアプリとしてTODOアプリが起動したかと思います。

デバグ用のコンソールが出ているかと思いますが、これは win.webContents.openDevTools() の部分をコメントアウトすれば消えます。
別の記事で紹介したツールを利用すれば、実行ファイルとしてまとめる事も簡単にできます。

無事に、HyperloopとElectronでデスクトップアプリを作る事ができました。

まとめ

OpalのフレームワークであるHyperloopで、つまり ほぼRuby で、デスクトップアプリを作る事ができました。Rubyを使って楽しくデスクトップアプリを作る事ができれば、嬉しい事この上ないですね。

今回の記事で書けなかった(書かなかった)事で、重要だろうと思われる項目には以下のようなものがあります。

  • メニューやショートカットの設定
  • IOの取り扱い

このあたりはHyperloopというフレームワークの中では完結しない事柄なので、私の技倆不足もあり解説ができなかった所ですが、実用的なアプリケーションを作り上げるには避けて通れないものだと思いますので、Electronを本格的に利用してみようと思われる方は是非とも調査していただけたらと思います。