diff --git a/tyrian/js/src/main/scala/tyrian/runtime/ModelHolder.scala b/tyrian/js/src/main/scala/tyrian/runtime/ModelHolder.scala deleted file mode 100644 index 95e6077a..00000000 --- a/tyrian/js/src/main/scala/tyrian/runtime/ModelHolder.scala +++ /dev/null @@ -1,3 +0,0 @@ -package tyrian.runtime - -final case class ModelHolder[Model](model: Model, updated: Boolean) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Renderer.scala b/tyrian/js/src/main/scala/tyrian/runtime/Renderer.scala new file mode 100644 index 00000000..1e6be345 --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/runtime/Renderer.scala @@ -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 + } diff --git a/tyrian/js/src/main/scala/tyrian/runtime/RendererState.scala b/tyrian/js/src/main/scala/tyrian/runtime/RendererState.scala new file mode 100644 index 00000000..e774261f --- /dev/null +++ b/tyrian/js/src/main/scala/tyrian/runtime/RendererState.scala @@ -0,0 +1,5 @@ +package tyrian.runtime + +enum RendererState derives CanEqual: + case Idle + case Running(lastTriggered: Long) diff --git a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala index 1eb99c2c..ca4f998f 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/Rendering.scala @@ -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 diff --git a/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala b/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala index 93c965c5..5e149e3b 100644 --- a/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala +++ b/tyrian/js/src/main/scala/tyrian/runtime/TyrianRuntime.scala @@ -1,6 +1,8 @@ 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 @@ -8,15 +10,12 @@ 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]( @@ -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))