diff --git a/.github/workflows/scala.yml b/.github/workflows/scala.yml index 7796cccf..3bc8b50a 100644 --- a/.github/workflows/scala.yml +++ b/.github/workflows/scala.yml @@ -13,7 +13,10 @@ jobs: fail-fast: false matrix: scala: - - 2.12.8 + - 2.13.4 + sbt-args: + - -J-XX:MaxRAMPercentage=90.0 --addPluginSbtFile=project/plugins.sbt.scala-js.0.6 + - -J-XX:MaxRAMPercentage=90.0 steps: - uses: actions/checkout@v2 @@ -33,12 +36,13 @@ jobs: ~/.sbt/ ~/.coursier/ key: | - ${{ runner.os }}-${{matrix.scala}}-${{ hashFiles('**/*.sbt') }} - ${{ runner.os }}-${{matrix.scala}}- + ${{runner.os}}-${{matrix.scala}}-${{hashFiles('**/*.sbt')}}-${{matrix.sbt-args}} + ${{runner.os}}-${{matrix.scala}}-${{hashFiles('**/*.sbt')}}- + ${{runner.os}}-${{matrix.scala}}- - name: Run tests - run: sbt ++${{ matrix.scala }} test + run: sbt ${{matrix.sbt-args}} ++${{matrix.scala}} test - name: Publish to Maven Central Repository env: GITHUB_PERSONAL_ACCESS_TOKEN: ${{secrets.PERSONAL_ACCESS_TOKEN}} if: ${{ env.GITHUB_PERSONAL_ACCESS_TOKEN != '' }} - run: sbt ++${{ matrix.scala }} "set every Seq(sonatypeSessionName := \"${{github.workflow}} ${{github.run_id}}-${{github.run_number}}-${{github.run_attempt}}-$$ ${{ matrix.scala }}\", publishTo := sonatypePublishToBundle.value)" publishSigned sonatypeBundleRelease + run: sbt ${{matrix.sbt-args}} ++${{ matrix.scala }} "set every Seq(sonatypeSessionName := \"${{github.workflow}} ${{github.run_id}}-${{github.run_number}}-${{github.run_attempt}}-$$ ${{ matrix.scala }}\", publishTo := sonatypePublishToBundle.value)" publishSigned sonatypeBundleRelease diff --git a/.gitignore b/.gitignore index 5387d4b3..c6011852 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,33 @@ +*.class +*.log +*.sjsir + +# sbt specific +.cache +.history +.lib/ +dist/* target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ + +# Scala-IDE specific +.scala_dependencies +.worksheet +.cache-* + +# IntelliJ specific +.idea/ +.classpath +.project +.settings/ + +# jenv +.java-version + +# Files created for deployment local.sbt secret/ .metals/ diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 00000000..407b6723 --- /dev/null +++ b/.sbtopts @@ -0,0 +1,2 @@ +-J-Xss5m +-J-XX:MaxMetaspaceSize=9999g diff --git a/.scalafmt.conf b/.scalafmt.conf index a6300edd..046b4054 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,3 +1,3 @@ runner.dialect = "scala213" -version = "3.1.1" +version = "3.3.3" maxColumn = 120 diff --git a/Binding/.js/build.sbt b/Binding/.js/build.sbt new file mode 120000 index 00000000..570a7b60 --- /dev/null +++ b/Binding/.js/build.sbt @@ -0,0 +1 @@ +../build.sbt.shared \ No newline at end of file diff --git a/Binding/.jvm/build.sbt b/Binding/.jvm/build.sbt new file mode 120000 index 00000000..570a7b60 --- /dev/null +++ b/Binding/.jvm/build.sbt @@ -0,0 +1 @@ +../build.sbt.shared \ No newline at end of file diff --git a/Binding/build.sbt.shared b/Binding/build.sbt.shared new file mode 100644 index 00000000..4ec9a4ce --- /dev/null +++ b/Binding/build.sbt.shared @@ -0,0 +1,15 @@ +enablePlugins(Example) + +description := "Reactive data-binding for Scala. This artifact is available for both Scala.js and JVM." + +libraryDependencies += "com.thoughtworks.enableIf" %% "enableif" % "1.1.7" + +libraryDependencies += "com.thoughtworks.sde" %%% "core" % "3.3.4" + +libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.10" % Test + +libraryDependencies += "org.scala-lang" % "scala-reflect" % scalaVersion.value + +libraryDependencies += "org.scala-lang" % "scala-compiler" % scalaVersion.value % Provided + +scalacOptions += "-Ymacro-annotations" diff --git a/Binding/src/main/scala/com/thoughtworks/binding/Binding.scala b/Binding/src/main/scala/com/thoughtworks/binding/Binding.scala new file mode 100644 index 00000000..104d8f10 --- /dev/null +++ b/Binding/src/main/scala/com/thoughtworks/binding/Binding.scala @@ -0,0 +1,2254 @@ +/* +The MIT License (MIT) + +Copyright (c) 2016 Yang Bo & REA Group Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +package com.thoughtworks.binding + +import java.util.EventObject + +import com.thoughtworks.sde.core.MonadicFactory._ +import com.thoughtworks.enableMembersIf +import com.thoughtworks.sde.core.MonadicFactory + +import scala.annotation.meta.companionMethod +import scala.annotation.tailrec +import scala.language.implicitConversions +import scala.language.higherKinds +import scala.collection.SeqView +import scala.collection.mutable.Buffer +import scala.collection.View +import scalaz.{Monad, MonadPlus} + +import scala.language.experimental.macros +import scala.language.existentials +import scala.collection.SeqOps +import scala.collection.mutable.ArrayBuffer + +/** @groupname typeClasses + * Type class instance + * @groupname implicits + * Implicits Conversions + * @groupname expressions + * Binding Expressions + * @groupdesc expressions + * AST nodes of binding expressions + * @author + * 杨博 (Yang Bo) <pop.atry@gmail.com> + */ +object Binding extends MonadicFactory.WithTypeClass[Monad, Binding] { + + sealed trait Watchable[+A] { + def watch(): Unit + def unwatch(): Unit + } + + private[binding] type SeqOpsIterable[+A] = Iterable[A] with SeqOps[A, CC, CC[A]] forSome { + type CC[+A] <: Iterable[A] + } + + private[binding] def addChangedListener[A](binding: Binding[A], listener: ChangedListener[A]) = { + binding.addChangedListener(listener) + } + + private[binding] def removeChangedListener[A](binding: Binding[A], listener: ChangedListener[A]) = { + binding.removeChangedListener(listener) + } + + override val typeClass = BindingInstances + + @enableMembersIf(c => !c.compilerSettings.exists(_.matches("""^-Xplugin:.*scalajs-compiler_[0-9\.\-]*\.jar$"""))) + private[Binding] object Jvm { + + type ConstantsData[+A] = Seq[A] + + @inline + def toConstantsData[A](seq: IterableOnce[A]) = Seq.from(seq) + + def toCacheData[A](seq: collection.Iterable[A]) = Vector.from(seq) + + def emptyCacheData[A]: HasCache[A]#Cache = Vector.empty + + trait HasCache[A] { + + private[Binding] type Cache = Vector[A] + + private[Binding] var cacheData: Cache + + @inline + private[Binding] final def getCache(n: Int): A = cacheData(n) + + @inline + private[Binding] final def updateCache(n: Int, newelem: A): Unit = { + cacheData = cacheData.updated(n, newelem) + } + + @inline + private[Binding] final def cacheLength: Int = cacheData.length + + @inline + private[Binding] final def clearCache(): Unit = { + cacheData = Vector.empty + } + + private[Binding] final def removeCache(n: Int): A = { + val result = cacheData(n) + cacheData = cacheData.patch(n, Nil, 1) + result + } + + private[Binding] final def removeCache(idx: Int, count: Int): Unit = { + cacheData = cacheData.patch(idx, Nil, count) + } + + private[Binding] final def appendCache(elements: IterableOnce[A]): Seq[A] = { + val seq = Seq.from(elements) + cacheData = cacheData ++ seq + seq + } + + private[Binding] final def appendCache(elem: A): Unit = { + cacheData = cacheData :+ elem + } + + private[Binding] final def prependCache(elem: A): Unit = { + cacheData = elem +: cacheData + } + + private[Binding] final def insertCache(n: Int, elems: IterableOnce[A]): Seq[A] = { + val seq = Seq.from(elems) + cacheData = cacheData.patch(n, seq, 0) + seq + } + + private[Binding] final def insertOneCache(n: Int, elem: A): Seq[A] = { + val seq = Seq(elem) + cacheData = cacheData.patch(n, seq, 0) + seq + } + + private[Binding] final def cacheIterator: Iterator[A] = { + cacheData.iterator + } + + private[Binding] final def spliceCache(from: Int, mappedNewChildren: IterableOnce[A], replaced: Int) = { + val oldCache = cacheData + if (from == 0) { + cacheData = mappedNewChildren ++: oldCache.drop(replaced) + } else { + cacheData = oldCache.patch(from, mappedNewChildren, replaced) + } + oldCache.view.slice(from, from + replaced) + } + + private[Binding] final def indexOfCache[B >: A](a: B): Int = { + cacheData.indexOf(a) + } + + } + + } + + @enableMembersIf(c => c.compilerSettings.exists(_.matches("""^-Xplugin:.*scalajs-compiler_[0-9\.\-]*\.jar$"""))) + private[Binding] object Js { + + type ConstantsData[+A] = scalajs.js.Array[_ <: A] + + @inline + def toConstantsData[A](seq: IterableOnce[A]) = { + import scalajs.js.JSConverters._ + seq.toJSArray + } + + @inline + def toCacheData[A](seq: collection.Iterable[A]) = { + import scalajs.js.JSConverters._ + seq.toJSArray + } + + @inline + def emptyCacheData[A]: HasCache[A]#Cache = scalajs.js.Array() + + trait HasCache[A] { + + private[Binding] type Cache = scalajs.js.Array[A] + + private[Binding] def cacheData: Cache + + @inline + private[Binding] final def getCache(n: Int): A = cacheData(n) + + @inline + private[Binding] final def updateCache(n: Int, newelem: A): Unit = { + cacheData(n) = newelem + } + + @inline + private[Binding] final def cacheLength: Int = cacheData.length + + @inline + private[Binding] final def clearCache(): Unit = { + cacheData.length = 0 + } + + @inline + private[Binding] final def removeCache(n: Int): A = { + cacheData.remove(n) + } + + private[Binding] final def removeCache(idx: Int, count: Int): Unit = { + cacheData.remove(idx, count) + } + + @inline + private[Binding] final def appendCache(elements: IterableOnce[A]): Seq[A] = { + val seq = Seq.from(elements) + cacheData ++= seq + seq + } + + @inline + private[Binding] final def appendCache(elem: A): Unit = { + cacheData += elem + } + + @inline + private[Binding] final def prependCache(elem: A): Unit = { + cacheData.unshift(elem) + } + + @inline + private[Binding] final def insertOneCache(n: Int, elem: A): Seq[A] = { + cacheData.insert(n, elem) + Seq(elem) + } + + @inline + private[Binding] final def insertCache(n: Int, elems: IterableOnce[A]): Seq[A] = { + val seq = Seq.from(elems) + cacheData.insertAll(n, elems) + seq + } + + @inline + private[Binding] final def cacheIterator: Iterator[A] = { + cacheData.iterator + } + + @inline + private[Binding] final def spliceCache(from: Int, mappedNewChildren: Cache, replaced: Int) = { + cacheData.splice(from, replaced, scalajs.runtime.toScalaVarArgs(mappedNewChildren): _*) + } + + @inline + private[Binding] final def spliceCache(from: Int, mappedNewChildren: IterableOnce[A], replaced: Int) = { + cacheData.splice(from, replaced, Seq.from(mappedNewChildren): _*) + } + + @inline + private[Binding] final def indexOfCache(a: A): Int = { + cacheData.indexOf(a) + } + + } + + } + + import Js._ + import Jvm._ + + final class ChangedEvent[+Value](source: Binding[Value], val newValue: Value) extends EventObject(source) { + override def getSource = super.getSource.asInstanceOf[Binding[Value]] + override def toString = raw"""ChangedEvent[source=$source newValue=$newValue]""" + + } + + final class PatchedEvent[+Element]( + source: BindingSeq[Element], + val from: Int, + val that: SeqOpsIterable[Element], + val replaced: Int + ) extends EventObject(source) { + override def getSource = super.getSource.asInstanceOf[BindingSeq[Element]] + override def toString = raw"""PatchedEvent[source=$source from=$from that=$that replaced=$replaced]""" + } + + trait ChangedListener[-Value] { + def changed(event: ChangedEvent[Value]): Unit + } + + trait PatchedListener[-Element] { + def patched(event: PatchedEvent[Element]): Unit + } + + /** A data binding expression that never changes. + * + * @group expressions + */ + final case class Constant[+A](override val value: A) extends Binding[A] { + + @inline + override protected def removeChangedListener(listener: ChangedListener[A]): Unit = { + // Do nothing because this Constant never changes + } + + @inline + override protected def addChangedListener(listener: ChangedListener[A]): Unit = { + // Do nothing because this Constant never changes + } + } + + /** @group expressions + */ + object Var { + @inline + def apply[A](initialValue: A) = new Var(initialValue) + } + + /** Source variable of data binding expression. + * + * You can manually change the value: + * + * {{{ + * import com.thoughtworks.binding.Binding.Var + * val bindingVar = Var("initial value") + * bindingVar.value = "changed value" + * }}} + * + * Then, any data binding expressions that depend on this [[Var]] will be changed automatically. + * + * @group expressions + */ + final class Var[A](private var cache: A) extends Binding[A] { + + private val publisher = new SafeBuffer[ChangedListener[A]] + + @inline + override def value = cache + + /** Changes the current value of this [[Var]], and reevaluates any expressions that depends on this [[Var]]. + * + * @note + * This method must not be invoked inside a `@dom` method body or a `Binding { ... }` block. + */ + def value_=(newValue: A): Unit = { + if (cache.isInstanceOf[View[_]] || cache != newValue) { + cache = newValue + val event = new ChangedEvent(this, newValue) + for (listener <- publisher) { + listener.changed(event) + } + } + } + + @inline + override protected def removeChangedListener(listener: ChangedListener[A]): Unit = { + publisher.-=(listener) + } + + @inline + override protected def addChangedListener(listener: ChangedListener[A]): Unit = { + publisher.+=(listener) + } + } + + /** @group expressions + */ + final class Map[A, B](upstream: Binding[A], f: A => B) extends Binding[B] with ChangedListener[A] { + + private val publisher = new SafeBuffer[ChangedListener[B]] + + private var cache: B = _ + + private def refreshCache() = { + cache = f(upstream.value) + } + + @inline + override protected def value: B = { + cache + } + + @inline + override protected def addChangedListener(listener: ChangedListener[B]): Unit = { + if (publisher.isEmpty) { + upstream.addChangedListener(this) + refreshCache() + } + publisher.+=(listener) + } + + @inline + override protected def removeChangedListener(listener: ChangedListener[B]): Unit = { + publisher.-=(listener) + if (publisher.isEmpty) { + upstream.removeChangedListener(this) + } + } + + override final def changed(upstreamEvent: ChangedEvent[A]): Unit = { + val oldCache = cache + val newCache = f(upstreamEvent.newValue) + cache = newCache + if (oldCache.isInstanceOf[View[_]] || oldCache != newCache) { + val event = new ChangedEvent(Map.this, newCache) + for (listener <- publisher) { + listener.changed(event) + } + } + } + } + + private val ReentryDetector = new Binding[Nothing] { + protected def throwException(): Nothing = + throw new IllegalStateException( + "Must not change an upstream value in a data binding expression that depends on the same upstream value!" + ) + def value: Nothing = throwException() + + protected def removeChangedListener(listener: ChangedListener[Nothing]): Unit = throwException() + + protected def addChangedListener(listener: ChangedListener[Nothing]): Unit = throwException() + } + + /** @group expressions + */ + final class FlatMap[A, B](upstream: Binding[A], f: A => Binding[B]) extends Binding[B] with ChangedListener[B] { + + private val publisher = new SafeBuffer[ChangedListener[B]] + + private val forwarder = new ChangedListener[A] { + + override final def changed(upstreamEvent: ChangedEvent[A]): Unit = { + val oldCache = cache + oldCache.removeChangedListener(FlatMap.this) + val newCache = f(upstreamEvent.newValue) + cache = newCache + newCache.addChangedListener(FlatMap.this) + if (oldCache.isInstanceOf[View[_]] || oldCache.value != newCache.value) { + val event = new ChangedEvent(FlatMap.this, newCache.value) + for (listener <- publisher) { + listener.changed(event) + } + } + } + } + + @inline + override def changed(upstreamEvent: ChangedEvent[B]) = { + val event = new ChangedEvent(FlatMap.this, upstreamEvent.newValue) + for (listener <- publisher) { + listener.changed(event) + } + } + + @inline + override def addChangedListener(listener: ChangedListener[B]): Unit = { + if (publisher.isEmpty) { + upstream.addChangedListener(forwarder) + refreshCache() + cache.addChangedListener(this) + } + publisher.+=(listener) + } + + private var cache: Binding[B] = ReentryDetector + + private def refreshCache() = { + cache = f(upstream.value) + } + + override protected def value: B = { + @tailrec + @inline + def tailrecGetValue(binding: Binding[B]): B = { + binding match { + case flatMap: FlatMap[_, B] => tailrecGetValue(flatMap.cache) + case _ => binding.value + } + } + tailrecGetValue(cache) + } + + override protected def removeChangedListener(listener: ChangedListener[B]): Unit = { + publisher.-=(listener) + if (publisher.isEmpty) { + upstream.removeChangedListener(forwarder) + cache.removeChangedListener(this) + } + } + + } + + /** Monad instances for [[Binding]]. + * + * @group typeClasses + */ + implicit object BindingInstances extends Monad[Binding] { + + override def map[A, B](fa: Binding[A])(f: A => B): Binding[B] = { + fa match { + case Constant(a) => + Constant(f(a)) + case _ => + new Map[A, B](fa, f) + } + } + + override def bind[A, B](fa: Binding[A])(f: A => Binding[B]): Binding[B] = { + fa match { + case Constant(a) => + f(a) + case _ => + new FlatMap[A, B](fa, f) + } + } + + @inline + override def point[A](a: => A): Binding[A] = Constant(a) + + override def ifM[B](value: Binding[Boolean], ifTrue: => Binding[B], ifFalse: => Binding[B]): Binding[B] = { + bind(value)(if (_) ifTrue else ifFalse) + } + + override def whileM[G[_], A](p: Binding[Boolean], body: => Binding[A])(implicit G: MonadPlus[G]): Binding[G[A]] = { + ifM(p, bind(body)(x => map(whileM(p, body))(xs => G.plus(G.point(x), xs))), point(G.empty)) + } + + override def whileM_[A](p: Binding[Boolean], body: => Binding[A]): Binding[Unit] = { + ifM(p, bind(body)(_ => whileM_(p, body)), point(())) + } + + override def untilM[G[_], A](f: Binding[A], cond: => Binding[Boolean])(implicit G: MonadPlus[G]): Binding[G[A]] = { + bind(f)(x => map(whileM(map(cond)(!_), f))(xs => G.plus(G.point(x), xs))) + } + + override def untilM_[A](f: Binding[A], cond: => Binding[Boolean]): Binding[Unit] = { + bind(f)(_ => whileM_(map(cond)(!_), f)) + } + + } + + private[Binding] class Macros(val c: scala.reflect.macros.blackbox.Context) { + + import c.universe._ + + lazy val functionOrFunctionLiteral: PartialFunction[Tree, (List[ValDef], Tree)] = { + case Function(vparams, body) => + (vparams, body) + case f => + val elementName = TermName(c.freshName("bindingElement")) + (List(q"val $elementName: ${TypeTree()} = $EmptyTree"), atPos(f.pos)(q"$f($elementName)")) + } + + final def foreach(f: Tree): Tree = { + val apply @ Apply( + TypeApply(Select(self, TermName("foreach")), List(u)), + List(f @ functionOrFunctionLiteral(vparams, body)) + ) = + c.macroApplication + val monadicBody = + q"""_root_.com.thoughtworks.binding.Binding.apply[$u]($body)""" + val monadicFunction = atPos(f.pos)(Function(vparams, monadicBody)) + atPos(apply.pos)( + q"""_root_.com.thoughtworks.sde.core.MonadicFactory.Instructions.each[ + _root_.com.thoughtworks.binding.Binding, + _root_.scala.Unit + ]($self.foreachBinding[$u]($monadicFunction))""" + ) + } + + final def map(f: Tree): Tree = { + val apply @ Apply( + TypeApply(Select(self, TermName("map")), List(b)), + List(f @ functionOrFunctionLiteral(vparams, body)) + ) = + c.macroApplication + val monadicBody = + q"""_root_.com.thoughtworks.binding.Binding.apply[$b]($body)""" + val monadicFunction = atPos(f.pos)(Function(vparams, monadicBody)) + atPos(apply.pos)(q"""$self.mapBinding[$b]($monadicFunction)""") + } + + final def flatMap(f: Tree): Tree = { + val apply @ Apply( + TypeApply(Select(self, TermName("flatMap")), List(b)), + List(f @ functionOrFunctionLiteral(vparams, body)) + ) = + c.macroApplication + val monadicBody = + q"""_root_.com.thoughtworks.binding.Binding.apply[_root_.com.thoughtworks.binding.Binding.BindingSeq[$b]]($body)""" + val monadicFunction = atPos(f.pos)(Function(vparams, monadicBody)) + atPos(apply.pos)(q"""$self.flatMapBinding[$b]($monadicFunction)""") + } + + final def withFilter(condition: Tree): Tree = { + val apply @ Apply(Select(self, TermName("withFilter")), List(f @ functionOrFunctionLiteral(vparams, body))) = + c.macroApplication + val monadicBody = + q"""_root_.com.thoughtworks.binding.Binding.apply[_root_.scala.Boolean]($body)""" + val monadicFunction = atPos(f.pos)(Function(vparams, monadicBody)) + atPos(apply.pos)(q"""$self.withFilterBinding($monadicFunction)""") + } + + final def bind: Tree = { + val q"$binding.$methodName" = c.macroApplication + q"""_root_.com.thoughtworks.sde.core.MonadicFactory.Instructions.each[ + _root_.com.thoughtworks.binding.Binding, + ${TypeTree(c.macroApplication.tpe)} + ]($binding)""" + } + + } + + private[binding] case class SingleSeq[+A](element: A) extends IndexedSeq[A] { + + @inline + override final def length: Int = 1 + + @inline + override final def apply(idx: Int) = { + if (idx == 0) { + element + } else { + throw new IndexOutOfBoundsException + } + } + + @inline + override final def iterator = Iterator.single(element) + + } + + private[binding] val Empty = new BindingSeq[Nothing] { + @inline + override protected def removePatchedListener(listener: PatchedListener[Nothing]): Unit = {} + + @inline + override protected def addPatchedListener(listener: PatchedListener[Nothing]): Unit = {} + + type All[+A] = List[A] + + @inline + override protected def value = Nil + } + + private[Binding] abstract class ValueProxy[B] extends SeqView[B] with HasCache[Binding[B]] { + + protected def underlying = cacheData + + @inline + override def length: Int = { + cacheData.length + } + + @inline + override def apply(idx: Int): B = { + cacheData(idx).value + } + + @inline + override def iterator: Iterator[B] = { + cacheData.iterator.map(_.value) + } + } + + /** The companion of a data binding expression of a sequence + * + * @group expressions + */ + object BindingSeq { + + private[binding] final class FlatProxy[B](protected val underlying: collection.Seq[BindingSeq[B]]) + extends SeqView[B] { + + @inline + override def length: Int = { + underlying.view.map(_.value.length).sum + } + + @inline + override def apply(idx: Int): B = { + val i = underlying.iterator + @tailrec + def findIndex(restIndex: Int): B = { + if (i.hasNext) { + val subSeq = i.next().value + val currentLength = subSeq.length + if (currentLength > restIndex) { + subSeq(restIndex) + } else { + findIndex(restIndex - currentLength) + } + } else { + throw new IndexOutOfBoundsException() + } + } + findIndex(idx) + } + + @inline + override def iterator: Iterator[B] = { + for { + subSeq <- underlying.iterator + element <- subSeq.value.iterator + } yield element + } + } + + private[binding] def addPatchedListener[A](binding: BindingSeq[A], listener: PatchedListener[A]) = { + binding.addPatchedListener(listener) + } + + private[binding] def removePatchedListener[A](binding: BindingSeq[A], listener: PatchedListener[A]) = { + binding.removePatchedListener(listener) + } + + private[Binding] abstract class MultiMountPoint[-Element](upstream: BindingSeq[Element]) extends MountPoint { + + protected def mount(): Unit = { + upstream.addPatchedListener(upstreamListener) + set(upstream.value) + } + + protected def unmount(): Unit = { + upstream.removePatchedListener(upstreamListener) + set(Seq.empty) + } + + protected def set(children: Iterable[Element]): Unit + + protected def splice(from: Int, that: Iterable[Element], replaced: Int): Unit + + private val upstreamListener = new PatchedListener[Element] { + + @inline + override def patched(upstreamEvent: PatchedEvent[Element]): Unit = { + splice(upstreamEvent.from, upstreamEvent.that, upstreamEvent.replaced) + } + + } + + } + + final class FlatMap[A, B](upstream: BindingSeq[A], f: A => BindingSeq[B]) + extends BindingSeq[B] + with HasCache[BindingSeq[B]] { + + private[Binding] var cacheData: Cache = _ + + private def refreshCache() = { + cacheData = toCacheData(for { + a <- upstream.value /*.view*/ + } yield f(a)) + } + + type All[+A] = SeqView[A] + + @inline + override protected def value = new FlatProxy(cacheData) + + @inline + private def flatIndex(oldCache: Cache, upstreamBegin: Int, upstreamEnd: Int): Int = { + oldCache.view.slice(upstreamBegin, upstreamEnd).map(_.value.length).sum + } + + private val upstreamListener = new PatchedListener[A] { + override def patched(upstreamEvent: PatchedEvent[A]): Unit = { + val mappedNewChildren: Cache = toCacheData(for { + child <- upstreamEvent.that /*.view*/ + } yield f(child)) + val flatNewChildren = new FlatProxy(mappedNewChildren) + val flattenFrom = flatIndex(cacheData, 0, upstreamEvent.from) + val flattenReplaced = flatIndex(cacheData, upstreamEvent.from, upstreamEvent.from + upstreamEvent.replaced) + val oldChildren = spliceCache(upstreamEvent.from, mappedNewChildren, upstreamEvent.replaced) + for (newChild <- mappedNewChildren) { + newChild.addPatchedListener(childListener) + } + for (oldChild <- oldChildren) { + oldChild.removePatchedListener(childListener) + } + if (upstreamEvent.replaced != 0 || flatNewChildren.nonEmpty) { + val event = new PatchedEvent(FlatMap.this, flattenFrom, flatNewChildren, flattenReplaced) + for (listener <- publisher) { + listener.patched(event) + } + } + } + + } + + private[binding] val publisher = new SafeBuffer[PatchedListener[B]] + + private val childListener = new PatchedListener[B] { + override def patched(upstreamEvent: PatchedEvent[B]): Unit = { + val source = upstreamEvent.getSource + val index = flatIndex(cacheData, 0, indexOfCache(source)) + upstreamEvent.from + val event = new PatchedEvent(FlatMap.this, index, upstreamEvent.that, upstreamEvent.replaced) + for (listener <- publisher) { + listener.patched(event) + } + } + } + + @inline + override protected def removePatchedListener(listener: PatchedListener[B]): Unit = { + publisher.-=(listener) + if (publisher.isEmpty) { + upstream.removePatchedListener(upstreamListener) + for (child <- cacheData) { + child.removePatchedListener(childListener) + } + } + } + + @inline + override protected def addPatchedListener(listener: PatchedListener[B]): Unit = { + if (publisher.isEmpty) { + upstream.addPatchedListener(upstreamListener) + refreshCache() + for (child <- cacheData) { + child.addPatchedListener(childListener) + } + } + publisher.+=(listener) + } + } + + private[Binding] final class ForeachBinding[A](upstream: BindingSeq[A], f: A => Binding[Any]) + extends MountPoint + with PatchedListener[A] + with HasCache[Binding[Any]] { + private[Binding] var cacheData: Cache = _ + + private def refreshCache() = { + cacheData = toCacheData(for { + a <- upstream.value /*.view*/ + } yield f(a)) + } + + protected def mount(): Unit = { + upstream.addPatchedListener(this) + refreshCache() + for (child <- cacheData) { + child.watch() + } + + } + protected def unmount(): Unit = { + upstream.removePatchedListener(this) + for (child <- cacheData) { + child.unwatch() + } + + } + def patched(upstreamEvent: PatchedEvent[A]): Unit = { + val mappedNewChildren: Cache = toCacheData(for { + child <- upstreamEvent.that /*.view*/ + } yield f(child)) + val oldChildren = spliceCache(upstreamEvent.from, mappedNewChildren, upstreamEvent.replaced) + for (newChild <- mappedNewChildren) { + newChild.watch() + } + for (oldChild <- oldChildren) { + oldChild.unwatch() + } + } + } + + final class MapBinding[A, B](upstream: BindingSeq[A], f: A => Binding[B]) + extends BindingSeq[B] + with HasCache[Binding[B]] { + + private[Binding] var cacheData: Cache = _ + + private def refreshCache() = { + cacheData = toCacheData(for { + a <- upstream.value /*.view*/ + } yield f(a)) + } + + type All[+A] = SeqView[A] + + override protected def value: SeqView[B] = { + val cacheData0 = cacheData + new ValueProxy[B] { + var cacheData = cacheData0 + } + } + + private val upstreamListener = new PatchedListener[A] { + override def patched(upstreamEvent: PatchedEvent[A]): Unit = { + val mappedNewChildren: Cache = toCacheData(for { + child <- upstreamEvent.that /*.view*/ + } yield f(child)) + val oldChildren = spliceCache(upstreamEvent.from, mappedNewChildren, upstreamEvent.replaced) + for (newChild <- mappedNewChildren) { + newChild.addChangedListener(childListener) + } + for (oldChild <- oldChildren) { + oldChild.removeChangedListener(childListener) + } + val proxy = new ValueProxy[B] { + var cacheData = mappedNewChildren + } + val event = + new PatchedEvent[B](MapBinding.this, upstreamEvent.from, proxy, upstreamEvent.replaced) + for (listener <- publisher) { + listener.patched(event) + } + } + + } + + private[binding] val publisher = new SafeBuffer[PatchedListener[B]] + + private val childListener = new ChangedListener[B] { + + override def changed(event: ChangedEvent[B]): Unit = { + val index = indexOfCache(event.getSource) + for (listener <- publisher) { + listener.patched(new PatchedEvent(MapBinding.this, index, SingleSeq(event.newValue), 1)) + } + } + } + + override protected def removePatchedListener(listener: PatchedListener[B]): Unit = { + publisher.-=(listener) + if (publisher.isEmpty) { + upstream.removePatchedListener(upstreamListener) + for (child <- cacheData) { + child.removeChangedListener(childListener) + } + } + } + + override protected def addPatchedListener(listener: PatchedListener[B]): Unit = { + if (publisher.isEmpty) { + upstream.addPatchedListener(upstreamListener) + refreshCache() + for (child <- cacheData) { + child.addChangedListener(childListener) + } + } + publisher.+=(listener) + } + + } + + private[binding] final class Length(bindingSeq: BindingSeq[_]) extends Binding[Int] with PatchedListener[Any] { + + private val publisher = new SafeBuffer[ChangedListener[Int]] + + @inline + override protected def value: Int = bindingSeq.value.length + + @inline + override protected def removeChangedListener(listener: ChangedListener[Int]): Unit = { + publisher.-=(listener) + if (publisher.isEmpty) { + bindingSeq.removePatchedListener(this) + } + } + + @inline + override protected def addChangedListener(listener: ChangedListener[Int]): Unit = { + if (publisher.isEmpty) { + bindingSeq.addPatchedListener(this) + } + publisher.+=(listener) + } + + @inline + override def patched(upstreamEvent: PatchedEvent[Any]): Unit = { + val event = new ChangedEvent[Int](this, bindingSeq.value.length) + for (subscriber <- publisher) { + subscriber.changed(event) + } + } + + } + + } + + /** Data binding expression of a sequence + * + * @group expressions + */ + trait BindingSeq[+A] extends Watchable[A] { + + /** Returns a new [[Binding]] expression of all elements in this [[BindingSeq]]. */ + final def all: Binding[All[A]] = new Binding[All[A]] { asBinding => + private val patchedListener = new PatchedListener[A] { + @inline + def patched(upstreamEvent: PatchedEvent[A]): Unit = { + val event = new ChangedEvent[All[A]](asBinding, asBinding.value) + for (listener <- publisher) { + listener.changed(event) + } + } + } + private val publisher = new SafeBuffer[ChangedListener[All[A]]] + + @inline + override protected def value: All[A] = BindingSeq.this.value + + @inline + override protected def removeChangedListener(listener: ChangedListener[All[A]]): Unit = { + publisher.-=(listener) + if (publisher.isEmpty) { + BindingSeq.this.removePatchedListener(patchedListener) + } + } + + @inline + override protected def addChangedListener(listener: ChangedListener[All[A]]): Unit = { + if (publisher.isEmpty) { + BindingSeq.this.addPatchedListener(patchedListener) + } + publisher.+=(listener) + } + } + + /** Enables automatic recalculation. + * + * You may invoke this method more than once. Then, when you want to disable automatic recalculation, you must + * invoke [[unwatch]] same times as the number of calls to this method. + * + * @note + * This method is recursive, which means that the dependencies of this [[BindingSeq]] will be watched as well. + */ + @inline + final def watch(): Unit = { + addPatchedListener(Binding.DummyPatchedListener) + } + + /** Disables automatic recalculation. + * + * @note + * This method is recursive, which means that the dependencies of this [[BindingSeq]] will be unwatched as well. + */ + @inline + final def unwatch(): Unit = { + removePatchedListener(Binding.DummyPatchedListener) + } + + /** The value type of [[all]] */ + type All[+A] <: SeqOpsIterable[A] + + /** Returns the current value of this [[BindingSeq]]. */ + protected def value: All[A] + + /** Returns the current value of this [[BindingSeq]]. + * + * @note + * This method is used for internal testing purpose only. + */ + private[binding] def get: All[A] = value + + protected def removePatchedListener(listener: PatchedListener[A]): Unit + + protected def addPatchedListener(listener: PatchedListener[A]): Unit + + def length: Binding[Int] = new BindingSeq.Length(this) + + def isEmpty: Binding[Boolean] = BindingInstances.map(all)(_.isEmpty) + + def nonEmpty: Binding[Boolean] = BindingInstances.map(all)(_.nonEmpty) + + def foreach[U](f: A => U): Unit = macro Macros.foreach + + /** Returns a [[BindingSeq]] that maps each element of this [[BindingSeq]] via `f` + * + * @param f + * The mapper function, which may contain magic [[Binding#bind bind]] calls. + */ + def map[B](f: A => B): BindingSeq[B] = macro Macros.map + + /** Returns a [[BindingSeq]] that flat-maps each element of this [[BindingSeq]] via `f` + * + * @param f + * The mapper function, which may contain magic [[Binding#bind bind]] calls. + */ + def flatMap[B](f: A => BindingSeq[B]): BindingSeq[B] = macro Macros.flatMap + + /** The underlying implementation of [[foreach]]. + * + * @note + * Don't use this method in user code. + */ + @inline + def foreachBinding[U](f: A => Binding[U]): Binding[Unit] = { + new BindingSeq.ForeachBinding[A](this, f) + } + + /** The underlying implementation of [[map]]. + * + * @note + * Don't use this method in user code. + */ + @inline + final def mapBinding[B](f: A => Binding[B]): BindingSeq[B] = new BindingSeq.MapBinding[A, B](this, f) + + /** The underlying implementation of [[flatMap]]. + * + * @note + * Don't use this method in user code. + */ + @inline + final def flatMapBinding[B](f: A => Binding[BindingSeq[B]]): BindingSeq[B] = { + new BindingSeq.FlatMap[BindingSeq[B], B](new BindingSeq.MapBinding[A, BindingSeq[B]](this, f), locally) + } + + /** Returns a view of this [[BindingSeq]] that applied a filter of `condition` + * + * @param f + * The mapper function, which may contain magic [[Binding#bind bind]] calls. + */ + def withFilter(condition: A => Boolean): BindingSeq[A]#WithFilter = macro Macros.withFilter + + /** The underlying implementation of [[withFilter]]. + * + * @note + * Don't use this method in user code. + */ + @inline + final def withFilterBinding(condition: A => Binding[Boolean]): BindingSeq[A]#WithFilter = { + new WithFilter(condition) + } + + /** A helper to build complicated comprehension expressions for [[BindingSeq]] + */ + final class WithFilter(condition: A => Binding[Boolean]) { + + /** Returns a [[BindingSeq]] that maps each element of this [[BindingSeq]] via `f` + */ + def map[B](f: A => B): BindingSeq[B] = macro Macros.map + + /** Returns a [[BindingSeq]] that flat-maps each element of this [[BindingSeq]] via `f` + */ + def flatMap[B](f: A => BindingSeq[B]): BindingSeq[B] = macro Macros.flatMap + + /** Returns a view of this [[BindingSeq]] that applied a filter of `condition` + */ + def withFilter(condition: A => Boolean): WithFilter = macro Macros.withFilter + + /** Underlying implementation of [[withFilter. + * + * @note + * Don't use this method in user code. + */ + @inline + def withFilterBinding(nextCondition: A => Binding[Boolean]): WithFilter = { + new WithFilter({ a => + Binding { + if (Instructions.each[Binding, Boolean](condition(a))) { + Instructions.each[Binding, Boolean](nextCondition(a)) + } else { + false + } + } + }) + } + + /** Underlying implementation of [[map]]. + * + * @note + * Don't use this method in user code. + */ + @inline + def mapBinding[B](f: (A) => Binding[B]): BindingSeq[B] = { + BindingSeq.this.flatMapBinding { a: A => + Binding { + if (Instructions.each[Binding, Boolean](condition(a))) { + Constants(Instructions.each[Binding, B](f(a))) + } else { + Empty + } + } + } + } + + /** Underlying implementation of [[flatMap]]. + * + * @note + * Don't use this method in user code. + */ + @inline + def flatMapBinding[B](f: (A) => Binding[BindingSeq[B]]): BindingSeq[B] = { + BindingSeq.this.flatMapBinding { a: A => + Binding { + if (Instructions.each[Binding, Boolean](condition(a))) { + Instructions.each[Binding, BindingSeq[B]](f(a)) + } else { + Empty + } + } + } + } + + } + + } + + /** An data binding expression of sequence that never changes. + * + * @group expressions + */ + final class Constants[+A] private[Binding] (underlying: ConstantsData[A]) extends BindingSeq[A] { + type All[+A] = collection.Seq[A] + + @inline + override def value: collection.Seq[A] = underlying + + @inline + override protected def removePatchedListener(listener: PatchedListener[A]): Unit = {} + + @inline + override protected def addPatchedListener(listener: PatchedListener[A]): Unit = {} + + } + + /** @group expressions + */ + object Constants { + + @inline + def apply[A](elements: A*) = new Constants(toConstantsData(elements)) + + @inline + def upapplySeq[A](constants: Constants[A]) = Some(constants.value) + + private final val Empty = Constants[Nothing]() + + @inline + def empty[A]: Constants[A] = Empty + + } + + /** @group expressions + */ + object Vars { + + @inline + def apply[A](initialValues: A*) = new Vars(toCacheData(initialValues)) + + @inline + def empty[A] = new Vars(emptyCacheData[A]) + + } + + /** Source sequence of data binding expression. + * + * @group expressions + */ + final class Vars[A] private (private[Binding] var cacheData: HasCache[A]#Cache) + extends BindingSeq[A] + with HasCache[A] { + + private[binding] val publisher = new SafeBuffer[PatchedListener[A]] + + type All[+A] = Buffer[_ <: A] with SeqOpsIterable[A] + + /** Returns a [[scala.collection.mutable.Buffer]] that allow you change the content of this [[Vars]]. + * + * Whenever you change the returned data, other binding expressions that depend on this [[Vars]] will be + * automatically changed. + * + * @note + * This method must not be invoked inside a `@dom` method body or a `Binding { ... }` block.. + */ + @inline + override def value: Buffer[A] = new Proxy + + private[binding] final class Proxy extends Buffer[A] { + + @inline + override def patchInPlace(from: Int, patch: IterableOnce[A], replaced: Int): this.type = { + val result = spliceCache(from, patch, replaced) + for (listener <- publisher) { + listener.patched(new PatchedEvent(Vars.this, from, result, replaced)) + } + this + } + + @inline + override def apply(n: Int): A = { + getCache(n) + } + + @inline + override def update(n: Int, newelem: A): Unit = { + updateCache(n, newelem) + for (listener <- publisher) { + listener.patched(new PatchedEvent(Vars.this, n, SingleSeq(newelem), 1)) + } + } + + @inline + override def clear(): Unit = { + val oldLength = cacheLength + clearCache() + val event = new PatchedEvent[A](Vars.this, 0, List.empty[A], oldLength) + for (listener <- publisher) { + listener.patched(event) + } + } + + @inline + override def length: Int = { + cacheLength + } + + @inline + override def remove(n: Int): A = { + val result = removeCache(n) + val event = new PatchedEvent[A](Vars.this, n, List.empty[A], 1) + for (listener <- publisher) { + listener.patched(event) + } + result + } + + @inline + override def remove(idx: Int, count: Int): Unit = { + removeCache(idx, count) + val event = new PatchedEvent[A](Vars.this, idx, List.empty[A], count) + for (listener <- publisher) { + listener.patched(event) + } + } + + @inline + override def addAll(elements: IterableOnce[A]): this.type = { + val oldLength = cacheLength + val seq = appendCache(elements) + for (listener <- publisher) { + listener.patched(new PatchedEvent(Vars.this, oldLength, seq, 0)) + } + Proxy.this + } + + @inline + override def prepend(elem: A): this.type = { + prependCache(elem) + for (listener <- publisher) { + listener.patched(new PatchedEvent(Vars.this, 0, SingleSeq(elem), 0)) + } + Proxy.this + } + + @inline + override def addOne(elem: A): this.type = { + val oldLength = cacheLength + appendCache(elem) + for (listener <- publisher) { + listener.patched(new PatchedEvent(Vars.this, oldLength, SingleSeq(elem), 0)) + } + Proxy.this + } + + @inline + override def insert(idx: Int, elem: A): Unit = { + val seq = insertOneCache(idx, elem) + for (listener <- publisher) { + listener.patched(new PatchedEvent(Vars.this, idx, seq, 0)) + } + + } + + @inline + override def insertAll(n: Int, elems: IterableOnce[A]): Unit = { + val seq = insertCache(n, elems) + for (listener <- publisher) { + listener.patched(new PatchedEvent(Vars.this, n, seq, 0)) + } + } + + @inline + override def iterator: Iterator[A] = { + cacheIterator + } + } + + @inline + override protected def removePatchedListener(listener: PatchedListener[A]): Unit = { + publisher.-=(listener) + } + + @inline + override protected def addPatchedListener(listener: PatchedListener[A]): Unit = { + publisher.+=(listener) + } + + } + + /** A [[BindingSeq]] that contains only one element + * + * @group expressions + */ + final case class SingletonBindingSeq[A](upstream: Binding[A]) extends BindingSeq[A] { + + private val publisher = new SafeBuffer[PatchedListener[A]] + + private val changedListener = new ChangedListener[A] { + + override def changed(event: ChangedEvent[A]) = { + val patchedEvent = new PatchedEvent[A](SingletonBindingSeq.this, 0, SingleSeq(event.newValue), 1) + for (listener <- publisher) { + listener.patched(patchedEvent) + } + } + + } + + override def length: Constant[Int] = Constant(1) + + type All[+A] = IndexedSeq[A] + + @inline + override protected def value = SingleSeq(upstream.value) + + @inline + override protected def removePatchedListener(listener: PatchedListener[A]): Unit = { + publisher.-=(listener) + if (publisher.isEmpty) { + upstream.removeChangedListener(changedListener) + } + } + + @inline + override protected def addPatchedListener(listener: PatchedListener[A]): Unit = { + if (publisher.isEmpty) { + upstream.addChangedListener(changedListener) + } + publisher.+=(listener) + } + + } + + /** A mechanism that mounts the result of a data binding expression into DOM or other system. + * + * @group expressions + */ + private[Binding] sealed trait MountPoint extends Binding[Unit] { + + private var referenceCount = 0 + + protected def mount(): Unit + + protected def unmount(): Unit + + @inline + override protected def addChangedListener(listener: ChangedListener[Unit]): Unit = { + if (referenceCount == 0) { + mount() + } + referenceCount += 1 + } + + @inline + override protected def removeChangedListener(listener: ChangedListener[Unit]): Unit = { + referenceCount -= 1 + if (referenceCount == 0) { + unmount() + } + } + + @inline + override protected def value: Unit = () + + } + + /** A mechanism that mounts the result of a data binding expression of a sequence into DOM or other system. + * + * @group expressions + */ + abstract class MultiMountPoint[-Element](upstream: BindingSeq[Element]) extends BindingSeq.MultiMountPoint(upstream) + + /** A mechanism that mounts the result of a data binding expression of a single value into DOM or other system. + * + * Use this class only if you must override [[mount]] or [[unmount]]. If you only want to override [[set]], you can + * use `Binding[Unit] { onUpstreamChange(upstream.bind) }` instead. + * + * @group expressions + */ + abstract class SingleMountPoint[-Value](upstream: Binding[Value]) extends MountPoint { + + protected def set(value: Value): Unit + + protected def mount(): Unit = { + upstream.addChangedListener(upstreamListener) + set(upstream.value) + } + + protected def unmount(): Unit = { + upstream.removeChangedListener(upstreamListener) + } + + private val upstreamListener = new ChangedListener[Value] { + @inline + override def changed(event: ChangedEvent[Value]): Unit = { + set(event.newValue) + } + } + + } + + private[binding] val DummyPatchedListener = new PatchedListener[Any] { + @inline + override def patched(event: PatchedEvent[Any]): Unit = {} + } + + private[binding] val DummyChangedListener = new ChangedListener[Any] { + @inline + override def changed(event: ChangedEvent[Any]): Unit = {} + } + + private class RxDefer[A](upstream: => Rx.Observable[A]) extends Rx.Observable[A] with ChangedListener[Option[A]] { + + def changed(upstream: ChangedEvent[Option[A]]): Unit = { + val event = new ChangedEvent(this, upstream.newValue) + for (listener <- publisher) { + listener.changed(event) + } + } + + private var upstreamCache: Rx.Observable[A] = _ + + private val publisher = new SafeBuffer[ChangedListener[Option[A]]] + + override protected def value: Option[A] = { + if (publisher.isEmpty) { + None + } else { + upstreamCache.value + } + } + + override protected def removeChangedListener(listener: ChangedListener[Option[A]]): Unit = { + publisher -= listener + if (publisher.isEmpty) { + val upstreamLocal = upstreamCache + upstreamCache = null + upstreamLocal.removeChangedListener(this) + } + } + + override protected def addChangedListener(listener: ChangedListener[Option[A]]): Unit = { + if (publisher.isEmpty) { + val upstreamLocal = upstream + upstreamLocal.addChangedListener(this) + upstreamCache = upstreamLocal + } + publisher += listener + } + + } + + private class RxMerge[A](upstream: BindingSeq[A]) extends Rx.Observable[A] with PatchedListener[A] { + + private var cache: Option[A] = None + + override def patched(upstreamEvent: PatchedEvent[A]): Unit = { + upstreamEvent.that.headOption match { + case None => + if (cache != None && upstream.get.isEmpty) { + cache = None + val event = new ChangedEvent[Option[A]](this, None) + for (listener <- publisher) { + listener.changed(event) + } + } + case someNew => + if (cache != someNew) { + cache = someNew + val event = new ChangedEvent[Option[A]](this, someNew) + for (listener <- publisher) { + listener.changed(event) + } + } + } + + } + + private val publisher = new SafeBuffer[ChangedListener[Option[A]]] + protected def value: Option[A] = cache + protected def addChangedListener(listener: ChangedListener[Option[A]]): Unit = { + if (publisher.isEmpty) { + BindingSeq.addPatchedListener(upstream, this) + cache = upstream.get.headOption + } + publisher += listener + } + + protected def removeChangedListener(listener: ChangedListener[Option[A]]): Unit = { + publisher -= listener + if (publisher.isEmpty) { + BindingSeq.removePatchedListener(upstream, this) + } + } + + } + + private final class RxToBindingSeq[A](observable: Rx.Observable[A]) + extends BindingSeq[A] + with ChangedListener[Option[A]] + with HasCache[A] { + + override def changed(upstreamEvent: ChangedEvent[Option[A]]): Unit = { + val oldLength = cacheLength + val event = upstreamEvent.newValue match { + case Some(newValue) => + appendCache(newValue) + new PatchedEvent(this, oldLength, Seq(newValue), 0) + case None => + clearCache() + new PatchedEvent(this, 0, Nil, oldLength) + } + for (listener <- publisher) { + listener.patched(event) + } + } + + private val publisher = new SafeBuffer[PatchedListener[A]] + type All[+A] = SeqOpsIterable[A] + + private[Binding] var cacheData: Cache = emptyCacheData + + override protected def value: All[A] = cacheData + + override protected def removePatchedListener(listener: PatchedListener[A]): Unit = { + publisher -= listener + if (publisher.isEmpty) { + observable.removeChangedListener(this) + } + } + + override protected def addPatchedListener(listener: PatchedListener[A]): Unit = { + if (publisher.isEmpty) { + observable.value.foreach(appendCache) + observable.addChangedListener(this) + } + publisher += listener + } + + } + + private class RxConcat[A](var observables: LazyList[Rx.Observable[A]]) + extends Rx.Observable[A] + with ChangedListener[Option[A]] { + private var pending = false + def changed(upstreamEvent: ChangedEvent[Option[A]]): Unit = { + if (pending) { + throw new IllegalStateException( + "Must not trigger a changed event when the listener is just added to a Binding" + ) + } + val newValue = upstreamEvent.newValue match { + case None => + observables match { + case head #:: tail => + if (head != upstreamEvent.getSource) { + throw new IllegalStateException( + "This ChangedListener should have been removed from the terminated observable." + ) + } + head.removeChangedListener(this) + observables = tail + nextLivingValue() + case _ => + throw new IllegalStateException( + "This ChangedListener should not be triggered when all observables have been terminated." + ) + } + case someValue => + someValue + } + val event = new ChangedEvent(RxConcat.this, newValue) + for (listener <- publisher) { + listener.changed(event) + } + } + + @tailrec + private def nextLivingValue(): Option[A] = { + observables match { + case head #:: tail => + pending = true + head.addChangedListener(this) + pending = false + head.value match { + case None => + head.removeChangedListener(this) + observables = tail + nextLivingValue() + case someValue => + someValue + } + case _ => + None + } + } + + private val publisher = new SafeBuffer[ChangedListener[Option[A]]] + protected def value: Option[A] = observables.headOption.flatMap(_.value) + protected def addChangedListener(listener: ChangedListener[Option[A]]): Unit = { + if (publisher.isEmpty) { + nextLivingValue() + } + publisher += listener + } + protected def removeChangedListener(listener: ChangedListener[Option[A]]): Unit = { + publisher -= listener + observables match { + case head #:: _ if publisher.isEmpty => + head.removeChangedListener(this) + case _ => + } + } + + } + + /** Reactive operators for [[Observable]]s. + * + * @see + * [[http://reactivex.io/ ReactiveX]] + * @note + * [[Rx]] operators are incomplete. Feel free to create a Pull Request if you need a certain operator. + */ + object Rx { + + /** A [[Binding]] that can be terminated. + * + * Once the value turned into a [[scala.None]], this [[Observable]] would be considered as terminated, and any + * future changes of this [[Observable]] will be ignored by any [[Rx]] operators derived from this [[Observable]], + * even if this [[Observable]] turns into a [[scala.Some]] value again. + * + * @note + * Even though an [[Observable]] is technically a [[Binding]], an [[Observable]] created from a [[Rx]] operator + * does not actually indicates data-binding. + * + * For example, given an [[Observable]] created from [[Rx.concat]], + * {{{ + * import com.thoughtworks.binding.Binding._ + * val sourceObservable0 = Var[Option[String]](Some("0")) + * val sourceObservable1 = Var[Option[String]](Some("1")) + * val sourceObservables = List(sourceObservable0, sourceObservable1) + * val derivedObservable = Rx.concat(sourceObservables) + * derivedObservable.watch() + * }}} + * + * when a source value gets changed, + * + * {{{ + * val originalDerivedObservableValue = derivedObservable.get + * sourceObservable0.value = None + * }}} + * + * and the source value is changed back to the original value, + * + * {{{ + * sourceObservable0.value = Some("0") + * }}} + * + * then the value of the derived observable might not be the original value. + * + * {{{ + * derivedObservable.get shouldNot be(originalDerivedObservableValue) + * }}} + * + * In contrast, if the `concat` operator is implemented by ordinary [[Binding.bind]] macros, the derived Binding is + * indeed a data-binding, i.e. it always perform the same calculation for the same values of source [[Binding]]s. + * + * {{{ + * import com.thoughtworks.binding.Binding._ + * val sourceBinding0 = Var[Option[String]](Some("0")) + * val sourceBinding1 = Var[Option[String]](Some("1")) + * val sourceBindings = List(sourceBinding0, sourceBinding1) + * def concatBinding( + * sourceBindings: collection.LinearSeq[Rx.Observable[String]] + * ): Rx.Observable[String] = { + * sourceBindings match { + * case head +: tail => + * Binding { + * head.bind match { + * case None => + * concatBinding(tail).bind + * case someValue => + * someValue + * } + * } + * case _ => + * Constant(None) + * } + * } + * val derivedBinding = concatBinding(sourceBindings) + * derivedBinding.watch() + * val originalDerivedBindingValue = derivedBinding.get + * sourceBinding0.value = None + * sourceBinding0.value = Some("0") + * derivedBinding.get should be(originalDerivedBindingValue) + * }}} + */ + type Observable[A] = Binding[Option[A]] + + /** Emit the emissions from two or more [[Observable]]s without interleaving them. + * + * @see + * [[http://reactivex.io/documentation/operators/concat.html ReactiveX - Concat operator]] + * + * @example + * Given a sequence of [[Observable]]s, + * {{{ + * import com.thoughtworks.binding.Binding._, BindingInstances.monadSyntax._ + * val observable0 = Var[Option[String]](None) + * val observable1 = Var[Option[String]](Some("1")) + * val observable2 = Var[Option[String]](Some("2")) + * val observable3 = Var[Option[String]](None) + * val observable4 = Var[Option[String]](None) + * + * val observable7 = Var[Option[String]](Some("7")) + * + * val observable8 = Binding { observable7.bind.map { v => s"8-$v-derived" } } + * val observable5 = Binding { observable7.bind.map { v => s"5-$v-derived" } } + * + * val observable6 = Var[Option[String]](None) + * val observable9 = Var[Option[String]](Some("9")) + * val observables = Seq( + * observable0, + * observable1, + * observable2, + * observable3, + * observable4, + * observable5, + * observable6, + * observable7, + * observable8, + * observable9, + * ) + * }}} + * + * when concatenate them together, + * + * {{{ + * val concatenated = Rx.concat(observables).map(identity) + * concatenated.watch() + * }}} + * + * the concatenated value should be the first [[scala.Some]] value in the sequence of observables; + * + * {{{ + * concatenated.get should be(Some("1")) + * }}} + * + * when the current observable becomes `None`, + * {{{ + * observable1.value = None + * }}} + * + * the concatenated value should be the next [[scala.Some]] value in the sequence of observables, + * + * {{{ + * concatenated.get should be(Some("2")) + * }}} + * + * even when the next [[scala.Some]] value is derived from another [[Binding]]; + * + * {{{ + * observable2.value = None + * concatenated.get should be(Some("5-7-derived")) + * }}} + * + * when the value of the upstream [[Binding]] is changed to another [[scala.Some]] value, + * + * {{{ + * observable7.value = Some("7-running") + * }}} + * + * the concatenated value should be changed accordingly; + * {{{ + * concatenated.get should be(Some("5-7-running-derived")) + * }}} + * + * when multiple observables become [[scala.None]] at once, + * + * {{{ + * observable7.value = None + * }}} + * + * they all should be skipped when calculate the concatenated value; + * {{{ + * concatenated.get should be(Some("9")) + * }}} + * + * when the last observable in the sequence becomes [[scala.None]], + * {{{ + * observable9.value = None + * }}} + * + * the concatenated value should become [[scala.None]] permanently, + * + * {{{ + * concatenated.get should be(None) + * }}} + * + * even when some observables in the sequence become [[scala.Some]] again. + * + * {{{ + * observable9.value = Some("9-after-termination") + * concatenated.get should be(None) + * observable7.value = Some("7-after-termination") + * concatenated.get should be(None) + * }}} + */ + def concat[A](observables: IterableOnce[Observable[A]]): Observable[A] = { + new RxConcat(LazyList.from(observables)) + } + + /** @see + * [[http://reactivex.io/documentation/operators/repeat.html ReactiveX - Repeat operator]] + */ + def repeat[A](source: => Observable[A]): Observable[A] = { + new RxConcat(LazyList.continually(source)) + } + + /** @see + * [[http://reactivex.io/documentation/operators/merge.html ReactiveX - Merge operator]] + */ + def merge[A](bindingSeq: BindingSeq[A]): Observable[A] = { + new RxMerge(bindingSeq) + } + + /** do not create the Observable until the observer subscribes + * + * @see + * [[http://reactivex.io/documentation/operators/defer.html ReactiveX - Defer operator]] + * + * @note + * This [[defer]] is slightly different from other implementation the + * [[http://reactivex.io/documentation/operators/defer.html ReactiveX Defer]] operator, because this [[defer]] + * shares the same upstream [[Observable]] instance for all subscribes. + * + * @example + * Circular referenced [[Observable]]s can be created with the help of [[defer]] + * {{{ + * import Binding._ + * val source = Var("init") + * lazy val observable1: Rx.Observable[String] = + * Binding[Option[String]] { + * source.bind match { + * case "init" => + * None + * case v => + * observable2.bind.map(_ + "_" + v) + * } + * } + * + * lazy val observable2: Rx.Observable[String] = Rx.defer( + * Binding[Option[String]] { + * Some(observable1.getClass.getSimpleName) + * } + * ) + * }}} + * + * Initially, `observable1` did not subscribe `observable2` because `source` is `init`, + * {{{ + * observable1.watch() + * }}} + * therefore observable2 should be `None`, + * {{{ + * observable1.get should be (None) + * observable2.get should be (None) + * }}} + * when `source` changed, + * {{{ + * source.value = "changed" + * }}} + * `observable1` should subscribe `observable2`, and there should be `Some` values. + * {{{ + * observable1.get should be (Some("FlatMap_changed")) + * observable2.get should be (Some("FlatMap")) + * }}} + * Even though circular referenced [[Observable]]s can be created in this way, their calculation must not be + * mutually dependent. + */ + def defer[A](upstream: => Observable[A]): Observable[A] = { + new RxDefer(upstream) + } + + /** Combine multiple [[Observable]]s into one by merging their emissions. + * + * @see + * [[http://reactivex.io/documentation/operators/merge.html ReactiveX - Merge operator]] + * + * @example + * Given a sequence of [[Observable]]s, + * {{{ + * import com.thoughtworks.binding.Binding._, BindingInstances.monadSyntax._ + * val observable0 = Var[Option[String]](None) + * val observable1 = Var[Option[String]](Some("1")) + * val observable2 = Var[Option[String]](Some("2")) + * val observable3 = Var[Option[String]](None) + * val observable4 = Var[Option[String]](None) + * + * val observable7 = Var[Option[String]](Some("7")) + * + * val observable8 = Binding { observable7.bind.map { v => s"8-$v-derived" } } + * val observable5 = Binding { observable7.bind.map { v => s"5-$v-derived" } } + * + * val observable6 = Var[Option[String]](None) + * val observable9 = Var[Option[String]](Some("9")) + * val observables = Seq( + * observable0, + * observable1, + * observable2, + * observable3, + * observable4, + * observable5, + * observable6, + * observable7, + * observable8, + * observable9, + * ) + * }}} + * + * when merge them together, + * + * {{{ + * val merged = Rx.merge(observables).map(identity) + * merged.watch() + * }}} + * + * the merged value should be the first [[scala.Some]] value in the sequence of observables; + * + * {{{ + * merged.get should be(Some("1")) + * }}} + * + * when the some but not all of the observable becomes `None`, + * + * {{{ + * observable1.value = None + * }}} + * + * the merged value should be unchanged, + * + * {{{ + * merged.get should be(Some("1")) + * }}} + * + * when any of the observable becomes `Some` value, + * + * {{{ + * observable2.value = Some("2-changed") + * }}} + * + * the merged value should be the new value, + * + * {{{ + * merged.get should be(Some("2-changed")) + * }}} + * + * even when a previous `None` observable becomes `Some` value,, + * + * {{{ + * observable3.value = Some("3-previous-None") + * }}} + * + * the merged value should be the new value of the previous `None` observable, + * + * {{{ + * merged.get should be(Some("3-previous-None")) + * }}} + * + * when multiple observables are changed at once, + * + * {{{ + * observable7.value = Some("7-changed") + * }}} + * + * the merged value should be the value of the last derived observable + * + * {{{ + * merged.get should be(Some("8-7-changed-derived")) + * }}} + * + * when all the observables become `None`, + * + * {{{ + * observable1.value = None + * merged.get should be(Some("8-7-changed-derived")) + * observable2.value = None + * merged.get should be(Some("8-7-changed-derived")) + * observable3.value = None + * merged.get should be(Some("8-7-changed-derived")) + * observable6.value = None + * merged.get should be(Some("8-7-changed-derived")) + * observable7.value = None + * merged.get should be(Some("8-7-changed-derived")) + * observable9.value = None + * merged.get should be(None) + * }}} + */ + def merge[A](observables: IterableOnce[Observable[A]]): Observable[A] = { + new RxMerge( + new Constants(toConstantsData(observables)).flatMapBinding( + BindingInstances.map(_) { option => + new Constants(toConstantsData(option)) + } + ) + ) + } + + /** convert an Observable into a [[BindingSeq]]. + * + * @see + * [[http://reactivex.io/documentation/operators/to.html ReactiveX - To operator]] + * + * @example + * Given a source observable, + * {{{ + * import com.thoughtworks.binding.Binding._ + * val observable = Var[Option[String]](Some("1")) + * }}} + * + * when converting it into a [[BindingSeq]], + * + * {{{ + * val bindingSeq = Rx.toBindingSeq(observable) + * }}} + * + * and flat-mapping to the result, + * {{{ + * val result = new BindingSeq.FlatMap( + * bindingSeq, + * { value: String => Constants("the value is", value) } + * ).all + * result.watch() + * }}} + * + * then result should have the values corresponding to the source observable, + * {{{ + * result.get.toSeq should contain theSameElementsInOrderAs Seq("the value is", "1") + * }}} + * + * when the source observable changes, + * {{{ + * observable.value = Some("2") + * }}} + * + * then the corresponding new value should be appended to the result, + * {{{ + * result.get.toSeq should contain theSameElementsInOrderAs Seq( + * "the value is", "1", + * "the value is", "2" + * ) + * }}} + * + * when the source observable terminates, + * {{{ + * observable.value = None + * }}} + * + * then the result should be empty + * {{{ + * result.get.toSeq should be(empty) + * }}} + */ + def toBindingSeq[A](observable: Observable[A]): BindingSeq[A] = { + new RxToBindingSeq(observable) + } + + } + +} + +/** A data binding expression that represents a value that automatically recalculates when its dependencies change. + * + * @example + * You may create a data binding expression via `Binding { ??? }` block annotation. + * + * {{{ + * val bindingInt: Binding[Int] = Binding { 100 } + * }}} + * + * A data binding expression may depend on other binding expressions via [[bind]] method: + * + * {{{ + * val bindingString: Binding[String] = Binding { bindingInt.bind.toString } + * }}} + * + * @author + * 杨博 (Yang Bo) <pop.atry@gmail.com> + */ +trait Binding[+A] extends Binding.Watchable[A] { + + /** Returns the current value of this [[Binding]] and marks the current `@dom` method depend on this [[Binding]]. + * + * Each time the value changes, in the current `@dom` method, all code after the current `bind` expression will be + * re-evaluated if the current `@dom` method is [[#watch watch]]ing. However, code in current `@dom` method and + * before the current `bind` expression will not be re-evaluated. The above rule is not applied to DOM nodes created + * by XHTML literal. A `bind` expression under a DOM node does not affect siblings and parents of that node. + * + * @note + * This method must be invoked inside a `@dom` method body or a `Binding { ... }` block.. + */ + final def bind: A = macro Binding.Macros.bind + + private[binding] def get: A = value + + /** Returns the current value of this [[Binding]] + * + * @note + * This method must not be invoked inside a `@dom` method body or a `Binding { ... }` block.. + */ + protected def value: A + + protected def removeChangedListener(listener: Binding.ChangedListener[A]): Unit + + protected def addChangedListener(listener: Binding.ChangedListener[A]): Unit + + /** Enable automatic recalculation. + * + * You may invoke this method more than once. Then, when you want to disable automatic recalculation, you must invoke + * [[#unwatch unwatch]] same times as the number of calls to this method. + * + * @note + * This method is recursive, which means that the dependencies of this [[Binding]] will be watched as well. + */ + @inline + final def watch(): Unit = { + addChangedListener(Binding.DummyChangedListener) + } + + /** Disable automatic recalculation. + * + * @note + * This method is recursive, which means that the dependencies of this [[Binding]] will be unwatched as well. + */ + @inline + final def unwatch(): Unit = { + removeChangedListener(Binding.DummyChangedListener) + } + +} diff --git a/Binding/src/test/scala/com/thoughtworks/binding/BindingTest.scala b/Binding/src/test/scala/com/thoughtworks/binding/BindingTest.scala new file mode 100644 index 00000000..3c7267c9 --- /dev/null +++ b/Binding/src/test/scala/com/thoughtworks/binding/BindingTest.scala @@ -0,0 +1,831 @@ +/* +The MIT License (MIT) + +Copyright (c) 2016 Yang Bo & REA Group Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +package com.thoughtworks.binding + +import scala.collection.mutable.ArrayBuffer +import scala.collection.mutable.Buffer + +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers +import scalaz._ + +import Binding._ +import BindingSeq.removePatchedListener +import BindingSeq.addPatchedListener + +final class BindingTest extends AnyFreeSpec with Matchers { + + final class BufferListener extends ArrayBuffer[Any] { + val listener = new ChangedListener[Any] with PatchedListener[Any] { + override def changed(event: ChangedEvent[Any]): Unit = { + BufferListener.this += event + } + + override def patched(event: PatchedEvent[Any]): Unit = { + BufferListener.this += event + } + } + } + + "hello world" in { + val target = Var("World") + val hello = Binding { + "Hello, " + target.bind + "!" + } + hello.watch() + + assert(hello.get == "Hello, World!") + target.value = "Each" + assert(hello.get == "Hello, Each!") + } + + "TripleBinding" in { + val input = Var(0) + val output = Binding { + input.bind + input.bind + input.bind + } + output.watch() + assert(output.get == 0) + for (i <- 0 until 10) { + input.value = i + assert(output.get == i * 3) + } + } + + "DataBindingShouldBeSupportedByScalaz" in { + + val expr3: Var[Int] = new Var(2000) + + val expr4: Binding[Int] = Binding { + 30000 + } + + val expr2: Binding[Int] = Binding { + expr3.bind + expr4.bind + } + + val expr1: Binding[Int] = Binding { + expr2.bind + 100 + } + + var resultChanged = 0 + + assert(expr1.get == 0) + + addChangedListener( + expr1, + new ChangedListener[Any] { + override def changed(event: ChangedEvent[Any]): Unit = { + resultChanged += 1 + } + } + ) + + assert(resultChanged == 0) + assert(expr1.get == 32100) + + expr3.value = 4000 + + assert(resultChanged == 1) + assert(expr1.get == 34100) + + } + + "CacheShouldBeUpdated" in { + val source = new Var(2.0) + val constant = new Constant(1.0) + val result = Binding { + val sourceValue = source.bind + val one = sourceValue / sourceValue / constant.bind + one / sourceValue + } + var resultChanged = 0 + + addChangedListener( + result, + new ChangedListener[Any] { + override def changed(event: ChangedEvent[Any]): Unit = { + resultChanged += 1 + } + } + ) + assert(result.get == 0.5) + assert(resultChanged == 0) + source.value = 4.0 + assert(result.get == 0.25) + assert(resultChanged == 1) + } + + "ForYieldWithFilter" in { + val prefix = new Var("ForYield") + val source = Vars(1, 2, 3) + val mapped = (for { + sourceElement <- source + if prefix.bind != sourceElement.toString + i <- Constants((0 until sourceElement): _*) + } yield { + raw"""${prefix.bind} $i/$sourceElement""" + }) + val mappedEvents = new BufferListener + val sourceEvents = new BufferListener + addPatchedListener(mapped, mappedEvents.listener) + assert(source.publisher.nonEmpty) + addPatchedListener(source, sourceEvents.listener) + assert(source.publisher.nonEmpty) + + assert(sourceEvents == ArrayBuffer.empty) + source.value.clear() + assert(sourceEvents.length == 1) + assert(mappedEvents.length == 1) + sourceEvents(0) match { + case event: PatchedEvent[_] => + assert(event.that.isEmpty) + assert(event.from == 0) + assert(event.replaced == 3) + assert(event.getSource == source) + } + mappedEvents(0) match { + case event: PatchedEvent[_] => + assert(event.that.isEmpty) + assert(event.from == 0) + assert(event.replaced == 6) + assert(event.getSource == mapped) + } + source.value ++= Seq(2, 3, 4) + assert(sourceEvents.length == 2) + assert(mappedEvents.length == 2) + sourceEvents(1) match { + case event: PatchedEvent[_] => + assert(event.from == 0) + assert(event.replaced == 0) + assert(event.that sameElements Seq(2, 3, 4)) + assert(event.getSource == source) + } + mappedEvents(1) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.from == 0) + assert(event.replaced == 0) + assert( + event.that sameElements Seq( + "ForYield 0/2", + "ForYield 1/2", + "ForYield 0/3", + "ForYield 1/3", + "ForYield 2/3", + "ForYield 0/4", + "ForYield 1/4", + "ForYield 2/4", + "ForYield 3/4" + ) + ) + } + source.value += 0 + assert(sourceEvents.length == 3) + assert(mappedEvents.length == 2) + sourceEvents(2) match { + case event: PatchedEvent[_] => + assert(event.getSource == source) + assert(event.from == 3) + assert(event.replaced == 0) + assert(event.that sameElements Seq(0)) + } + source.value += 3 + assert(sourceEvents.length == 4) + assert(mappedEvents.length == 3) + sourceEvents(3) match { + case event: PatchedEvent[_] => + assert(event.getSource == source) + assert(event.from == 4) + assert(event.replaced == 0) + assert(event.that sameElements Seq(3)) + } + mappedEvents(2) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.from == 9) + assert(event.replaced == 0) + assert(event.that sameElements Seq("ForYield 0/3", "ForYield 1/3", "ForYield 2/3")) + } + assert( + mapped.get sameElements Seq( + "ForYield 0/2", + "ForYield 1/2", + "ForYield 0/3", + "ForYield 1/3", + "ForYield 2/3", + "ForYield 0/4", + "ForYield 1/4", + "ForYield 2/4", + "ForYield 3/4", + "ForYield 0/3", + "ForYield 1/3", + "ForYield 2/3" + ) + ) + prefix.value = "3" + assert(sourceEvents.length == 4) + assert(mapped.get sameElements Seq("3 0/2", "3 1/2", "3 0/4", "3 1/4", "3 2/4", "3 3/4")) + + removePatchedListener(mapped, mappedEvents.listener) + removePatchedListener(source, sourceEvents.listener) + + assert(source.publisher.isEmpty) + } + + "ForYield" in { + val prefix = new Var("ForYield") + val source = Vars(1, 2, 3) + val mapped = (for { + sourceElement <- source + i <- Constants((0 until sourceElement): _*) + } yield { + raw"""${prefix.bind} $i/$sourceElement""" + }) + val mappedEvents = new BufferListener + val sourceEvents = new BufferListener + addPatchedListener(mapped, mappedEvents.listener) + assert(source.publisher.nonEmpty) + addPatchedListener(source, sourceEvents.listener) + assert(source.publisher.nonEmpty) + + assert(sourceEvents == ArrayBuffer.empty) + source.value.clear() + assert(mappedEvents.length == 1) + assert(sourceEvents.length == 1) + sourceEvents(0) match { + case event: PatchedEvent[_] => + assert(event.that.isEmpty) + assert(event.from == 0) + assert(event.replaced == 3) + assert(event.getSource == source) + } + mappedEvents(0) match { + case event: PatchedEvent[_] => + assert(event.that.isEmpty) + assert(event.from == 0) + assert(event.replaced == 6) + assert(event.getSource == mapped) + } + source.value ++= Seq(2, 3, 4) + assert(mappedEvents.length == 2) + assert(sourceEvents.length == 2) + sourceEvents(1) match { + case event: PatchedEvent[_] => + assert(event.from == 0) + assert(event.replaced == 0) + assert(event.that sameElements Seq(2, 3, 4)) + assert(event.getSource == source) + } + mappedEvents(1) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.from == 0) + assert(event.replaced == 0) + assert( + event.that sameElements Seq( + "ForYield 0/2", + "ForYield 1/2", + "ForYield 0/3", + "ForYield 1/3", + "ForYield 2/3", + "ForYield 0/4", + "ForYield 1/4", + "ForYield 2/4", + "ForYield 3/4" + ) + ) + } + source.value += 0 + assert(sourceEvents.length == 3) + assert(mappedEvents.length == 2) + sourceEvents(2) match { + case event: PatchedEvent[_] => + assert(event.getSource == source) + assert(event.from == 3) + assert(event.replaced == 0) + assert(event.that sameElements Seq(0)) + } + source.value += 3 + assert(sourceEvents.length == 4) + assert(mappedEvents.length == 3) + sourceEvents(3) match { + case event: PatchedEvent[_] => + assert(event.getSource == source) + assert(event.from == 4) + assert(event.replaced == 0) + assert(event.that sameElements Seq(3)) + } + mappedEvents(2) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.from == 9) + assert(event.replaced == 0) + assert(event.that sameElements Seq("ForYield 0/3", "ForYield 1/3", "ForYield 2/3")) + } + prefix.value = "p" + assert(sourceEvents.length == 4) + assert(mappedEvents.length == 15) + val expected = + Seq("p 0/2", "p 1/2", "p 0/3", "p 1/3", "p 2/3", "p 0/4", "p 1/4", "p 2/4", "p 3/4", "p 0/3", "p 1/3", "p 2/3") + for (i <- 0 until 12) { + mappedEvents(i + 3) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.replaced == 1) + assert(event.that sameElements Seq(expected(event.from))) + } + } + + removePatchedListener(mapped, mappedEvents.listener) + removePatchedListener(source, sourceEvents.listener) + + assert(source.publisher.isEmpty) + } + + "FlatMappedVarBuffer" in { + val prefix = new Var("") + val source = Vars(1, 2, 3) + val mapped = new BindingSeq.FlatMap( + source, + { sourceElement: Int => + new BindingSeq.MapBinding( + Constants((0 until sourceElement): _*), + { i: Int => + Binding { + raw"""${prefix.bind}$sourceElement""" + } + } + ) + } + ) + val mappedEvents = new BufferListener + val sourceEvents = new BufferListener + addPatchedListener(mapped, mappedEvents.listener) + assert(mapped.publisher.nonEmpty) + assert(source.publisher.nonEmpty) + addPatchedListener(source, sourceEvents.listener) + assert(mapped.publisher.nonEmpty) + assert(source.publisher.nonEmpty) + + assert(sourceEvents == ArrayBuffer.empty) + source.value.clear() + assert(mappedEvents.length == 1) + assert(sourceEvents.length == 1) + sourceEvents(0) match { + case event: PatchedEvent[_] => + assert(event.that.isEmpty) + assert(event.from == 0) + assert(event.replaced == 3) + assert(event.getSource == source) + } + mappedEvents(0) match { + case event: PatchedEvent[_] => + assert(event.that.isEmpty) + assert(event.from == 0) + assert(event.replaced == 6) + assert(event.getSource == mapped) + } + source.value ++= Seq(2, 3, 4) + assert(mappedEvents.length == 2) + assert(sourceEvents.length == 2) + sourceEvents(1) match { + case event: PatchedEvent[_] => + assert(event.from == 0) + assert(event.replaced == 0) + assert(event.that sameElements Seq(2, 3, 4)) + assert(event.getSource == source) + } + mappedEvents(1) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.from == 0) + assert(event.replaced == 0) + assert(event.that sameElements Seq("2", "2", "3", "3", "3", "4", "4", "4", "4")) + } + source.value += 0 + assert(sourceEvents.length == 3) + assert(mappedEvents.length == 2) + sourceEvents(2) match { + case event: PatchedEvent[_] => + assert(event.getSource == source) + assert(event.from == 3) + assert(event.replaced == 0) + assert(event.that sameElements Seq(0)) + } + source.value += 3 + assert(sourceEvents.length == 4) + assert(mappedEvents.length == 3) + sourceEvents(3) match { + case event: PatchedEvent[_] => + assert(event.getSource == source) + assert(event.from == 4) + assert(event.replaced == 0) + assert(event.that sameElements Seq(3)) + } + mappedEvents(2) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.from == 9) + assert(event.replaced == 0) + assert(event.that sameElements Seq("3", "3", "3")) + } + prefix.value = "p" + assert(sourceEvents.length == 4) + assert(mappedEvents.length == 15) + val expected = Seq("p2", "p2", "p3", "p3", "p3", "p4", "p4", "p4", "p4", "p3", "p3", "p3") + for (i <- 0 until 12) { + mappedEvents(i + 3) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.replaced == 1) + assert(event.that sameElements Seq(expected(event.from))) + } + } + + removePatchedListener(mapped, mappedEvents.listener) + removePatchedListener(source, sourceEvents.listener) + + assert(mapped.publisher.isEmpty) + assert(source.publisher.isEmpty) + } + + "MappedVarBuffer" in { + val prefix = new Var("") + val source = Vars(1, 2, 3) + val mapped = new BindingSeq.MapBinding( + source, + { a: Int => + Binding { + raw"""${prefix.bind}${a}""" + } + } + ) + val mappedEvents = new BufferListener + val sourceEvents = new BufferListener + addPatchedListener(mapped, mappedEvents.listener) + assert(mapped.publisher.nonEmpty) + assert(source.publisher.nonEmpty) + addPatchedListener(source, sourceEvents.listener) + assert(mapped.publisher.nonEmpty) + assert(source.publisher.nonEmpty) + + assert(sourceEvents == ArrayBuffer.empty) + source.value.clear() + assert(mappedEvents.length == 1) + assert(sourceEvents.length == 1) + sourceEvents(0) match { + case event: PatchedEvent[_] => + assert(event.that.isEmpty) + assert(event.from == 0) + assert(event.replaced == 3) + assert(event.getSource == source) + } + mappedEvents(0) match { + case event: PatchedEvent[_] => + assert(event.that.isEmpty) + assert(event.from == 0) + assert(event.replaced == 3) + assert(event.getSource == mapped) + } + source.value ++= Seq(2, 3, 4) + assert(mappedEvents.length == 2) + assert(sourceEvents.length == 2) + sourceEvents(1) match { + case event: PatchedEvent[_] => + assert(event.from == 0) + assert(event.replaced == 0) + assert(event.that sameElements Seq(2, 3, 4)) + assert(event.getSource == source) + } + mappedEvents(1) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.from == 0) + assert(event.replaced == 0) + assert(event.that sameElements Seq("2", "3", "4")) + } + source.value += 20 + assert(sourceEvents.length == 3) + assert(mappedEvents.length == 3) + sourceEvents(2) match { + case event: PatchedEvent[_] => + assert(event.getSource == source) + assert(event.from == 3) + assert(event.replaced == 0) + assert(event.that sameElements Seq(20)) + } + mappedEvents(2) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.from == 3) + assert(event.replaced == 0) + assert(event.that sameElements Seq("20")) + } + 300 +=: source.value + assert(mappedEvents.length == 4) + assert(sourceEvents.length == 4) + sourceEvents(3) match { + case event: PatchedEvent[_] => + assert(event.getSource == source) + assert(event.from == 0) + assert(event.replaced == 0) + assert(event.that sameElements Seq(300)) + } + mappedEvents(3) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.from == 0) + assert(event.replaced == 0) + assert(event.that sameElements Seq("300")) + } + prefix.value = "p" + assert(sourceEvents.length == 4) + assert(mappedEvents.length == 9) + val expected = Seq("p300", "p2", "p3", "p4", "p20") + for (i <- 0 until 5) { + mappedEvents(i + 4) match { + case event: PatchedEvent[_] => + assert(event.getSource == mapped) + assert(event.replaced == 1) + assert(event.that sameElements Seq(expected(event.from))) + } + } + + removePatchedListener(mapped, mappedEvents.listener) + removePatchedListener(source, sourceEvents.listener) + + assert(mapped.publisher.isEmpty) + assert(source.publisher.isEmpty) + } + + "Length" in { + val source = Vars(1) + val length = source.length + val lengthEvents = new BufferListener + addChangedListener(length, lengthEvents.listener) + val length2 = source.length + length2.watch() + length2.unwatch() + source.value(0) = 100 + assert(lengthEvents.length == 1) + lengthEvents(0) match { + case event: ChangedEvent[_] => + assert(event.getSource == length) + assert(event.newValue == 1) + } + + source.value += 200 + assert(lengthEvents.length == 2) + lengthEvents(1) match { + case event: ChangedEvent[_] => + assert(event.getSource == length) + assert(event.newValue == 2) + } + + source.value -= 100 + assert(lengthEvents.length == 3) + lengthEvents(2) match { + case event: ChangedEvent[_] => + assert(event.getSource == length) + assert(event.newValue == 1) + } + + } + + "WithFilter" in { + Binding { + val myVars = Vars(1, 2, 100, 3) + val filtered = myVars.withFilter(_ < 10).map(x => x) + filtered.watch() + assert(filtered.get sameElements Seq(1, 2, 3)) + } + } + + "++=" in { + val myVars = Vars(1, 2, 3) + myVars.watch() + myVars.value ++= Seq(4, 5) + assert(myVars.value sameElements Seq(1, 2, 3, 4, 5)) + } + + "ScalaRxLeakExample" in { + import scalaz._, Scalaz._ + + var count: Int = 0 + val a: Var[Int] = Var(1) + val b: Var[Int] = Var(2) + def mkRx(i: Int) = (b: Binding[Int]).map { v => + count += 1; i + v + } + + val c: Binding[Int] = (a: Binding[Int]).flatMap(mkRx) + c.watch() + + var result: (Int, Int) = null + assert((3, 1) == ((c.get, count))) + + a.value = 4 + assert((6, 2) == ((c.get, count))) + + b.value = 3 + assert((7, 3) == ((c.get, count))) + + (0 to 100).foreach { i => + a.value = i + } + assert((103, 104) == ((c.get, count))) + + b.value = 4 + assert((104, 105) == ((c.get, count))) + } + + "multi to one dependencies" in { + import scalaz.syntax.all._ + import scalaz._ + + val a: Var[Int] = Var(100) + val b: Var[Int] = Var(200) + var aFlushCount = 0 + var bFlushCount = 0 + val aPlusOne = (a: Binding[Int]).map { value => + aFlushCount += 1 + value + 1 + } + val bPlusOne = (b: Binding[Int]).map { value => + bFlushCount += 1 + value + 1 + } + val aPlusOneTimesBPlusOn = Binding.BindingInstances.apply2(aPlusOne, bPlusOne) { (aValue, bValue) => + aValue * bValue + } + aPlusOneTimesBPlusOn.watch() + aPlusOneTimesBPlusOn.get should be((100 + 1) * (200 + 1)) + aFlushCount should be(1) + bFlushCount should be(1) + a.value = 500 + aPlusOneTimesBPlusOn.get should be((500 + 1) * (200 + 1)) + aFlushCount should be(2) + bFlushCount should be(2) + b.value = 600 + aPlusOneTimesBPlusOn.get should be((500 + 1) * (600 + 1)) + aFlushCount should be(2) + bFlushCount should be(3) + + } + + "for / yield / if" in { + def domMethod() = Binding { + val myVars = Vars(1, 2, 100, 3) + val filtered = for { + myVar <- myVars + if myVar < 10 + } yield myVar + filtered.watch() + assert(filtered.get sameElements Seq(1, 2, 3)) + } + domMethod() + } + + "flatMap" in { + val flatMapped = Constants(Constants(1, 2), Constants(), Constants(3)).flatMap(identity) + flatMapped.watch() + assert(flatMapped.get sameElements Seq(1, 2, 3)) + } + + "foreach" in { + val vars = Vars(Var(1), Var(2), Var(3)) + val logs = new StringBuilder + + val mounting = Binding { + logs ++= "Binding\n" + for (i <- vars) { + logs ++= s"creating mount point ${i.value}\n" + new SingleMountPoint(i) { + override def mount() = { + super.mount() + logs ++= s"mount ${i.value}\n" + } + override def unmount() = { + logs ++= s"unmount ${i.value}\n" + super.unmount() + } + override def set(newValue: Int) = { + logs ++= s"set ${newValue}\n" + } + }.bind + } + } + logs.toString should be("Binding\n") + logs.clear() + + mounting.watch() + logs.toString should be("""creating mount point 1 +creating mount point 2 +creating mount point 3 +set 1 +mount 1 +set 2 +mount 2 +set 3 +mount 3 +""") + logs.clear() + + vars.value -= vars.value(1) + logs.toString should be("""unmount 2 +""") + logs.clear() + + vars.value(1).value += 5 + logs.toString should be("""set 8 +""") + logs.clear() + + vars.value ++= Seq(Var(10), Var(20)) + logs.toString should be("""creating mount point 10 +creating mount point 20 +set 10 +mount 10 +set 20 +mount 20 +""") + logs.clear() + + vars.value(0) = Var(100) + logs.toString should be("""creating mount point 100 +set 100 +mount 100 +unmount 1 +""") + logs.clear() + + vars.value.prependAll(Seq(Var(1000), Var(2000))) + logs.toString should be("""creating mount point 1000 +creating mount point 2000 +set 1000 +mount 1000 +set 2000 +mount 2000 +""") + logs.clear() + + vars.value.map(_.value) should be(Seq(1000, 2000, 100, 8, 10, 20)) + + mounting.unwatch() + + } + + "vars.all.bind" in { + val vars: Vars[String] = Vars[String]("one", "two", "three") + val bufferBinding = Binding { + val seq = vars.all.bind + seq + } + bufferBinding.watch() + assert(bufferBinding.get == Buffer("one", "two", "three")) + } + + "constants.all.bind" in { + val constants: Constants[String] = Constants[String]("one", "two", "three") + val seqBinding = Binding { + val seq = constants.all.bind + seq + } + seqBinding.watch() + assert(seqBinding.get == Seq("one", "two", "three")) + } + + "bindingSeq.all.bind" in { + val bindingSeq: BindingSeq[String] = Constants[String]("one", "two", "three") + val seqOpsIterableBinding = Binding { + val seq = bindingSeq.all.bind + seq + } + seqOpsIterableBinding.watch() + assert(seqOpsIterableBinding.get == Seq("one", "two", "three")) + } + +} diff --git a/Binding/src/test/scala/com/thoughtworks/binding/BufferListener.scala b/Binding/src/test/scala/com/thoughtworks/binding/BufferListener.scala new file mode 100644 index 00000000..8930e827 --- /dev/null +++ b/Binding/src/test/scala/com/thoughtworks/binding/BufferListener.scala @@ -0,0 +1,45 @@ +/* +The MIT License (MIT) + +Copyright (c) 2016 Yang Bo & REA Group Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +package com.thoughtworks.binding + +import Binding.{PatchedEvent, ChangedEvent, PatchedListener, ChangedListener} +import com.thoughtworks.binding.Binding.{PatchedEvent, ChangedEvent, PatchedListener, ChangedListener} + +import scala.collection.mutable.ArrayBuffer + +/** @author + * 杨博 (Yang Bo) <pop.atry@gmail.com> + */ +final class BufferListener extends ArrayBuffer[Any] { + val listener = new ChangedListener[Seq[Any]] with PatchedListener[Any] { + override def changed(event: ChangedEvent[Seq[Any]]): Unit = { + BufferListener.this += event + } + + override def patched(event: PatchedEvent[Any]): Unit = { + BufferListener.this += event + } + } +} diff --git a/Binding/src/test/scala/com/thoughtworks/binding/regression/FlatMapRemove.scala b/Binding/src/test/scala/com/thoughtworks/binding/regression/FlatMapRemove.scala new file mode 100644 index 00000000..e47d8eac --- /dev/null +++ b/Binding/src/test/scala/com/thoughtworks/binding/regression/FlatMapRemove.scala @@ -0,0 +1,45 @@ +package com.thoughtworks.binding.regression + +import com.thoughtworks.binding.Binding._ +import com.thoughtworks.binding._ +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +import scala.collection.mutable.ArrayBuffer + +/** @author + * 杨博 (Yang Bo) <pop.atry@gmail.com> + */ +final class FlatMapRemove extends AnyFreeSpec with Matchers { + "removed source of a flatMap" in { + + val data = Vars.empty[Either[String, String]] + + val left = for { + s <- data + if s.isLeft + } yield s + + val events = ArrayBuffer.empty[String] + val autoPrint = Binding { + if (left.length.bind > 0) { + events += "has left" + } else { + events += "does not has left" + } + } + assert(events.forall(_ == "does not has left")) + autoPrint.watch() + assert(events.forall(_ == "does not has left")) + data.value += Right("1") + assert(events.forall(_ == "does not has left")) + data.value += Right("2") + assert(events.forall(_ == "does not has left")) + data.value += Right("3") + assert(events.forall(_ == "does not has left")) + data.value(1) = Left("left 2") + assert(events.last == "has left") + data.value --= Seq(Left("left 2")) + assert(events.last == "does not has left") + } +} diff --git a/Binding/src/test/scala/com/thoughtworks/binding/regression/InsertThenClear.scala b/Binding/src/test/scala/com/thoughtworks/binding/regression/InsertThenClear.scala new file mode 100644 index 00000000..65526e28 --- /dev/null +++ b/Binding/src/test/scala/com/thoughtworks/binding/regression/InsertThenClear.scala @@ -0,0 +1,51 @@ +/* +The MIT License (MIT) + +Copyright (c) 2016 Yang Bo & REA Group Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +package com.thoughtworks.binding.regression + +import com.thoughtworks.binding.Binding._ +import com.thoughtworks.binding._ +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +import scala.collection.mutable.ArrayBuffer + +/** @author + * 杨博 (Yang Bo) <pop.atry@gmail.com> + */ +final class InsertThenClear extends AnyFreeSpec with Matchers { + "insert then clear" in { + val items = Vars(1 to 10: _*) + + val mapped = items.map(-_) + mapped.watch() + assert(mapped.get sameElements Seq(-1, -2, -3, -4, -5, -6, -7, -8, -9, -10)) + + items.value.insertAll(3, 100 to 103) + assert(mapped.get sameElements Seq(-1, -2, -3, -100, -101, -102, -103, -4, -5, -6, -7, -8, -9, -10)) + + items.value.clear() + assert(mapped.get sameElements Seq.empty) + } +} diff --git a/Binding/src/test/scala/com/thoughtworks/binding/regression/Issue188.scala b/Binding/src/test/scala/com/thoughtworks/binding/regression/Issue188.scala new file mode 100644 index 00000000..4b560820 --- /dev/null +++ b/Binding/src/test/scala/com/thoughtworks/binding/regression/Issue188.scala @@ -0,0 +1,27 @@ +package com.thoughtworks.binding.regression + +import com.thoughtworks.binding.Binding +import com.thoughtworks.binding.Binding.{Var, Vars} +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +final class Issue188 extends AnyFreeSpec with Matchers { + "non-regression test for https://github.com/ThoughtWorksInc/Binding.scala/issues/188" in { + + val objectCache: Var[Int] = Var(1) + + val objectValue: Binding[Int] = Binding { + val cache = objectCache.bind + + if (cache == 1) { + objectCache.value = 2 + 2 + } else { + cache + } + } + + an[IllegalStateException] should be thrownBy objectValue.watch() + + } +} diff --git a/Binding/src/test/scala/com/thoughtworks/binding/regression/Issue56.scala b/Binding/src/test/scala/com/thoughtworks/binding/regression/Issue56.scala new file mode 100644 index 00000000..681b63c7 --- /dev/null +++ b/Binding/src/test/scala/com/thoughtworks/binding/regression/Issue56.scala @@ -0,0 +1,36 @@ +package com.thoughtworks.binding.regression + +import com.thoughtworks.binding.Binding +import com.thoughtworks.binding.Binding.{Var, Vars} +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +/** Test for https://github.com/ThoughtWorksInc/Binding.scala/issues/56 + * @author + * 杨博 (Yang Bo) + */ +final class Issue56 extends AnyFreeSpec with Matchers { + + "test" in { + var dataSource = Var[Int](100) + val isEnabled = Var[Boolean](false) + + val mappedData = Binding { + dataSource.bind + 1 + } + + val result = Binding { + if (isEnabled.bind) { + mappedData.bind + } else { + 0 + } + } + + result.watch() + dataSource.value = 300 + isEnabled.value = true + result.get should be(301) + } + +} diff --git a/Binding/src/test/scala/com/thoughtworks/binding/regression/Stackoverflow58206168.scala b/Binding/src/test/scala/com/thoughtworks/binding/regression/Stackoverflow58206168.scala new file mode 100644 index 00000000..341dfc6e --- /dev/null +++ b/Binding/src/test/scala/com/thoughtworks/binding/regression/Stackoverflow58206168.scala @@ -0,0 +1,25 @@ +package com.thoughtworks.binding +package regression +import Binding._ +import scala.collection.mutable +import Binding.BindingInstances.functorSyntax._ +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +final class Stackoverflow58206168 extends AnyFreeSpec with Matchers { + // See https://stackoverflow.com/questions/58206168/binding-scala-vars-bind-seems-to-not-work-correctly + "Binding.scala: Vars.bind seems to not work correctly" in { + val events = mutable.Buffer.empty[List[Int]] + val test: Vars[Int] = Vars(1, 2, 3, 4) + + test.all + .map { + events += _.toList + } + .watch() + + test.value.append(1111) + assert(events == mutable.Buffer(List(1, 2, 3, 4), List(1, 2, 3, 4, 1111))) + } + +} diff --git a/Binding/src/test/scala/com/thoughtworks/binding/regression/Zhihu50863924.scala b/Binding/src/test/scala/com/thoughtworks/binding/regression/Zhihu50863924.scala new file mode 100644 index 00000000..932d0447 --- /dev/null +++ b/Binding/src/test/scala/com/thoughtworks/binding/regression/Zhihu50863924.scala @@ -0,0 +1,66 @@ +/* +The MIT License (MIT) + +Copyright (c) 2016 Yang Bo & REA Group Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + */ + +package com.thoughtworks.binding.regression + +import com.thoughtworks.binding.Binding +import com.thoughtworks.binding.Binding.Var +import org.scalatest.freespec.AnyFreeSpec +import org.scalatest.matchers.should.Matchers + +/** @author + * 杨博 (Yang Bo) <pop.atry@gmail.com> + */ +final class Zhihu50863924 extends AnyFreeSpec with Matchers { + + "Newly created Binding expression should not re-render immediately" in { + + var renderCount0 = 0 + var renderCount1 = 0 + + def subComponent(value: Var[Option[String]]) = Binding { + renderCount0 += 1 + assert(value.bind == Some("Changed")) + renderCount1 += 1 + Right(value.bind.get) + } + + val value: Var[Option[String]] = Var(None) + + val render = Binding { + if (value.bind.isDefined) { + subComponent(value).bind + } else { + Left("None here!") + } + } + + render.watch() + assert(render.get == Left("None here!")) + value.value = Some("Changed") + assert(render.get == Right("Changed")) + assert(renderCount0 == 1) + assert(renderCount1 == 1) + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..a6b582f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Yang Bo & REA Group Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README-zh.md b/README-zh.md new file mode 100644 index 00000000..db0f2151 --- /dev/null +++ b/README-zh.md @@ -0,0 +1,493 @@ +# Binding.scala + +[![Join the chat at https://gitter.im/ThoughtWorksInc/Binding.scala](https://badges.gitter.im/ThoughtWorksInc/Binding.scala.svg)](https://gitter.im/ThoughtWorksInc/Binding.scala?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![Build Status](https://travis-ci.org/ThoughtWorksInc/Binding.scala.svg)](https://travis-ci.org/ThoughtWorksInc/Binding.scala) +[![Maven Central (core funtionality)](https://img.shields.io/maven-central/v/com.thoughtworks.binding/binding_2.11.svg?label=maven-central%20%28Binding.scala%29)](https://maven-badges.herokuapp.com/maven-central/com.thoughtworks.binding/binding_2.11) +[![Maven Central (DOM integration)](https://img.shields.io/maven-central/v/com.thoughtworks.binding/dom_sjs0.6_2.11.svg?label=maven-central%20%28dom.scala%29)](https://maven-badges.herokuapp.com/maven-central/com.thoughtworks.binding/dom_sjs0.6_2.11) +[![Maven Central (remote data-binding for scala.concurrent.Future)](https://img.shields.io/maven-central/v/com.thoughtworks.binding/futurebinding_2.11.svg?label=maven-central%20%28FutureBinding.scala%29)](https://maven-badges.herokuapp.com/maven-central/com.thoughtworks.binding/futurebinding_2.11) +[![Maven Central (remote data-binding for ECMAScript 2015 Promise)](https://img.shields.io/maven-central/v/com.thoughtworks.binding/jspromisebinding_sjs0.6_2.11.svg?label=maven-central%20%28JsPromiseBinding.scala%29)](https://maven-badges.herokuapp.com/maven-central/com.thoughtworks.binding/jspromisebinding_sjs0.6_2.11) + +**Binding.scala** 是一个用 [Scala](http://www.scala-lang.org/) 语言编写的数据绑定框架,可以在 JVM 和 [Scala.js](http://www.scala-js.org/) 上运行。 + +Binding.scala 可以用作 **[reactive](https://en.wikipedia.org/wiki/Reactive_programming) web freamework**。 +它允许你使用原生 XHTML 语法去创建 reactive DOM 节点,这种 DOM 节点可以在数据源发生变化时自动地改变。 + +使用 Binding.scala 时可以参考 [Binding.scala • TodoMVC](http://todomvc.com/examples/binding-scala/) 或者[其他 DEMOs](https://thoughtworksinc.github.io/Binding.scala/),他们中包含了许多常见功能的实现。 + +## 与其他 reactive web framework 对比 + +与其他 reactive web framework(例如 [ReactJS](https://facebook.github.io/react/))对比,Binding.scala 有更多的特性以及更少的概念。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Binding.scalaReactJS
HTML 语法支持支持部分支持。Regular HTML 不会编译,除非开发者人为地将 class 属性和 for 属性改为 classNamehtmlFor,并且人为地将 行内样式 的语法从 CSS 语法改为 JSON 语法。
DOM 更新算法精准的数据绑定,比虚拟 DOM 更快虚拟 DOM 之间存在差异,对于复杂的 DOM 你需要手动地管理 key 属性。
数据绑定表达式的生命周期管理完全自动
静态类型检查支持,甚至是 HTML 标签和属性不支持
学习曲线一直很简单容易上手,但在理解边界情况时需要投入大量精力。
+ +更多详细信息,请查看[设计](#设计)。 + +## 开始使用 + +我们将在接下来的步骤里编写一个 Binding.scala 网页。 + +### 第 0 步:配置一个 Sbt Scala.js 项目 + +参考 http://www.scala-js.org/tutorial/basic/ 获取详细信息。 + +### 第 1 步:在你的 `build.sbt` 中添加 Binding.scala 依赖项 + +``` scala +libraryDependencies += "com.thoughtworks.binding" %%% "dom" % "latest.release" + +addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) +``` + +### 第 2 步:创建一个 `data` 域,其中包含一些 `Var` 和 `Vars` 作为你数据绑定表达式的数据源 + +``` scala +case class Contact(name: Var[String], email: Var[String]) + +val data = Vars.empty[Contact] +``` + +一个 `Var` 代表一个可绑定变量, +同时它也实现了 `Binding` 特性, +这就意味着一个 `Var` 也可以被视为一个是数据绑定表达式。 +如果其他数据绑定表达式依赖与一个 `Var` ,那么该表达式的值将在这个 `Var` 的值改变时作出相应的改变。 + +一个 `Vars` 代表一个可绑定变量序列, +同时它也实现了 `BindingSeq` 特性, +这就意味着一个 `Vars` 也可以被视为一个序列的数据绑定表达式。 +如果另一个数据绑定表达式依赖与一个 `Vars` , +那么该表达式的值将在这个 `Vars` 的值改变时作出相应的改变。 + +### 第 3 步:创建一个含有数据绑定表达式的 `@dom` 方法 + +``` scala +@dom +def table: Binding[Table] = { + + + + + + + + + { + for (contact <- data) yield { + + + + + } + } + +
NameE-mail
+ {contact.name.bind} + + {contact.email.bind} +
+} +``` + +一个 `@dom` 方法代表一个数据绑定表达式。 + +其返回值类型永远被包装成 `com.thoughtworks.binding.Binding` 特性。 +例如, `@dom def x: Binding[Int] = 1`, `@dom def message: Binding[String] = "content"` + +`@dom` 方法支持 HTML 语法。 +并不像通常 Scala 方法中的 XML 语法, +HTML 语法的类型是 `org.scalajs.dom.raw.Node` 或者 `com.thoughtworks.binding.BindingSeq[org.scalajs.dom.raw.Node]` 的子类型, +而不是 `scala.xml.Node` 或者 `scala.xml.NodeSeq`。 +因此我们写出这样的代码,`@dom def node: Binding[org.scalajs.dom.raw.HTMLBRElement] =
` +以及 `@dom def node: Binding[BindingSeq[org.scalajs.dom.raw.HTMLBRElement]] =

`。 + +由其他数据绑定表达式组成的 `@dom` 方法有两种编写方式: + + 1. 你可以在 `@dom` 方法中使用 `bind` 方法来获取其他 `Binding` 的值。 + 2. 你可以在 `@dom` 方法中使用 `for` 或者 `yield` 表达式将 `BindingSeq` 映射到其他的表达式上。 + +你可以通过使用 `{ ... }` 插入语法来在其他 HTML 元素中嵌入 `Node` 或者 `BindingSeq[Node]`。 + +### 第 4 步:在 `main` 方法中将数据绑定表达式渲染至 DOM + +``` scala +@JSExport +def main(): Unit = { + dom.render(document.body, table) +} +``` + +### 第 5 步: 在 HTML 页面中调用 `main` 方法 + +``` html + + + + + + + + + +``` + +至此,你会看见一个只含有表头的空表格,这是因为 `data` 现在是空的。 + +### 第 6 步:添加一些 ` + + + + + + + + + + + { + for (contact <- data) yield { + + + + + + } + } + +
NameE-mailOperation
+ {contact.name.bind} + + {contact.email.bind} + + +
+} +``` + +当你点击 "Add a contact" 按钮时, 它会在 `data` 中添加一个新的联系人, +于此同时,因为 Binding.scala 知道 DOM 与 `data` 之间的关系, +那么它就会添加一个新的、与新增的联系人信息对应的 `` 。 + +当你点击 "Modify the name" 按钮时,对应联系人的 `name` 将会发生改变, +这是因为 Binding.scala 将对应的 `tr` 中 `name` 的值改变了。 + +完整的例子请参考 https://github.com/ThoughtWorksInc/Binding.scala-sample 。 + + +## 设计 + +### 精准的数据绑定 + +ReactJS 需要用户为每一个组件提供 `render` 函数。 +`render` 函数需要将 `props` 和 `state` 映射至 ReactJS 的虚拟 DOM, +然后,ReactJS 框架会根据虚拟 DOM 的结构,创建一套结构相同的真实的 DOM。 + +当 `state` 改变时,ReactJS 框架会调用 `render` 函数去获取一个新的虚拟 DOM。 +然而很不幸的是,ReactJS 并不能够确切地知道 `state` 改变了什么。 +因此,ReactJS 不得不将新的虚拟 DOM 和原来的虚拟 DOM 进行比较, +并由此来猜测两个虚拟 DOM 之间的改变,最终应用到实际的 DOM 上。 + +举例来说,在你在 `` 中的 `` 的开头添加了一行 ``, +ReactJS 可能会认为你改变了 `` 中所有的 ``, +并且在末尾添加了一行 ``。 + +原因是 ReactJS 中的 `render` 函数并不能描述 `state` 和 DOM 之间的关系。 +相反,它表达的是创建虚拟 DOM 的过程。 +也就是说,尽管作为数据绑定框架毫无疑问需要关于 `state` 改变的信息,但是 ReactJS 中的 `render` 函数却不能提供相关信息。 + +与 ReactJS 不同,Binding.scala 中的 `@dom` 方法并不是一个普通的函数。 +它是一个描述数据源和 DOM 之间关系的模板。 +当数据源发生部分改变时,Binding.scala 可以知道 DOM 中具体的哪些部分对应这些改变的数据。 +因此,Binding.scala 只需要重新计算部分的 `@dom` 方法来获取部分的 DOM。 + +有着 Binding.scala 提供的精准的数据绑定能力的帮助,你可以摆脱不必要的、用于应对 ReactJS 的猜测算法的概念, +比如 `key` 属性,`shouldComponentUpdate` 方法,`componentDidUpdate` 方法以及 `componentWillUpdate` 方法。 + +### 模块性 + +在 ReactJS 中最小的组成单元是组件。 + +毫无疑问 React 组件要比 AngularJS 控制器更加轻量级,然而 Binding.scala 又比它更加优秀。 + +在 Binding.scala 中最小的组成单元仅仅是 `@dom` 方法。 +每一个 `@dom` 方法有能力通过 `.bind` 组合其他的 `@dom` 方法。 + +``` scala +case class Contact(name: Var[String], email: Var[String]) + +@dom +def bindingButton(contact: Contact): Binding[Button] = { + +} + +@dom +def bindingTr(contact: Contact): Binding[TableRow] = { + + + + + +} + +@dom +def bindingTable(contacts: BindingSeq[Contact]): Binding[Table] = { +
{ contact.name.bind }{ contact.email.bind }{ bindingButton(contact).bind }
+ + { + for (contact <- contacts) yield { + bindingTr(contact).bind + } + } + +
+} + +@JSExport +def main(): Unit = { + val data = Vars(Contact(Var("Yang Bo"), Var("yang.bo@rea-group.com"))) + dom.render(document.body, bindingTable(data)) +} +``` + +你可以发现这种方法要比 ReactJS 简单很多,因为: + + * 你可以简单地为 Binding.scala 提供参数,而不用像在 ReactJS 中一样传递 `props`。 + * 在 Binding.scala 中,你可以简单地定义参数类型,而不用像在 ReactJS 中一样指定 `propTypes`。 + * 你可以在编译期进行类型检查,而不用像在 ReactJS 中一样只能在运行期间获得异常。 + +### 数据绑定表达式的生命周期管理 + +毫无疑问,Binding.scala 具有的精准数据绑定能力需要监听者对数据源注册。 +其他的拥有这种能力的 reactive frameworks 需要用户管理数据绑定的生命周期。 + +例如,[MetaRx](https://github.com/MetaStack-pl/MetaRx/issues/45) 提供了 `dispose` 方法用于注销监听者。 +在表达式改变后,每次调用 `map` 和 `flatMap` 方法,MetaRx 的用户都有义务去调用 `dispose` 方法, +否则 MetaRx 会有内存泄漏。然而非常不幸的是,对于复杂的绑定表达式,每次都需要手动地正确调用 `dispose` 实在是太困难了。 + +另一个 reactive web framework [Widok](https://github.com/widok/widok/issues/29) 没有提供任何机制用于管理数据绑定表达式的生命周期。 +而这样的结果就是,Widok 一直在泄漏内存。 + +在 Binding.scala 中,与 MetaRx 以及 Widok 不同,所有的数据绑定表达式都是纯函数式的,不带有任何副作用。 +Binding.scala 不会在用户创建表达式式注册任何的监听者, +因此用户并不需要像在 MetaRx 一样手动地注销监听者。 + +当用户在根表达式中调用 `dom.render` 或者 `Binding.watch` 时,Binding.scala 一同创建了所有的监听者,而不仅仅是根表达式的直接监听者。 + +简而言之,Binding.scala 将函数分为两类: + * 用户定义的 `@dom` 方法,用于产生不带副作用的纯函数式的数据绑定表达式。 + * 调用 `dom.render` 以及 `Binding.watch` 的函数,自动地管理所有副作用。 + +### HTML 语法和静态类型检查 + +如你所见,你可以在 Scala 源码文件中的 `@dom` 方法里嵌入 HTML 语法。 +你也可以在 HTML 标签里在 `{ ... }` 中或者在属性值中书写 Scala 代码。 +``` scala +@dom +def notificationBox(message: String): Binding[Div] = { +
+ { + message + } +
+} +``` + +不论 HTML 语法在 Binding.scala 和 ReactJS 之间的相似性, +Binding.scala 创建真正的 DOM 而不是 ReactJS 中的虚拟 DOM。 + +在上述例子中,`
...
` 创建了一个类型为 `org.scalajs.dom.html.Div` 的 DOM 元素。 +之后,神奇的 `@dom` 使得返回值被包装成 `Binding` 类型。 + +你甚至可以将 `Div` 赋值给局部变量并且在它上面调用原生 DOM 方法: + +``` scala +@dom +def notificationBox(message: String): Binding[Div] = { + val result: Div =
+ { + message + } +
+ + result.scrollIntoView() + + result +} +``` + +`scrollIntoView` 方法会在 `Div` 创建后被调用。 +如果你在 `Div` 上调用了一个并没有定义的方法, +Scala 编译器会报告一个编译器错误而不是只有在运行时才会发生错误。 +原因是 Scala 是静态类型语言而且 Scala 编译器可以理解 `Div` 类型。 + +你可能也注意到了 `class` 和 `title`。 他们是 DOM 属性或者 `Div` 上的 HTML 属性。 +他们也会被 Scala 编译器进行类型检查。 + +例如,给出下面的 `typo` 方法: + +``` scala +@dom +def typo = { + val myDiv =
content
+ myDiv.typoMethod() + myDiv +} +``` + +Scala 会报告出如下错误: + +``` +typo.scala:23: value typoProperty is not a member of org.scalajs.dom.html.Div + val myDiv =
content
+ ^ +typo.scala:24: value typoMethod is not a member of org.scalajs.dom.html.Div + myDiv.typoMethod() + ^ +``` + +有着静态类型系统的帮助, `@dom` 方法可以比 ReactJS 组件具有更充足的鲁棒性。 + +你可以在 [scaladoc of scalajs-dom](http://www.scala-js.org/api/scalajs-dom/0.8/org/scalajs/dom/raw/HTMLElement.html) 或者 [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement) 上找到一个完整的被支持的属性和方法的列表。 + +#### 自定义属性 + +如果你想要关闭属性的静态类型检查,在属性前添加 `data:` 前缀: + +``` scala +@dom def myCustomDiv = { + val myDiv =
+ assert(myDiv.getAttribute("customAttributeName") == "attributeValue") + myDiv +} +``` + +现在 Scala 编译器就不会报错了。 + +## 下载 + +Binding.scala 只有一个很小的代码基。 +源代码被分为 4 个库,每个库一个文件。 + +### 数据绑定表达式核心 (Binding.scala) + +这个模块在 JVM 和 Scala.js 上均可使用。你可以把它加入到你的 `build.sbt`。 + +``` scala +// For JVM projects +libraryDependencies += "com.thoughtworks.binding" %% "binding" % "latest.release" + +addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) +``` + +``` scala +// For Scala.js projects, or JS/JVM cross projects +libraryDependencies += "com.thoughtworks.binding" %%% "binding" % "latest.release" + +addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) +``` + +### HTML DOM 集成 (dom.scala) + +这个模块只可用于 Scala.js。你可以把它加入到你的 `build.sbt`。 + +``` scala +// For Scala.js projects, or JS/JVM cross projects +libraryDependencies += "com.thoughtworks.binding" %%% "dom" % "latest.release" + +addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) +``` + +### 用于 `scala.concurrent.Future` 的远程数据绑定(FutureBinding.scala) + +这个模块在 JVM 和 Scala.js 上均可使用。你可以把它加入到你的 `build.sbt`。 + +``` scala +// For JVM projects +libraryDependencies += "com.thoughtworks.binding" %% "futurebinding" % "latest.release" + +addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) +``` + +``` scala +// For Scala.js projects, or JS/JVM cross projects +libraryDependencies += "com.thoughtworks.binding" %%% "futurebinding" % "latest.release" + +addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) +``` + +### 用于 ECMAScript 2015's `Promise` 的远程数据绑定(JsPromiseBinding.scala) + +这个模块只可用于 Scala.js。你可以把它加入到你的 `build.sbt`。 + +``` scala +// For Scala.js projects, or JS/JVM cross projects +libraryDependencies += "com.thoughtworks.binding" %%% "jspromisebinding" % "latest.release" + +addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.0" cross CrossVersion.full) +``` + +## 其他链接 + +* [The API documentation](https://oss.sonatype.org/service/local/repositories/releases/archive/com/thoughtworks/binding/unidoc_2.11/9.0.2/unidoc_2.11-9.0.2-javadoc.jar/!/com/thoughtworks/binding/package.html) +* [Binding.scala • TodoMVC](http://todomvc.com/examples/binding-scala/) +* [Other live DEMOs](https://thoughtworksinc.github.io/Binding.scala/) +* [Frequently Asked Questions](https://github.com/ThoughtWorksInc/Binding.scala/wiki/FAQ) diff --git a/README.md b/README.md new file mode 100644 index 00000000..6b6cceb9 --- /dev/null +++ b/README.md @@ -0,0 +1,556 @@ +# Binding.scala + +[![Production Ready](https://img.shields.io/badge/%F0%9F%91%8C-Production%20Ready-00ddcc.svg)](https://github.com/search?l=Scala&o=desc&q="com.thoughtworks.binding"&s=indexed&type=Code&utf8=✓) +[![Extremely Lightweight](https://img.shields.io/badge/%F0%9F%A6%8B-Extremely%20Lightweight-7799cc.svg)](http://todomvc.com/examples/binding-scala/) + +[![Join the chat at https://gitter.im/ThoughtWorksInc/Binding.scala](https://badges.gitter.im/ThoughtWorksInc/Binding.scala.svg)](https://gitter.im/ThoughtWorksInc/Binding.scala?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![StackOverflow](https://img.shields.io/stackexchange/stackoverflow/t/binding.scala.svg?label=StackOverflow+questions)](https://stackoverflow.com/questions/tagged/binding.scala?sort=votes) +[![Scala CI](https://github.com/ThoughtWorksInc/Binding.scala/actions/workflows/scala.yml/badge.svg)](https://github.com/ThoughtWorksInc/Binding.scala/actions/workflows/scala.yml) +[![Scaladoc](https://javadoc.io/badge/com.thoughtworks.binding/binding_2.13.svg?label=scaladoc)](https://javadoc.io/page/com.thoughtworks.binding/binding_2.13/latest/com/thoughtworks/binding/index.html) +[![Latest version](https://index.scala-lang.org/thoughtworksinc/Binding.scala/latest.svg)](https://index.scala-lang.org/thoughtworksinc/binding.scala) + +**Binding.scala** is a data-binding library for [Scala](http://www.scala-lang.org/), running on both JVM and [Scala.js](http://www.scala-js.org/). + +Binding.scala can be used as the basis of UI frameworks, however latest Binding.scala 12.x does not contain any build-in UI frameworks any more. For creating reactive HTML UI, you may want to check out [html.scala](https://github.com/GlasslabGames/html.scala), which is an UI framework based on Binding.scala, and it is also the successor of previously built-in [dom](https://javadoc.io/page/com.thoughtworks.binding/dom_sjs0.6_2.12/latest/com/thoughtworks/binding/dom.html) library. + +See [Binding.scala • TodoMVC](http://todomvc.com/examples/binding-scala/) or [ScalaFiddle DEMOs](https://github.com/ThoughtWorksInc/Binding.scala/wiki/ScalaFiddle-DEMOs) as examples for common tasks when working with Binding.scala. + +## Comparison to other reactive web frameworks + +Binding.scala and html.scala has more features and less concepts than other reactive web frameworks like [ReactJS](https://facebook.github.io/react/). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Binding.scalaReactJS
Support HTML literal?YesPartially supported. Regular HTML does not compile, unless developers manually replaces class and for attributes to className and htmlFor, and manually converts inline styles from CSS syntax to JSON syntax.
Algorithm to update DOMPrecise data-binding, which is faster than virtual DOMVirtual DOM differentiation, which requires manually managed key attributes for complicated DOM.
Lifecycle management for data-binding expressionsAutomaticallyN/A
Statically type checkingYes, even for HTML tags and attribuesNo
Learning curveAlways easyEasy to start. Requires much more efforts to understand its corner cases.
+ +See [Design](#design) section for more information. + +## Getting started + +We will build an Binding.scala web page during the following steps. + +### Step 0: Setup a Sbt Scala.js project + +See http://www.scala-js.org/tutorial/basic/ for information about how to setup such a project. + +### Step 1: Add html.scala dependencies into your `build.sbt`: + +``` scala +// Enable macro annotations by setting scalac flags for Scala 2.13 +scalacOptions ++= { + import Ordering.Implicits._ + if (VersionNumber(scalaVersion.value).numbers >= Seq(2L, 13L)) { + Seq("-Ymacro-annotations") + } else { + Nil + } +} + +// Enable macro annotations by adding compiler plugins for Scala 2.12 +libraryDependencies ++= { + import Ordering.Implicits._ + if (VersionNumber(scalaVersion.value).numbers >= Seq(2L, 13L)) { + Nil + } else { + Seq(compilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full)) + } +} + +libraryDependencies += "org.lrng.binding" %%% "html" % "latest.release" +``` + +### Step 2: Create a `data` field, which contains some `Var` and `Vars` as data source for your data-binding expressions + +``` scala +case class Contact(name: Var[String], email: Var[String]) + +val data = Vars.empty[Contact] +``` + +A `Var` represents a bindable variable, +which also implements `Binding` trait, +hence a `Var` can be seen as a binding expression as well. +If another expression depends on a `Var`, the value of the expression changes whenever value of the `Var` changes. + +A `Vars` represents a sequence of bindable variables, +which also implements `BindingSeq` trait, +hence a `Vars` can be seen as a binding expression of a sequence as well. +If another comprehension expression depends on a `Vars`, +the value of the expression changes whenever value of the `Vars` changes. + +### Step 3: Create a `@html` method that contains data-binding expressions + +``` scala +@html +def table: Binding[Table] = { + + + + + + + + + { + for (contact <- data) yield { + + + + + } + } + +
NameE-mail
+ {contact.name.bind} + + {contact.email.bind} +
+} +``` + +A `@html` method represents an reactive XHTML template, which supports HTML literal. +Unlike normal XML literal in a normal Scala method, +the types of HTML literal are specific subtypes of `org.scalajs.dom.raw.Node` or `com.thoughtworks.binding.BindingSeq[org.scalajs.dom.raw.Node]`, +instead of `scala.xml.Node` or `scala.xml.NodeSeq`. +So we could have `@html def node: Binding[org.scalajs.dom.raw.HTMLBRElement] =
` +and `@html def node: Binding[BindingSeq[org.scalajs.dom.raw.HTMLBRElement]] =

`. + +A `@html` method is composed with other data-binding expressions in two ways: + + 1. You could use `bind` method in a `@html` method to get value of another `Binding`. + 2. You could use `for` / `yield` expression in a `@html` method to map a `BindingSeq` to another. + +You can nest `Node` or `BindingSeq[Node]` in other HTML element literals via `{ ... }` interpolation syntax. + +### Step 4: Render the data-binding expressions to DOM in the `main` method + +``` scala +@JSExport +def main(): Unit = { + html.render(document.body, table) +} +``` + +### Step 5: Invoke the `main` method in a HTML page + +``` html + + + + + + + + + +``` + +Now you will see a table that just contains a header, because `data` is empty at the moment. + +### Step 6: Add some ` + + + + + + + + + + + { + for (contact <- data) yield { + + + + + + } + } + +
NameE-mailOperation
+ {contact.name.bind} + + {contact.email.bind} + + +
+} +``` + +When you click the "Add a contact" button, it appends a new Contact into `data`, +then, Binding.scala knows the relationship between DOM and `data`, +so it decides to append a new `` corresponding to the newly appended Contact. + +And when you click the "Modify the name", the `name` field on `contact` changes, +then, Binding.scala decides to change the content of the corresponding `tr` to new value of `name` field. + + +## Design + +### Precise data-binding + +ReactJS requires users to provide a `render` function for each component. +The `render` function should map `props` and `state` to a ReactJS's virtual DOM, +then ReactJS framework creates a DOM with the same structure as the virtual DOM. + +When `state` changes, ReactJS framework invokes `render` function to get a new virtual DOM. +Unfortunately, ReactJS framework does not precisely know what the `state` changing is. +ReactJS framework has to compare the new virtual DOM and the original virtual DOM, +and guess the changeset between the two virtual DOM, +then apply the guessed changeset to the real DOM as well. + +For example, after you prepend a table row `` into an existing `` in a ``, +ReactJS may think you also changed the content of every existing `` of the ``. + +The reason for this is that the `render` function for ReactJS does not describe the relationship between `state` and DOM. +Instead, it describes the process to create a virtual DOM. +As a result, the `render` function does not provide any information about the purpose of the `state` changing, +although a data-binding framework should need the information. + +Unlike ReactJS, a Binding.scala `@html` method is NOT a regular function. +It is a template that describes the relationship between data source and the DOM. +When part of the data source changes, Binding.scala knows about the exact corresponding partial DOM affected by the change, +thus only re-evaluating that part of the `@html` method to reflect the change in the DOM. + +With the help of the ability of precise data-binding provided by Binding.scala, +you can get rid of concepts for hinting ReactJS's guessing algorithm, +like `key` attribute, `shouldComponentUpdate` method, `componentDidUpdate` method or `componentWillUpdate` method. + +### Composability + +The smallest composable unit in ReactJS is a component. +It is fair to say that a React component is lighter than an AngularJS controller, +while Binding.scala is better than that. + +The smallest composable unit in Binding.scala is a `@html` method. +Every `@html` method is able to compose other `@html` methods via `.bind`. + +``` scala +case class Contact(name: Var[String], email: Var[String]) + +@html +def bindingButton(contact: Contact): Binding[Button] = { + +} + +@html +def bindingTr(contact: Contact): Binding[TableRow] = { + + + + + +} + +@html +def bindingTable(contacts: BindingSeq[Contact]): Binding[Table] = { +
{ contact.name.bind }{ contact.email.bind }{ bindingButton(contact).bind }
+ + { + for (contact <- contacts) yield { + bindingTr(contact).bind + } + } + +
+} + +@JSExport +def main(): Unit = { + val data = Vars(Contact(Var("Yang Bo"), Var("yang.bo@rea-group.com"))) + dom.render(document.body, bindingTable(data)) +} +``` + +You may find out this approach is much simpler than ReactJS, as: + + * Instead of passing `props` in ReactJS, you just simply provide parameters for Binding.scala. + * Instead of specifying `propTypes` in ReactJS, you just simply define the types of parameters in Binding.scala. + * Instead of raising a run-time error when types of props do not match in ReactJS, you just check the types at compile-time. + +### Lifecycle management for data-binding expressions + +The ability of precise data-binding in Binding.scala requires listener registrations on the data source. +Other reactive frameworks that have the ability ask users manage the lifecycle of data-binding expressions. + +For example, [MetaRx](https://github.com/MetaStack-pl/MetaRx/issues/45) provides a `dispose` method to unregister the listeners created when building data-binding expressions. +The users of MetaRx have the responsibility to call `dispose` method for every `map` and `flatMap` call after the expression changes, +otherwise MetaRx leaks memory. Unfortunately, manually `dispose`ing everything is too hard to be right for complicated binding expressions. + +Another reactive web framework [Widok](https://github.com/widok/widok/issues/29) did not provide any mechanism to manage lifecycle of of data-binding expressions. +As a result, it simply always leaks memory. + +In Binding.scala, unlike MetaRx or Widok, all data-binding expressions are pure functional, with no side-effects. +Binding.scala does not register any listeners when users create individual expressions, +thus users do not need to manually unregister listeners for a single expression like MetaRx. + +Instead, Binding.scala creates all internal listeners together, +when the user calls `dom.render` or `Binding.watch` on the root expression. +Note that `dom.render` or `Binding.watch` manages listeners on all upstream expressions, +not only the direct listeners of the root expression. + +In brief, Binding.scala separates functionality in two kinds: + * User-defined `@html` methods, which produce pure functional expressions with no side-effects. + * Calls to `dom.render` or `Binding.watch`, which manage all side-effects automatically. + +### HTML literal and statically type checking + +As you see, you can embed HTML literals in `@html` methods in Scala source files. +You can also embed Scala expressions in braces in content or attribute values of the HTML literal. + +``` scala +@html +def notificationBox(message: String): Binding[Div] = { +
+ { + message + } +
+} +``` + +Despite the similar syntax of HTML literal between Binding.scala and ReactJS, +Binding.scala creates real DOM instead of ReactJS's virtual DOM. + +In the above example, `
...
` creates a DOM element with the type of `org.scalajs.dom.html.Div`. +Then, the magic `@html` lets the method wrap the result as a `Binding`. + +You can even assign the `Div` to a local variable and invoke native DOM methods on the variable: + +``` scala +@html +def notificationBox(message: String): Binding[Div] = { + val result: Div =
+ { + message + } +
+ + result.scrollIntoView() + + result +} +``` + +`scrollIntoView` method will be invoked when the `Div` is created. +If you invoke another method not defined in `Div`, +the Scala compiler will report a compile-time error instead of bringing the failure to run-time, +because Scala is a statically typed language and the Scala compiler understands the type of `Div`. + +You may also notice `class` and `title`. They are DOM properties or HTML attributes on `Div`. +They are type-checked by Scala compiler as well. + +For example, given the following `typo` method: + +``` scala +@html +def typo = { + val myDiv =
content
+ myDiv.typoMethod() + myDiv +} +``` + +The Scala compiler will report errors like this: + +``` +typo.scala:23: value typoProperty is not a member of org.scalajs.dom.html.Div + val myDiv =
content
+ ^ +typo.scala:24: value typoMethod is not a member of org.scalajs.dom.html.Div + myDiv.typoMethod() + ^ +``` + +With the help of the static type system, `@html` methods can be much more robust than ReactJS components. + +You can find a complete list of supported properties and methods on [scaladoc of scalajs-dom](http://www.scala-js.org/api/scalajs-dom/0.8/org/scalajs/dom/raw/HTMLElement.html) or [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement) + +#### Custom attributes + +If you want to suppress the static type checking of attributes, add a `data:` prefix to the attribute: + +``` scala +@html def myCustomDiv = { + val myDiv =
+ assert(myDiv.getAttribute("customAttributeName") == "attributeValue") + myDiv +} +``` + +The Scala compiler will not report errors now. + +## Showcases + + * [TodoMVC](http://todomvc.com/examples/binding-scala/): a project which offers the same Todo application implemented using MV* concepts in most of the popular JavaScript MV* frameworks of today. + * [Granblue Raid Finder](https://github.com/walfie/gbf-raidfinder): a site for finding Granblue Fantasy raid tweets. + * [Game of Life](https://github.com/zhanglongyang/game-of-life): Conway's Game of Life implemented with Binding.scala. + * [playground-binding.scala +](https://github.com/ccamel/playground-binding.scala): Various DEMOs with scala, scalajs and binding.scala + * [CITE Application](https://github.com/cite-architecture/CITE-App): A single-page browser application for exploring citable resources. + * [hmt-reader](https://github.com/homermultitext/hmt-reader): A package of application and data for reading Homer Multitext textual data, in its current release. + * [Full-Stack-Scala-Starter](https://github.com/Algomancer/Full-Stack-Scala-Starter): Play 2.5, ScalaJS, Binding.scala starter project. + * [scala-adapters](https://pme123.github.io/scala-adapters/): A simple framework to implement your server jobs - providing a standard UI-client to monitor and test them. (Used in Production) + * [Binding.scala-Google-Maps](https://github.com/pme123/Binding.scala-Google-Maps): A step-by-step tutorial to get you started with Binding.scala. + * [scala-adapters.g8](https://github.com/pme123/scala-adapters.g8): A Giter8 template for a full-stack Scala project that uses [scala-adapters](https://pme123.github.io/scala-adapters/). + * [play-akka-telegrambot4s-incidents](https://github.com/pme123/play-akka-telegrambot4s-incidents): An incident management app - where you can send incidents with a chat bot. + * [scala-adapters-images](https://github.com/pme123/scala-adapters-images): A demo project that uses [scala-adapters](https://pme123.github.io/scala-adapters/) and [play-akka-telegrambot4s](https://github.com/pme123/play-akka-telegrambot4s). + * [jmh-view](https://github.com/tom91136/jmh-view): An embeddable JMH report viewer, which is used to to create [the report for the benchmarks of Scala parsers](https://tom91136.github.io/scala-parser-benchmarks/report.html). + * [RL-Playground](https://github.com/basimkhajwal/RL-Playground): A web-based interactive reinforcement learning demonstration for games. + * [Word Cloud Generator](https://github.com/emanresusername/word-cloud-generator): A browser extension to generate word cloud visualizations of web pages, text files, or other arbitrary text inputs. + + +(Feel free to add your project here) + +## Modules + +Binding.scala has an extremely tiny code base. +The source files are split into few libraries, one file per library. + +### Core data-binding expressions (Binding.scala) + +This module is available for both JVM and Scala.js. You could add it in your `build.sbt`. + +``` scala +// For JVM projects +libraryDependencies += "com.thoughtworks.binding" %% "binding" % "latest.release" +``` + +``` scala +// For Scala.js projects, or JS/JVM cross projects +libraryDependencies += "com.thoughtworks.binding" %%% "binding" % "latest.release" +``` + +### HTML DOM integration (html.scala) + +This is the new HTML templating library based on [Name Based XML Literals](https://docs.scala-lang.org/sips/name-based-xml.html), the module is only available for Scala.js, and the Scala version must between 2.12 and 2.13. You could add it in your `build.sbt`. + +``` +// Enable macro annotations by setting scalac flags for Scala 2.13 +scalacOptions ++= { + import Ordering.Implicits._ + if (VersionNumber(scalaVersion.value).numbers >= Seq(2L, 13L)) { + Seq("-Ymacro-annotations") + } else { + Nil + } +} + +// Enable macro annotations by adding compiler plugins for Scala 2.12 +libraryDependencies ++= { + import Ordering.Implicits._ + if (VersionNumber(scalaVersion.value).numbers >= Seq(2L, 13L)) { + Nil + } else { + Seq(compilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full)) + } +} + +// For Scala.js projects (Scala 2.12 - 2.13) +libraryDependencies += "org.lrng.binding" %%% "html" % "latest.release" +``` + +See [html.scala](https://github.com/GlasslabGames/html.scala) for more information. + +### Remote data-binding for `scala.concurrent.Future` (FutureBinding.scala) + +This module is available for both JVM and Scala.js. You could add it in your `build.sbt`. + +``` scala +// For JVM projects +libraryDependencies += "com.thoughtworks.binding" %% "futurebinding" % "latest.release" +``` + +``` scala +// For Scala.js projects, or JS/JVM cross projects +libraryDependencies += "com.thoughtworks.binding" %%% "futurebinding" % "latest.release" +``` + +See [FutureBinding](https://github.com/Atry/FutureBinding.scala) for more information. + +### Remote data-binding for ECMAScript 2015's `Promise` (JsPromiseBinding.scala) + +This module is only available for Scala.js. You could add it in your `build.sbt`. + +``` scala +// For Scala.js projects +libraryDependencies += "com.thoughtworks.binding" %%% "jspromisebinding" % "latest.release" +``` + +See [FutureBinding](https://github.com/Atry/JsPromiseBinding.scala) for more information. + +## Requirements + +Due to collection API changes, Binding.scala 12.x only works on Scala 2.13, targeting JVM, Scala.js 0.6 and Scala.js 1.x. + +For Scala 2.10, 2.11 and 2.12 on JVM or Scala.js 0.6, use [Binding.scala 11.x](https://github.com/ThoughtWorksInc/Binding.scala/tree/11.9.x) instead. + +## Links + +* [html.scala](https://github.com/GlasslabGames/html.scala) +* [The API documentation](https://javadoc.io/page/com.thoughtworks.binding/binding_2.12/latest/com/thoughtworks/binding/index.html) +* [Binding.scala • TodoMVC](http://todomvc.com/examples/binding-scala/) +* [ScalaFiddle DEMOs](https://github.com/ThoughtWorksInc/Binding.scala/wiki/ScalaFiddle-DEMOs) +* [Binding.scala Questions on Stack Overflow](https://stackoverflow.com/questions/tagged/binding.scala) +* [html.scala](https://github.com/GlasslabGames/html.scala) +* [本README的中文版](https://github.com/ThoughtWorksInc/Binding.scala/blob/10.0.x/README-zh.md) diff --git a/SafeBuffer/.js/build.sbt b/SafeBuffer/.js/build.sbt new file mode 120000 index 00000000..570a7b60 --- /dev/null +++ b/SafeBuffer/.js/build.sbt @@ -0,0 +1 @@ +../build.sbt.shared \ No newline at end of file diff --git a/SafeBuffer/.jvm/build.sbt b/SafeBuffer/.jvm/build.sbt new file mode 120000 index 00000000..570a7b60 --- /dev/null +++ b/SafeBuffer/.jvm/build.sbt @@ -0,0 +1 @@ +../build.sbt.shared \ No newline at end of file diff --git a/SafeBuffer/build.sbt.shared b/SafeBuffer/build.sbt.shared new file mode 100644 index 00000000..9be84a89 --- /dev/null +++ b/SafeBuffer/build.sbt.shared @@ -0,0 +1,5 @@ +libraryDependencies += "com.thoughtworks.enableIf" %% "enableif" % "1.1.7" + +libraryDependencies += "org.scalatest" %%% "scalatest" % "3.2.10" % Test + +scalacOptions += "-Ymacro-annotations" diff --git a/SafeBuffer/src/main/scala/com/thoughtworks/binding/SafeBuffer.scala b/SafeBuffer/src/main/scala/com/thoughtworks/binding/SafeBuffer.scala new file mode 100644 index 00000000..423ab8fe --- /dev/null +++ b/SafeBuffer/src/main/scala/com/thoughtworks/binding/SafeBuffer.scala @@ -0,0 +1,190 @@ +package com.thoughtworks.binding + +import com.thoughtworks.{enableIf, enableMembersIf} + +import scala.annotation.tailrec +import scala.collection.mutable + +private[binding] object SafeBuffer { + + @enableMembersIf(c => !c.compilerSettings.exists(_.matches("""^-Xplugin:.*scalajs-compiler_[0-9\.\-]*\.jar$"""))) + private[SafeBuffer] object Jvm { + def newBuffer[A] = collection.mutable.ArrayBuffer.empty[A] + + // Used for Scala 2.13 + @inline + implicit final class ReduceToSizeOps[A] @inline() (buffer: collection.mutable.ArrayBuffer[A]) { + @inline def reduceToSize(newSize: Int) = { + buffer.remove(newSize, buffer.size - newSize) + } + } + } + + @enableMembersIf(c => c.compilerSettings.exists(_.matches("""^-Xplugin:.*scalajs-compiler_[0-9\.\-]*\.jar$"""))) + private[SafeBuffer] object Js { + + @inline + def newBuffer[A] = new scalajs.js.Array[A] + + @inline + implicit final class ReduceToSizeOps[A] @inline() (array: scalajs.js.Array[A]) { + @inline def reduceToSize(newSize: Int) = array.length = newSize + } + + } + + private[SafeBuffer] sealed trait State + + case object Idle extends State + + case object CleanForeach extends State + + case object DirtyForeach extends State + + final val Hole = new AnyRef + +} + +/** Similar to [[scala.collection.mutable.ArrayBuffer]], except that this [[SafeBuffer]] allows adding or removing + * elements via [[+=]] and [[-=]] inside a [[foreach]] block. + * + * @note + * A [[java.lang.IllegalStateException]] will be thrown when invoking methods other than [[+=]] and [[-=]] in a + * [[foreach]] block. + */ +final class SafeBuffer[A] extends mutable.Buffer[A] { + + import SafeBuffer._ + + import Js._ + import Jvm._ + private val data = newBuffer[Any] + + @volatile + private var state: State = Idle + + @inline + override def isEmpty = data.forall(_ == Hole) + + override def foreach[U](f: A => U): Unit = { + state match { + case Idle => + state = CleanForeach + data.withFilter(_ != Hole).foreach(f.asInstanceOf[Any => U]) + state match { + case DirtyForeach => { + @tailrec + def compact(i: Int, j: Int): Unit = { + if (i < data.length) { + val x = data(i) + if (x == Hole) { + compact(i + 1, j) + } else { + data(j) = x + compact(i + 1, j + 1) + } + } else { + data.reduceToSize(j) + } + } + compact(0, 0) + state = Idle + } + case CleanForeach => + state = Idle + case Idle => + throw new IllegalStateException("Expect CleanForeach or DirtyForeach") + } + case CleanForeach | DirtyForeach => + data.withFilter(_ != Hole).foreach(f.asInstanceOf[Any => U]) + } + } + + def addOne(x: A): this.type = { + data += x + this + } + + @inline + override def subtractOne(x: A): this.type = { + state match { + case Idle => + data -= x + case CleanForeach => + data(data.indexOf(x)) = Hole + state = DirtyForeach + case DirtyForeach => + data(data.indexOf(x)) = Hole + } + this + } + + private def checkIdle() = { + if (Idle != state) + throw new IllegalStateException( + "Not allowed to invoke methods other than `+=` and `-=` when `foreach` is running." + ) + } + + def apply(n: Int): A = { + checkIdle() + data(n).asInstanceOf[A] + } + + def clear(): Unit = { + checkIdle() + data.clear() + } + + def insert(idx: Int, elem: A): Unit = { + checkIdle() + data.insert(idx, elem) + } + + def insertAll(idx: Int, elems: scala.collection.IterableOnce[A]): Unit = { + checkIdle() + data.insertAll(idx, elems) + } + + def patchInPlace(from: Int, patch: scala.collection.IterableOnce[A], replaced: Int): this.type = { + checkIdle() + data.patchInPlace(from, patch, replaced) + this + } + + def prepend(elem: A): this.type = { + checkIdle() + data.prepend(elem) + this + } + + def remove(idx: Int, count: Int): Unit = { + checkIdle() + data.remove(idx, count) + } + + def insertAll(n: Int, elems: Iterable[A]): Unit = { + checkIdle() + data.insertAll(n, elems) + } + + def length: Int = { + checkIdle() + data.length + } + + def remove(n: Int): A = { + checkIdle() + data.remove(n).asInstanceOf[A] + } + + def update(n: Int, newelem: A): Unit = { + checkIdle() + data.update(n, newelem) + } + + def iterator: Iterator[A] = { + checkIdle() + data.iterator.asInstanceOf[Iterator[A]] + } +} diff --git a/build.sbt b/build.sbt new file mode 100644 index 00000000..a9872489 --- /dev/null +++ b/build.sbt @@ -0,0 +1,19 @@ +import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType} + +parallelExecution in Global := false + +lazy val SafeBuffer = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Pure).build + +lazy val Binding = crossProject(JSPlatform, JVMPlatform).crossType(CrossType.Pure).dependsOn(SafeBuffer) + +organization in ThisBuild := "com.thoughtworks.binding" + +publish / skip := true + +enablePlugins(ScalaUnidocPlugin) + +ScalaUnidoc / unidoc / unidocProjectFilter := { + inAnyProject -- inProjects(SafeBuffer.jvm, Binding.jvm) +} + +scalacOptions += "-Ymacro-annotations" diff --git a/project/build.properties b/project/build.properties index 10fd9eee..1e70b0c1 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.5.5 +sbt.version=1.6.0 diff --git a/project/plugins.sbt b/project/plugins.sbt index 0bdc8bcd..c02c3c22 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,11 +1,17 @@ -addSbtPlugin("com.thoughtworks.sbt-best-practice" % "sbt-best-practice" % "8.0.0") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.1.0") -addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.8") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.8.0") -addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.0.0") +addSbtPlugin("com.thoughtworks.sbt-scala-js-map" % "sbt-scala-js-map" % "4.1.1") -addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.2") +addSbtPlugin("com.thoughtworks.sbt-best-practice" % "sbt-best-practice" % "8.2.1") + +addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.9.10") + +addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1") + +addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") addSbtPlugin("org.lyranthe.sbt" % "partial-unification" % "1.1.2") -addSbtPlugin("com.thoughtworks.example" % "sbt-example" % "7.0.0") +addSbtPlugin("com.thoughtworks.example" % "sbt-example" % "9.0.0") diff --git a/project/plugins.sbt.scala-js.0.6 b/project/plugins.sbt.scala-js.0.6 new file mode 100644 index 00000000..6c8cdf94 --- /dev/null +++ b/project/plugins.sbt.scala-js.0.6 @@ -0,0 +1,22 @@ +// An optional sbt file to replace Scala.js 1.0 with 0.6 +dependencyOverrides += Defaults.sbtPluginExtra( + "org.scala-js" % "sbt-scalajs" % "0.6.33", + sbtBinaryVersion.value, + scalaBinaryVersion.value, +) + +Compile / sourceGenerators += Def.task { + val file = (Compile / sourceManaged).value / "SkipPublishForNonScalaJSProjects.scala" + IO.write(file, """ + import scalajscrossproject.ScalaJSCrossPlugin.autoImport._ + import sbtcrossproject.CrossPlugin.autoImport._ + import sbt._, Keys._ + object SkipPublishForNonScalaJSProjects extends AutoPlugin { + override def trigger = allRequirements + override def projectSettings = Seq( + publish / skip := crossProjectPlatform.value != JSPlatform + ) + } + """) + Seq(file) +}.taskValue diff --git a/pubring.asc b/pubring.asc new file mode 100644 index 00000000..2b929c43 --- /dev/null +++ b/pubring.asc @@ -0,0 +1,18 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: BCPG v1.51 + +mQENBFapKd0BCACEjOwIKC6OXozOeNjocDA24rQCw1H6ChvhbXcdY0T9MVHMmL4M +PRrc6r7niAO2jCV0zMl2ogAy53qmlBbfam0+9YWzwHEmHaf4MAUbgxkNDRo2U2Gl +ZJmTsdMcJMApMsSkhGAuWEpNr2sWx3oZzKsmy99j7pTnSYDZ5bpel4cxJYvDa5uX +O4nqJmotm8aPcrl7gN7bzFvBter8M3o0BSUKpCGebwgrEqf4UJ6meC6ih5dHwyH7 +tmfDjNhsj1LoEY0tIoT7RmAIYu0Xkr3g55hctLTFMhoB15U4itQHLlV0REDltumi +10az1flp7m1YV9am3ZwEt2TyaBbSKtd9wHWTABEBAAG0HFlhbmcgQm8gPHBvcC5h +dHJ5QGdtYWlsLmNvbT6JARwEEwECAAYFAlapKd0ACgkQWeOsiuc+fAmRxQf9E+4N +Cc/q4TsZ0wOjeo5zlHGEIpF4Yw4sdJO3XVHeTscKjzD6bDt0xXPSQHpS2cozQeMa +unIl8WltoqGzpVV0B89ywZFvAEPlHCFzPtmpNeoJ3WKDFUkm/OO0wihlRKXf5D4E +nMaUpb4qBh3zyhhgN+VeySmwPNMfQO9Z7wekdZNVuxYweiC4jk89MGeQbfQOXHV3 +w4CY+M84PciO1lQeN0SgGXL/lyhrgQxKYERVn0iLypZBeiq2uQLXrFSO/UAWBTCW +M8pR3woNRWoBdo4IMVDl579RO+kztm//9PbYFhb/h9gPWN2bO2U7bsIdQYRVV6OI +RZpu6Y3T9l3knLvJGg== +=2YuA +-----END PGP PUBLIC KEY BLOCK-----