diff --git a/core/src/main.scala b/core/src/main.scala deleted file mode 100644 index 0d22314..0000000 --- a/core/src/main.scala +++ /dev/null @@ -1,149 +0,0 @@ -import scala.collection.immutable.ArraySeq -import zio.json.* -import zio.json.ast.* -import scala.collection.mutable.ListBuffer - -@main -def main = - val r = TestLiveView.render(MyModel("Initial title")) - println(JsonWriter.toJson(r.buildClientStateInit)) - r.update(MyModel("Updated title")) - println(JsonWriter.toJson(r.buildClientStateDiff)) - r.update(MyModel("Updated title")) - println(JsonWriter.toJson(r.buildClientStateDiff)) - -trait LiveView[Model]: - val model: Dyn[Model, Model] = Dyn.id - def view: HtmlTag[Model] - -final case class MyModel(title: String) - -object TestLiveView extends LiveView[MyModel]: - val view: HtmlTag[MyModel] = - div( - div("before"), - model(_.title), - div("after") - ) - -object JsonWriter: - def toJson(init: ClientState.Init): String = - Json - .Obj("s" -> Json.Arr(init.static.map(Json.Str(_))*)) - .merge( - Json.Obj( - init.dynamic.zipWithIndex.map((v, i) => i.toString -> Json.Str(v))* - ) - ) - .toJsonPretty - def toJson(diff: ClientState.Diff): String = - Json - .Obj( - "diff" -> - Json.Obj( - diff.dynamic.map((i, v) => i.toString -> Json.Str(v))* - ) - ) - .toJsonPretty - -class RenderedDyn[I, O](d: Dyn[I, O], init: I): - private var value: O = d.run(init) - private var updated: Boolean = false - - def wasUpdated: Boolean = updated - def currentValue: O = value - - def update(v: I): Unit = - val newValue = d.run(v) - if value == newValue then updated = false - else - value = newValue - updated = true - -opaque type Dyn[I, O] = I => O -extension [I, O](d: Dyn[I, O]) - def apply[O2](f: O => O2): Dyn[I, O2] = d.andThen(f) - def run(v: I): O = d(v) -object Dyn: - def id[T]: Dyn[T, T] = identity - -enum Mod[T]: - case Tag(v: HtmlTag[T]) - case Text(v: String) - case DynText(v: Dyn[T, String]) - -given [T]: Conversion[HtmlTag[T], Mod[T]] = Mod.Tag(_) -given [T]: Conversion[String, Mod[T]] = Mod.Text(_) -given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_) - -object ClientState: - final case class Init(static: Seq[String], dynamic: Seq[String]) - final case class Diff(dynamic: Seq[(Int, String)]) - -extension [Model](lv: LiveView[Model]) - def render(model: Model): RenderedLiveView[Model] = - RenderedLiveView(lv.view, model) - -class RenderedLiveView[Model] private ( - private val static: ArraySeq[String], - private val dynamic: ArraySeq[ - RenderedDyn[Model, String] // | RenderedLiveView[Model] - ] -): - def update(model: Model): Unit = - dynamic.foreach(_.update(model)) - - def buildClientStateInit: ClientState.Init = - ClientState.Init( - static, - dynamic.map(_.currentValue) - ) - def buildClientStateDiff: ClientState.Diff = - ClientState.Diff( - dynamic.zipWithIndex.collect { - case (dyn, i) if dyn.wasUpdated => i -> dyn.currentValue - } - ) - -object RenderedLiveView: - def apply[Model](tag: HtmlTag[Model], model: Model) = - val static = ListBuffer.empty[String] - val dynamic = ListBuffer.empty[RenderedDyn[Model, String]] - - var staticFragment = "" - for elem <- buildTag(tag, model) do - elem match - case s: String => - staticFragment += s - case d: Dyn[Model, String] => - static.append(staticFragment) - staticFragment = "" - dynamic.append(RenderedDyn(d, model)) - if staticFragment.nonEmpty then static.append(staticFragment) - new RenderedLiveView(static.to(ArraySeq), dynamic.to(ArraySeq)) - - private def buildTag[Model]( - tag: HtmlTag[Model], - model: Model - ): List[String | Dyn[Model, String]] = - (s"<${tag.name}>" - :: tag.mods.flatMap(buildMod(_, model))) :+ - (s"") - - private def buildMod[Model]( - mod: Mod[Model], - model: Model - ): List[String | Dyn[Model, String]] = - mod match - case Mod.Tag(v) => buildTag(v, model) - case Mod.Text(v) => List(v) - case Mod.DynText[Model](v) => List(v) - -trait HtmlTag[Model]: - def name: String - def mods: List[Mod[Model]] - -class Div[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]: - val name = "div" - -def div[Model](mods: Mod[Model]*): Div[Model] = Div(mods.toList) diff --git a/core/src/scalive/JsonAstBuilder.scala b/core/src/scalive/JsonAstBuilder.scala new file mode 100644 index 0000000..ab0673f --- /dev/null +++ b/core/src/scalive/JsonAstBuilder.scala @@ -0,0 +1,29 @@ +package scalive + +import zio.json.ast.* + +object JsonAstBuilder: + def buildInit(static: Seq[String], dynamic: Seq[RenderedMod[?]]): Json = + Json + .Obj("s" -> Json.Arr(static.map(Json.Str(_))*)) + .merge(buildDiffValue(dynamic, includeUnchanged = true)) + + def buildDiff(dynamic: Seq[RenderedMod[?]]): Json = + Json.Obj("diff" -> buildDiffValue(dynamic)) + + private def buildDiffValue( + dynamic: Seq[RenderedMod[?]], + includeUnchanged: Boolean = false + ): Json = + Json.Obj( + dynamic.zipWithIndex.filter(includeUnchanged || _._1.wasUpdated).map { + case (v: RenderedMod.Dynamic[?, ?], i) => + i.toString -> Json.Str(v.currentValue.toString) + case (v: RenderedMod.When[?], i) => + if v.displayed then + if includeUnchanged || v.dynCond.wasUpdated then + i.toString -> v.nested.buildInitJson + else i.toString -> buildDiffValue(v.nested.dynamic) + else i.toString -> Json.Bool(false) + }* + ) diff --git a/core/src/scalive/LiveView.scala b/core/src/scalive/LiveView.scala new file mode 100644 index 0000000..64ceedc --- /dev/null +++ b/core/src/scalive/LiveView.scala @@ -0,0 +1,32 @@ +package scalive + +trait LiveView[Model]: + val model: Dyn[Model, Model] = Dyn.id + def view: HtmlTag[Model] + +opaque type Dyn[I, O] = I => O +extension [I, O](d: Dyn[I, O]) + def apply[O2](f: O => O2): Dyn[I, O2] = d.andThen(f) + def when(f: O => Boolean)(tag: HtmlTag[I]) = Mod.When(d.andThen(f), tag) + def run(v: I): O = d(v) +object Dyn: + def id[T]: Dyn[T, T] = identity + +enum Mod[T]: + case Tag(tag: HtmlTag[T]) + case Text(text: String) + case DynText(dynText: Dyn[T, String]) + case When(dynCond: Dyn[T, Boolean], tag: HtmlTag[T]) + +given [T]: Conversion[HtmlTag[T], Mod[T]] = Mod.Tag(_) +given [T]: Conversion[String, Mod[T]] = Mod.Text(_) +given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_) + +trait HtmlTag[Model]: + def name: String + def mods: List[Mod[Model]] + +class Div[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]: + val name = "div" + +def div[Model](mods: Mod[Model]*): Div[Model] = Div(mods.toList) diff --git a/core/src/scalive/LiveViewRenderer.scala b/core/src/scalive/LiveViewRenderer.scala new file mode 100644 index 0000000..01ef436 --- /dev/null +++ b/core/src/scalive/LiveViewRenderer.scala @@ -0,0 +1,91 @@ +package scalive + +import scala.collection.mutable.ListBuffer +import scala.collection.immutable.ArraySeq +import zio.json.ast.Json + +sealed trait RenderedMod[Model]: + def update(model: Model): Unit + def wasUpdated: Boolean + +object RenderedMod: + class Tag[Model] private[scalive] ( + val static: ArraySeq[String], + val dynamic: ArraySeq[RenderedMod[Model]] + ) extends RenderedMod[Model]: + def update(model: Model): Unit = + dynamic.foreach(_.update(model)) + def wasUpdated: Boolean = dynamic.exists(_.wasUpdated) + def buildInitJson: Json = JsonAstBuilder.buildInit(static, dynamic) + def buildDiffJson: Json = JsonAstBuilder.buildDiff(dynamic) + + class Dynamic[I, O](d: Dyn[I, O], init: I) extends RenderedMod[I]: + private var value: O = d.run(init) + private var updated: Boolean = false + def wasUpdated: Boolean = updated + def currentValue: O = value + def update(v: I): Unit = + val newValue = d.run(v) + if value == newValue then updated = false + else + value = newValue + updated = true + + class When[Model]( + val dynCond: Dynamic[Model, Boolean], + val nested: Tag[Model] + ) extends RenderedMod[Model]: + def displayed: Boolean = dynCond.currentValue + def wasUpdated: Boolean = dynCond.wasUpdated || nested.wasUpdated + def update(model: Model): Unit = + dynCond.update(model) + nested.update(model) + +object LiveViewRenderer: + + def render[Model](lv: LiveView[Model], model: Model): RenderedMod.Tag[Model] = + render(lv.view, model) + + private def render[Model]( + tag: HtmlTag[Model], + model: Model + ): RenderedMod.Tag[Model] = + val static = ListBuffer.empty[String] + val dynamic = ListBuffer.empty[RenderedMod[Model]] + + var staticFragment = "" + for elem <- renderTag(tag, model) do + elem match + case s: String => + staticFragment += s + case d: RenderedMod[Model] => + static.append(staticFragment) + staticFragment = "" + dynamic.append(d) + if staticFragment.nonEmpty then static.append(staticFragment) + new RenderedMod.Tag(static.to(ArraySeq), dynamic.to(ArraySeq)) + + private def renderTag[Model]( + tag: HtmlTag[Model], + model: Model + ): List[String | RenderedMod[Model]] = + (s"<${tag.name}>" + :: tag.mods.flatMap(renderMod(_, model))) :+ + (s"") + + private def renderMod[Model]( + mod: Mod[Model], + model: Model + ): List[String | RenderedMod[Model]] = + mod match + case Mod.Tag(tag) => renderTag(tag, model) + case Mod.Text(text) => List(text) + case Mod.DynText[Model](dynText) => + List(RenderedMod.Dynamic(dynText, model)) + case Mod.When[Model](dynCond, tag) => + List( + RenderedMod.When( + RenderedMod.Dynamic(dynCond, model), + LiveViewRenderer.render(tag, model) + ) + ) diff --git a/core/src/scalive/main.scala b/core/src/scalive/main.scala new file mode 100644 index 0000000..82c619a --- /dev/null +++ b/core/src/scalive/main.scala @@ -0,0 +1,33 @@ +package scalive + +import zio.json.* + +@main +def main = + val r = + LiveViewRenderer.render( + TestLiveView, + MyModel("Initial string", true, "nested init") + ) + println(r.buildInitJson.toJsonPretty) + r.update(MyModel("Updated string", true, "nested updated")) + println(r.buildDiffJson.toJsonPretty) + r.update(MyModel("Updated string", false, "nested updated")) + println(r.buildDiffJson.toJsonPretty) + r.update(MyModel("Updated string", true, "nested displayed again")) + println(r.buildDiffJson.toJsonPretty) + r.update(MyModel("Updated string", true, "nested updated")) + println(r.buildDiffJson.toJsonPretty) + +final case class MyModel(title: String, bool: Boolean, nestedTitle: String) + +object TestLiveView extends LiveView[MyModel]: + val view: HtmlTag[MyModel] = + div( + div("Static string 1"), + model(_.title), + div("Static string 2"), + model.when(_.bool)( + div("maybe rendered", model(_.nestedTitle)) + ) + )