From bb062b967905c1952f2fef9b9347b775eb18b176 Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Sun, 17 Aug 2025 21:55:13 +0200 Subject: [PATCH] Complete redesign to allow assigns style state --- build.mill | 2 +- core/src/scalive/Diff.scala | 50 -- core/src/scalive/DiffBuilder.scala | 108 ++-- core/src/scalive/Fingerprint.scala | 53 ++ core/src/scalive/HtmlBuilder.scala | 24 +- core/src/scalive/HtmlElement.scala | 68 +++ core/src/scalive/LiveDyn.scala | 68 --- core/src/scalive/LiveState.scala | 31 ++ core/src/scalive/LiveView.scala | 114 +---- core/src/scalive/Rendered.scala | 122 +++++ core/src/scalive/Scalive.scala | 2 +- core/src/scalive/Socket.scala | 19 + core/src/scalive/View.scala | 66 --- core/src/scalive/main.scala | 138 +++-- core/test/src/scalive/LiveViewSpec.scala | 616 ++++++++++++----------- zio/src/scalive/Example.scala | 17 +- zio/src/scalive/RootLayout.scala | 13 + zio/src/scalive/ScaliveZio.scala | 30 ++ 18 files changed, 802 insertions(+), 739 deletions(-) delete mode 100644 core/src/scalive/Diff.scala create mode 100644 core/src/scalive/Fingerprint.scala create mode 100644 core/src/scalive/HtmlElement.scala delete mode 100644 core/src/scalive/LiveDyn.scala create mode 100644 core/src/scalive/LiveState.scala create mode 100644 core/src/scalive/Rendered.scala create mode 100644 core/src/scalive/Socket.scala delete mode 100644 core/src/scalive/View.scala create mode 100644 zio/src/scalive/RootLayout.scala create mode 100644 zio/src/scalive/ScaliveZio.scala diff --git a/build.mill b/build.mill index d53f335..2d5c4b6 100644 --- a/build.mill +++ b/build.mill @@ -7,7 +7,7 @@ import mill.api.Task.Simple trait Common extends ScalaModule: def scalaVersion = "3.7.2" - def scalacOptions = Seq("-Wunused:all") + def scalacOptions = Seq("-Wunused:all", "-preview", "-feature", "-language:implicitConversions") object core extends Common: def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44") diff --git a/core/src/scalive/Diff.scala b/core/src/scalive/Diff.scala deleted file mode 100644 index 037c22c..0000000 --- a/core/src/scalive/Diff.scala +++ /dev/null @@ -1,50 +0,0 @@ -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 Static(value: String) - case Dynamic(index: Int, 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.index.toString -> 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.index.toString -> toJson(d.diff))* - ) - ) - ) - ) - case Diff.Static(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 731aee8..9efd3f3 100644 --- a/core/src/scalive/DiffBuilder.scala +++ b/core/src/scalive/DiffBuilder.scala @@ -1,71 +1,53 @@ package scalive +import zio.Chunk +import zio.json.ast.Json + object DiffBuilder: - def build( - static: Seq[String], - dynamic: Seq[LiveDyn[?]], - includeUnchanged: Boolean = false - ): Diff.Tag = - Diff.Tag( - static = static, - dynamic = dynamic.zipWithIndex - .filter(includeUnchanged || _._1.wasUpdated) - .map { - case (v: LiveDyn.Value[?, ?], i) => - Diff.Dynamic(i, Diff.Static(v.currentValue.toString)) - case (v: LiveDyn.When[?], i) => build(v, i, includeUnchanged) - case (v: LiveDyn.Split[?, ?], i) => - Diff.Dynamic(i, build(v, includeUnchanged)) - } - ) - - private def build( - mod: LiveDyn.When[?], - index: Int, - includeUnchanged: Boolean - ): Diff.Dynamic = - if mod.displayed then - if includeUnchanged || mod.cond.wasUpdated then - Diff.Dynamic( - index, - build( - mod.nested.static, - mod.nested.dynamic, - includeUnchanged = true - ) - ) - else - Diff.Dynamic( - index, - build( - static = Seq.empty, - mod.nested.dynamic, - includeUnchanged - ) - ) - else Diff.Dynamic(index, Diff.Deleted) - - private def build( - mod: LiveDyn.Split[?, ?], - includeUnchanged: Boolean - ): Diff.Split = - Diff.Split( - static = if includeUnchanged then mod.static else Seq.empty, - entries = mod.dynamic.toList.zipWithIndex - .filter(includeUnchanged || _._1.exists(_.wasUpdated)) - .map[Diff.Dynamic]((mods, i) => - Diff.Dynamic( - i, - build( - static = Seq.empty, - dynamic = mods, - includeUnchanged - ) - ) + 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( - mod.removedIndexes - .map(i => Diff.Dynamic(i, Diff.Deleted)) + rendered.dynamic.zipWithIndex + .map((render, index) => index.toString -> build(render(true), nestedFingerprintIter)) + ).filterNot(_._2 == Json.Obj.empty) + ) + + 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(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/Fingerprint.scala b/core/src/scalive/Fingerprint.scala new file mode 100644 index 0000000..ac6b642 --- /dev/null +++ b/core/src/scalive/Fingerprint.scala @@ -0,0 +1,53 @@ +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.dummy))) + 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 b3db457..0e45c91 100644 --- a/core/src/scalive/HtmlBuilder.scala +++ b/core/src/scalive/HtmlBuilder.scala @@ -4,27 +4,25 @@ import java.io.StringWriter object HtmlBuilder: - def build(lv: LiveView[?]): String = + def build(rendered: Rendered, isRoot: Boolean = false): String = val strw = new StringWriter() - build(lv.static, lv.dynamic, strw) + if isRoot then strw.append("") + build(rendered.static, rendered.dynamic, strw) strw.toString() private def build( static: Seq[String], - dynamic: Seq[LiveDyn[?]], + dynamic: Seq[Boolean => RenderedDyn], strw: StringWriter ): Unit = for i <- dynamic.indices do strw.append(static(i)) - dynamic(i) match - case mod: LiveDyn.Value[?, ?] => - strw.append(mod.currentValue.toString) - case mod: LiveDyn.When[?] => build(mod, strw) - case mod: LiveDyn.Split[?, ?] => build(mod, strw) + 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) - private def build(mod: LiveDyn.When[?], strw: StringWriter): Unit = - if mod.displayed then build(mod.nested.static, mod.nested.dynamic, strw) - - private def build(mod: LiveDyn.Split[?, ?], strw: StringWriter): Unit = - mod.dynamic.foreach(entry => build(mod.static, entry, strw)) + private def build(comp: Comprehension, strw: StringWriter): Unit = + comp.entries.foreach(entry => build(comp.static, entry(false).map(d => _ => d), strw)) diff --git a/core/src/scalive/HtmlElement.scala b/core/src/scalive/HtmlElement.scala new file mode 100644 index 0000000..4c479b0 --- /dev/null +++ b/core/src/scalive/HtmlElement.scala @@ -0,0 +1,68 @@ +package scalive + +import scalive.codecs.BooleanAsAttrPresenceCodec +import scalive.codecs.Codec + +class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]): + lazy val static = Rendered.buildStatic(this) + +class HtmlTag(val name: String, val void: Boolean = false): + def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector) + +class HtmlAttr[V](val name: String, val codec: Codec[V, String]): + private inline def isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec + + def :=(value: V): Mod = + if isBooleanAsAttrPresence then + Mod.StaticAttrValueAsPresence( + this.asInstanceOf[HtmlAttr[Boolean]], + value.asInstanceOf[Boolean] + ) + else Mod.StaticAttr(this, codec.encode(value)) + + def :=(value: Dyn[V]): Mod = + if isBooleanAsAttrPresence then + Mod.DynAttrValueAsPresence( + this.asInstanceOf[HtmlAttr[Boolean]], + value.asInstanceOf[Dyn[Boolean]] + ) + else Mod.DynAttr(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) + +given [T]: Conversion[HtmlElement, Mod] = Mod.Tag(_) +given [T]: Conversion[String, Mod] = Mod.Text(_) +given [T]: Conversion[Dyn[String], Mod] = Mod.DynText(_) + +final case class Dyn[T](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 + + def map[T2](f2: T => T2): Dyn[T2] = Dyn(key, f.andThen(f2)) + + inline def apply[T2](f2: T => T2): Dyn[T2] = map(f2) + + 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 dummy[T] = Dyn(LiveState.Key[T], identity) diff --git a/core/src/scalive/LiveDyn.scala b/core/src/scalive/LiveDyn.scala deleted file mode 100644 index 07f9ea7..0000000 --- a/core/src/scalive/LiveDyn.scala +++ /dev/null @@ -1,68 +0,0 @@ -package scalive - -import scala.collection.immutable.ArraySeq -import scala.collection.mutable.ArrayBuffer - -sealed trait LiveDyn[Model]: - def update(model: Model): Unit - def wasUpdated: Boolean - -object LiveDyn: - - class Value[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false) extends LiveDyn[I]: - private var value: O = d.run(init) - private var updated: Boolean = startsUpdated - 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]( - dynCond: Dyn[Model, Boolean], - el: HtmlElement[Model], - init: Model) - extends LiveDyn[Model]: - val cond = LiveDyn.Value(dynCond, init) - val nested = LiveView.render(el, init) - def displayed: Boolean = cond.currentValue - def wasUpdated: Boolean = cond.wasUpdated || nested.wasUpdated - def update(model: Model): Unit = - cond.update(model) - nested.update(model) - - class Split[Model, Item]( - dynList: Dyn[Model, List[Item]], - project: Dyn[Item, Item] => HtmlElement[Item], - init: Model) - extends LiveDyn[Model]: - private val el = project(Dyn.id) - val static: ArraySeq[String] = LiveView.buildStatic(el) - val dynamic: ArrayBuffer[ArraySeq[LiveDyn[Item]]] = - dynList - .run(init) - .map(LiveView.buildDynamic(el, _).to(ArraySeq)) - .to(ArrayBuffer) - var removedIndexes: Seq[Int] = Seq.empty - - def wasUpdated: Boolean = - removedIndexes.nonEmpty || dynamic.exists(_.exists(_.wasUpdated)) - - def update(model: Model): Unit = - val items = dynList.run(model) - removedIndexes = - if items.size < dynamic.size then items.size until dynamic.size - else Seq.empty - dynamic.takeInPlace(items.size) - items.zipWithIndex.map((item, i) => - if i >= dynamic.size then - dynamic.append( - LiveView.buildDynamic(el, item, startsUpdated = true).to(ArraySeq) - ) - else dynamic(i).foreach(_.update(item)) - ) - end Split -end LiveDyn diff --git a/core/src/scalive/LiveState.scala b/core/src/scalive/LiveState.scala new file mode 100644 index 0000000..8907983 --- /dev/null +++ b/core/src/scalive/LiveState.scala @@ -0,0 +1,31 @@ +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(k: LiveState.Key, v: k.Type): LiveState = + copy(data = data.updated(k, LiveState.Entry(true, v))) + def apply(k: LiveState.Key): LiveState.Entry[k.Type] = get(k).get + 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 id: Dyn[Type] = Dyn(this, identity) + def apply[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 b27b38c..fa6c9a0 100644 --- a/core/src/scalive/LiveView.scala +++ b/core/src/scalive/LiveView.scala @@ -1,114 +1,4 @@ package scalive -import scala.annotation.nowarn -import scala.collection.immutable.ArraySeq -import scala.collection.mutable.ListBuffer -import scalive.codecs.BooleanAsAttrPresenceCodec - -class LiveView[Model] private ( - val static: ArraySeq[String], - val dynamic: ArraySeq[LiveDyn[Model]]): - assert( - static.size == dynamic.size + 1, - s"Static size : ${static.size}, Dynamic size : ${dynamic.size}" - ) - def update(model: Model): Unit = - dynamic.foreach(_.update(model)) - - def wasUpdated: Boolean = dynamic.exists(_.wasUpdated) - - def fullDiff: Diff = - DiffBuilder.build(static, dynamic, includeUnchanged = true) - - def diff: Diff = - DiffBuilder.build(static = Seq.empty, dynamic) - -object LiveView: - - inline def apply[Model]( - lv: View[Model], - model: Model - ): LiveView[Model] = - render(lv.root, model) - - def render[Model]( - tag: HtmlElement[Model], - model: Model - ): LiveView[Model] = - new LiveView(buildStatic(tag), buildDynamic(tag, model).to(ArraySeq)) - - def buildStatic[Model](el: HtmlElement[Model]): ArraySeq[String] = - buildStaticFragments(el).flatten.to(ArraySeq) - - private def buildStaticFragments[Model]( - el: HtmlElement[Model] - ): 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[Model]( - el: HtmlElement[Model] - ): (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[Model](_) => Right(List(None)) - case Mod.When[Model](_, _) => Right(List(None)) - case Mod.Split[Model, Any](_, _) => Right(List(None)) - } - (attrs.flatten, children.flatten) - - @nowarn("cat=unchecked") - def buildDynamic[Model]( - el: HtmlElement[Model], - model: Model, - startsUpdated: Boolean = false - ): Seq[LiveDyn[Model]] = - 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.DynAttr(attr, value) => - Right(List(LiveDyn.Value(value(attr.codec.encode), model, startsUpdated))) - case Mod.DynAttrValueAsPresence(attr, value) => - Right(List(LiveDyn.Value(value(if _ then s" ${attr.name}" else ""), model, startsUpdated))) - case Mod.Tag(el) => - Right(buildDynamic(el, model, startsUpdated)) - case Mod.DynText[Model](dynText) => - Right(List(LiveDyn.Value(dynText, model, startsUpdated))) - case Mod.When[Model](dynCond, el) => - Right(List(LiveDyn.When(dynCond, el, model))) - case Mod.Split[Model, Any](dynList, project) => - Right(List(LiveDyn.Split(dynList, project, model))) - } - attrs.flatten ++ children.flatten -end LiveView +trait LiveView: + def render: HtmlElement diff --git a/core/src/scalive/Rendered.scala b/core/src/scalive/Rendered.scala new file mode 100644 index 0000000..0bf7a2a --- /dev/null +++ b/core/src/scalive/Rendered.scala @@ -0,0 +1,122 @@ +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.dummy) + Comprehension( + Fingerprint.apply(el), + el.static, + items.map(item => + val localKey = LiveState.Key[Any] + val localState = LiveState.empty.set(localKey, item) + val localElem = mod.project(localKey.id) + 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 630225c..4513774 100644 --- a/core/src/scalive/Scalive.scala +++ b/core/src/scalive/Scalive.scala @@ -1,8 +1,8 @@ package scalive -import scalive.defs.tags.HtmlTags import scalive.defs.attrs.HtmlAttrs import scalive.defs.complex.ComplexHtmlKeys +import scalive.defs.tags.HtmlTags object Scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys diff --git a/core/src/scalive/Socket.scala b/core/src/scalive/Socket.scala new file mode 100644 index 0000000..7bce940 --- /dev/null +++ b/core/src/scalive/Socket.scala @@ -0,0 +1,19 @@ +package scalive + +import zio.json.* + +final case class Socket(view: LiveView, initState: LiveState): + private var state: LiveState = initState + private var fingerprint: Fingerprint = Fingerprint.empty + val id: String = "scl-123" + + def setState(newState: LiveState): Unit = state = newState + def updateState(f: LiveState => LiveState): Unit = state = f(state) + def renderHtml: String = + HtmlBuilder.build(Rendered.render(view.render, state), isRoot = true) + def syncClient: Unit = + val r = Rendered.render(view.render, state) + println(DiffBuilder.build(r, fingerprint).toJsonPretty) + fingerprint = Fingerprint(r) + println(fingerprint) + state = state.setAllUnchanged diff --git a/core/src/scalive/View.scala b/core/src/scalive/View.scala deleted file mode 100644 index 0e0ada2..0000000 --- a/core/src/scalive/View.scala +++ /dev/null @@ -1,66 +0,0 @@ -package scalive - -import scalive.codecs.Codec -import scalive.codecs.BooleanAsAttrPresenceCodec - -trait View[Model]: - val model: Dyn[Model, Model] = Dyn.id - def root: HtmlElement[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)(el: HtmlElement[I]): Mod.When[I] = - Mod.When(d.andThen(f), el) - - inline def whenNot(f: O => Boolean)(el: HtmlElement[I]): Mod.When[I] = - when(f.andThen(!_))(el) - - def splitByIndex[O2]( - f: O => List[O2] - )( - project: Dyn[O2, O2] => HtmlElement[O2] - ): Mod.Split[I, O2] = - Mod.Split(d.andThen(f), project) - - def run(v: I): O = d(v) - -object Dyn: - def id[T]: Dyn[T, T] = identity - -enum Mod[T]: - case StaticAttr(attr: HtmlAttr[?], value: String) - case StaticAttrValueAsPresence(attr: HtmlAttr[?], value: Boolean) - case DynAttr[T, V](attr: HtmlAttr[V], value: Dyn[T, V]) extends Mod[T] - case DynAttrValueAsPresence[T, V](attr: HtmlAttr[V], value: Dyn[T, Boolean]) extends Mod[T] - case Tag(el: HtmlElement[T]) - case Text(text: String) - case DynText(dynText: Dyn[T, String]) - case When(dynCond: Dyn[T, Boolean], el: HtmlElement[T]) - case Split[T, O]( - dynList: Dyn[T, List[O]], - project: Dyn[O, O] => HtmlElement[O]) extends Mod[T] - -given [T]: Conversion[HtmlElement[T], Mod[T]] = Mod.Tag(_) -given [T]: Conversion[String, Mod[T]] = Mod.Text(_) -given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_) - -class HtmlTag(val name: String, val void: Boolean = false): - def apply[Model](mods: Mod[Model]*): HtmlElement[Model] = - HtmlElement(this, mods.toVector) - -class HtmlElement[Model](val tag: HtmlTag, val mods: Vector[Mod[Model]]) - -class HtmlAttr[V](val name: String, val codec: Codec[V, String]): - val isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec - def :=[T](value: V): Mod[T] = - val stringValue = codec.encode(value) - if isBooleanAsAttrPresence then - Mod.StaticAttrValueAsPresence(this, BooleanAsAttrPresenceCodec.decode(stringValue)) - else Mod.StaticAttr(this, stringValue) - def :=[T](value: Dyn[T, V]): Mod[T] = - val stringDyn = value(codec.encode) - if isBooleanAsAttrPresence then - Mod.DynAttrValueAsPresence(this, stringDyn(BooleanAsAttrPresenceCodec.decode)) - else Mod.DynAttr(this, value) diff --git a/core/src/scalive/main.scala b/core/src/scalive/main.scala index 8dc0451..3b7fa79 100644 --- a/core/src/scalive/main.scala +++ b/core/src/scalive/main.scala @@ -1,12 +1,11 @@ package scalive -import zio.json.* - @main def main = - val lv = - LiveView( - TestView, + val s = Socket( + TestView, + LiveState.empty.set( + TestView.model, MyModel( List( NestedModel("a", 10), @@ -15,69 +14,104 @@ def main = ) ) ) - println(lv.fullDiff.toJsonPretty) - println(HtmlBuilder.build(lv)) + ) + println("Init") + println(s.renderHtml) + s.syncClient + s.syncClient println("Edit first and last") - lv.update( - MyModel( - List( - NestedModel("x", 10), - NestedModel("b", 15), - NestedModel("c", 99) + s.updateState( + _.set( + TestView.model, + MyModel( + List( + NestedModel("x", 10), + NestedModel("b", 15), + NestedModel("c", 99) + ) ) ) ) - println(lv.diff.toJsonPretty) - println(HtmlBuilder.build(lv)) - - println("Add one") - lv.update( - MyModel( - List( - NestedModel("x", 10), - NestedModel("b", 15), - NestedModel("c", 99), - NestedModel("d", 35) - ) - ) - ) - println(lv.diff.toJsonPretty) - println(HtmlBuilder.build(lv)) - - 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)) + s.syncClient + // val lv = + // LiveView( + // TestView, + // LiveState.empty.set( + // TestView.model, + // MyModel( + // List( + // NestedModel("a", 10), + // NestedModel("b", 15), + // NestedModel("c", 20) + // ) + // ) + // ) + // ) + // println(lv.fullDiff.toJsonPretty) + // println(HtmlBuilder.build(lv)) + // + // println("Edit first and last") + // lv.update( + // MyModel( + // List( + // NestedModel("x", 10), + // NestedModel("b", 15), + // NestedModel("c", 99) + // ) + // ) + // ) + // println(lv.diff.toJsonPretty) + // println(HtmlBuilder.build(lv)) + // + // println("Add one") + // lv.update( + // MyModel( + // List( + // NestedModel("x", 10), + // NestedModel("b", 15), + // NestedModel("c", 99), + // NestedModel("d", 35) + // ) + // ) + // ) + // println(lv.diff.toJsonPretty) + // println(HtmlBuilder.build(lv)) + // + // 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)) end main final case class MyModel(elems: List[NestedModel], cls: String = "text-xs", bool: Boolean = true) final case class NestedModel(name: String, age: Int) -object TestView extends View[MyModel]: - val root: HtmlElement[MyModel] = +object TestView extends LiveView: + val model = LiveState.Key[MyModel] + val render = div( idAttr := "42", cls := model(_.cls), draggable := model(_.bool), disabled := model(_.bool), ul( - model.splitByIndex(_.elems)(elem => + model(_.elems).splitByIndex(elem => li( "Nom: ", elem(_.name), diff --git a/core/test/src/scalive/LiveViewSpec.scala b/core/test/src/scalive/LiveViewSpec.scala index a96f45e..ae8c76c 100644 --- a/core/test/src/scalive/LiveViewSpec.scala +++ b/core/test/src/scalive/LiveViewSpec.scala @@ -14,316 +14,320 @@ object LiveViewSpec extends TestSuite: items: List[NestedModel] = List.empty) final case class NestedModel(name: String, age: Int) - def assertEqualsJson(actual: Diff, expected: Json) = - assert(actual.toJsonPretty == expected.toJsonPretty) + // def assertEqualsJson(actual: Diff, expected: Json) = + // assert(actual.toJsonPretty == expected.toJsonPretty) val emptyDiff = Json.Obj.empty val tests = Tests { - test("Static only") { - val lv = - LiveView( - new View[Unit]: - val root: HtmlElement[Unit] = - 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[TestModel]: - val root: HtmlElement[TestModel] = - 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[TestModel]: - val root: HtmlElement[TestModel] = - 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[TestModel]: - val root: HtmlElement[TestModel] = - 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[TestModel]: - val root: HtmlElement[TestModel] = - 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 = + // 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) + // ) + // ) + // ) + // ) + // + // } + // } } end LiveViewSpec diff --git a/zio/src/scalive/Example.scala b/zio/src/scalive/Example.scala index 7aa2937..020c462 100644 --- a/zio/src/scalive/Example.scala +++ b/zio/src/scalive/Example.scala @@ -9,9 +9,10 @@ import zio.http.template.Html object Example extends ZIOAppDefault: - val lv = - LiveView( - TestView, + val s = Socket( + TestView, + LiveState.empty.set( + TestView.model, MyModel( List( NestedModel("a", 10), @@ -20,6 +21,7 @@ object Example extends ZIOAppDefault: ) ) ) + ) val socketApp: WebSocketApp[Any] = Handler.webSocket { channel => @@ -66,7 +68,7 @@ object Example extends ZIOAppDefault: val routes: Routes[Any, Response] = Routes( Method.GET / "" -> handler { (_: Request) => - Response.html(Html.raw(HtmlBuilder.build(lv))) + Response.html(Html.raw(s.renderHtml)) }, Method.GET / "live" / "ws" -> handler(socketApp.toResponse) ) @@ -77,13 +79,14 @@ end Example final case class MyModel(elems: List[NestedModel], cls: String = "text-xs") final case class NestedModel(name: String, age: Int) -object TestView extends View[MyModel]: - val root: HtmlElement[MyModel] = +object TestView extends LiveView: + val model = LiveState.Key[MyModel] + val render = div( idAttr := "42", cls := model(_.cls), ul( - model.splitByIndex(_.elems)(elem => + model(_.elems).splitByIndex(elem => li( "Nom: ", elem(_.name), diff --git a/zio/src/scalive/RootLayout.scala b/zio/src/scalive/RootLayout.scala new file mode 100644 index 0000000..3675f7a --- /dev/null +++ b/zio/src/scalive/RootLayout.scala @@ -0,0 +1,13 @@ +package scalive + +import scalive.HtmlElement + +object RootLayout: + def apply[RootModel](content: HtmlElement): HtmlElement = + htmlRootTag( + lang := "en", + metaTag(charset := "utf-8"), + bodyTag( + // content + ) + ) diff --git a/zio/src/scalive/ScaliveZio.scala b/zio/src/scalive/ScaliveZio.scala new file mode 100644 index 0000000..41190dd --- /dev/null +++ b/zio/src/scalive/ScaliveZio.scala @@ -0,0 +1,30 @@ +package scalive + +import zio.http.Response +import zio.http.template.Html + +// trait LiveRouter: +// type RootModel +// private lazy val viewsMap: Map[String, View] = views.map(r => (r.name, r.view)).toMap +// def rootLayout: HtmlElement[RootModel] +// def views: Seq[LiveRoute] +// +// final case class LiveRoute(name: String, view: View) + +object ZioLiveApp: +// 1 Request to live route +// 2 Create live view with stateless token containing user id if connected, http params, live view id +// 3 Response with HTML and token +// 4 Websocket connection with token +// 5 Recreate exact same liveview as before using token data + + // val testRoute = LiveRoute("test", TestView) + // val router = new LiveRouter: + // val rootLayout = htmlRootTag() + // val views = Seq(testRoute) + + // def htmlRender(v: View, model: v.Model) = + // val lv = LiveView(v, model) + // Response.html(Html.raw(HtmlBuilder.build(lv, isRoot = true))) + + private val socketApp = ???