Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reworking the runtime #263

Merged
merged 1 commit into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions tyrian/js/src/main/scala/tyrian/runtime/ModelHolder.scala

This file was deleted.

89 changes: 89 additions & 0 deletions tyrian/js/src/main/scala/tyrian/runtime/Renderer.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package tyrian.runtime

import cats.effect.kernel.Async
import cats.effect.kernel.Clock
import cats.effect.kernel.Ref
import cats.effect.std.Dispatcher
import cats.syntax.all.*
import org.scalajs.dom
import snabbdom.VNode
import tyrian.Html
import tyrian.Location

final case class Renderer(vnode: VNode, state: RendererState):

def runningAt(t: Long): Renderer =
this.copy(state = RendererState.Running(t))

object Renderer:

def init[F[_]](vnode: VNode)(using F: Async[F]): F[Ref[F, Renderer]] =
F.ref(
Renderer(vnode, RendererState.Idle)
)

private val timeout: Long = 1000

// This function gets called on every model update
def redraw[F[_], Model, Msg](
dispatcher: Dispatcher[F],
renderer: Ref[F, Renderer],
model: Ref[F, Model],
view: Model => Html[Msg],
onMsg: Msg => Unit,
router: Location => Msg
)(using F: Async[F], clock: Clock[F]): F[Unit] =
clock.realTime.flatMap { time =>
renderer.modify { r =>
r.state match
case RendererState.Idle =>
// If the render state is idle, update the last triggered time and begin.
r.runningAt(time.toMillis) ->
F.delay(
dom.window.requestAnimationFrame(_ =>
render(dispatcher, renderer, model, view, onMsg, router)(time.toMillis)
)
).void

case RendererState.Running(_) =>
// If the render state is running, just update the triggered time.
r.runningAt(time.toMillis) -> F.unit
}
}.flatten

private def render[F[_], Model, Msg](
dispatcher: Dispatcher[F],
renderer: Ref[F, Renderer],
model: Ref[F, Model],
view: Model => Html[Msg],
onMsg: Msg => Unit,
router: Location => Msg
)(t: Long)(using F: Async[F], clock: Clock[F]): Unit =
dispatcher.unsafeRunAndForget {
for {
time <- clock.realTime.map(_.toMillis)
m <- model.get

res <- renderer.modify { r =>
r.state match
case RendererState.Idle =>
// Something has gone wrong, do nothing.
r -> F.unit

case RendererState.Running(lastTriggered) =>
// If nothing has happened, set to idle and do not loop
if t - lastTriggered >= timeout then r.copy(state = RendererState.Idle) -> F.unit
else
// Otherwise, re-render and set the state appropriately
r.copy(
vnode = Rendering.render(r.vnode, m, view, onMsg, router)
) ->
F.delay(
// Loop
dom.window.requestAnimationFrame(_ =>
render(dispatcher, renderer, model, view, onMsg, router)(time)
)
).void
}.flatten
} yield res
}
5 changes: 5 additions & 0 deletions tyrian/js/src/main/scala/tyrian/runtime/RendererState.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package tyrian.runtime

enum RendererState derives CanEqual:
case Idle
case Running(lastTriggered: Long)
17 changes: 3 additions & 14 deletions tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,9 @@ package tyrian.runtime
import org.scalajs.dom
import org.scalajs.dom.Element
import org.scalajs.dom.window
import snabbdom._
import snabbdom.modules._
import tyrian.Attr
import tyrian.Attribute
import tyrian.Empty
import tyrian.Event
import tyrian.Html
import tyrian.Location
import tyrian.NamedAttribute
import tyrian.PropertyBoolean
import tyrian.PropertyString
import tyrian.RawTag
import tyrian.Tag
import tyrian.Text
import snabbdom.*
import snabbdom.modules.*
import tyrian.*

import scala.scalajs.js

Expand Down
140 changes: 73 additions & 67 deletions tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
package tyrian.runtime

import cats.effect.kernel.Async
import cats.effect.kernel.Clock
import cats.effect.kernel.Ref
import cats.effect.std.AtomicCell
import cats.effect.std.Dispatcher
import cats.effect.std.Queue
import cats.effect.syntax.all.*
import cats.syntax.all.*
import org.scalajs.dom
import org.scalajs.dom.Element
import snabbdom.VNode
import snabbdom.toVNode
import tyrian.Cmd
import tyrian.Html
import tyrian.Location
import tyrian.Sub

import scala.annotation.nowarn

object TyrianRuntime:

def apply[F[_], Model, Msg](
Expand All @@ -27,72 +26,79 @@ object TyrianRuntime:
update: Model => Msg => (Model, Cmd[F, Msg]),
view: Model => Html[Msg],
subscriptions: Model => Sub[F, Msg]
)(using F: Async[F]): F[Nothing] = Dispatcher.sequential[F].use { dispatcher =>
(F.ref(ModelHolder(initModel, true)), AtomicCell[F].of(List.empty[(String, F[Unit])]), Queue.unbounded[F, Msg])
.flatMapN { (model, currentSubs, msgQueue) =>

def runCmd(cmd: Cmd[F, Msg]): F[Unit] =
CmdHelper.cmdToTaskList(cmd).foldMapM { task =>
task.handleError(_ => None).flatMap(_.traverse_(msgQueue.offer(_))).start.void
}

def runSub(sub: Sub[F, Msg]): F[Unit] =
currentSubs.evalUpdate { oldSubs =>
val allSubs = SubHelper.flatten(sub)
val (stillAlive, discarded) = SubHelper.aliveAndDead(allSubs, oldSubs)

val newSubs = SubHelper
.findNewSubs(allSubs, stillAlive.map(_._1), Nil)
.traverse(SubHelper.runObserve(_) { result =>
dispatcher.unsafeRunAndForget(
result.toOption.flatten.foldMapM(msgQueue.offer(_).void)
)
})

discarded.foldMapM(_.start.void) *> newSubs.map(_ ++ stillAlive)
)(using F: Async[F]): F[Nothing] =
Dispatcher.sequential[F].use { dispatcher =>
val loop = mainLoop(dispatcher, router, initCmd, update, view, subscriptions)
val model = F.ref(initModel)
val currentSubs = AtomicCell[F].of(List.empty[(String, F[Unit])])
val msgQueue = Queue.unbounded[F, Msg]
val renderer = Renderer.init(toVNode(node))

(model, currentSubs, msgQueue, renderer).flatMapN(loop)
}

def mainLoop[F[_], Model, Msg](
dispatcher: Dispatcher[F],
router: Location => Msg,
initCmd: Cmd[F, Msg],
update: Model => Msg => (Model, Cmd[F, Msg]),
view: Model => Html[Msg],
subscriptions: Model => Sub[F, Msg]
)(
model: Ref[F, Model],
currentSubs: AtomicCell[F, List[(String, F[Unit])]],
msgQueue: Queue[F, Msg],
renderer: Ref[F, Renderer]
)(using F: Async[F], clock: Clock[F]): F[Nothing] =
val runCmd: Cmd[F, Msg] => F[Unit] = runCommands(msgQueue)
val runSub: Sub[F, Msg] => F[Unit] = runSubscriptions(currentSubs, msgQueue, dispatcher)
val onMsg: Msg => Unit = postMsg(dispatcher, msgQueue)

val msgLoop: F[Nothing] =
msgQueue.take.flatMap { msg =>
for {
cmdsAndSubs <- model.modify { oldModel =>
val (newModel, cmd) = update(oldModel)(msg)
val sub = subscriptions(newModel)

(newModel, (cmd, sub))
}
// end runSub

val msgLoop = msgQueue.take.flatMap { msg =>
model
.modify { case ModelHolder(oldModel, _) =>
val (newModel, cmd) = update(oldModel)(msg)
val sub = subscriptions(newModel)
(ModelHolder(newModel, true), (cmd, sub))
}
.flatMap { (cmd, sub) =>
runCmd(cmd) *> runSub(sub)
}
.void
}.foreverM
// end msgLoop

val renderLoop =
val onMsg = (msg: Msg) => dispatcher.unsafeRunAndForget(msgQueue.offer(msg))

@nowarn("msg=discarded")
val requestAnimationFrame = F.async_ { cb =>
dom.window.requestAnimationFrame(_ => cb(Either.unit))
()
_ <- runCmd(cmdsAndSubs._1) *> runSub(cmdsAndSubs._2)
_ <- Renderer.redraw(dispatcher, renderer, model, view, onMsg, router)
} yield ()
}.foreverM

msgLoop.background.surround {
runCmd(initCmd) *> F.never
}

def runCommands[F[_], Msg](msgQueue: Queue[F, Msg])(cmd: Cmd[F, Msg])(using F: Async[F]): F[Unit] =
CmdHelper.cmdToTaskList(cmd).foldMapM { task =>
task.handleError(_ => None).flatMap(_.traverse_(msgQueue.offer(_))).start.void
}

def runSubscriptions[F[_], Msg](
currentSubs: AtomicCell[F, List[(String, F[Unit])]],
msgQueue: Queue[F, Msg],
dispatcher: Dispatcher[F]
)(sub: Sub[F, Msg])(using F: Async[F]): F[Unit] =
currentSubs.evalUpdate { oldSubs =>
val allSubs = SubHelper.flatten(sub)
val (stillAlive, discarded) = SubHelper.aliveAndDead(allSubs, oldSubs)

val newSubs = SubHelper
.findNewSubs(allSubs, stillAlive.map(_._1), Nil)
.traverse(
SubHelper.runObserve(_) { result =>
dispatcher.unsafeRunAndForget(
result.toOption.flatten.foldMapM(msgQueue.offer(_).void)
)
}
)

def redraw(vnode: VNode) =
model.getAndUpdate(m => ModelHolder(m.model, false)).flatMap { m =>
if m.updated then F.delay(Rendering.render(vnode, m.model, view, onMsg, router))
else F.pure(vnode)
}

def loop(vnode: VNode): F[Nothing] =
requestAnimationFrame *> redraw(vnode).flatMap(loop(_))

F.delay(toVNode(node)).flatMap(loop)
// end renderLoop

renderLoop.background.surround {
msgLoop.background.surround {
runCmd(initCmd) *> F.never
}
}
}
discarded.foldMapM(_.start.void) *> newSubs.map(_ ++ stillAlive)
}

}
def postMsg[F[_], Msg](dispatcher: Dispatcher[F], msgQueue: Queue[F, Msg]): Msg => Unit =
msg => dispatcher.unsafeRunAndForget(msgQueue.offer(msg))
Loading