メインコンテンツまでスキップ

Laminarで電卓を作ってみた

· 約8分

投稿の内容

本投稿では、Scala.jsのUIフレームワークであるLaminarを使って電卓アプリを作ってみます。

プロジェクトの作成

プロジェクトのディレクトリを作成します。

$ mkdir laminar-dentaku
$ cd laminar-dentaku

Scala.jsのプラグインの設定をします。

project/plugins.sbt
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0")

sbtのバージョンを設定します。

project/build.properties
sbt.version=1.7.1

sbtのビルドファイルを作成します。ここでLaminarを追加します。

build.sbt
lazy val commonSettings = Seq(
organization := "com.example.dentaku",
scalaVersion := "3.2.2",
version := "1.0.0"
)


lazy val root = (project in file("."))
.enablePlugins(ScalaJSPlugin)
.settings(
inThisBuild(commonSettings),
name := "dentaku",
scalaJSUseMainModuleInitializer := true,
libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "2.4.0",
libraryDependencies += "com.raquo" %%% "laminar" % "0.14.2",
)

Main.scalaの作成

まずは、Hello World

src/main/scala/Main.scala

@main def main = println("Hello World")

JSファイル生成

ビルド。

$ sbt
sbt> fastLinkJS

JSファイルは以下に出力されます。

target/scala-3.2.2/dentaku-fastopt/main.js

JSファイルの出力先を変更

デプロイするディレクトリpublicにJSファイルを出力するように変更します。以下のようにbuild.sbtを修正するとfastLinkJSタスクでpublicディレクトリにindex.jsを作成してくれます。

build.sbt

...
.settings(
...
fastLinkJS := fastLinkJSWrapper("./public/index.js").value
...
)
...

def fastLinkJSWrapper(newOutputPath: String) = Def.taskDyn {
Def.task {
val oldOutputDir = (ThisProject / Compile / fastLinkJSOutput).value
val report = (ThisProject / Compile / fastLinkJS).value
val outputFileName = report.data.publicModules.head.jsFileName
val outputFilePath =
java.nio.file.Path.of(oldOutputDir.toString, outputFileName).toFile

IO.copyFile(
outputFilePath,
file(newOutputPath)
)

val sourceMapName = report.data.publicModules.head.sourceMapName
sourceMapName.foreach { fname =>
val sourceMapPath =
java.nio.file.Path.of(oldOutputDir.toString, fname).toFile
IO.copyFile(
sourceMapPath,
file(s"${newOutputPath}.map")
)
}

report
}
}

HTMLファイルの作成

ビルドされたJSファイルを以下のHTMLで使用します。

public/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dentaku</title>
</head>
<body>
<script type="text/javascript" src="index.js"></script>
</body>
</html>

LaminarでHello World

Laminarを使ってブラウザにHello Worldを表示してみましょう。

src/main/scala/Main.scala
package com.example.dentaku

import org.scalajs.dom
import com.raquo.laminar.api.L.{*, given}

@main def main =
documentEvents.onDomContentLoaded.foreach { _ =>
val appContainer: dom.Element = dom.document.body
val appElement: Div = div(
"Hello World"
)
render(appContainer, appElement)
}(unsafeWindowOwner)

ビルドから表示まで。

$ stb fastLinkJS
$ npm i -D serve
$ npx serve -p 3333 public

ブラウザでhttp://localhost:3333を表示してHello Worldと表示されているのを確認します。

Laminarで電卓の画面を作る

LaminarのHTMLタグのDSLを使用して電卓の画面を作ります。LaminarのHTMLの記述は見れば分かるほど簡単です。スタイルも属性として書くことができます。

src/main/scala/Main.scala
package com.example.dentaku

import org.scalajs.dom
import scala.scalajs.js
import com.raquo.laminar.api.L.{*, given}

@main def main =
documentEvents.onDomContentLoaded.foreach { _ =>
val appContainer: dom.Element = dom.document.body
val appElement: Div = div(
width := "300px",
div(
padding := "4px",
display := "flex",
flexDirection := "row",
input(
margin := "4px",
flexGrow := 1,
border := "1px solid silver",
readOnly := true,
typ := "text"
),
button(
margin := "4px",
"AC"
)
),
div(
padding := "4px",
display := "flex",
flexDirection := "column",
js.Array(
js.Array("1", "2", "3", "/"),
js.Array("4", "5", "6", "*"),
js.Array("7", "8", "9", "-"),
js.Array("0", ".", "=", "+")
).map { row =>
div(
display := "flex",
flexDirection := "row",
row.map { cell => button(flexGrow := 1, margin := "4px", cell) }
)
}
)
)
render(appContainer, appElement)
}(unsafeWindowOwner)

出来た画面はこちら!

画面

Laminarでイベント処理

電卓のそれぞれのボタンを押した時の挙動を実装する前に、Laminarでのイベント処理の仕組みを簡単に見ていきましょう。

Laminarでのイベントの処理はObserverObservableを使います。 Observerは、イベントを検知する役割を担っています。 Observableは、Observerが検知した値を受け取って、それをDOM要素のプロパティに設定する役割を担っています。

以下はボタンのクリックを検知する例です。

button(
onClick --> observer
)

以下は検知した値をTextNodeに設定する例です。

div(
child.text <-- observable
)

EventBusを使ってObseverObservableを関連づけることができます。

val bus = EventBus[dom.MouseEvent]
val observer = bus.writer
val observable = bus.events

上記の例は、observerで受け取ったMouseEventobservableに渡しています。このままだとMouseEventを要素のプロパティに渡してしまうことになるので、observableの値をmapを使って、例えばクリックした時刻に変更する時は以下のようにします。

val observable = bus.events.map(evt => (new js.Date).toISOString)

MouseEventevtは使わないのでmapToを使って省略することもできます。

val observable = bus.events.mapTo((new js.Date).toISOString)

イベントで受け取った値を変換するだけではなくて、状態を変更することもできます。例えば、ボタンをクリックする度に1づつ増えるカウンターを作ってみましょう。

val bus = EventBus[dom.MouseEvent]
val observer = bus.writer
val observable = bus.events.foldLeft(0)((cnt, _) => cnt + 1).map(_.toString)

button(
onClick --> observer
)

div(
child.text <-- observable
)

電卓のイベント処理

ボタンをクリックしたらボタンの文字をObserverに流します。Observableで文字によって適用する処理を変えます。 例えばACが入力されたらクリアします。

計算式の評価はjs.evalを使います。計算式の評価がエラーになったらLeftにエラーメッセージを入れます。ObservableLeftの時はAC以外入力できないようにしています。

src/main/scala/Main.scala
val bus = EventBus[String]
val observer = bus.writer
val observable = bus.events
.foldLeft[Either[String, String]](Right("")) {
case (Left(msg), "AC") => Right("")
case (Left(msg), _) => Left(msg)
case (Right(exp), cmd) =>
cmd match
case "AC" => Right("")
case "=" =>
Try(js.eval(exp)) match
case Success(r) => Right(r.toString)
case Failure(ex) => Left(ex.getMessage)
case "+" | "-" | "*" | "/" =>
Right(if exp == "" then cmd else s"${exp} ${cmd} ")
case _ => Right(s"${exp}${cmd}")
}
.map {
case Left(msg) => msg
case Right(exp) => exp
}

以下全コードです。

package com.example.dentaku

import org.scalajs.dom
import scala.scalajs.js
import com.raquo.laminar.api.L.{*, given}
import scala.util.Try
import scala.util.Success
import scala.util.Failure

@main def main =
documentEvents.onDomContentLoaded.foreach { _ =>
val appContainer: dom.Element = dom.document.body

val bus = EventBus[String]
val observer = bus.writer
val observable = bus.events
.foldLeft[Either[String, String]](Right("")) {
case (Left(msg), "AC") => Right("")
case (Left(msg), _) => Left(msg)
case (Right(exp), cmd) =>
cmd match
case "AC" => Right("")
case "=" =>
Try(js.eval(exp)) match
case Success(r) => Right(r.toString)
case Failure(ex) => Left(ex.getMessage)
case "+" | "-" | "*" | "/" =>
Right(if exp == "" then cmd else s"${exp} ${cmd} ")
case _ => Right(s"${exp}${cmd}")
}
.map {
case Left(msg) => msg
case Right(exp) => exp
}

val appElement: Div = div(
width := "300px",
div(
padding := "4px",
display := "flex",
flexDirection := "row",
input(
margin := "4px",
flexGrow := 1,
border := "1px solid silver",
readOnly := true,
typ := "text",
value <-- observable
),
button(
margin := "4px",
"AC",
onClick.mapTo("AC") --> observer
)
),
div(
padding := "4px",
display := "flex",
flexDirection := "column",
js.Array(
js.Array("1", "2", "3", "/"),
js.Array("4", "5", "6", "*"),
js.Array("7", "8", "9", "-"),
js.Array("0", ".", "=", "+")
).map { row =>
div(
display := "flex",
flexDirection := "row",
row.map { cell =>
button(
flexGrow := 1,
margin := "4px",
cell,
onClick.mapTo(cell) --> observer
)
}
)
}
)
)
render(appContainer, appElement)
}(unsafeWindowOwner)

出来上がった電卓はこちらで確認できます。

以上、Laminarで電卓を作ってみました。