Add static attributes

This commit is contained in:
Paul-Henri Froidmont 2025-08-15 00:37:04 +02:00
parent 4be9831edd
commit 82c0922cfa
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
7 changed files with 116 additions and 91 deletions

View file

@ -3,7 +3,7 @@ package scalive
object DiffBuilder: object DiffBuilder:
def build( def build(
static: Seq[String], static: Seq[String],
dynamic: Seq[LiveMod[?]], dynamic: Seq[LiveDyn[?]],
includeUnchanged: Boolean = false includeUnchanged: Boolean = false
): Diff.Tag = ): Diff.Tag =
Diff.Tag( Diff.Tag(
@ -11,16 +11,16 @@ object DiffBuilder:
dynamic = dynamic.zipWithIndex dynamic = dynamic.zipWithIndex
.filter(includeUnchanged || _._1.wasUpdated) .filter(includeUnchanged || _._1.wasUpdated)
.map { .map {
case (v: LiveMod.Dynamic[?, ?], i) => case (v: LiveDyn.Value[?, ?], i) =>
Diff.Dynamic(i, Diff.Static(v.currentValue.toString)) Diff.Dynamic(i, Diff.Static(v.currentValue.toString))
case (v: LiveMod.When[?], i) => build(v, i, includeUnchanged) case (v: LiveDyn.When[?], i) => build(v, i, includeUnchanged)
case (v: LiveMod.Split[?, ?], i) => case (v: LiveDyn.Split[?, ?], i) =>
Diff.Dynamic(i, build(v, includeUnchanged)) Diff.Dynamic(i, build(v, includeUnchanged))
} }
) )
private def build( private def build(
mod: LiveMod.When[?], mod: LiveDyn.When[?],
index: Int, index: Int,
includeUnchanged: Boolean includeUnchanged: Boolean
): Diff.Dynamic = ): Diff.Dynamic =
@ -46,7 +46,7 @@ object DiffBuilder:
else Diff.Dynamic(index, Diff.Deleted) else Diff.Dynamic(index, Diff.Deleted)
private def build( private def build(
mod: LiveMod.Split[?, ?], mod: LiveDyn.Split[?, ?],
includeUnchanged: Boolean includeUnchanged: Boolean
): Diff.Split = ): Diff.Split =
Diff.Split( Diff.Split(

View file

@ -11,20 +11,20 @@ object HtmlBuilder:
private def build( private def build(
static: Seq[String], static: Seq[String],
dynamic: Seq[LiveMod[?]], dynamic: Seq[LiveDyn[?]],
strw: StringWriter strw: StringWriter
): Unit = ): Unit =
for i <- dynamic.indices do for i <- dynamic.indices do
strw.append(static(i)) strw.append(static(i))
dynamic(i) match dynamic(i) match
case mod: LiveMod.Dynamic[?, ?] => case mod: LiveDyn.Value[?, ?] =>
strw.append(mod.currentValue.toString) strw.append(mod.currentValue.toString)
case mod: LiveMod.When[?] => build(mod, strw) case mod: LiveDyn.When[?] => build(mod, strw)
case mod: LiveMod.Split[?, ?] => build(mod, strw) case mod: LiveDyn.Split[?, ?] => build(mod, strw)
strw.append(static.last) strw.append(static.last)
private def build(mod: LiveMod.When[?], strw: StringWriter): Unit = private def build(mod: LiveDyn.When[?], strw: StringWriter): Unit =
if mod.displayed then build(mod.nested.static, mod.nested.dynamic, strw) if mod.displayed then build(mod.nested.static, mod.nested.dynamic, strw)
private def build(mod: LiveMod.Split[?, ?], strw: StringWriter): Unit = private def build(mod: LiveDyn.Split[?, ?], strw: StringWriter): Unit =
mod.dynamic.foreach(entry => build(mod.static, entry, strw)) mod.dynamic.foreach(entry => build(mod.static, entry, strw))

View file

@ -3,14 +3,14 @@ package scalive
import scala.collection.immutable.ArraySeq import scala.collection.immutable.ArraySeq
import scala.collection.mutable.ArrayBuffer import scala.collection.mutable.ArrayBuffer
sealed trait LiveMod[Model]: sealed trait LiveDyn[Model]:
def update(model: Model): Unit def update(model: Model): Unit
def wasUpdated: Boolean def wasUpdated: Boolean
object LiveMod: object LiveDyn:
class Dynamic[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false) class Value[I, O](d: Dyn[I, O], init: I, startsUpdated: Boolean = false)
extends LiveMod[I]: extends LiveDyn[I]:
private var value: O = d.run(init) private var value: O = d.run(init)
private var updated: Boolean = startsUpdated private var updated: Boolean = startsUpdated
def wasUpdated: Boolean = updated def wasUpdated: Boolean = updated
@ -24,11 +24,11 @@ object LiveMod:
class When[Model]( class When[Model](
dynCond: Dyn[Model, Boolean], dynCond: Dyn[Model, Boolean],
tag: HtmlTag[Model], el: HtmlElement[Model],
init: Model init: Model
) extends LiveMod[Model]: ) extends LiveDyn[Model]:
val cond = LiveMod.Dynamic(dynCond, init) val cond = LiveDyn.Value(dynCond, init)
val nested = LiveView.render(tag, init) val nested = LiveView.render(el, init)
def displayed: Boolean = cond.currentValue def displayed: Boolean = cond.currentValue
def wasUpdated: Boolean = cond.wasUpdated || nested.wasUpdated def wasUpdated: Boolean = cond.wasUpdated || nested.wasUpdated
def update(model: Model): Unit = def update(model: Model): Unit =
@ -37,13 +37,16 @@ object LiveMod:
class Split[Model, Item]( class Split[Model, Item](
dynList: Dyn[Model, List[Item]], dynList: Dyn[Model, List[Item]],
project: Dyn[Item, Item] => HtmlTag[Item], project: Dyn[Item, Item] => HtmlElement[Item],
init: Model init: Model
) extends LiveMod[Model]: ) extends LiveDyn[Model]:
private val tag = project(Dyn.id) private val el = project(Dyn.id)
val static: ArraySeq[String] = LiveView.buildStatic(tag) val static: ArraySeq[String] = LiveView.buildStatic(el)
val dynamic: ArrayBuffer[ArraySeq[LiveMod[Item]]] = val dynamic: ArrayBuffer[ArraySeq[LiveDyn[Item]]] =
dynList.run(init).map(LiveView.buildDynamic(tag, _)).to(ArrayBuffer) dynList
.run(init)
.map(LiveView.buildDynamic(el, _).to(ArraySeq))
.to(ArrayBuffer)
var removedIndexes: Seq[Int] = Seq.empty var removedIndexes: Seq[Int] = Seq.empty
def wasUpdated: Boolean = def wasUpdated: Boolean =
@ -57,6 +60,8 @@ object LiveMod:
dynamic.takeInPlace(items.size) dynamic.takeInPlace(items.size)
items.zipWithIndex.map((item, i) => items.zipWithIndex.map((item, i) =>
if i >= dynamic.size then if i >= dynamic.size then
dynamic.append(LiveView.buildDynamic(tag, item, startsUpdated = true)) dynamic.append(
LiveView.buildDynamic(el, item, startsUpdated = true).to(ArraySeq)
)
else dynamic(i).foreach(_.update(item)) else dynamic(i).foreach(_.update(item))
) )

View file

@ -6,7 +6,7 @@ import scala.collection.mutable.ListBuffer
class LiveView[Model] private ( class LiveView[Model] private (
val static: ArraySeq[String], val static: ArraySeq[String],
val dynamic: ArraySeq[LiveMod[Model]] val dynamic: ArraySeq[LiveDyn[Model]]
): ):
def update(model: Model): Unit = def update(model: Model): Unit =
dynamic.foreach(_.update(model)) dynamic.foreach(_.update(model))
@ -21,65 +21,79 @@ class LiveView[Model] private (
object LiveView: object LiveView:
def apply[Model]( inline def apply[Model](
lv: View[Model], lv: View[Model],
model: Model model: Model
): LiveView[Model] = ): LiveView[Model] =
render(lv.view, model) render(lv.root, model)
def buildStatic[Model](tag: HtmlTag[Model]): ArraySeq[String] = def render[Model](
buildNestedStatic(tag).flatten.to(ArraySeq) tag: HtmlElement[Model],
model: Model
): LiveView[Model] =
new LiveView(buildStatic(tag), buildDynamic(tag, model).to(ArraySeq))
private def buildNestedStatic[Model]( def buildStatic[Model](el: HtmlElement[Model]): ArraySeq[String] =
tag: HtmlTag[Model] buildStaticFragments(el).flatten.to(ArraySeq)
private def buildStaticFragments[Model](
el: HtmlElement[Model]
): Seq[Option[String]] = ): Seq[Option[String]] =
val (attrs, children) = buildStaticFragmentsByType(el)
val static = ListBuffer.empty[Option[String]] val static = ListBuffer.empty[Option[String]]
var staticFragment = s"<${tag.name}>" var staticFragment = s"<${el.tag.name}"
for mod <- tag.mods.flatMap(buildStatic) do for attr <- attrs do
mod match attr match
case Some(s) => case Some(s) =>
staticFragment += s staticFragment += s
case None => case None =>
static.append(Some(staticFragment)) static.append(Some(staticFragment))
static.append(None) static.append(None)
staticFragment = "" staticFragment = ""
staticFragment += s"</${tag.name}>" staticFragment += s">"
for child <- children do
child match
case Some(s) =>
staticFragment += s
case None =>
static.append(Some(staticFragment))
static.append(None)
staticFragment = ""
staticFragment += s"</${el.tag.name}>"
static.append(Some(staticFragment)) static.append(Some(staticFragment))
static.toSeq static.toSeq
def buildStatic[Model](mod: Mod[Model]): Seq[Option[String]] = @nowarn("cat=unchecked")
mod match private def buildStaticFragmentsByType[Model](
case Mod.Tag(tag) => buildNestedStatic(tag) el: HtmlElement[Model]
case Mod.Text(text) => List(Some(text)) ): (attrs: Seq[Option[String]], children: Seq[Option[String]]) =
case Mod.DynText(_) => List(None) val (attrs, children) = el.mods.partitionMap {
case Mod.When(_, _) => List(None) case Mod.StaticAttr(attr, value) =>
case Mod.Split(_, _) => List(None) Left(List(Some(s""" ${attr.name}="$value"""")))
case Mod.Tag(el) => Right(buildStaticFragments(el))
def buildDynamic[Model]( case Mod.Text(text) => Right(List(Some(text)))
tag: HtmlTag[Model], case Mod.DynText[Model](_) => Right(List(None))
model: Model, case Mod.When[Model](_, _) => Right(List(None))
startsUpdated: Boolean = false case Mod.Split[Model, Any](_, _) => Right(List(None))
): ArraySeq[LiveMod[Model]] = }
tag.mods.flatMap(buildDynamic(_, model, startsUpdated)).to(ArraySeq) (attrs.flatten, children.flatten)
@nowarn("cat=unchecked") @nowarn("cat=unchecked")
def buildDynamic[Model]( def buildDynamic[Model](
mod: Mod[Model], el: HtmlElement[Model],
model: Model, model: Model,
startsUpdated: Boolean startsUpdated: Boolean = false
): Seq[LiveMod[Model]] = ): Seq[LiveDyn[Model]] =
mod match val (attrs, children) = el.mods.partitionMap {
case Mod.Tag(tag) => buildDynamic(tag, model, startsUpdated) case Mod.StaticAttr(_, _) => Left(List.empty)
case Mod.Text(text) => List.empty case Mod.Text(_) => Right(List.empty)
case Mod.Tag(el) =>
Right(buildDynamic(el, model, startsUpdated))
case Mod.DynText[Model](dynText) => case Mod.DynText[Model](dynText) =>
List(LiveMod.Dynamic(dynText, model, startsUpdated)) Right(List(LiveDyn.Value(dynText, model, startsUpdated)))
case Mod.When[Model](dynCond, tag) => case Mod.When[Model](dynCond, el) =>
List(LiveMod.When(dynCond, tag, model)) Right(List(LiveDyn.When(dynCond, el, model)))
case Mod.Split[Model, Any](dynList, project) => case Mod.Split[Model, Any](dynList, project) =>
List(LiveMod.Split(dynList, project, model)) Right(List(LiveDyn.Split(dynList, project, model)))
}
def render[Model]( attrs.flatten ++ children.flatten
tag: HtmlTag[Model],
model: Model
): LiveView[Model] =
new LiveView(buildStatic(tag), buildDynamic(tag, model))

View file

@ -2,20 +2,20 @@ package scalive
trait View[Model]: trait View[Model]:
val model: Dyn[Model, Model] = Dyn.id val model: Dyn[Model, Model] = Dyn.id
def view: HtmlTag[Model] def root: HtmlElement[Model]
opaque type Dyn[I, O] = I => O opaque type Dyn[I, O] = I => O
extension [I, O](d: Dyn[I, O]) extension [I, O](d: Dyn[I, O])
def apply[O2](f: O => O2): Dyn[I, O2] = d.andThen(f) def apply[O2](f: O => O2): Dyn[I, O2] = d.andThen(f)
def when(f: O => Boolean)(tag: HtmlTag[I]): Mod.When[I] = def when(f: O => Boolean)(el: HtmlElement[I]): Mod.When[I] =
Mod.When(d.andThen(f), tag) Mod.When(d.andThen(f), el)
inline def whenNot(f: O => Boolean)(tag: HtmlTag[I]): Mod.When[I] = inline def whenNot(f: O => Boolean)(el: HtmlElement[I]): Mod.When[I] =
when(f.andThen(!_))(tag) when(f.andThen(!_))(el)
def splitByIndex[O2](f: O => List[O2])( def splitByIndex[O2](f: O => List[O2])(
project: Dyn[O2, O2] => HtmlTag[O2] project: Dyn[O2, O2] => HtmlElement[O2]
): Mod.Split[I, O2] = ): Mod.Split[I, O2] =
Mod.Split(d.andThen(f), project) Mod.Split(d.andThen(f), project)
@ -25,26 +25,31 @@ object Dyn:
def id[T]: Dyn[T, T] = identity def id[T]: Dyn[T, T] = identity
enum Mod[T]: enum Mod[T]:
case Tag(tag: HtmlTag[T]) case StaticAttr(attr: HtmlAttr, value: String)
case Tag(el: HtmlElement[T])
case Text(text: String) case Text(text: String)
case DynText(dynText: Dyn[T, String]) case DynText(dynText: Dyn[T, String])
case When(dynCond: Dyn[T, Boolean], tag: HtmlTag[T]) case When(dynCond: Dyn[T, Boolean], el: HtmlElement[T])
case Split[T, O]( case Split[T, O](
dynList: Dyn[T, List[O]], dynList: Dyn[T, List[O]],
project: Dyn[O, O] => HtmlTag[O] project: Dyn[O, O] => HtmlElement[O]
) extends Mod[T] ) extends Mod[T]
given [T]: Conversion[HtmlTag[T], Mod[T]] = Mod.Tag(_) given [T]: Conversion[HtmlElement[T], Mod[T]] = Mod.Tag(_)
given [T]: Conversion[String, Mod[T]] = Mod.Text(_) given [T]: Conversion[String, Mod[T]] = Mod.Text(_)
given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_) given [T]: Conversion[Dyn[T, String], Mod[T]] = Mod.DynText(_)
trait HtmlTag[Model](val name: String): class HtmlTag(val name: String):
def mods: List[Mod[Model]] def apply[Model](mods: Mod[Model]*): HtmlElement[Model] =
HtmlElement(this, mods.toVector)
class Div[Model](val mods: List[Mod[Model]]) extends HtmlTag[Model]("div") class HtmlElement[Model](val tag: HtmlTag, val mods: Vector[Mod[Model]])
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) val div = HtmlTag("div")
def ul[Model](mods: Mod[Model]*): Ul[Model] = Ul(mods.toList) val ul = HtmlTag("ul")
def li[Model](mods: Mod[Model]*): Li[Model] = Li(mods.toList) val li = HtmlTag("li")
class HtmlAttr(val name: String):
def :=[T](value: String): Mod.StaticAttr[T] = Mod.StaticAttr(this, value)
val idAttr = HtmlAttr("id")

View file

@ -66,8 +66,9 @@ final case class MyModel(elems: List[NestedModel])
final case class NestedModel(name: String, age: Int) final case class NestedModel(name: String, age: Int)
object TestView extends View[MyModel]: object TestView extends View[MyModel]:
val view: HtmlTag[MyModel] = val root: HtmlElement[MyModel] =
div( div(
idAttr := "42",
ul( ul(
model.splitByIndex(_.elems)(elem => model.splitByIndex(_.elems)(elem =>
li( li(

View file

@ -25,7 +25,7 @@ object LiveViewSpec extends TestSuite:
val lv = val lv =
LiveView( LiveView(
new View[Unit]: new View[Unit]:
val view: HtmlTag[Unit] = val root: HtmlElement[Unit] =
div("Static string") div("Static string")
, ,
() ()
@ -47,7 +47,7 @@ object LiveViewSpec extends TestSuite:
val lv = val lv =
LiveView( LiveView(
new View[TestModel]: new View[TestModel]:
val view: HtmlTag[TestModel] = val root: HtmlElement[TestModel] =
div(model(_.title)) div(model(_.title))
, ,
TestModel() TestModel()
@ -83,7 +83,7 @@ object LiveViewSpec extends TestSuite:
val lv = val lv =
LiveView( LiveView(
new View[TestModel]: new View[TestModel]:
val view: HtmlTag[TestModel] = val root: HtmlElement[TestModel] =
div( div(
model.when(_.bool)( model.when(_.bool)(
div("static string", model(_.nestedTitle)) div("static string", model(_.nestedTitle))
@ -152,7 +152,7 @@ object LiveViewSpec extends TestSuite:
val lv = val lv =
LiveView( LiveView(
new View[TestModel]: new View[TestModel]:
val view: HtmlTag[TestModel] = val root: HtmlElement[TestModel] =
div( div(
ul( ul(
model.splitByIndex(_.items)(elem => model.splitByIndex(_.items)(elem =>