diff --git a/build.mill b/build.mill index 2d5c4b6..acd9648 100644 --- a/build.mill +++ b/build.mill @@ -7,7 +7,13 @@ import mill.api.Task.Simple trait Common extends ScalaModule: def scalaVersion = "3.7.2" - def scalacOptions = Seq("-Wunused:all", "-preview", "-feature", "-language:implicitConversions") + def scalacOptions = Seq( + "-Wunused:all", + "-preview", + "-feature", + "-language:implicitConversions", + "-Wvalue-discard" + ) object core extends Common: def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44") diff --git a/core/src/TestLiveView.scala b/core/src/TestLiveView.scala index c27e3de..a1cd6e9 100644 --- a/core/src/TestLiveView.scala +++ b/core/src/TestLiveView.scala @@ -1,41 +1,27 @@ import scalive.* -final case class MyModel(cls: String = "text-xs", bool: Boolean = true) +final case class MyModel( + cls: String = "text-xs", + bool: Boolean = true, + elems: List[Elem] = List.empty) final case class Elem(name: String, age: Int) -class TestView extends LiveView[TestView.Cmd]: +class TestView(initialModel: MyModel) extends LiveView[TestView.Cmd]: import TestView.Cmd.* - private val textCls = Dyn[String] - private val someBool = Dyn[Boolean] - private val elems = Dyn[List[Elem]] + private val modelVar = Var[MyModel](initialModel) - def mount(state: LiveState): LiveState = - state - .set(textCls, "text-xs") - .set(someBool, true) - .set( - elems, - List( - Elem("a", 10), - Elem("b", 15), - Elem("c", 20) - ) - ) + def handleCommand(cmd: TestView.Cmd): Unit = + cmd match + case UpdateModel(f) => modelVar.update(f) - def handleCommand(cmd: TestView.Cmd, state: LiveState): LiveState = cmd match - case UpdateElems(es) => state.set(elems, es) - case UpdateBool(b) => state.set(someBool, b) - case UpdateTextCls(cls) => state.set(textCls, cls) - - val render = + val el: HtmlElement = div( - idAttr := "42", - cls := textCls, - draggable := someBool, - disabled := someBool, + idAttr := "42", + cls := modelVar(_.cls), + disabled := modelVar(_.bool), ul( - elems.splitByIndex(elem => + modelVar(_.elems).splitByIndex((_, elem) => li( "Nom: ", elem(_.name), @@ -45,10 +31,7 @@ class TestView extends LiveView[TestView.Cmd]: ) ) ) -end TestView object TestView: enum Cmd: - case UpdateElems(es: List[Elem]) - case UpdateBool(b: Boolean) - case UpdateTextCls(cls: String) + case UpdateModel(f: MyModel => MyModel) diff --git a/core/src/main.scala b/core/src/main.scala index 3de870b..e652ea1 100644 --- a/core/src/main.scala +++ b/core/src/main.scala @@ -2,61 +2,82 @@ import scalive.* @main def main = - val s = Socket(TestView()) + val initModel = MyModel(elems = + List( + Elem("a", 10), + Elem("b", 15), + Elem("c", 30) + ) + ) + val s = Socket(TestView(initModel)) println("Init") println(s.renderHtml) s.syncClient s.syncClient - println("Edit first and last") + println("Edit class attribue") s.receiveCommand( - TestView.Cmd.UpdateTextCls("text-lg") + TestView.Cmd.UpdateModel(_.copy(cls = "text-lg")) ) s.syncClient println("Edit first and last") s.receiveCommand( - TestView.Cmd.UpdateElems( - List( - Elem("x", 10), - Elem("b", 15), - Elem("c", 99) + TestView.Cmd.UpdateModel( + _.copy(elems = + List( + Elem("x", 10), + Elem("b", 15), + Elem("c", 99) + ) ) ) ) s.syncClient + println(s.renderHtml) println("Add one") s.receiveCommand( - TestView.Cmd.UpdateElems( - List( - Elem("x", 10), - Elem("b", 15), - Elem("c", 99), - Elem("d", 35) + TestView.Cmd.UpdateModel( + _.copy(elems = + List( + Elem("x", 10), + Elem("b", 15), + Elem("c", 99), + Elem("d", 35) + ) ) ) ) s.syncClient + println(s.renderHtml) - // - // println("Remove first") - // lv.update( - // MyModel( - // List( - // NestedModel("b", 15), - // NestedModel("c", 99), - // NestedModel("d", 35) - // ) - // ) - // ) - // println(lv.diff.toJsonPretty) - // println(HtmlBuilder.build(lv)) - // - // println("Remove all") - // lv.update( - // MyModel(List.empty, "text-lg", bool = false) - // ) - // println(lv.diff.toJsonPretty) - // println(HtmlBuilder.build(lv)) + println("Remove first") + s.receiveCommand( + TestView.Cmd.UpdateModel( + _.copy(elems = + List( + Elem("b", 15), + Elem("c", 99), + Elem("d", 35) + ) + ) + ) + ) + s.syncClient + println(s.renderHtml) + + println("Remove all") + s.receiveCommand( + TestView.Cmd.UpdateModel( + _.copy( + cls = "text-lg", + bool = false, + elems = List.empty + ) + ) + ) + s.syncClient + s.syncClient + println(s.renderHtml) end main diff --git a/core/src/scalive/Diff.scala b/core/src/scalive/Diff.scala new file mode 100644 index 0000000..69c013a --- /dev/null +++ b/core/src/scalive/Diff.scala @@ -0,0 +1,50 @@ +package scalive + +import zio.Chunk +import zio.json.JsonEncoder +import zio.json.ast.Json + +enum Diff: + case Tag( + static: Seq[String] = Seq.empty, + dynamic: Seq[Diff.Dynamic] = Seq.empty) + case Split( + static: Seq[String] = Seq.empty, + entries: Seq[Diff.Dynamic] = Seq.empty) + case Value(value: String) + case Dynamic(key: String, diff: Diff) + case Deleted + +object Diff: + given JsonEncoder[Diff] = JsonEncoder[Json].contramap(toJson) + + private def toJson(diff: Diff): Json = + diff match + case Diff.Tag(static, dynamic) => + Json.Obj( + Option + .when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*)) + .to(Chunk) + .appendedAll( + dynamic.map(d => d.key -> toJson(d.diff)) + ) + ) + case Diff.Split(static, entries) => + Json.Obj( + Option + .when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*)) + .to(Chunk) + .appendedAll( + Option.when(entries.nonEmpty)( + "d" -> + Json.Obj( + entries.map(d => d.key -> toJson(d.diff))* + ) + ) + ) + ) + case Diff.Value(value) => Json.Str(value) + case Diff.Dynamic(index, diff) => + Json.Obj(index.toString -> toJson(diff)) + case Diff.Deleted => Json.Bool(false) +end Diff diff --git a/core/src/scalive/DiffBuilder.scala b/core/src/scalive/DiffBuilder.scala index 9efd3f3..d4eb81a 100644 --- a/core/src/scalive/DiffBuilder.scala +++ b/core/src/scalive/DiffBuilder.scala @@ -1,53 +1,62 @@ package scalive -import zio.Chunk -import zio.json.ast.Json +import scalive.Mod.Attr +import scalive.Mod.Content object DiffBuilder: - def build(rendered: Rendered, fingerprint: Fingerprint): Json = - val nestedFingerprintIter = fingerprint.nested.iterator - Json.Obj( - Option - .when(rendered.static.nonEmpty && fingerprint.value != rendered.fingerprint)( - "s" -> Json.Arr(rendered.static.map(Json.Str(_))*) - ) - .to(Chunk) - .appendedAll( - rendered.dynamic.zipWithIndex - .map((render, index) => index.toString -> build(render(true), nestedFingerprintIter)) - ).filterNot(_._2 == Json.Obj.empty) + def build(el: HtmlElement, trackUpdates: Boolean = true): Diff = + build( + static = if trackUpdates then Seq.empty else el.static, + dynamicMods = el.dynamicMods, + trackUpdates = trackUpdates ) - private def build(comp: Comprehension, fingerprint: Fingerprint): Json = - val nestedFingerprintIter = fingerprint.nested.iterator - Json.Obj( - Option - .when(comp.static.nonEmpty && fingerprint.value != comp.fingerprint)( - "s" -> Json.Arr(comp.static.map(Json.Str(_))*) - ) - .to(Chunk) - .appendedAll( - Option.when(comp.entries.nonEmpty)( - "d" -> - Json.Arr( - comp.entries.map(render => - Json.Obj( - render(true).zipWithIndex - .map((dyn, index) => - index.toString -> build(dyn, nestedFingerprintIter) - ).filterNot(_._2 == Json.Obj.empty)* - ) - )* + private def build(static: Seq[String], dynamicMods: Seq[DynamicMod], trackUpdates: Boolean) + : Diff = + Diff.Tag( + static = static, + dynamic = + buildDynamic(dynamicMods, trackUpdates).zipWithIndex.collect { case (Some(diff), index) => + Diff.Dynamic(index.toString, diff) + } + ) + + private def buildDynamic(dynamicMods: Seq[DynamicMod], trackUpdates: Boolean): Seq[Option[Diff]] = + dynamicMods.flatMap { + case Attr.Dyn(attr, value) => + List(value.render(trackUpdates).map(v => Diff.Value(v.toString))) + case Attr.DynValueAsPresence(attr, value) => + List(value.render(trackUpdates).map(v => Diff.Value(if v then s" ${attr.name}" else ""))) + case Content.Tag(el) => buildDynamic(el.dynamicMods, trackUpdates) + case Content.DynText(dyn) => List(dyn.render(trackUpdates).map(Diff.Value(_))) + case Content.DynElement(dyn) => ??? + case Content.DynOptionElement(dyn) => + List(dyn.render(trackUpdates) match + // Element is added + case Some(Some(el)) => Some(build(el, trackUpdates = false)) + // Element is removed + case Some(None) => Some(Diff.Deleted) + // Element is updated if present + case None => dyn.currentValue.map(build(_, trackUpdates))) + case Content.DynElementColl(dyn) => ??? + case Content.DynSplit(splitVar) => + val entries = splitVar.render(trackUpdates) + if entries.isEmpty then List.empty + else + val static = + entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty) + List( + Some( + Diff.Split( + static = if trackUpdates then Seq.empty else static, + entries = entries.map { + case (key, Some(el)) => + Diff.Dynamic(key.toString, build(Seq.empty, el.dynamicMods, trackUpdates)) + case (key, None) => Diff.Dynamic(key.toString, Diff.Deleted) + } ) + ) ) - ) - ) + } - private def build(dyn: RenderedDyn, fingerprintIter: Iterator[Fingerprint]): Json = - dyn match - case Some(s: String) => Json.Str(s) - case Some(r: Rendered) => build(r, fingerprintIter.nextOption.getOrElse(Fingerprint.empty)) - case Some(c: Comprehension) => - build(c, fingerprintIter.nextOption.getOrElse(Fingerprint.empty)) - case None => Json.Obj.empty end DiffBuilder diff --git a/core/src/scalive/Dyn.scala b/core/src/scalive/Dyn.scala new file mode 100644 index 0000000..e3facb6 --- /dev/null +++ b/core/src/scalive/Dyn.scala @@ -0,0 +1,136 @@ +package scalive + +import scala.collection.mutable + +/** Represents a mutable dynamic value that keeps track of its own state and whether it was changed + * or not. It doesn't observe its parent, therefore sync() must be called manually for updates to + * propagate. Likewise, setUnchanged must be called to reset the change tracking once the latest + * diff has been sent to the client. + * + * Observables are not used on purpose to avoid the complexity of managing their cleanup. The + * tradeoff of micro managing the updates is acceptable as it is only done internally. + */ +sealed trait Dyn[T]: + private[scalive] def currentValue: T + private[scalive] def changed: Boolean + + def apply[T2](f: T => T2): Dyn[T2] + + def when(zoom: T => Boolean)(el: HtmlElement): Mod = + this match + case v: Var[T] => Mod.Content.DynOptionElement(v.apply(i => Option.when(zoom(i))(el))) + case v: DerivedVar[?, T] => + Mod.Content.DynOptionElement(v.apply(i => Option.when(zoom(i))(el))) + + inline def whenNot(f: T => Boolean)(el: HtmlElement): Mod = + when(f.andThen(!_))(el) + + private[scalive] def render(trackUpdates: Boolean): Option[T] + + /** Dynamic values do not observe their parent state, sync() must be called manually to update the + * currentValue. + */ + private[scalive] def sync(): Unit + + private[scalive] def setUnchanged(): Unit + + private[scalive] def callOnEveryChild(f: T => Unit): Unit + +extension [T](parent: Dyn[List[T]]) + def splitByIndex(project: (Int, Dyn[T]) => HtmlElement): Mod = + Mod.Content.DynSplit( + new SplitVar( + parent.apply(_.zipWithIndex), + key = _._2, + project = (index, v) => project(index, v(_._1)) + ) + ) + +class Var[T] private (initial: T) extends Dyn[T]: + private[scalive] var currentValue: T = initial + private[scalive] var changed: Boolean = true + def set(value: T): Unit = + if value != currentValue then + changed = true + currentValue = value + def update(f: T => T): Unit = set(f(currentValue)) + def apply[T2](f: T => T2): DerivedVar[T, T2] = new DerivedVar(this, f) + private[scalive] def render(trackUpdates: Boolean): Option[T] = + if !trackUpdates || changed then Some(currentValue) + else None + private[scalive] def setUnchanged(): Unit = changed = false + private[scalive] inline def sync(): Unit = () + private[scalive] def callOnEveryChild(f: T => Unit): Unit = f(currentValue) +object Var: + def apply[T](initial: T): Var[T] = new Var(initial) + +class DerivedVar[I, O] private[scalive] (parent: Var[I], f: I => O) extends Dyn[O]: + private[scalive] var currentValue: O = f(parent.currentValue) + private[scalive] var changed: Boolean = true + + def apply[O2](zoom: O => O2): DerivedVar[I, O2] = + new DerivedVar(parent, f.andThen(zoom)) + + private[scalive] def sync(): Unit = + if parent.changed then + val value = f(parent.currentValue) + if value != currentValue then + changed = true + currentValue = value + + private[scalive] def render(trackUpdates: Boolean): Option[O] = + if !trackUpdates || changed then Some(currentValue) + else None + + private[scalive] def setUnchanged(): Unit = + changed = false + parent.setUnchanged() + + private[scalive] def callOnEveryChild(f: O => Unit): Unit = f(currentValue) + +class SplitVar[I, O, Key]( + parent: Dyn[List[I]], + key: I => Key, + project: (Key, Dyn[I]) => O): + + // Deleted elements have value none + private val memoized: mutable.Map[Key, Option[(Var[I], O)]] = + mutable.Map.empty + + private[scalive] def sync(): Unit = + parent.sync() + if parent.changed then + // We keep track of the key to set deleted ones to None + val nextKeys = mutable.HashSet.empty[Key] + parent.currentValue.foreach(input => + val entryKey = key(input) + nextKeys += entryKey + memoized.updateWith(entryKey) { + // Update matching key + case varAndOutput @ Some(Some((entryVar, _))) => + entryVar.set(input) + varAndOutput + // Create new item + case Some(None) | None => + val newVar = Var(input) + Some(Some(newVar, project(entryKey, newVar))) + } + ) + memoized.keys.foreach(k => if !nextKeys.contains(k) then memoized.update(k, None)) + + private[scalive] def render(trackUpdates: Boolean): List[(Key, Option[O])] = + memoized.collect { + case (k, Some(entryVar, output)) if !trackUpdates || entryVar.changed => (k, Some(output)) + case (k, None) => (k, None) + }.toList + + private[scalive] def setUnchanged(): Unit = + parent.setUnchanged() + // Remove previously deleted + memoized.filterInPlace((_, v) => v.nonEmpty) + + // Usefull to call setUnchanged when the output is an HtmlElement as only the caller can know the type + private[scalive] def callOnEveryChild(f: O => Unit): Unit = + memoized.values.foreach(_.foreach((_, output) => f(output))) + +end SplitVar diff --git a/core/src/scalive/Fingerprint.scala b/core/src/scalive/Fingerprint.scala deleted file mode 100644 index 48935a0..0000000 --- a/core/src/scalive/Fingerprint.scala +++ /dev/null @@ -1,53 +0,0 @@ -package scalive - -import java.nio.ByteBuffer -import java.security.MessageDigest - -final case class Fingerprint(value: Long, nested: Seq[Fingerprint]) - -object Fingerprint: - val empty = Fingerprint(0, Seq.empty) - def apply(rendered: Rendered): Fingerprint = - Fingerprint( - rendered.fingerprint, - rendered.dynamic.flatMap(render => apply(render(false))) - ) - def apply(comp: Comprehension): Fingerprint = Fingerprint(comp.fingerprint, Seq.empty) - def apply(dyn: RenderedDyn): Option[Fingerprint] = - dyn match - case Some(r: Rendered) => Some(apply(r)) - case Some(c: Comprehension) => Some(apply(c)) - case Some(_: String) => None - case None => None - - val dynText = digest("text") - val dynAttr = digest("attr") - val dynAttrValueAsPresence = digest("attrValueAsPresence") - val dynWhen = digest("when") - val dynSplit = digest("split") - - def digest(s: String): Array[Byte] = - MessageDigest.getInstance("MD5").digest(s.getBytes) - - def digest(el: HtmlElement): Array[Byte] = - val md = MessageDigest.getInstance("MD5") - el.static.foreach(s => md.update(s.getBytes)) - el.mods.foreach { - case Mod.Text(_) => () - case Mod.StaticAttr(_, _) => () - case Mod.StaticAttrValueAsPresence(_, _) => () - case Mod.DynAttr(_, _) => md.update(Fingerprint.dynAttr) - case Mod.DynAttrValueAsPresence(_, _) => md.update(Fingerprint.dynAttrValueAsPresence) - case Mod.DynText(_) => md.update(Fingerprint.dynText) - case Mod.When(_, _) => md.update(Fingerprint.dynWhen) - case Mod.Split(_, project) => md.update(digest(project(Dyn.apply))) - case Mod.Tag(el) => Right(digest(el)) - } - md.digest() - - def apply(el: HtmlElement): Long = digestToFingerprint(digest(el)) - - private def digestToFingerprint(digest: Array[Byte]): Long = - ByteBuffer.wrap(digest, 0, 8).getLong - -end Fingerprint diff --git a/core/src/scalive/HtmlBuilder.scala b/core/src/scalive/HtmlBuilder.scala index 0e45c91..b04d077 100644 --- a/core/src/scalive/HtmlBuilder.scala +++ b/core/src/scalive/HtmlBuilder.scala @@ -1,28 +1,45 @@ package scalive import java.io.StringWriter +import scalive.Mod.Attr +import scalive.Mod.Content object HtmlBuilder: - def build(rendered: Rendered, isRoot: Boolean = false): String = + def build(el: HtmlElement, isRoot: Boolean = false): String = val strw = new StringWriter() - if isRoot then strw.append("") - build(rendered.static, rendered.dynamic, strw) + if isRoot then strw.write("") + build(el.static, el.dynamicMods, strw) strw.toString() private def build( static: Seq[String], - dynamic: Seq[Boolean => RenderedDyn], + dynamic: Seq[(Mod.Attr | Mod.Content) & DynamicMod], strw: StringWriter ): Unit = for i <- dynamic.indices do - strw.append(static(i)) - dynamic(i)(false).foreach { - case s: String => strw.append(s) - case r: Rendered => build(r) - case c: Comprehension => build(c, strw) - } - strw.append(static.last) + strw.write(static(i)) + dynamic(i) match + case Attr.Dyn(attr, value) => + strw.write(value.render(false).map(attr.codec.encode).getOrElse("")) + case Attr.DynValueAsPresence(attr, value) => + strw.write( + value.render(false).map(if _ then s" ${attr.name}" else "").getOrElse("") + ) + case Content.Tag(el) => build(el.static, el.dynamicMods, strw) + case Content.DynText(dyn) => strw.write(dyn.render(false).getOrElse("")) + case Content.DynElement(dyn) => ??? + case Content.DynOptionElement(dyn) => + dyn.render(false).foreach(_.foreach(el => build(el.static, el.dynamicMods, strw))) + case Content.DynElementColl(dyn) => ??? + case Content.DynSplit(splitVar) => + val entries = splitVar.render(false) + val staticOpt = entries.collectFirst { case (_, Some(el)) => el.static } + entries.foreach { + case (_, Some(entryEl)) => + build(staticOpt.getOrElse(Nil), entryEl.dynamicMods, strw) + case _ => () + } + strw.write(static.last) - private def build(comp: Comprehension, strw: StringWriter): Unit = - comp.entries.foreach(entry => build(comp.static, entry(false).map(d => _ => d), strw)) +end HtmlBuilder diff --git a/core/src/scalive/HtmlElement.scala b/core/src/scalive/HtmlElement.scala index c6503b6..e89c3a3 100644 --- a/core/src/scalive/HtmlElement.scala +++ b/core/src/scalive/HtmlElement.scala @@ -2,9 +2,27 @@ package scalive import scalive.codecs.BooleanAsAttrPresenceCodec import scalive.codecs.Codec +import scalive.Mod.Attr +import scalive.Mod.Content class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]): - lazy val static = Rendered.buildStatic(this) + def static: Seq[String] = StaticBuilder.build(this) + def attrMods: Seq[Mod.Attr] = + mods.collect { case mod: Mod.Attr => mod } + def contentMods: Seq[Mod.Content] = + mods.collect { case mod: Mod.Content => mod } + def dynamicMods: Seq[(Mod.Attr | Mod.Content) & DynamicMod] = + dynamicAttrMods ++ dynamicContentMods.flatMap { + case Content.Tag(el) => el.dynamicMods + case mod => List(mod) + + } + def dynamicAttrMods: Seq[Mod.Attr & DynamicMod] = + mods.collect { case mod: (Mod.Attr & DynamicMod) => mod } + def dynamicContentMods: Seq[Mod.Content & DynamicMod] = + mods.collect { case mod: (Mod.Content & DynamicMod) => mod } + private[scalive] def syncAll(): Unit = mods.foreach(_.syncAll()) + private[scalive] def setAllUnchanged(): Unit = dynamicMods.foreach(_.setAllUnchanged()) class HtmlTag(val name: String, val void: Boolean = false): def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector) @@ -12,53 +30,87 @@ class HtmlTag(val name: String, val void: Boolean = false): class HtmlAttr[V](val name: String, val codec: Codec[V, String]): private inline def isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec - def :=(value: V): Mod = + def :=(value: V): Mod.Attr = if isBooleanAsAttrPresence then - Mod.StaticAttrValueAsPresence( + Mod.Attr.StaticValueAsPresence( this.asInstanceOf[HtmlAttr[Boolean]], value.asInstanceOf[Boolean] ) - else Mod.StaticAttr(this, codec.encode(value)) + else Mod.Attr.Static(this, codec.encode(value)) - def :=(value: Dyn[V]): Mod = + def :=(value: Dyn[V]): Mod.Attr = if isBooleanAsAttrPresence then - Mod.DynAttrValueAsPresence( + Mod.Attr.DynValueAsPresence( this.asInstanceOf[HtmlAttr[Boolean]], value.asInstanceOf[Dyn[Boolean]] ) - else Mod.DynAttr(this, value) + else Mod.Attr.Dyn(this, value) -enum Mod: - case Text(text: String) - case StaticAttr(attr: HtmlAttr[?], value: String) - case StaticAttrValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean) - case DynAttr[T](attr: HtmlAttr[T], value: Dyn[T]) - case DynAttrValueAsPresence(attr: HtmlAttr[Boolean], value: Dyn[Boolean]) - case Tag(el: HtmlElement) - case DynText(dyn: Dyn[String]) - case When(dyn: Dyn[Boolean], el: HtmlElement) - case Split[T]( - dynList: Dyn[List[T]], - project: Dyn[T] => HtmlElement) +sealed trait Mod +sealed trait StaticMod extends Mod +sealed trait DynamicMod extends Mod -final case class Dyn[T] private[scalive] (key: LiveState.Key, f: key.Type => T): - def render(state: LiveState, trackUpdates: Boolean): Option[T] = - val entry = state(key) - if !trackUpdates | entry.changed then Some(f(entry.value)) - else None +object Mod: + enum Attr extends Mod: + case Static(attr: HtmlAttr[?], value: String) extends Attr with StaticMod + case StaticValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean) extends Attr with StaticMod + case Dyn[T](attr: HtmlAttr[T], value: scalive.Dyn[T]) extends Attr with DynamicMod + case DynValueAsPresence(attr: HtmlAttr[Boolean], value: scalive.Dyn[Boolean]) + extends Attr + with DynamicMod - def map[T2](f2: T => T2): Dyn[T2] = Dyn(key, f.andThen(f2)) + enum Content extends Mod: + case Text(text: String) extends Content with StaticMod + case Tag(el: HtmlElement) extends Content with StaticMod with DynamicMod + case DynText(dyn: Dyn[String]) extends Content with DynamicMod + case DynElement(dyn: Dyn[HtmlElement]) extends Content with DynamicMod + // TODO support arbitrary collection + case DynOptionElement(dyn: Dyn[Option[HtmlElement]]) extends Content with DynamicMod + case DynElementColl(dyn: Dyn[Iterable[HtmlElement]]) extends Content with DynamicMod + case DynSplit(v: SplitVar[?, HtmlElement, ?]) extends Content with DynamicMod - inline def apply[T2](f2: T => T2): Dyn[T2] = map(f2) +extension (mod: Mod) + private[scalive] def setAllUnchanged(): Unit = + mod match + case Attr.Static(_, _) => () + case Attr.StaticValueAsPresence(_, _) => () + case Attr.Dyn(_, value) => value.setUnchanged() + case Attr.DynValueAsPresence(attr, value) => value.setUnchanged() + case Content.Text(text) => () + case Content.Tag(el) => el.setAllUnchanged() + case Content.DynText(dyn) => dyn.setUnchanged() + case Content.DynElement(dyn) => + dyn.setUnchanged() + dyn.callOnEveryChild(_.setAllUnchanged()) + case Content.DynOptionElement(dyn) => + dyn.setUnchanged() + dyn.callOnEveryChild(_.foreach(_.setAllUnchanged())) + case Content.DynElementColl(dyn) => + dyn.setUnchanged() + dyn.callOnEveryChild(_.foreach(_.setAllUnchanged())) + case Content.DynSplit(v) => + v.setUnchanged() + v.callOnEveryChild(_.setAllUnchanged()) - def when(f2: T => Boolean)(el: HtmlElement): Mod.When = Mod.When(map(f2), el) - - inline def whenNot(f2: T => Boolean)(el: HtmlElement): Mod.When = - when(f2.andThen(!_))(el) - -extension [T](dyn: Dyn[List[T]]) - def splitByIndex(project: Dyn[T] => HtmlElement): Mod.Split[T] = - Mod.Split(dyn, project) - -object Dyn: - def apply[T]: Dyn[T] = Dyn(LiveState.Key[T], identity) + private[scalive] def syncAll(): Unit = + mod match + case Attr.Static(_, _) => () + case Attr.StaticValueAsPresence(_, _) => () + case Attr.Dyn(_, value) => value.sync() + case Attr.DynValueAsPresence(attr, value) => value.sync() + case Content.Text(text) => () + case Content.Tag(el) => el.syncAll() + case Content.DynText(dyn) => dyn.sync() + case Content.DynElement(dyn) => + dyn.sync() + dyn.callOnEveryChild(_.syncAll()) + case Content.DynOptionElement(dyn) => + dyn.sync() + dyn.callOnEveryChild(_.foreach(_.syncAll())) + case Content.DynElementColl(dyn) => + dyn.sync() + dyn.callOnEveryChild(_.foreach(_.syncAll())) + case Content.DynSplit(v) => + v.sync() + v.callOnEveryChild(_.syncAll()) +end extension diff --git a/core/src/scalive/LiveState.scala b/core/src/scalive/LiveState.scala deleted file mode 100644 index a7d6a08..0000000 --- a/core/src/scalive/LiveState.scala +++ /dev/null @@ -1,32 +0,0 @@ -package scalive - -final case class LiveState private (val data: Map[LiveState.Key, LiveState.Entry[Any]]): - def get(k: LiveState.Key): Option[LiveState.Entry[k.Type]] = - data.get(k).asInstanceOf[Option[LiveState.Entry[k.Type]]] - def set[T](k: Dyn[T], v: T): LiveState = - copy(data = data.updated(k.key, LiveState.Entry(true, v))) - def apply(k: LiveState.Key): LiveState.Entry[k.Type] = - get(k).getOrElse(throw new IllegalArgumentException("An assign of type")) - def update(k: LiveState.Key, update: k.Type => k.Type): LiveState = - copy(data = - data.updatedWith(k)( - _.asInstanceOf[Option[LiveState.Entry[k.Type]]] - .map(e => LiveState.Entry(true, update(e.value))) - ) - ) - def remove(k: LiveState.Key): LiveState = copy(data = data - k) - def setAllUnchanged: LiveState = - LiveState(data.view.mapValues(_.copy(changed = false)).toMap) - -object LiveState: - final case class Entry[T](changed: Boolean, value: T) - - val empty = LiveState(Map.empty) - - class Key: - type Type - def toDyn: Dyn[Type] = Dyn(this, identity) - def toDyn[T](f: Type => T): Dyn[T] = Dyn(this, f) - object Key: - def apply[T] = new Key: - type Type = T diff --git a/core/src/scalive/LiveView.scala b/core/src/scalive/LiveView.scala index 48e2f27..13b6e97 100644 --- a/core/src/scalive/LiveView.scala +++ b/core/src/scalive/LiveView.scala @@ -1,6 +1,5 @@ package scalive trait LiveView[Cmd]: - def mount(state: LiveState): LiveState - def handleCommand(cmd: Cmd, state: LiveState): LiveState - def render: HtmlElement + def handleCommand(cmd: Cmd): Unit + val el: HtmlElement diff --git a/core/src/scalive/Rendered.scala b/core/src/scalive/Rendered.scala deleted file mode 100644 index 185efbd..0000000 --- a/core/src/scalive/Rendered.scala +++ /dev/null @@ -1,122 +0,0 @@ -package scalive - -import scala.annotation.nowarn -import scala.collection.immutable.ArraySeq -import scala.collection.mutable.ListBuffer - -type RenderedDyn = Option[String | Rendered | Comprehension] - -final case class Rendered( - // val root: Boolean, - val fingerprint: Long, - val static: Seq[String], - val dynamic: Seq[Boolean => RenderedDyn]) - -final case class Comprehension( - // val hasKey: Boolean, - val fingerprint: Long, - val static: Seq[String], - val entries: Seq[Boolean => Seq[RenderedDyn]]) - -object Rendered: - - def render(el: HtmlElement, state: LiveState): Rendered = - Rendered(Fingerprint.apply(el), el.static, buildDynamicRendered(el, state)) - - def render[T](mod: Mod.DynAttr[T], state: LiveState): Boolean => RenderedDyn = - trackUpdates => mod.value.render(state, trackUpdates).map(mod.attr.codec.encode) - def render(mod: Mod.DynAttrValueAsPresence, state: LiveState): Boolean => RenderedDyn = - trackUpdates => - mod.value.render(state, trackUpdates).map(if _ then s" ${mod.attr.name}" else "") - def render(mod: Mod.DynText, state: LiveState): Boolean => RenderedDyn = - trackUpdates => mod.dyn.render(state, trackUpdates) - def render(mod: Mod.When, state: LiveState): Boolean => RenderedDyn = - trackUpdates => - mod.dyn - .render(state, trackUpdates) - .collect { case true => render(mod.el, state) } - def render(mod: Mod.Split[Any], state: LiveState): Boolean => RenderedDyn = - trackUpdates => - mod.dynList - .render(state, trackUpdates) - .collect { - case items if items.nonEmpty => - val el = mod.project(Dyn.apply) - Comprehension( - Fingerprint.apply(el), - el.static, - items.map(item => - val localDyn = Dyn[Any] - val localState = LiveState.empty.set(localDyn, item) - val localElem = mod.project(localDyn) - trackElemUpdates => - buildDynamicRendered(localElem, localState).map(_(trackElemUpdates)) - ) - ) - } - - def buildStatic(el: HtmlElement): ArraySeq[String] = - buildStaticFragments(el).flatten.to(ArraySeq) - - private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] = - val (attrs, children) = buildStaticFragmentsByType(el) - val static = ListBuffer.empty[Option[String]] - var staticFragment = s"<${el.tag.name}" - for attr <- attrs do - attr match - case Some(s) => - staticFragment += s - case None => - static.append(Some(staticFragment)) - static.append(None) - staticFragment = "" - staticFragment += (if el.tag.void then "/>" else ">") - for child <- children do - child match - case Some(s) => - staticFragment += s - case None => - static.append(Some(staticFragment)) - static.append(None) - staticFragment = "" - staticFragment += (if el.tag.void then "" else s"") - static.append(Some(staticFragment)) - static.toSeq - - @nowarn("cat=unchecked") - private def buildStaticFragmentsByType(el: HtmlElement) - : (attrs: Seq[Option[String]], children: Seq[Option[String]]) = - val (attrs, children) = el.mods.partitionMap { - case Mod.StaticAttr(attr, value) => Left(List(Some(s""" ${attr.name}="$value""""))) - case Mod.StaticAttrValueAsPresence(attr, true) => Left(List(Some(s" ${attr.name}"))) - case Mod.StaticAttrValueAsPresence(attr, false) => Left(List.empty) - case Mod.DynAttr(attr, _) => - Left(List(Some(s""" ${attr.name}=""""), None, Some('"'.toString))) - case Mod.DynAttrValueAsPresence(attr, _) => - Left(List(Some(""), None, Some(""))) - case Mod.Tag(el) => Right(buildStaticFragments(el)) - case Mod.Text(text) => Right(List(Some(text))) - case Mod.DynText(_) => Right(List(None)) - case Mod.When(_, _) => Right(List(None)) - case Mod.Split[Any](_, _) => Right(List(None)) - } - (attrs.flatten, children.flatten) - - @nowarn("cat=unchecked") - def buildDynamicRendered( - el: HtmlElement, - state: LiveState - ): Seq[Boolean => RenderedDyn] = - val (attrs, children) = el.mods.partitionMap { - case Mod.Text(_) => Right(List.empty) - case Mod.StaticAttr(_, _) => Left(List.empty) - case Mod.StaticAttrValueAsPresence(_, _) => Left(List.empty) - case mod: Mod.DynAttr[?] => Right(List(Rendered.render(mod, state))) - case mod: Mod.DynAttrValueAsPresence => Right(List(Rendered.render(mod, state))) - case Mod.Tag(el) => Right(buildDynamicRendered(el, state)) - case mod: Mod.DynText => Right(List(Rendered.render(mod, state))) - case mod: Mod.When => Right(List(Rendered.render(mod, state))) - case mod: Mod.Split[Any] => Right(List(Rendered.render(mod, state))) - } - attrs.flatten ++ children.flatten -end Rendered diff --git a/core/src/scalive/Scalive.scala b/core/src/scalive/Scalive.scala index c42b5f6..7b1312e 100644 --- a/core/src/scalive/Scalive.scala +++ b/core/src/scalive/Scalive.scala @@ -3,6 +3,6 @@ import scalive.defs.complex.ComplexHtmlKeys import scalive.defs.tags.HtmlTags package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys: - implicit def stringToMod(v: String): Mod = Mod.Text(v) - implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Tag(el) - implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.DynText(d) + implicit def stringToMod(v: String): Mod = Mod.Content.Text(v) + implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el) + implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.Content.DynText(d) diff --git a/core/src/scalive/Socket.scala b/core/src/scalive/Socket.scala index bd58672..155a4fb 100644 --- a/core/src/scalive/Socket.scala +++ b/core/src/scalive/Socket.scala @@ -3,18 +3,21 @@ package scalive import zio.json.* final case class Socket[Cmd](lv: LiveView[Cmd]): - private var state: LiveState = lv.mount(LiveState.empty) - private var fingerprint: Fingerprint = Fingerprint.empty - val id: String = "scl-123" + + lv.el.syncAll() + + private var clientInitialized = false + val id: String = "scl-123" def receiveCommand(cmd: Cmd): Unit = - state = lv.handleCommand(cmd, state) + lv.handleCommand(cmd) def renderHtml: String = - HtmlBuilder.build(Rendered.render(lv.render, state), isRoot = true) + lv.el.syncAll() + HtmlBuilder.build(lv.el, isRoot = true) def syncClient: Unit = - val r = Rendered.render(lv.render, state) - println(DiffBuilder.build(r, fingerprint).toJsonPretty) - fingerprint = Fingerprint(r) - state = state.setAllUnchanged + lv.el.syncAll() + println(DiffBuilder.build(lv.el, trackUpdates = clientInitialized).toJsonPretty) + clientInitialized = true + lv.el.setAllUnchanged() diff --git a/core/src/scalive/StaticBuilder.scala b/core/src/scalive/StaticBuilder.scala new file mode 100644 index 0000000..b3d22c2 --- /dev/null +++ b/core/src/scalive/StaticBuilder.scala @@ -0,0 +1,53 @@ +package scalive + +import scala.collection.immutable.ArraySeq +import scala.collection.mutable.ListBuffer +import scalive.Mod.Attr +import scalive.Mod.Content + +object StaticBuilder: + + def build(el: HtmlElement): ArraySeq[String] = + buildStaticFragments(el).flatten.to(ArraySeq) + + private def buildStaticFragments(el: HtmlElement): Seq[Option[String]] = + val attrs = el.attrMods.flatMap { + case Attr.Static(attr, value) => List(Some(s""" ${attr.name}="$value"""")) + case Attr.StaticValueAsPresence(attr, value) => List(Some(s" ${attr.name}")) + case Attr.Dyn(attr, value) => List(Some(s""" ${attr.name}=""""), None, Some('"'.toString)) + case Attr.DynValueAsPresence(attr, value) => List(Some(""), None, Some("")) + } + val children = el.contentMods.flatMap { + case Content.Text(text) => List(Some(text)) + case Content.Tag(el) => buildStaticFragments(el) + case Content.DynText(_) => List(None) + case Content.DynElement(_) => List(None) + case Content.DynOptionElement(_) => List(None) + case Content.DynElementColl(_) => List(None) + case Content.DynSplit(_) => List(None) + } + val static = ListBuffer.empty[Option[String]] + var staticFragment = s"<${el.tag.name}" + for attr <- attrs do + attr match + case Some(s) => + staticFragment += s + case None => + static.append(Some(staticFragment)) + static.append(None) + staticFragment = "" + staticFragment += (if el.tag.void then "/>" else ">") + for child <- children do + child match + case Some(s) => + staticFragment += s + case None => + static.append(Some(staticFragment)) + static.append(None) + staticFragment = "" + staticFragment += (if el.tag.void then "" else s"") + static.append(Some(staticFragment)) + static.toSeq + end buildStaticFragments + +end StaticBuilder diff --git a/core/test/src/scalive/LiveViewSpec.scala b/core/test/src/scalive/LiveViewSpec.scala index ae8c76c..5f38245 100644 --- a/core/test/src/scalive/LiveViewSpec.scala +++ b/core/test/src/scalive/LiveViewSpec.scala @@ -8,326 +8,352 @@ object LiveViewSpec extends TestSuite: final case class TestModel( title: String = "title value", + otherString: String = "other string value", bool: Boolean = false, nestedTitle: String = "nested title value", cls: String = "text-sm", items: List[NestedModel] = List.empty) final case class NestedModel(name: String, age: Int) + final case class UpdateCmd(f: TestModel => TestModel) - // def assertEqualsJson(actual: Diff, expected: Json) = - // assert(actual.toJsonPretty == expected.toJsonPretty) + def assertEqualsDiff(el: HtmlElement, expected: Json, trackChanges: Boolean = true) = + el.syncAll() + val actual = DiffBuilder.build(el, trackUpdates = trackChanges) + assert(actual.toJsonPretty == expected.toJsonPretty) val emptyDiff = Json.Obj.empty val tests = Tests { - // test("Static only") { - // val lv = - // LiveView( - // new View: - // type Model = Unit - // val render = div("Static string") - // , - // () - // ) - // test("init") { - // assertEqualsJson( - // lv.fullDiff, - // Json.Obj( - // "s" -> Json.Arr(Json.Str("
Static string
")) - // ) - // ) - // } - // test("diff") { - // assertEqualsJson(lv.diff, emptyDiff) - // } - // } - // - // test("Dynamic string") { - // val lv = - // LiveView( - // new View: - // type Model = TestModel - // val render = - // div(model(_.title)) - // , - // TestModel() - // ) - // test("init") { - // assertEqualsJson( - // lv.fullDiff, - // Json - // .Obj( - // "s" -> Json.Arr(Json.Str("
"), Json.Str("
")), - // "0" -> Json.Str("title value") - // ) - // ) - // } - // test("diff no update") { - // assertEqualsJson(lv.diff, emptyDiff) - // } - // test("diff with update") { - // lv.update(TestModel(title = "title updated")) - // assertEqualsJson( - // lv.diff, - // Json.Obj("0" -> Json.Str("title updated")) - // ) - // } - // test("diff with update and no change") { - // lv.update(TestModel(title = "title updated")) - // lv.update(TestModel(title = "title updated")) - // assertEqualsJson(lv.diff, emptyDiff) - // } - // } - // - // test("Dynamic attribute") { - // val lv = - // LiveView( - // new View: - // type Model = TestModel - // val render = - // div(cls := model(_.cls)) - // , - // TestModel() - // ) - // test("init") { - // assertEqualsJson( - // lv.fullDiff, - // Json - // .Obj( - // "s" -> Json - // .Arr(Json.Str("
")), - // "0" -> Json.Str("text-sm") - // ) - // ) - // } - // test("diff no update") { - // assertEqualsJson(lv.diff, emptyDiff) - // } - // test("diff with update") { - // lv.update(TestModel(cls = "text-md")) - // assertEqualsJson( - // lv.diff, - // Json.Obj("0" -> Json.Str("text-md")) - // ) - // } - // test("diff with update and no change") { - // lv.update(TestModel(cls = "text-md")) - // lv.update(TestModel(cls = "text-md")) - // assertEqualsJson(lv.diff, emptyDiff) - // } - // } - // - // test("when mod") { - // val lv = - // LiveView( - // new View: - // type Model = TestModel - // val render = - // div( - // model.when(_.bool)( - // div("static string", model(_.nestedTitle)) - // ) - // ) - // , - // TestModel() - // ) - // test("init") { - // assertEqualsJson( - // lv.fullDiff, - // Json - // .Obj( - // "s" -> Json.Arr(Json.Str("
"), Json.Str("
")), - // "0" -> Json.Bool(false) - // ) - // ) - // } - // test("diff no update") { - // assertEqualsJson(lv.diff, emptyDiff) - // } - // test("diff with unrelated update") { - // lv.update(TestModel(title = "title updated")) - // assertEqualsJson(lv.diff, emptyDiff) - // } - // test("diff when true") { - // lv.update(TestModel(bool = true)) - // assertEqualsJson( - // lv.diff, - // Json.Obj( - // "0" -> - // Json - // .Obj( - // "s" -> Json - // .Arr(Json.Str("
static string"), Json.Str("
")), - // "0" -> Json.Str("nested title value") - // ) - // ) - // ) - // } - // test("diff when nested change") { - // lv.update(TestModel(bool = true)) - // lv.update(TestModel(bool = true, nestedTitle = "nested title updated")) - // assertEqualsJson( - // lv.diff, - // Json.Obj( - // "0" -> - // Json - // .Obj( - // "0" -> Json.Str("nested title updated") - // ) - // ) - // ) - // } - // } - // - // test("splitByIndex mod") { - // val initModel = - // TestModel( - // items = List( - // NestedModel("a", 10), - // NestedModel("b", 15), - // NestedModel("c", 20) - // ) - // ) - // val lv = - // LiveView( - // new View: - // type Model = TestModel - // val render = - // div( - // ul( - // model.splitByIndex(_.items)(elem => - // li( - // "Nom: ", - // elem(_.name), - // " Age: ", - // elem(_.age.toString) - // ) - // ) - // ) - // ) - // , - // initModel - // ) - // test("init") { - // assertEqualsJson( - // lv.fullDiff, - // Json - // .Obj( - // "s" -> Json.Arr(Json.Str("
")), - // "0" -> Json.Obj( - // "s" -> Json.Arr( - // Json.Str("
  • Nom: "), - // Json.Str(" Age: "), - // Json.Str("
  • ") - // ), - // "d" -> Json.Obj( - // "0" -> Json.Obj( - // "0" -> Json.Str("a"), - // "1" -> Json.Str("10") - // ), - // "1" -> Json.Obj( - // "0" -> Json.Str("b"), - // "1" -> Json.Str("15") - // ), - // "2" -> Json.Obj( - // "0" -> Json.Str("c"), - // "1" -> Json.Str("20") - // ) - // ) - // ) - // ) - // ) - // } - // test("diff no update") { - // assertEqualsJson(lv.diff, emptyDiff) - // } - // test("diff with unrelated update") { - // lv.update(initModel.copy(title = "title updated")) - // assertEqualsJson(lv.diff, emptyDiff) - // } - // test("diff with item changed") { - // lv.update( - // initModel.copy(items = initModel.items.updated(2, NestedModel("c", 99))) - // ) - // assertEqualsJson( - // lv.diff, - // Json.Obj( - // "0" -> - // Json - // .Obj( - // "d" -> Json.Obj( - // "2" -> Json.Obj( - // "1" -> Json.Str("99") - // ) - // ) - // ) - // ) - // ) - // } - // test("diff with item added") { - // lv.update( - // initModel.copy(items = initModel.items.appended(NestedModel("d", 35))) - // ) - // assertEqualsJson( - // lv.diff, - // Json.Obj( - // "0" -> - // Json - // .Obj( - // "d" -> Json.Obj( - // "3" -> Json.Obj( - // "0" -> Json.Str("d"), - // "1" -> Json.Str("35") - // ) - // ) - // ) - // ) - // ) - // } - // test("diff with first item removed") { - // lv.update( - // initModel.copy(items = initModel.items.tail) - // ) - // assertEqualsJson( - // lv.diff, - // Json.Obj( - // "0" -> - // Json - // .Obj( - // "d" -> Json.Obj( - // "0" -> Json.Obj( - // "0" -> Json.Str("b"), - // "1" -> Json.Str("15") - // ), - // "1" -> Json.Obj( - // "0" -> Json.Str("c"), - // "1" -> Json.Str("20") - // ), - // "2" -> Json.Bool(false) - // ) - // ) - // ) - // ) - // } - // test("diff all removed") { - // lv.update(initModel.copy(items = List.empty)) - // assertEqualsJson( - // lv.diff, - // Json.Obj( - // "0" -> - // Json - // .Obj( - // "d" -> Json.Obj( - // "0" -> Json.Bool(false), - // "1" -> Json.Bool(false), - // "2" -> Json.Bool(false) - // ) - // ) - // ) - // ) - // - // } - // } + test("Static only") { + val lv = + new LiveView[Unit]: + val el = div("Static string") + def handleCommand(cmd: Unit): Unit = () + lv.el.syncAll() + + test("init") { + assertEqualsDiff( + lv.el, + Json.Obj( + "s" -> Json.Arr(Json.Str("
    Static string
    ")) + ), + trackChanges = false + ) + } + test("diff") { + assertEqualsDiff(lv.el, emptyDiff) + } + } + + test("Dynamic string") { + val lv = + new LiveView[UpdateCmd]: + val model = Var(TestModel()) + val el = + div( + h1(model(_.title)), + p(model(_.otherString)) + ) + def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f) + + lv.el.syncAll() + lv.el.setAllUnchanged() + + test("init") { + assertEqualsDiff( + lv.el, + Json + .Obj( + "s" -> Json.Arr(Json.Str("

    "), Json.Str("

    "), Json.Str("

    ")), + "0" -> Json.Str("title value"), + "1" -> Json.Str("other string value") + ), + trackChanges = false + ) + } + test("diff no update") { + assertEqualsDiff(lv.el, emptyDiff) + } + test("diff with update") { + lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) + assertEqualsDiff( + lv.el, + Json.Obj("0" -> Json.Str("title updated")) + ) + } + test("diff with update and no change") { + lv.handleCommand(UpdateCmd(_.copy(title = "title value"))) + assertEqualsDiff(lv.el, emptyDiff) + } + test("diff with update in multiple commands") { + lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) + lv.handleCommand(UpdateCmd(_.copy(otherString = "other string updated"))) + assertEqualsDiff( + lv.el, + Json + .Obj( + "0" -> Json.Str("title updated"), + "1" -> Json.Str("other string updated") + ) + ) + } + } + + test("Dynamic attribute") { + val lv = + new LiveView[UpdateCmd]: + val model = Var(TestModel()) + val el = + div(cls := model(_.cls)) + def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f) + + lv.el.syncAll() + lv.el.setAllUnchanged() + + test("init") { + assertEqualsDiff( + lv.el, + Json + .Obj( + "s" -> Json + .Arr(Json.Str("
    ")), + "0" -> Json.Str("text-sm") + ), + trackChanges = false + ) + } + test("diff no update") { + assertEqualsDiff(lv.el, emptyDiff) + } + test("diff with update") { + lv.handleCommand(UpdateCmd(_.copy(cls = "text-md"))) + assertEqualsDiff( + lv.el, + Json.Obj("0" -> Json.Str("text-md")) + ) + } + } + + test("when mod") { + val lv = + new LiveView[UpdateCmd]: + val model = Var(TestModel()) + val el = + div( + model.when(_.bool)( + div("static string", model(_.nestedTitle)) + ) + ) + def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f) + + lv.el.syncAll() + lv.el.setAllUnchanged() + + test("init") { + assertEqualsDiff( + lv.el, + Json + .Obj( + "s" -> Json.Arr(Json.Str("
    "), Json.Str("
    ")), + "0" -> Json.Bool(false) + ), + trackChanges = false + ) + } + test("diff no update") { + assertEqualsDiff(lv.el, emptyDiff) + } + test("diff with unrelated update") { + lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) + assertEqualsDiff(lv.el, emptyDiff) + } + test("diff when true and nested update") { + lv.handleCommand(UpdateCmd(_.copy(bool = true))) + assertEqualsDiff( + lv.el, + Json.Obj( + "0" -> + Json + .Obj( + "s" -> Json + .Arr(Json.Str("
    static string"), Json.Str("
    ")), + "0" -> Json.Str("nested title value") + ) + ) + ) + } + test("diff when nested change") { + lv.handleCommand(UpdateCmd(_.copy(bool = true))) + lv.el.syncAll() + lv.el.setAllUnchanged() + lv.handleCommand(UpdateCmd(_.copy(bool = true, nestedTitle = "nested title updated"))) + assertEqualsDiff( + lv.el, + Json.Obj( + "0" -> + Json + .Obj( + "0" -> Json.Str("nested title updated") + ) + ) + ) + } + } + + test("splitByIndex mod") { + val initModel = TestModel( + items = List( + NestedModel("a", 10), + NestedModel("b", 15), + NestedModel("c", 20) + ) + ) + val lv = + new LiveView[UpdateCmd]: + val model = Var(initModel) + val el = + div( + ul( + model(_.items).splitByIndex((_, elem) => + li( + "Nom: ", + elem(_.name), + " Age: ", + elem(_.age.toString) + ) + ) + ) + ) + def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f) + + lv.el.syncAll() + lv.el.setAllUnchanged() + + test("init") { + assertEqualsDiff( + lv.el, + Json + .Obj( + "s" -> Json.Arr(Json.Str("
    ")), + "0" -> Json.Obj( + "s" -> Json.Arr( + Json.Str("
  • Nom: "), + Json.Str(" Age: "), + Json.Str("
  • ") + ), + "d" -> Json.Obj( + "0" -> Json.Obj( + "0" -> Json.Str("a"), + "1" -> Json.Str("10") + ), + "1" -> Json.Obj( + "0" -> Json.Str("b"), + "1" -> Json.Str("15") + ), + "2" -> Json.Obj( + "0" -> Json.Str("c"), + "1" -> Json.Str("20") + ) + ) + ) + ), + trackChanges = false + ) + } + test("diff no update") { + assertEqualsDiff(lv.el, emptyDiff) + } + test("diff with unrelated update") { + lv.handleCommand(UpdateCmd(_.copy(title = "title updated"))) + assertEqualsDiff(lv.el, emptyDiff) + } + test("diff with item changed") { + lv.handleCommand( + UpdateCmd(_.copy(items = initModel.items.updated(2, NestedModel("c", 99)))) + ) + assertEqualsDiff( + lv.el, + Json.Obj( + "0" -> + Json + .Obj( + "d" -> Json.Obj( + "2" -> Json.Obj( + "1" -> Json.Str("99") + ) + ) + ) + ) + ) + } + test("diff with item added") { + lv.handleCommand( + UpdateCmd( + _.copy(items = initModel.items.appended(NestedModel("d", 35))) + ) + ) + assertEqualsDiff( + lv.el, + Json.Obj( + "0" -> + Json + .Obj( + "d" -> Json.Obj( + "3" -> Json.Obj( + "0" -> Json.Str("d"), + "1" -> Json.Str("35") + ) + ) + ) + ) + ) + } + test("diff with first item removed") { + lv.handleCommand( + UpdateCmd( + _.copy(items = initModel.items.tail) + ) + ) + assertEqualsDiff( + lv.el, + Json.Obj( + "0" -> + Json + .Obj( + "d" -> Json.Obj( + "0" -> Json.Obj( + "0" -> Json.Str("b"), + "1" -> Json.Str("15") + ), + "1" -> Json.Obj( + "0" -> Json.Str("c"), + "1" -> Json.Str("20") + ), + "2" -> Json.Bool(false) + ) + ) + ) + ) + } + test("diff all removed") { + lv.handleCommand(UpdateCmd(_.copy(items = List.empty))) + assertEqualsDiff( + lv.el, + Json.Obj( + "0" -> + Json + .Obj( + "d" -> Json.Obj( + "0" -> Json.Bool(false), + "1" -> Json.Bool(false), + "2" -> Json.Bool(false) + ) + ) + ) + ) + } + } + } end LiveViewSpec diff --git a/zio/src/scalive/Example.scala b/zio/src/scalive/Example.scala index 020c462..bb037ea 100644 --- a/zio/src/scalive/Example.scala +++ b/zio/src/scalive/Example.scala @@ -4,24 +4,11 @@ import zio.* import zio.http.ChannelEvent.{ExceptionCaught, Read, UserEvent, UserEventTriggered} import zio.http.* -import zio.http.codec.PathCodec.string import zio.http.template.Html object Example extends ZIOAppDefault: - val s = Socket( - TestView, - LiveState.empty.set( - TestView.model, - MyModel( - List( - NestedModel("a", 10), - NestedModel("b", 15), - NestedModel("c", 20) - ) - ) - ) - ) + val s = Socket(new TestView()) val socketApp: WebSocketApp[Any] = Handler.webSocket { channel => @@ -79,14 +66,26 @@ end Example final case class MyModel(elems: List[NestedModel], cls: String = "text-xs") final case class NestedModel(name: String, age: Int) -object TestView extends LiveView: - val model = LiveState.Key[MyModel] - val render = +class TestView extends LiveView[Nothing]: + + val model = Var( + MyModel( + List( + NestedModel("a", 10), + NestedModel("b", 15), + NestedModel("c", 20) + ) + ) + ) + + def handleCommand(cmd: Nothing): Unit = () + + val el = div( idAttr := "42", cls := model(_.cls), ul( - model(_.elems).splitByIndex(elem => + model(_.elems).splitByIndex((_, elem) => li( "Nom: ", elem(_.name),