mirror of
https://github.com/phfroidmont/scalive.git
synced 2025-12-25 05:26:59 +01:00
Rework diff aware state management allowing an API similar to Laminar
This commit is contained in:
parent
8f19ccfbb4
commit
cff02a4c96
17 changed files with 857 additions and 710 deletions
|
|
@ -7,7 +7,13 @@ import mill.api.Task.Simple
|
||||||
|
|
||||||
trait Common extends ScalaModule:
|
trait Common extends ScalaModule:
|
||||||
def scalaVersion = "3.7.2"
|
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:
|
object core extends Common:
|
||||||
def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44")
|
def mvnDeps = Seq(mvn"dev.zio::zio-json:0.7.44")
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,27 @@
|
||||||
import scalive.*
|
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)
|
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.*
|
import TestView.Cmd.*
|
||||||
|
|
||||||
private val textCls = Dyn[String]
|
private val modelVar = Var[MyModel](initialModel)
|
||||||
private val someBool = Dyn[Boolean]
|
|
||||||
private val elems = Dyn[List[Elem]]
|
|
||||||
|
|
||||||
def mount(state: LiveState): LiveState =
|
def handleCommand(cmd: TestView.Cmd): Unit =
|
||||||
state
|
cmd match
|
||||||
.set(textCls, "text-xs")
|
case UpdateModel(f) => modelVar.update(f)
|
||||||
.set(someBool, true)
|
|
||||||
.set(
|
|
||||||
elems,
|
|
||||||
List(
|
|
||||||
Elem("a", 10),
|
|
||||||
Elem("b", 15),
|
|
||||||
Elem("c", 20)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def handleCommand(cmd: TestView.Cmd, state: LiveState): LiveState = cmd match
|
val el: HtmlElement =
|
||||||
case UpdateElems(es) => state.set(elems, es)
|
|
||||||
case UpdateBool(b) => state.set(someBool, b)
|
|
||||||
case UpdateTextCls(cls) => state.set(textCls, cls)
|
|
||||||
|
|
||||||
val render =
|
|
||||||
div(
|
div(
|
||||||
idAttr := "42",
|
idAttr := "42",
|
||||||
cls := textCls,
|
cls := modelVar(_.cls),
|
||||||
draggable := someBool,
|
disabled := modelVar(_.bool),
|
||||||
disabled := someBool,
|
|
||||||
ul(
|
ul(
|
||||||
elems.splitByIndex(elem =>
|
modelVar(_.elems).splitByIndex((_, elem) =>
|
||||||
li(
|
li(
|
||||||
"Nom: ",
|
"Nom: ",
|
||||||
elem(_.name),
|
elem(_.name),
|
||||||
|
|
@ -45,10 +31,7 @@ class TestView extends LiveView[TestView.Cmd]:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end TestView
|
|
||||||
|
|
||||||
object TestView:
|
object TestView:
|
||||||
enum Cmd:
|
enum Cmd:
|
||||||
case UpdateElems(es: List[Elem])
|
case UpdateModel(f: MyModel => MyModel)
|
||||||
case UpdateBool(b: Boolean)
|
|
||||||
case UpdateTextCls(cls: String)
|
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,29 @@ import scalive.*
|
||||||
|
|
||||||
@main
|
@main
|
||||||
def 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("Init")
|
||||||
println(s.renderHtml)
|
println(s.renderHtml)
|
||||||
s.syncClient
|
s.syncClient
|
||||||
s.syncClient
|
s.syncClient
|
||||||
|
|
||||||
println("Edit first and last")
|
println("Edit class attribue")
|
||||||
s.receiveCommand(
|
s.receiveCommand(
|
||||||
TestView.Cmd.UpdateTextCls("text-lg")
|
TestView.Cmd.UpdateModel(_.copy(cls = "text-lg"))
|
||||||
)
|
)
|
||||||
s.syncClient
|
s.syncClient
|
||||||
|
|
||||||
println("Edit first and last")
|
println("Edit first and last")
|
||||||
s.receiveCommand(
|
s.receiveCommand(
|
||||||
TestView.Cmd.UpdateElems(
|
TestView.Cmd.UpdateModel(
|
||||||
|
_.copy(elems =
|
||||||
List(
|
List(
|
||||||
Elem("x", 10),
|
Elem("x", 10),
|
||||||
Elem("b", 15),
|
Elem("b", 15),
|
||||||
|
|
@ -24,11 +32,14 @@ def main =
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
s.syncClient
|
s.syncClient
|
||||||
|
println(s.renderHtml)
|
||||||
|
|
||||||
println("Add one")
|
println("Add one")
|
||||||
s.receiveCommand(
|
s.receiveCommand(
|
||||||
TestView.Cmd.UpdateElems(
|
TestView.Cmd.UpdateModel(
|
||||||
|
_.copy(elems =
|
||||||
List(
|
List(
|
||||||
Elem("x", 10),
|
Elem("x", 10),
|
||||||
Elem("b", 15),
|
Elem("b", 15),
|
||||||
|
|
@ -37,26 +48,36 @@ def main =
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
s.syncClient
|
s.syncClient
|
||||||
|
println(s.renderHtml)
|
||||||
|
|
||||||
//
|
println("Remove first")
|
||||||
// println("Remove first")
|
s.receiveCommand(
|
||||||
// lv.update(
|
TestView.Cmd.UpdateModel(
|
||||||
// MyModel(
|
_.copy(elems =
|
||||||
// List(
|
List(
|
||||||
// NestedModel("b", 15),
|
Elem("b", 15),
|
||||||
// NestedModel("c", 99),
|
Elem("c", 99),
|
||||||
// NestedModel("d", 35)
|
Elem("d", 35)
|
||||||
// )
|
)
|
||||||
// )
|
)
|
||||||
// )
|
)
|
||||||
// println(lv.diff.toJsonPretty)
|
)
|
||||||
// println(HtmlBuilder.build(lv))
|
s.syncClient
|
||||||
//
|
println(s.renderHtml)
|
||||||
// println("Remove all")
|
|
||||||
// lv.update(
|
println("Remove all")
|
||||||
// MyModel(List.empty, "text-lg", bool = false)
|
s.receiveCommand(
|
||||||
// )
|
TestView.Cmd.UpdateModel(
|
||||||
// println(lv.diff.toJsonPretty)
|
_.copy(
|
||||||
// println(HtmlBuilder.build(lv))
|
cls = "text-lg",
|
||||||
|
bool = false,
|
||||||
|
elems = List.empty
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.syncClient
|
||||||
|
s.syncClient
|
||||||
|
println(s.renderHtml)
|
||||||
end main
|
end main
|
||||||
|
|
|
||||||
50
core/src/scalive/Diff.scala
Normal file
50
core/src/scalive/Diff.scala
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,53 +1,62 @@
|
||||||
package scalive
|
package scalive
|
||||||
|
|
||||||
import zio.Chunk
|
import scalive.Mod.Attr
|
||||||
import zio.json.ast.Json
|
import scalive.Mod.Content
|
||||||
|
|
||||||
object DiffBuilder:
|
object DiffBuilder:
|
||||||
def build(rendered: Rendered, fingerprint: Fingerprint): Json =
|
def build(el: HtmlElement, trackUpdates: Boolean = true): Diff =
|
||||||
val nestedFingerprintIter = fingerprint.nested.iterator
|
build(
|
||||||
Json.Obj(
|
static = if trackUpdates then Seq.empty else el.static,
|
||||||
Option
|
dynamicMods = el.dynamicMods,
|
||||||
.when(rendered.static.nonEmpty && fingerprint.value != rendered.fingerprint)(
|
trackUpdates = trackUpdates
|
||||||
"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)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private def build(comp: Comprehension, fingerprint: Fingerprint): Json =
|
private def build(static: Seq[String], dynamicMods: Seq[DynamicMod], trackUpdates: Boolean)
|
||||||
val nestedFingerprintIter = fingerprint.nested.iterator
|
: Diff =
|
||||||
Json.Obj(
|
Diff.Tag(
|
||||||
Option
|
static = static,
|
||||||
.when(comp.static.nonEmpty && fingerprint.value != comp.fingerprint)(
|
dynamic =
|
||||||
"s" -> Json.Arr(comp.static.map(Json.Str(_))*)
|
buildDynamic(dynamicMods, trackUpdates).zipWithIndex.collect { case (Some(diff), index) =>
|
||||||
)
|
Diff.Dynamic(index.toString, diff)
|
||||||
.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 =
|
private def buildDynamic(dynamicMods: Seq[DynamicMod], trackUpdates: Boolean): Seq[Option[Diff]] =
|
||||||
dyn match
|
dynamicMods.flatMap {
|
||||||
case Some(s: String) => Json.Str(s)
|
case Attr.Dyn(attr, value) =>
|
||||||
case Some(r: Rendered) => build(r, fingerprintIter.nextOption.getOrElse(Fingerprint.empty))
|
List(value.render(trackUpdates).map(v => Diff.Value(v.toString)))
|
||||||
case Some(c: Comprehension) =>
|
case Attr.DynValueAsPresence(attr, value) =>
|
||||||
build(c, fingerprintIter.nextOption.getOrElse(Fingerprint.empty))
|
List(value.render(trackUpdates).map(v => Diff.Value(if v then s" ${attr.name}" else "")))
|
||||||
case None => Json.Obj.empty
|
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)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
end DiffBuilder
|
end DiffBuilder
|
||||||
|
|
|
||||||
136
core/src/scalive/Dyn.scala
Normal file
136
core/src/scalive/Dyn.scala
Normal file
|
|
@ -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
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,28 +1,45 @@
|
||||||
package scalive
|
package scalive
|
||||||
|
|
||||||
import java.io.StringWriter
|
import java.io.StringWriter
|
||||||
|
import scalive.Mod.Attr
|
||||||
|
import scalive.Mod.Content
|
||||||
|
|
||||||
object HtmlBuilder:
|
object HtmlBuilder:
|
||||||
|
|
||||||
def build(rendered: Rendered, isRoot: Boolean = false): String =
|
def build(el: HtmlElement, isRoot: Boolean = false): String =
|
||||||
val strw = new StringWriter()
|
val strw = new StringWriter()
|
||||||
if isRoot then strw.append("<!doctype html>")
|
if isRoot then strw.write("<!doctype html>")
|
||||||
build(rendered.static, rendered.dynamic, strw)
|
build(el.static, el.dynamicMods, strw)
|
||||||
strw.toString()
|
strw.toString()
|
||||||
|
|
||||||
private def build(
|
private def build(
|
||||||
static: Seq[String],
|
static: Seq[String],
|
||||||
dynamic: Seq[Boolean => RenderedDyn],
|
dynamic: Seq[(Mod.Attr | Mod.Content) & DynamicMod],
|
||||||
strw: StringWriter
|
strw: StringWriter
|
||||||
): Unit =
|
): Unit =
|
||||||
for i <- dynamic.indices do
|
for i <- dynamic.indices do
|
||||||
strw.append(static(i))
|
strw.write(static(i))
|
||||||
dynamic(i)(false).foreach {
|
dynamic(i) match
|
||||||
case s: String => strw.append(s)
|
case Attr.Dyn(attr, value) =>
|
||||||
case r: Rendered => build(r)
|
strw.write(value.render(false).map(attr.codec.encode).getOrElse(""))
|
||||||
case c: Comprehension => build(c, strw)
|
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.append(static.last)
|
strw.write(static.last)
|
||||||
|
|
||||||
private def build(comp: Comprehension, strw: StringWriter): Unit =
|
end HtmlBuilder
|
||||||
comp.entries.foreach(entry => build(comp.static, entry(false).map(d => _ => d), strw))
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,27 @@ package scalive
|
||||||
|
|
||||||
import scalive.codecs.BooleanAsAttrPresenceCodec
|
import scalive.codecs.BooleanAsAttrPresenceCodec
|
||||||
import scalive.codecs.Codec
|
import scalive.codecs.Codec
|
||||||
|
import scalive.Mod.Attr
|
||||||
|
import scalive.Mod.Content
|
||||||
|
|
||||||
class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
|
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):
|
class HtmlTag(val name: String, val void: Boolean = false):
|
||||||
def apply(mods: Mod*): HtmlElement = HtmlElement(this, mods.toVector)
|
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]):
|
class HtmlAttr[V](val name: String, val codec: Codec[V, String]):
|
||||||
private inline def isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec
|
private inline def isBooleanAsAttrPresence = codec == BooleanAsAttrPresenceCodec
|
||||||
|
|
||||||
def :=(value: V): Mod =
|
def :=(value: V): Mod.Attr =
|
||||||
if isBooleanAsAttrPresence then
|
if isBooleanAsAttrPresence then
|
||||||
Mod.StaticAttrValueAsPresence(
|
Mod.Attr.StaticValueAsPresence(
|
||||||
this.asInstanceOf[HtmlAttr[Boolean]],
|
this.asInstanceOf[HtmlAttr[Boolean]],
|
||||||
value.asInstanceOf[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
|
if isBooleanAsAttrPresence then
|
||||||
Mod.DynAttrValueAsPresence(
|
Mod.Attr.DynValueAsPresence(
|
||||||
this.asInstanceOf[HtmlAttr[Boolean]],
|
this.asInstanceOf[HtmlAttr[Boolean]],
|
||||||
value.asInstanceOf[Dyn[Boolean]]
|
value.asInstanceOf[Dyn[Boolean]]
|
||||||
)
|
)
|
||||||
else Mod.DynAttr(this, value)
|
else Mod.Attr.Dyn(this, value)
|
||||||
|
|
||||||
enum Mod:
|
sealed trait Mod
|
||||||
case Text(text: String)
|
sealed trait StaticMod extends Mod
|
||||||
case StaticAttr(attr: HtmlAttr[?], value: String)
|
sealed trait DynamicMod extends Mod
|
||||||
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)
|
|
||||||
|
|
||||||
final case class Dyn[T] private[scalive] (key: LiveState.Key, f: key.Type => T):
|
object Mod:
|
||||||
def render(state: LiveState, trackUpdates: Boolean): Option[T] =
|
enum Attr extends Mod:
|
||||||
val entry = state(key)
|
case Static(attr: HtmlAttr[?], value: String) extends Attr with StaticMod
|
||||||
if !trackUpdates | entry.changed then Some(f(entry.value))
|
case StaticValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean) extends Attr with StaticMod
|
||||||
else None
|
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)
|
private[scalive] def syncAll(): Unit =
|
||||||
|
mod match
|
||||||
inline def whenNot(f2: T => Boolean)(el: HtmlElement): Mod.When =
|
case Attr.Static(_, _) => ()
|
||||||
when(f2.andThen(!_))(el)
|
case Attr.StaticValueAsPresence(_, _) => ()
|
||||||
|
case Attr.Dyn(_, value) => value.sync()
|
||||||
extension [T](dyn: Dyn[List[T]])
|
case Attr.DynValueAsPresence(attr, value) => value.sync()
|
||||||
def splitByIndex(project: Dyn[T] => HtmlElement): Mod.Split[T] =
|
case Content.Text(text) => ()
|
||||||
Mod.Split(dyn, project)
|
case Content.Tag(el) => el.syncAll()
|
||||||
|
case Content.DynText(dyn) => dyn.sync()
|
||||||
object Dyn:
|
case Content.DynElement(dyn) =>
|
||||||
def apply[T]: Dyn[T] = Dyn(LiveState.Key[T], identity)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package scalive
|
package scalive
|
||||||
|
|
||||||
trait LiveView[Cmd]:
|
trait LiveView[Cmd]:
|
||||||
def mount(state: LiveState): LiveState
|
def handleCommand(cmd: Cmd): Unit
|
||||||
def handleCommand(cmd: Cmd, state: LiveState): LiveState
|
val el: HtmlElement
|
||||||
def render: HtmlElement
|
|
||||||
|
|
|
||||||
|
|
@ -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"</${el.tag.name}>")
|
|
||||||
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
|
|
||||||
|
|
@ -3,6 +3,6 @@ import scalive.defs.complex.ComplexHtmlKeys
|
||||||
import scalive.defs.tags.HtmlTags
|
import scalive.defs.tags.HtmlTags
|
||||||
|
|
||||||
package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys:
|
package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys:
|
||||||
implicit def stringToMod(v: String): Mod = Mod.Text(v)
|
implicit def stringToMod(v: String): Mod = Mod.Content.Text(v)
|
||||||
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Tag(el)
|
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el)
|
||||||
implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.DynText(d)
|
implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.Content.DynText(d)
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,21 @@ package scalive
|
||||||
import zio.json.*
|
import zio.json.*
|
||||||
|
|
||||||
final case class Socket[Cmd](lv: LiveView[Cmd]):
|
final case class Socket[Cmd](lv: LiveView[Cmd]):
|
||||||
private var state: LiveState = lv.mount(LiveState.empty)
|
|
||||||
private var fingerprint: Fingerprint = Fingerprint.empty
|
lv.el.syncAll()
|
||||||
|
|
||||||
|
private var clientInitialized = false
|
||||||
val id: String = "scl-123"
|
val id: String = "scl-123"
|
||||||
|
|
||||||
def receiveCommand(cmd: Cmd): Unit =
|
def receiveCommand(cmd: Cmd): Unit =
|
||||||
state = lv.handleCommand(cmd, state)
|
lv.handleCommand(cmd)
|
||||||
|
|
||||||
def renderHtml: String =
|
def renderHtml: String =
|
||||||
HtmlBuilder.build(Rendered.render(lv.render, state), isRoot = true)
|
lv.el.syncAll()
|
||||||
|
HtmlBuilder.build(lv.el, isRoot = true)
|
||||||
|
|
||||||
def syncClient: Unit =
|
def syncClient: Unit =
|
||||||
val r = Rendered.render(lv.render, state)
|
lv.el.syncAll()
|
||||||
println(DiffBuilder.build(r, fingerprint).toJsonPretty)
|
println(DiffBuilder.build(lv.el, trackUpdates = clientInitialized).toJsonPretty)
|
||||||
fingerprint = Fingerprint(r)
|
clientInitialized = true
|
||||||
state = state.setAllUnchanged
|
lv.el.setAllUnchanged()
|
||||||
|
|
|
||||||
53
core/src/scalive/StaticBuilder.scala
Normal file
53
core/src/scalive/StaticBuilder.scala
Normal file
|
|
@ -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"</${el.tag.name}>")
|
||||||
|
static.append(Some(staticFragment))
|
||||||
|
static.toSeq
|
||||||
|
end buildStaticFragments
|
||||||
|
|
||||||
|
end StaticBuilder
|
||||||
|
|
@ -8,326 +8,352 @@ object LiveViewSpec extends TestSuite:
|
||||||
|
|
||||||
final case class TestModel(
|
final case class TestModel(
|
||||||
title: String = "title value",
|
title: String = "title value",
|
||||||
|
otherString: String = "other string value",
|
||||||
bool: Boolean = false,
|
bool: Boolean = false,
|
||||||
nestedTitle: String = "nested title value",
|
nestedTitle: String = "nested title value",
|
||||||
cls: String = "text-sm",
|
cls: String = "text-sm",
|
||||||
items: List[NestedModel] = List.empty)
|
items: List[NestedModel] = List.empty)
|
||||||
final case class NestedModel(name: String, age: Int)
|
final case class NestedModel(name: String, age: Int)
|
||||||
|
final case class UpdateCmd(f: TestModel => TestModel)
|
||||||
|
|
||||||
// def assertEqualsJson(actual: Diff, expected: Json) =
|
def assertEqualsDiff(el: HtmlElement, expected: Json, trackChanges: Boolean = true) =
|
||||||
// assert(actual.toJsonPretty == expected.toJsonPretty)
|
el.syncAll()
|
||||||
|
val actual = DiffBuilder.build(el, trackUpdates = trackChanges)
|
||||||
|
assert(actual.toJsonPretty == expected.toJsonPretty)
|
||||||
|
|
||||||
val emptyDiff = Json.Obj.empty
|
val emptyDiff = Json.Obj.empty
|
||||||
|
|
||||||
val tests = Tests {
|
val tests = Tests {
|
||||||
|
|
||||||
// test("Static only") {
|
test("Static only") {
|
||||||
// val lv =
|
val lv =
|
||||||
// LiveView(
|
new LiveView[Unit]:
|
||||||
// new View:
|
val el = div("Static string")
|
||||||
// type Model = Unit
|
def handleCommand(cmd: Unit): Unit = ()
|
||||||
// val render = div("Static string")
|
lv.el.syncAll()
|
||||||
// ,
|
|
||||||
// ()
|
test("init") {
|
||||||
// )
|
assertEqualsDiff(
|
||||||
// test("init") {
|
lv.el,
|
||||||
// assertEqualsJson(
|
Json.Obj(
|
||||||
// lv.fullDiff,
|
"s" -> Json.Arr(Json.Str("<div>Static string</div>"))
|
||||||
// Json.Obj(
|
),
|
||||||
// "s" -> Json.Arr(Json.Str("<div>Static string</div>"))
|
trackChanges = false
|
||||||
// )
|
)
|
||||||
// )
|
}
|
||||||
// }
|
test("diff") {
|
||||||
// test("diff") {
|
assertEqualsDiff(lv.el, emptyDiff)
|
||||||
// assertEqualsJson(lv.diff, emptyDiff)
|
}
|
||||||
// }
|
}
|
||||||
// }
|
|
||||||
//
|
test("Dynamic string") {
|
||||||
// test("Dynamic string") {
|
val lv =
|
||||||
// val lv =
|
new LiveView[UpdateCmd]:
|
||||||
// LiveView(
|
val model = Var(TestModel())
|
||||||
// new View:
|
val el =
|
||||||
// type Model = TestModel
|
div(
|
||||||
// val render =
|
h1(model(_.title)),
|
||||||
// div(model(_.title))
|
p(model(_.otherString))
|
||||||
// ,
|
)
|
||||||
// TestModel()
|
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
|
||||||
// )
|
|
||||||
// test("init") {
|
lv.el.syncAll()
|
||||||
// assertEqualsJson(
|
lv.el.setAllUnchanged()
|
||||||
// lv.fullDiff,
|
|
||||||
// Json
|
test("init") {
|
||||||
// .Obj(
|
assertEqualsDiff(
|
||||||
// "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
|
lv.el,
|
||||||
// "0" -> Json.Str("title value")
|
Json
|
||||||
// )
|
.Obj(
|
||||||
// )
|
"s" -> Json.Arr(Json.Str("<div><h1>"), Json.Str("</h1><p>"), Json.Str("</p></div>")),
|
||||||
// }
|
"0" -> Json.Str("title value"),
|
||||||
// test("diff no update") {
|
"1" -> Json.Str("other string value")
|
||||||
// assertEqualsJson(lv.diff, emptyDiff)
|
),
|
||||||
// }
|
trackChanges = false
|
||||||
// test("diff with update") {
|
)
|
||||||
// lv.update(TestModel(title = "title updated"))
|
}
|
||||||
// assertEqualsJson(
|
test("diff no update") {
|
||||||
// lv.diff,
|
assertEqualsDiff(lv.el, emptyDiff)
|
||||||
// Json.Obj("0" -> Json.Str("title updated"))
|
}
|
||||||
// )
|
test("diff with update") {
|
||||||
// }
|
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
|
||||||
// test("diff with update and no change") {
|
assertEqualsDiff(
|
||||||
// lv.update(TestModel(title = "title updated"))
|
lv.el,
|
||||||
// lv.update(TestModel(title = "title updated"))
|
Json.Obj("0" -> Json.Str("title updated"))
|
||||||
// assertEqualsJson(lv.diff, emptyDiff)
|
)
|
||||||
// }
|
}
|
||||||
// }
|
test("diff with update and no change") {
|
||||||
//
|
lv.handleCommand(UpdateCmd(_.copy(title = "title value")))
|
||||||
// test("Dynamic attribute") {
|
assertEqualsDiff(lv.el, emptyDiff)
|
||||||
// val lv =
|
}
|
||||||
// LiveView(
|
test("diff with update in multiple commands") {
|
||||||
// new View:
|
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
|
||||||
// type Model = TestModel
|
lv.handleCommand(UpdateCmd(_.copy(otherString = "other string updated")))
|
||||||
// val render =
|
assertEqualsDiff(
|
||||||
// div(cls := model(_.cls))
|
lv.el,
|
||||||
// ,
|
Json
|
||||||
// TestModel()
|
.Obj(
|
||||||
// )
|
"0" -> Json.Str("title updated"),
|
||||||
// test("init") {
|
"1" -> Json.Str("other string updated")
|
||||||
// assertEqualsJson(
|
)
|
||||||
// lv.fullDiff,
|
)
|
||||||
// Json
|
}
|
||||||
// .Obj(
|
}
|
||||||
// "s" -> Json
|
|
||||||
// .Arr(Json.Str("<div class=\""), Json.Str("\"></div>")),
|
test("Dynamic attribute") {
|
||||||
// "0" -> Json.Str("text-sm")
|
val lv =
|
||||||
// )
|
new LiveView[UpdateCmd]:
|
||||||
// )
|
val model = Var(TestModel())
|
||||||
// }
|
val el =
|
||||||
// test("diff no update") {
|
div(cls := model(_.cls))
|
||||||
// assertEqualsJson(lv.diff, emptyDiff)
|
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
|
||||||
// }
|
|
||||||
// test("diff with update") {
|
lv.el.syncAll()
|
||||||
// lv.update(TestModel(cls = "text-md"))
|
lv.el.setAllUnchanged()
|
||||||
// assertEqualsJson(
|
|
||||||
// lv.diff,
|
test("init") {
|
||||||
// Json.Obj("0" -> Json.Str("text-md"))
|
assertEqualsDiff(
|
||||||
// )
|
lv.el,
|
||||||
// }
|
Json
|
||||||
// test("diff with update and no change") {
|
.Obj(
|
||||||
// lv.update(TestModel(cls = "text-md"))
|
"s" -> Json
|
||||||
// lv.update(TestModel(cls = "text-md"))
|
.Arr(Json.Str("<div class=\""), Json.Str("\"></div>")),
|
||||||
// assertEqualsJson(lv.diff, emptyDiff)
|
"0" -> Json.Str("text-sm")
|
||||||
// }
|
),
|
||||||
// }
|
trackChanges = false
|
||||||
//
|
)
|
||||||
// test("when mod") {
|
}
|
||||||
// val lv =
|
test("diff no update") {
|
||||||
// LiveView(
|
assertEqualsDiff(lv.el, emptyDiff)
|
||||||
// new View:
|
}
|
||||||
// type Model = TestModel
|
test("diff with update") {
|
||||||
// val render =
|
lv.handleCommand(UpdateCmd(_.copy(cls = "text-md")))
|
||||||
// div(
|
assertEqualsDiff(
|
||||||
// model.when(_.bool)(
|
lv.el,
|
||||||
// div("static string", model(_.nestedTitle))
|
Json.Obj("0" -> Json.Str("text-md"))
|
||||||
// )
|
)
|
||||||
// )
|
}
|
||||||
// ,
|
}
|
||||||
// TestModel()
|
|
||||||
// )
|
test("when mod") {
|
||||||
// test("init") {
|
val lv =
|
||||||
// assertEqualsJson(
|
new LiveView[UpdateCmd]:
|
||||||
// lv.fullDiff,
|
val model = Var(TestModel())
|
||||||
// Json
|
val el =
|
||||||
// .Obj(
|
div(
|
||||||
// "s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
|
model.when(_.bool)(
|
||||||
// "0" -> Json.Bool(false)
|
div("static string", model(_.nestedTitle))
|
||||||
// )
|
)
|
||||||
// )
|
)
|
||||||
// }
|
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
|
||||||
// test("diff no update") {
|
|
||||||
// assertEqualsJson(lv.diff, emptyDiff)
|
lv.el.syncAll()
|
||||||
// }
|
lv.el.setAllUnchanged()
|
||||||
// test("diff with unrelated update") {
|
|
||||||
// lv.update(TestModel(title = "title updated"))
|
test("init") {
|
||||||
// assertEqualsJson(lv.diff, emptyDiff)
|
assertEqualsDiff(
|
||||||
// }
|
lv.el,
|
||||||
// test("diff when true") {
|
Json
|
||||||
// lv.update(TestModel(bool = true))
|
.Obj(
|
||||||
// assertEqualsJson(
|
"s" -> Json.Arr(Json.Str("<div>"), Json.Str("</div>")),
|
||||||
// lv.diff,
|
"0" -> Json.Bool(false)
|
||||||
// Json.Obj(
|
),
|
||||||
// "0" ->
|
trackChanges = false
|
||||||
// Json
|
)
|
||||||
// .Obj(
|
}
|
||||||
// "s" -> Json
|
test("diff no update") {
|
||||||
// .Arr(Json.Str("<div>static string"), Json.Str("</div>")),
|
assertEqualsDiff(lv.el, emptyDiff)
|
||||||
// "0" -> Json.Str("nested title value")
|
}
|
||||||
// )
|
test("diff with unrelated update") {
|
||||||
// )
|
lv.handleCommand(UpdateCmd(_.copy(title = "title updated")))
|
||||||
// )
|
assertEqualsDiff(lv.el, emptyDiff)
|
||||||
// }
|
}
|
||||||
// test("diff when nested change") {
|
test("diff when true and nested update") {
|
||||||
// lv.update(TestModel(bool = true))
|
lv.handleCommand(UpdateCmd(_.copy(bool = true)))
|
||||||
// lv.update(TestModel(bool = true, nestedTitle = "nested title updated"))
|
assertEqualsDiff(
|
||||||
// assertEqualsJson(
|
lv.el,
|
||||||
// lv.diff,
|
Json.Obj(
|
||||||
// Json.Obj(
|
"0" ->
|
||||||
// "0" ->
|
Json
|
||||||
// Json
|
.Obj(
|
||||||
// .Obj(
|
"s" -> Json
|
||||||
// "0" -> Json.Str("nested title updated")
|
.Arr(Json.Str("<div>static string"), Json.Str("</div>")),
|
||||||
// )
|
"0" -> Json.Str("nested title value")
|
||||||
// )
|
)
|
||||||
// )
|
)
|
||||||
// }
|
)
|
||||||
// }
|
}
|
||||||
//
|
test("diff when nested change") {
|
||||||
// test("splitByIndex mod") {
|
lv.handleCommand(UpdateCmd(_.copy(bool = true)))
|
||||||
// val initModel =
|
lv.el.syncAll()
|
||||||
// TestModel(
|
lv.el.setAllUnchanged()
|
||||||
// items = List(
|
lv.handleCommand(UpdateCmd(_.copy(bool = true, nestedTitle = "nested title updated")))
|
||||||
// NestedModel("a", 10),
|
assertEqualsDiff(
|
||||||
// NestedModel("b", 15),
|
lv.el,
|
||||||
// NestedModel("c", 20)
|
Json.Obj(
|
||||||
// )
|
"0" ->
|
||||||
// )
|
Json
|
||||||
// val lv =
|
.Obj(
|
||||||
// LiveView(
|
"0" -> Json.Str("nested title updated")
|
||||||
// new View:
|
)
|
||||||
// type Model = TestModel
|
)
|
||||||
// val render =
|
)
|
||||||
// div(
|
}
|
||||||
// ul(
|
}
|
||||||
// model.splitByIndex(_.items)(elem =>
|
|
||||||
// li(
|
test("splitByIndex mod") {
|
||||||
// "Nom: ",
|
val initModel = TestModel(
|
||||||
// elem(_.name),
|
items = List(
|
||||||
// " Age: ",
|
NestedModel("a", 10),
|
||||||
// elem(_.age.toString)
|
NestedModel("b", 15),
|
||||||
// )
|
NestedModel("c", 20)
|
||||||
// )
|
)
|
||||||
// )
|
)
|
||||||
// )
|
val lv =
|
||||||
// ,
|
new LiveView[UpdateCmd]:
|
||||||
// initModel
|
val model = Var(initModel)
|
||||||
// )
|
val el =
|
||||||
// test("init") {
|
div(
|
||||||
// assertEqualsJson(
|
ul(
|
||||||
// lv.fullDiff,
|
model(_.items).splitByIndex((_, elem) =>
|
||||||
// Json
|
li(
|
||||||
// .Obj(
|
"Nom: ",
|
||||||
// "s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
|
elem(_.name),
|
||||||
// "0" -> Json.Obj(
|
" Age: ",
|
||||||
// "s" -> Json.Arr(
|
elem(_.age.toString)
|
||||||
// Json.Str("<li>Nom: "),
|
)
|
||||||
// Json.Str(" Age: "),
|
)
|
||||||
// Json.Str("</li>")
|
)
|
||||||
// ),
|
)
|
||||||
// "d" -> Json.Obj(
|
def handleCommand(cmd: UpdateCmd): Unit = model.update(cmd.f)
|
||||||
// "0" -> Json.Obj(
|
|
||||||
// "0" -> Json.Str("a"),
|
lv.el.syncAll()
|
||||||
// "1" -> Json.Str("10")
|
lv.el.setAllUnchanged()
|
||||||
// ),
|
|
||||||
// "1" -> Json.Obj(
|
test("init") {
|
||||||
// "0" -> Json.Str("b"),
|
assertEqualsDiff(
|
||||||
// "1" -> Json.Str("15")
|
lv.el,
|
||||||
// ),
|
Json
|
||||||
// "2" -> Json.Obj(
|
.Obj(
|
||||||
// "0" -> Json.Str("c"),
|
"s" -> Json.Arr(Json.Str("<div><ul>"), Json.Str("</ul></div>")),
|
||||||
// "1" -> Json.Str("20")
|
"0" -> Json.Obj(
|
||||||
// )
|
"s" -> Json.Arr(
|
||||||
// )
|
Json.Str("<li>Nom: "),
|
||||||
// )
|
Json.Str(" Age: "),
|
||||||
// )
|
Json.Str("</li>")
|
||||||
// )
|
),
|
||||||
// }
|
"d" -> Json.Obj(
|
||||||
// test("diff no update") {
|
"0" -> Json.Obj(
|
||||||
// assertEqualsJson(lv.diff, emptyDiff)
|
"0" -> Json.Str("a"),
|
||||||
// }
|
"1" -> Json.Str("10")
|
||||||
// test("diff with unrelated update") {
|
),
|
||||||
// lv.update(initModel.copy(title = "title updated"))
|
"1" -> Json.Obj(
|
||||||
// assertEqualsJson(lv.diff, emptyDiff)
|
"0" -> Json.Str("b"),
|
||||||
// }
|
"1" -> Json.Str("15")
|
||||||
// test("diff with item changed") {
|
),
|
||||||
// lv.update(
|
"2" -> Json.Obj(
|
||||||
// initModel.copy(items = initModel.items.updated(2, NestedModel("c", 99)))
|
"0" -> Json.Str("c"),
|
||||||
// )
|
"1" -> Json.Str("20")
|
||||||
// assertEqualsJson(
|
)
|
||||||
// lv.diff,
|
)
|
||||||
// Json.Obj(
|
)
|
||||||
// "0" ->
|
),
|
||||||
// Json
|
trackChanges = false
|
||||||
// .Obj(
|
)
|
||||||
// "d" -> Json.Obj(
|
}
|
||||||
// "2" -> Json.Obj(
|
test("diff no update") {
|
||||||
// "1" -> Json.Str("99")
|
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") {
|
||||||
// test("diff with item added") {
|
lv.handleCommand(
|
||||||
// lv.update(
|
UpdateCmd(_.copy(items = initModel.items.updated(2, NestedModel("c", 99))))
|
||||||
// initModel.copy(items = initModel.items.appended(NestedModel("d", 35)))
|
)
|
||||||
// )
|
assertEqualsDiff(
|
||||||
// assertEqualsJson(
|
lv.el,
|
||||||
// lv.diff,
|
Json.Obj(
|
||||||
// Json.Obj(
|
"0" ->
|
||||||
// "0" ->
|
Json
|
||||||
// Json
|
.Obj(
|
||||||
// .Obj(
|
"d" -> Json.Obj(
|
||||||
// "d" -> Json.Obj(
|
"2" -> Json.Obj(
|
||||||
// "3" -> Json.Obj(
|
"1" -> Json.Str("99")
|
||||||
// "0" -> Json.Str("d"),
|
)
|
||||||
// "1" -> Json.Str("35")
|
)
|
||||||
// )
|
)
|
||||||
// )
|
)
|
||||||
// )
|
)
|
||||||
// )
|
}
|
||||||
// )
|
test("diff with item added") {
|
||||||
// }
|
lv.handleCommand(
|
||||||
// test("diff with first item removed") {
|
UpdateCmd(
|
||||||
// lv.update(
|
_.copy(items = initModel.items.appended(NestedModel("d", 35)))
|
||||||
// initModel.copy(items = initModel.items.tail)
|
)
|
||||||
// )
|
)
|
||||||
// assertEqualsJson(
|
assertEqualsDiff(
|
||||||
// lv.diff,
|
lv.el,
|
||||||
// Json.Obj(
|
Json.Obj(
|
||||||
// "0" ->
|
"0" ->
|
||||||
// Json
|
Json
|
||||||
// .Obj(
|
.Obj(
|
||||||
// "d" -> Json.Obj(
|
"d" -> Json.Obj(
|
||||||
// "0" -> Json.Obj(
|
"3" -> Json.Obj(
|
||||||
// "0" -> Json.Str("b"),
|
"0" -> Json.Str("d"),
|
||||||
// "1" -> Json.Str("15")
|
"1" -> Json.Str("35")
|
||||||
// ),
|
)
|
||||||
// "1" -> Json.Obj(
|
)
|
||||||
// "0" -> Json.Str("c"),
|
)
|
||||||
// "1" -> Json.Str("20")
|
)
|
||||||
// ),
|
)
|
||||||
// "2" -> Json.Bool(false)
|
}
|
||||||
// )
|
test("diff with first item removed") {
|
||||||
// )
|
lv.handleCommand(
|
||||||
// )
|
UpdateCmd(
|
||||||
// )
|
_.copy(items = initModel.items.tail)
|
||||||
// }
|
)
|
||||||
// test("diff all removed") {
|
)
|
||||||
// lv.update(initModel.copy(items = List.empty))
|
assertEqualsDiff(
|
||||||
// assertEqualsJson(
|
lv.el,
|
||||||
// lv.diff,
|
Json.Obj(
|
||||||
// Json.Obj(
|
"0" ->
|
||||||
// "0" ->
|
Json
|
||||||
// Json
|
.Obj(
|
||||||
// .Obj(
|
"d" -> Json.Obj(
|
||||||
// "d" -> Json.Obj(
|
"0" -> Json.Obj(
|
||||||
// "0" -> Json.Bool(false),
|
"0" -> Json.Str("b"),
|
||||||
// "1" -> Json.Bool(false),
|
"1" -> Json.Str("15")
|
||||||
// "2" -> Json.Bool(false)
|
),
|
||||||
// )
|
"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
|
end LiveViewSpec
|
||||||
|
|
|
||||||
|
|
@ -4,24 +4,11 @@ import zio.*
|
||||||
|
|
||||||
import zio.http.ChannelEvent.{ExceptionCaught, Read, UserEvent, UserEventTriggered}
|
import zio.http.ChannelEvent.{ExceptionCaught, Read, UserEvent, UserEventTriggered}
|
||||||
import zio.http.*
|
import zio.http.*
|
||||||
import zio.http.codec.PathCodec.string
|
|
||||||
import zio.http.template.Html
|
import zio.http.template.Html
|
||||||
|
|
||||||
object Example extends ZIOAppDefault:
|
object Example extends ZIOAppDefault:
|
||||||
|
|
||||||
val s = Socket(
|
val s = Socket(new TestView())
|
||||||
TestView,
|
|
||||||
LiveState.empty.set(
|
|
||||||
TestView.model,
|
|
||||||
MyModel(
|
|
||||||
List(
|
|
||||||
NestedModel("a", 10),
|
|
||||||
NestedModel("b", 15),
|
|
||||||
NestedModel("c", 20)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val socketApp: WebSocketApp[Any] =
|
val socketApp: WebSocketApp[Any] =
|
||||||
Handler.webSocket { channel =>
|
Handler.webSocket { channel =>
|
||||||
|
|
@ -79,14 +66,26 @@ end Example
|
||||||
final case class MyModel(elems: List[NestedModel], cls: String = "text-xs")
|
final case class MyModel(elems: List[NestedModel], cls: String = "text-xs")
|
||||||
final case class NestedModel(name: String, age: Int)
|
final case class NestedModel(name: String, age: Int)
|
||||||
|
|
||||||
object TestView extends LiveView:
|
class TestView extends LiveView[Nothing]:
|
||||||
val model = LiveState.Key[MyModel]
|
|
||||||
val render =
|
val model = Var(
|
||||||
|
MyModel(
|
||||||
|
List(
|
||||||
|
NestedModel("a", 10),
|
||||||
|
NestedModel("b", 15),
|
||||||
|
NestedModel("c", 20)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def handleCommand(cmd: Nothing): Unit = ()
|
||||||
|
|
||||||
|
val el =
|
||||||
div(
|
div(
|
||||||
idAttr := "42",
|
idAttr := "42",
|
||||||
cls := model(_.cls),
|
cls := model(_.cls),
|
||||||
ul(
|
ul(
|
||||||
model(_.elems).splitByIndex(elem =>
|
model(_.elems).splitByIndex((_, elem) =>
|
||||||
li(
|
li(
|
||||||
"Nom: ",
|
"Nom: ",
|
||||||
elem(_.name),
|
elem(_.name),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue