Reorganize

This commit is contained in:
Paul-Henri Froidmont 2025-08-13 20:16:09 +02:00
parent 844a1f7953
commit c523ba7858
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
10 changed files with 401 additions and 409 deletions

View file

@ -0,0 +1,51 @@
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)

View file

@ -0,0 +1,70 @@
package scalive
object DiffBuilder:
def build(
static: Seq[String],
dynamic: Seq[LiveMod[?]],
includeUnchanged: Boolean = false
): Diff.Tag =
Diff.Tag(
static = static,
dynamic = dynamic.zipWithIndex
.filter(includeUnchanged || _._1.wasUpdated)
.map {
case (v: LiveMod.Dynamic[?, ?], i) =>
Diff.Dynamic(i, Diff.Static(v.currentValue.toString))
case (v: LiveMod.When[?], i) => build(v, i, includeUnchanged)
case (v: LiveMod.Split[?, ?], i) =>
Diff.Dynamic(i, build(v, includeUnchanged))
}
)
private def build(
mod: LiveMod.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: LiveMod.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
)
)
)
.appendedAll(
mod.removedIndexes
.map(i => Diff.Dynamic(i, Diff.Deleted))
)
)

View file

@ -1,91 +0,0 @@
package scalive
import zio.json.ast.*
enum Diff:
case Mod(
static: Seq[String] = Seq.empty,
dynamic: Seq[Diff.Dynamic] = Seq.empty
)
case Split(
static: Seq[String] = Seq.empty,
dynamic: Seq[Diff.Dynamic] = Seq.empty
)
case Static(value: String)
case Dynamic(index: Int, diff: Diff)
case Deleted
object DiffEngine:
def buildInitJson(lv: RenderedLiveView[?]): Json =
JsonAstBuilder
.diffToJson(
buildDiffValue(lv.static, lv.dynamic, includeUnchanged = true)
)
def buildDiffJson(lv: RenderedLiveView[?]): Json =
Json
.Obj(
"diff" -> JsonAstBuilder.diffToJson(
buildDiffValue(static = Seq.empty, lv.dynamic)
)
)
private def buildDiffValue(
static: Seq[String],
dynamic: Seq[RenderedMod[?]],
includeUnchanged: Boolean = false
): Diff.Mod =
Diff.Mod(
static = static,
dynamic = dynamic.zipWithIndex
.filter(includeUnchanged || _._1.wasUpdated)
.map {
case (v: RenderedMod.Dynamic[?, ?], i) =>
Diff.Dynamic(i, Diff.Static(v.currentValue.toString))
case (v: RenderedMod.When[?], i) =>
if v.displayed then
if includeUnchanged || v.cond.wasUpdated then
Diff.Dynamic(
i,
buildDiffValue(
v.nested.static,
v.nested.dynamic,
includeUnchanged = true
)
)
else
Diff.Dynamic(
i,
buildDiffValue(
static = Seq.empty,
v.nested.dynamic,
includeUnchanged
)
)
else Diff.Dynamic(i, Diff.Deleted)
case (v: RenderedMod.Split[?, ?], i) =>
Diff.Dynamic(
i,
Diff.Split(
static = if includeUnchanged then v.static else Seq.empty,
dynamic = v.dynamic.toList.zipWithIndex
.filter(includeUnchanged || _._1.exists(_.wasUpdated))
.map[Diff.Dynamic]((mods, i) =>
Diff.Dynamic(
i,
buildDiffValue(
static = Seq.empty,
dynamic = mods,
includeUnchanged
)
)
)
.appendedAll(
v.removedIndexes
.map(i => Diff.Dynamic(i, Diff.Deleted))
)
)
)
}
)

View file

@ -1,36 +0,0 @@
package scalive
import zio.Chunk
import zio.json.ast.*
object JsonAstBuilder:
def diffToJson(diff: Diff): Json =
diff match
case Diff.Mod(static, dynamic) =>
Json.Obj(
Option
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk)
.appendedAll(
dynamic.map(d => d.index.toString -> diffToJson(d.diff))
)
)
case Diff.Split(static, dynamic) =>
Json.Obj(
Option
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk)
.appendedAll(
Option.when(dynamic.nonEmpty)(
"d" ->
Json.Obj(
dynamic.map(d => d.index.toString -> diffToJson(d.diff))*
)
)
)
)
case Diff.Static(value) => Json.Str(value)
case Diff.Dynamic(index, diff) =>
Json.Obj(index.toString -> diffToJson(diff))
case Diff.Deleted => Json.Bool(false)

View file

@ -0,0 +1,62 @@
package scalive
import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ArrayBuffer
sealed trait LiveMod[Model]:
def update(model: Model): Unit
def wasUpdated: Boolean
object LiveMod:
class Dynamic[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false)
extends LiveMod[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],
tag: HtmlTag[Model],
init: Model
) extends LiveMod[Model]:
val cond = LiveMod.Dynamic(dynCond, init)
val nested = LiveView.render(tag, 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] => HtmlTag[Item],
init: Model
) extends LiveMod[Model]:
private val tag = project(Dyn.id)
val static: ArraySeq[String] = LiveView.buildStatic(tag)
val dynamic: ArrayBuffer[ArraySeq[LiveMod[Item]]] =
dynList.run(init).map(LiveView.buildDynamic(tag, _)).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(tag, item, startsUpdated = true))
else dynamic(i).foreach(_.update(item))
)

View file

@ -1,50 +1,85 @@
package scalive
trait LiveView[Model]:
val model: Dyn[Model, Model] = Dyn.id
def view: HtmlTag[Model]
import scala.annotation.nowarn
import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ListBuffer
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)
class LiveView[Model] private (
val static: ArraySeq[String],
val dynamic: ArraySeq[LiveMod[Model]]
):
def update(model: Model): Unit =
dynamic.foreach(_.update(model))
def when(f: O => Boolean)(tag: HtmlTag[I]): Mod.When[I] =
Mod.When(d.andThen(f), tag)
def wasUpdated: Boolean = dynamic.exists(_.wasUpdated)
inline def whenNot(f: O => Boolean)(tag: HtmlTag[I]): Mod.When[I] =
when(f.andThen(!_))(tag)
def fullDiff: Diff =
DiffBuilder.build(static, dynamic, includeUnchanged = true)
def splitByIndex[O2](f: O => List[O2])(
project: Dyn[O2, O2] => HtmlTag[O2]
): Mod.Split[I, O2] =
Mod.Split(d.andThen(f), project)
def diff: Diff =
DiffBuilder.build(static = Seq.empty, dynamic)
def run(v: I): O = d(v)
object LiveView:
object Dyn:
def id[T]: Dyn[T, T] = identity
def apply[Model](
lv: View[Model],
model: Model
): LiveView[Model] =
render(lv.view, model)
enum Mod[T]:
case Tag(tag: HtmlTag[T])
case Text(text: String)
case DynText(dynText: Dyn[T, String])
case When(dynCond: Dyn[T, Boolean], tag: HtmlTag[T])
case Split[T, O](
dynList: Dyn[T, List[O]],
project: Dyn[O, O] => HtmlTag[O]
) extends Mod[T]
def buildStatic[Model](tag: HtmlTag[Model]): ArraySeq[String] =
buildNestedStatic(tag).flatten.to(ArraySeq)
given [T]: Conversion[HtmlTag[T], Mod[T]] = Mod.Tag(_)
given [T]: Conversion[String, Mod[T]] = Mod.Text(_)
given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_)
private def buildNestedStatic[Model](
tag: HtmlTag[Model]
): Seq[Option[String]] =
val static = ListBuffer.empty[Option[String]]
var staticFragment = s"<${tag.name}>"
for mod <- tag.mods.flatMap(buildStatic) do
mod match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += s"</${tag.name}>"
static.append(Some(staticFragment))
static.toSeq
trait HtmlTag[Model](val name: String):
def mods: List[Mod[Model]]
def buildStatic[Model](mod: Mod[Model]): Seq[Option[String]] =
mod match
case Mod.Tag(tag) => buildNestedStatic(tag)
case Mod.Text(text) => List(Some(text))
case Mod.DynText(_) => List(None)
case Mod.When(_, _) => List(None)
case Mod.Split(_, _) => List(None)
class Div[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("div")
class Ul[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("ul")
class Li[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("li")
def buildDynamic[Model](
tag: HtmlTag[Model],
model: Model,
startsUpdated: Boolean = false
): ArraySeq[LiveMod[Model]] =
tag.mods.flatMap(buildDynamic(_, model, startsUpdated)).to(ArraySeq)
def div[Model](mods: Mod[Model]*): Div[Model] = Div(mods.toList)
def ul[Model](mods: Mod[Model]*): Ul[Model] = Ul(mods.toList)
def li[Model](mods: Mod[Model]*): Li[Model] = Li(mods.toList)
@nowarn("cat=unchecked")
def buildDynamic[Model](
mod: Mod[Model],
model: Model,
startsUpdated: Boolean
): Seq[LiveMod[Model]] =
mod match
case Mod.Tag(tag) => buildDynamic(tag, model, startsUpdated)
case Mod.Text(text) => List.empty
case Mod.DynText[Model](dynText) =>
List(LiveMod.Dynamic(dynText, model, startsUpdated))
case Mod.When[Model](dynCond, tag) =>
List(LiveMod.When(dynCond, tag, model))
case Mod.Split[Model, Any](dynList, project) =>
List(LiveMod.Split(dynList, project, model))
def render[Model](
tag: HtmlTag[Model],
model: Model
): LiveView[Model] =
new LiveView(buildStatic(tag), buildDynamic(tag, model))

View file

@ -1,140 +0,0 @@
package scalive
import scalive.LiveViewRenderer.buildDynamic
import scalive.LiveViewRenderer.buildStatic
import scala.annotation.nowarn
import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ArrayBuffer
import scala.collection.mutable.ListBuffer
class RenderedLiveView[Model] private[scalive] (
val static: ArraySeq[String],
val dynamic: ArraySeq[RenderedMod[Model]]
):
def update(model: Model): Unit =
dynamic.foreach(_.update(model))
def wasUpdated: Boolean = dynamic.exists(_.wasUpdated)
sealed trait RenderedMod[Model]:
def update(model: Model): Unit
def wasUpdated: Boolean
object RenderedMod:
class Dynamic[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false)
extends RenderedMod[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],
tag: HtmlTag[Model],
init: Model
) extends RenderedMod[Model]:
val cond = RenderedMod.Dynamic(dynCond, init)
val nested = LiveViewRenderer.render(tag, 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] => HtmlTag[Item],
init: Model
) extends RenderedMod[Model]:
private val tag = project(Dyn.id)
val static: ArraySeq[String] = buildStatic(tag)
val dynamic: ArrayBuffer[ArraySeq[RenderedMod[Item]]] =
dynList.run(init).map(buildDynamic(tag, _)).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(buildDynamic(tag, item, startsUpdated = true))
else dynamic(i).foreach(_.update(item))
)
object LiveViewRenderer:
def render[Model](
lv: LiveView[Model],
model: Model
): RenderedLiveView[Model] =
render(lv.view, model)
def buildStatic[Model](tag: HtmlTag[Model]): ArraySeq[String] =
buildNestedStatic(tag).flatten.to(ArraySeq)
private def buildNestedStatic[Model](
tag: HtmlTag[Model]
): Seq[Option[String]] =
val static = ListBuffer.empty[Option[String]]
var staticFragment = s"<${tag.name}>"
for mod <- tag.mods.flatMap(buildStatic) do
mod match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += s"</${tag.name}>"
static.append(Some(staticFragment))
static.toSeq
def buildStatic[Model](mod: Mod[Model]): Seq[Option[String]] =
mod match
case Mod.Tag(tag) => buildNestedStatic(tag)
case Mod.Text(text) => List(Some(text))
case Mod.DynText(_) => List(None)
case Mod.When(_, _) => List(None)
case Mod.Split(_, _) => List(None)
def buildDynamic[Model](
tag: HtmlTag[Model],
model: Model,
startsUpdated: Boolean = false
): ArraySeq[RenderedMod[Model]] =
tag.mods.flatMap(buildDynamic(_, model, startsUpdated)).to(ArraySeq)
@nowarn("cat=unchecked")
def buildDynamic[Model](
mod: Mod[Model],
model: Model,
startsUpdated: Boolean
): Seq[RenderedMod[Model]] =
mod match
case Mod.Tag(tag) => buildDynamic(tag, model, startsUpdated)
case Mod.Text(text) => List.empty
case Mod.DynText[Model](dynText) =>
List(RenderedMod.Dynamic(dynText, model, startsUpdated))
case Mod.When[Model](dynCond, tag) =>
List(RenderedMod.When(dynCond, tag, model))
case Mod.Split[Model, Any](dynList, project) =>
List(RenderedMod.Split(dynList, project, model))
def render[Model](
tag: HtmlTag[Model],
model: Model
): RenderedLiveView[Model] =
new RenderedLiveView(buildStatic(tag), buildDynamic(tag, model))

View file

@ -0,0 +1,50 @@
package scalive
trait View[Model]:
val model: Dyn[Model, Model] = Dyn.id
def view: HtmlTag[Model]
opaque type Dyn[I, O] = I => O
extension [I, O](d: Dyn[I, O])
def apply[O2](f: O => O2): Dyn[I, O2] = d.andThen(f)
def when(f: O => Boolean)(tag: HtmlTag[I]): Mod.When[I] =
Mod.When(d.andThen(f), tag)
inline def whenNot(f: O => Boolean)(tag: HtmlTag[I]): Mod.When[I] =
when(f.andThen(!_))(tag)
def splitByIndex[O2](f: O => List[O2])(
project: Dyn[O2, O2] => HtmlTag[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 Tag(tag: HtmlTag[T])
case Text(text: String)
case DynText(dynText: Dyn[T, String])
case When(dynCond: Dyn[T, Boolean], tag: HtmlTag[T])
case Split[T, O](
dynList: Dyn[T, List[O]],
project: Dyn[O, O] => HtmlTag[O]
) extends Mod[T]
given [T]: Conversion[HtmlTag[T], Mod[T]] = Mod.Tag(_)
given [T]: Conversion[String, Mod[T]] = Mod.Text(_)
given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_)
trait HtmlTag[Model](val name: String):
def mods: List[Mod[Model]]
class Div[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("div")
class Ul[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("ul")
class Li[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("li")
def div[Model](mods: Mod[Model]*): Div[Model] = Div(mods.toList)
def ul[Model](mods: Mod[Model]*): Ul[Model] = Ul(mods.toList)
def li[Model](mods: Mod[Model]*): Li[Model] = Li(mods.toList)

View file

@ -4,9 +4,9 @@ import zio.json.*
@main
def main =
val r =
LiveViewRenderer.render(
TestLiveView,
val lv =
LiveView(
TestView,
MyModel(
List(
NestedModel("a", 10),
@ -15,9 +15,10 @@ def main =
)
)
)
println(DiffEngine.buildInitJson(r).toJsonPretty)
println(lv.fullDiff.toJsonPretty)
println("Edit first and last")
r.update(
lv.update(
MyModel(
List(
NestedModel("x", 10),
@ -26,9 +27,10 @@ def main =
)
)
)
println(DiffEngine.buildDiffJson(r).toJsonPretty)
println(lv.diff.toJsonPretty)
println("Add one")
r.update(
lv.update(
MyModel(
List(
NestedModel("x", 10),
@ -38,9 +40,10 @@ def main =
)
)
)
println(DiffEngine.buildDiffJson(r).toJsonPretty)
println(lv.diff.toJsonPretty)
println("Remove first")
r.update(
lv.update(
MyModel(
List(
NestedModel("b", 15),
@ -49,17 +52,18 @@ def main =
)
)
)
println(DiffEngine.buildDiffJson(r).toJsonPretty)
println(lv.diff.toJsonPretty)
println("Remove all")
r.update(
lv.update(
MyModel(List.empty)
)
println(DiffEngine.buildDiffJson(r).toJsonPretty)
println(lv.diff.toJsonPretty)
final case class MyModel(elems: List[NestedModel])
final case class NestedModel(name: String, age: Int)
object TestLiveView extends LiveView[MyModel]:
object TestView extends View[MyModel]:
val view: HtmlTag[MyModel] =
div(
ul(