Support click events

This commit is contained in:
Paul-Henri Froidmont 2025-08-27 02:28:22 +02:00
parent 1bd65fd49c
commit 124239925d
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
16 changed files with 277 additions and 162 deletions

View file

@ -6,13 +6,13 @@ final case class MyModel(
elems: List[Elem] = List.empty)
final case class Elem(name: String, age: Int)
class TestView(initialModel: MyModel) extends LiveView[TestView.Cmd]:
import TestView.Cmd.*
class TestView(initialModel: MyModel) extends LiveView[String, TestView.Event]:
import TestView.Event.*
private val modelVar = Var[MyModel](initialModel)
def handleCommand(cmd: TestView.Cmd): Unit =
cmd match
override def handleServerEvent(e: TestView.Event): Unit =
e match
case UpdateModel(f) => modelVar.update(f)
val el: HtmlElement =
@ -33,5 +33,5 @@ class TestView(initialModel: MyModel) extends LiveView[TestView.Cmd]:
)
object TestView:
enum Cmd:
enum Event:
case UpdateModel(f: MyModel => MyModel)

View file

@ -1,4 +1,5 @@
import scalive.*
import zio.json.JsonCodec
@main
def main =
@ -9,21 +10,21 @@ def main =
Elem("c", 30)
)
)
val s = Socket(TestView(initModel))
val s = Socket("", "", TestView(initModel))
println("Init")
println(s.renderHtml())
s.syncClient
s.syncClient
println("Edit class attribue")
s.receiveCommand(
TestView.Cmd.UpdateModel(_.copy(cls = "text-lg"))
s.lv.handleServerEvent(
TestView.Event.UpdateModel(_.copy(cls = "text-lg"))
)
s.syncClient
println("Edit first and last")
s.receiveCommand(
TestView.Cmd.UpdateModel(
s.lv.handleServerEvent(
TestView.Event.UpdateModel(
_.copy(elems =
List(
Elem("x", 10),
@ -37,8 +38,8 @@ def main =
println(s.renderHtml())
println("Add one")
s.receiveCommand(
TestView.Cmd.UpdateModel(
s.lv.handleServerEvent(
TestView.Event.UpdateModel(
_.copy(elems =
List(
Elem("x", 10),
@ -53,8 +54,8 @@ def main =
println(s.renderHtml())
println("Remove first")
s.receiveCommand(
TestView.Cmd.UpdateModel(
s.lv.handleServerEvent(
TestView.Event.UpdateModel(
_.copy(elems =
List(
Elem("b", 15),
@ -68,8 +69,8 @@ def main =
println(s.renderHtml())
println("Remove all")
s.receiveCommand(
TestView.Cmd.UpdateModel(
s.lv.handleServerEvent(
TestView.Event.UpdateModel(
_.copy(
cls = "text-lg",
bool = false,

View file

@ -10,7 +10,8 @@ enum Diff:
dynamic: Seq[Diff.Dynamic] = Seq.empty)
case Comprehension(
static: Seq[String] = Seq.empty,
entries: Seq[Diff.Dynamic] = Seq.empty)
entries: Seq[Diff.Dynamic] = Seq.empty,
count: Int = 0)
case Value(value: String)
case Dynamic(key: String, diff: Diff)
case Deleted
@ -29,19 +30,17 @@ object Diff:
dynamic.map(d => d.key -> toJson(d.diff))
)
)
case Diff.Comprehension(static, entries) =>
case Diff.Comprehension(static, entries, count) =>
Json.Obj(
Option
.when(static.nonEmpty)("s" -> Json.Arr(static.map(Json.Str(_))*))
.to(Chunk)
.appendedAll(
Option.when(entries.nonEmpty)(
"k" ->
Json
.Obj(
entries.map(d => d.key -> toJson(d.diff))*
).add("kc", Json.Num(entries.length))
)
.appended(
"k" ->
Json
.Obj(
entries.map(d => d.key -> toJson(d.diff))*
).add("kc", Json.Num(count))
)
)
case Diff.Value(value) => Json.Str(value)

View file

@ -23,10 +23,10 @@ object DiffBuilder:
private def buildDynamic(dynamicMods: Seq[DynamicMod], trackUpdates: Boolean): Seq[Option[Diff]] =
dynamicMods.flatMap {
case Attr.Dyn(attr, value) =>
case Attr.Dyn(name, value, _) =>
List(value.render(trackUpdates).map(v => Diff.Value(v.toString)))
case Attr.DynValueAsPresence(attr, value) =>
List(value.render(trackUpdates).map(v => Diff.Value(if v then s" ${attr.name}" else "")))
case Attr.DynValueAsPresence(name, value) =>
List(value.render(trackUpdates).map(v => Diff.Value(if v then s" $name" else "")))
case Content.Tag(el) => buildDynamic(el.dynamicMods, trackUpdates)
case Content.DynText(dyn) => List(dyn.render(trackUpdates).map(Diff.Value(_)))
case Content.DynElement(dyn) => ???
@ -40,23 +40,25 @@ object DiffBuilder:
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.Comprehension(
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)
}
splitVar.render(trackUpdates) match
case Some((entries, keysCount)) =>
val static =
entries.collectFirst { case (_, Some(el)) => el.static }.getOrElse(List.empty)
List(
Some(
Diff.Comprehension(
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)
},
count = keysCount
)
)
)
)
case None => List(None)
}
end DiffBuilder

View file

@ -94,7 +94,7 @@ class SplitVar[I, O, Key](
project: (Key, Dyn[I]) => O):
// Deleted elements have value none
private val memoized: mutable.Map[Key, Option[(Var[I], O)]] =
private val memoized: mutable.Map[Key, (Var[I], O)] =
mutable.Map.empty
private[scalive] def sync(): Unit =
@ -107,30 +107,38 @@ class SplitVar[I, O, Key](
nextKeys += entryKey
memoized.updateWith(entryKey) {
// Update matching key
case varAndOutput @ Some(Some((entryVar, _))) =>
case varAndOutput @ Some((entryVar, _)) =>
entryVar.set(input)
varAndOutput
// Create new item
case Some(None) | None =>
case None =>
val newVar = Var(input)
Some(Some(newVar, project(entryKey, newVar)))
Some(newVar, project(entryKey, newVar))
}
)
memoized.keys.foreach(k => if !nextKeys.contains(k) then memoized.update(k, None))
memoized.keys.foreach(k =>
if !nextKeys.contains(k) then
val _ = memoized.remove(k)
)
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 render(trackUpdates: Boolean)
: Option[(changeList: List[(Key, Option[O])], keysCount: Int)] =
if parent.changed || !trackUpdates then
Some(
(
memoized.collect {
case (k, (entryVar, output)) if !trackUpdates || entryVar.changed => (k, Some(output))
}.toList,
memoized.size
)
)
else None
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)))
memoized.values.foreach((_, output) => f(output))
end SplitVar

View file

@ -20,11 +20,11 @@ object HtmlBuilder:
for i <- dynamic.indices do
strw.write(static(i))
dynamic(i) match
case Attr.Dyn(attr, value) =>
strw.write(value.render(false).map(attr.codec.encode).getOrElse(""))
case Attr.DynValueAsPresence(attr, value) =>
case Attr.Dyn(name, value, isJson) =>
strw.write(value.render(false).getOrElse(""))
case Attr.DynValueAsPresence(name, value) =>
strw.write(
value.render(false).map(if _ then s" ${attr.name}" else "").getOrElse("")
value.render(false).map(if _ then s" $name" else "").getOrElse("")
)
case Content.Tag(el) => build(el.static, el.dynamicMods, strw)
case Content.DynText(dyn) => strw.write(dyn.render(false).getOrElse(""))
@ -33,8 +33,8 @@ object HtmlBuilder:
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 }
val (entries, _) = splitVar.render(false).getOrElse(List.empty -> 0)
val staticOpt = entries.collectFirst { case (_, Some(el)) => el.static }
entries.foreach {
case (_, Some(entryEl)) =>
build(staticOpt.getOrElse(Nil), entryEl.dynamicMods, strw)

View file

@ -1,9 +1,10 @@
package scalive
import scalive.codecs.BooleanAsAttrPresenceCodec
import scalive.codecs.Codec
import scalive.Mod.Attr
import scalive.Mod.Content
import scalive.codecs.BooleanAsAttrPresenceCodec
import scalive.codecs.Codec
import zio.json.*
class HtmlElement(val tag: HtmlTag, val mods: Vector[Mod]):
def static: Seq[String] = StaticBuilder.build(this)
@ -36,18 +37,26 @@ class HtmlAttr[V](val name: String, val codec: Codec[V, String]):
def :=(value: V): Mod.Attr =
if isBooleanAsAttrPresence then
Mod.Attr.StaticValueAsPresence(
this.asInstanceOf[HtmlAttr[Boolean]],
name,
value.asInstanceOf[Boolean]
)
else Mod.Attr.Static(this, codec.encode(value))
else Mod.Attr.Static(name, codec.encode(value))
def :=(value: Dyn[V]): Mod.Attr =
if isBooleanAsAttrPresence then
Mod.Attr.DynValueAsPresence(
this.asInstanceOf[HtmlAttr[Boolean]],
name,
value.asInstanceOf[Dyn[Boolean]]
)
else Mod.Attr.Dyn(this, value)
else Mod.Attr.Dyn(name, value(codec.encode))
class HtmlAttrJsonValue(val name: String):
def :=[V: JsonCodec](value: V): Mod.Attr =
Mod.Attr.Static(name, value.toJson, isJson = true)
def :=[V: JsonCodec](value: Dyn[V]): Mod.Attr =
Mod.Attr.Dyn(name, value(_.toJson), isJson = true)
sealed trait Mod
sealed trait StaticMod extends Mod
@ -55,12 +64,12 @@ sealed trait DynamicMod extends Mod
object Mod:
enum Attr extends Mod:
case Static(attr: HtmlAttr[?], value: String) extends Attr with StaticMod
case StaticValueAsPresence(attr: HtmlAttr[Boolean], value: Boolean) extends Attr with StaticMod
case Dyn[T](attr: HtmlAttr[T], value: scalive.Dyn[T]) extends Attr with DynamicMod
case DynValueAsPresence(attr: HtmlAttr[Boolean], value: scalive.Dyn[Boolean])
case Static(name: String, value: String, isJson: Boolean = false) extends Attr with StaticMod
case StaticValueAsPresence(name: String, value: Boolean) extends Attr with StaticMod
case Dyn(name: String, value: scalive.Dyn[String], isJson: Boolean = false)
extends Attr
with DynamicMod
case DynValueAsPresence(name: String, value: scalive.Dyn[Boolean]) extends Attr with DynamicMod
enum Content extends Mod:
case Text(text: String) extends Content with StaticMod
@ -75,14 +84,14 @@ object Mod:
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) =>
case Attr.Static(_, _, _) => ()
case Attr.StaticValueAsPresence(_, _) => ()
case Attr.Dyn(_, value, _) => value.setUnchanged()
case Attr.DynValueAsPresence(_, 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) =>
@ -97,14 +106,14 @@ extension (mod: Mod)
private[scalive] def syncAll(): Unit =
mod match
case Attr.Static(_, _) => ()
case Attr.StaticValueAsPresence(_, _) => ()
case Attr.Dyn(_, value) => value.sync()
case Attr.DynValueAsPresence(attr, value) => value.sync()
case Content.Text(text) => ()
case Content.Tag(el) => el.syncAll()
case Content.DynText(dyn) => dyn.sync()
case Content.DynElement(dyn) =>
case Attr.Static(_, _, _) => ()
case Attr.StaticValueAsPresence(_, _) => ()
case Attr.Dyn(_, value, _) => value.sync()
case Attr.DynValueAsPresence(_, value) => value.sync()
case Content.Text(text) => ()
case Content.Tag(el) => el.syncAll()
case Content.DynText(dyn) => dyn.sync()
case Content.DynElement(dyn) =>
dyn.sync()
dyn.callOnEveryChild(_.syncAll())
case Content.DynOptionElement(dyn) =>

View file

@ -1,5 +1,6 @@
package scalive
trait LiveView[Cmd]:
def handleCommand(cmd: Cmd): Unit
trait LiveView[ClientEvt, ServerEvent]:
def handleClientEvent(evt: ClientEvt): Unit = ()
def handleServerEvent(evt: ServerEvent): Unit = ()
val el: HtmlElement

View file

@ -1,3 +1,4 @@
import scalive.codecs.StringAsIsCodec
import scalive.defs.attrs.HtmlAttrs
import scalive.defs.complex.ComplexHtmlKeys
import scalive.defs.tags.HtmlTags
@ -6,6 +7,18 @@ package object scalive extends HtmlTags with HtmlAttrs with ComplexHtmlKeys:
lazy val defer = htmlAttr("defer", codecs.BooleanAsOnOffStringCodec)
object phx:
private def phxAttr(suffix: String): HtmlAttr[String] =
new HtmlAttr(s"phx-$suffix", StringAsIsCodec)
private def phxAttrJson(suffix: String): HtmlAttrJsonValue =
new HtmlAttrJsonValue(s"phx-$suffix")
private def dataPhxAttr(suffix: String): HtmlAttr[String] =
new HtmlAttr(s"data-phx-$suffix", StringAsIsCodec)
private[scalive] lazy val session = dataPhxAttr("session")
lazy val click = phxAttrJson("click")
def value(key: String) = phxAttr(s"value-$key")
implicit def stringToMod(v: String): Mod = Mod.Content.Text(v)
implicit def htmlElementToMod(el: HtmlElement): Mod = Mod.Content.Tag(el)
implicit def dynStringToMod(d: Dyn[String]): Mod = Mod.Content.DynText(d)

View file

@ -2,28 +2,23 @@ package scalive
import zio.json.*
import java.util.Base64
import scala.util.Random
final case class Socket[Cmd](lv: LiveView[Cmd]):
final case class Socket[CliEvt: JsonCodec, SrvEvt](
id: String,
token: String,
lv: LiveView[CliEvt, SrvEvt]):
val clientEventCodec = JsonCodec[CliEvt]
private var clientInitialized = false
val id: String =
s"phx-${Base64.getUrlEncoder().withoutPadding().encodeToString(Random().nextBytes(8))}"
private val token = Token.sign("secret", id, "")
lv.el.syncAll()
def receiveCommand(cmd: Cmd): Unit =
lv.handleCommand(cmd)
def renderHtml(rootLayout: HtmlElement => HtmlElement = identity): String =
lv.el.syncAll()
HtmlBuilder.build(
rootLayout(
div(
idAttr := id,
dataAttr("phx-session") := token,
idAttr := id,
phx.session := token,
lv.el
)
)

View file

@ -12,10 +12,14 @@ object StaticBuilder:
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(""))
case Attr.Static(name, value, isJson) =>
if isJson then List(Some(s" $name='$value'"))
else List(Some(s""" $name="$value""""))
case Attr.StaticValueAsPresence(name, value) => List(Some(s" $name"))
case Attr.Dyn(name, value, isJson) =>
if isJson then List(Some(s" $name='"), None, Some("'"))
else List(Some(s""" $name=""""), None, Some('"'.toString))
case Attr.DynValueAsPresence(_, value) => List(Some(""), None, Some(""))
}
val children = el.contentMods.flatMap {
case Content.Text(text) => List(Some(text))

View file

@ -35,10 +35,10 @@ object Token:
mac.doFinal(value)
private def base64Encode(value: Array[Byte]): String =
Base64.getEncoder().encodeToString(value)
Base64.getUrlEncoder().withoutPadding().encodeToString(value)
private def base64Decode(value: String): Array[Byte] =
Base64.getDecoder().decode(value)
Base64.getUrlDecoder().decode(value)
def verify[T: JsonCodec](secret: String, token: String, maxAge: Duration)
: Either[String, (liveViewId: String, payload: T)] =