Complete redesign to allow assigns style state

This commit is contained in:
Paul-Henri Froidmont 2025-08-17 21:55:13 +02:00
parent ae0dc04a9e
commit bb062b9679
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
18 changed files with 802 additions and 739 deletions

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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("<!doctype html>")
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))

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"</${el.tag.name}>")
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

View file

@ -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"</${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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -1,12 +1,11 @@
package scalive
import zio.json.*
@main
def main =
val lv =
LiveView(
val s = Socket(
TestView,
LiveState.empty.set(
TestView.model,
MyModel(
List(
NestedModel("a", 10),
@ -15,11 +14,16 @@ 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(
s.updateState(
_.set(
TestView.model,
MyModel(
List(
NestedModel("x", 10),
@ -28,56 +32,86 @@ def main =
)
)
)
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),

View file

@ -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("<div>Static string</div>"))
)
)
}
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("<div>"), Json.Str("</div>")),
"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("<div class=\""), Json.Str("\"></div>")),
"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("<div>"), Json.Str("</div>")),
"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("<div>static string"), Json.Str("</div>")),
"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("<div><ul>"), Json.Str("</ul></div>")),
"0" -> Json.Obj(
"s" -> Json.Arr(
Json.Str("<li>Nom: "),
Json.Str(" Age: "),
Json.Str("</li>")
),
"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("<div>Static string</div>"))
// )
// )
// }
// 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("<div>"), Json.Str("</div>")),
// "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("<div class=\""), Json.Str("\"></div>")),
// "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("<div>"), Json.Str("</div>")),
// "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("<div>static string"), Json.Str("</div>")),
// "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("<div><ul>"), Json.Str("</ul></div>")),
// "0" -> Json.Obj(
// "s" -> Json.Arr(
// Json.Str("<li>Nom: "),
// Json.Str(" Age: "),
// Json.Str("</li>")
// ),
// "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

View file

@ -9,9 +9,10 @@ import zio.http.template.Html
object Example extends ZIOAppDefault:
val lv =
LiveView(
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),

View file

@ -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
)
)

View file

@ -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 = ???