From 16c9ac2dc19f8071f1671da266ff2f1a7f2e3fea Mon Sep 17 00:00:00 2001 From: Ryan Rempel Date: Sat, 25 Jun 2016 19:54:06 -0500 Subject: [PATCH 1/4] Update for prelude 1.0, expand the API, and improve the polyfill. * Updates the code for purescript-prelude 1.0, and psc 0.9.1 * Updates the polyfill to match the spec more closely * Implements `cancelAnimationFrame` * Allows the scheduled effect to depend on the time value provided in the callback. * Adds tests The original API is mostly preserved, but now returns a `Request` instead of `Unit`, to allow for `cancelAnimationFrame` to work. So, it is technically a breaking change, but should not be difficult for existing code to adapt to. --- bower.json | 13 +- docs/DOM/RequestAnimationFrame.md | 43 +++++- src/DOM/RequestAnimationFrame.js | 108 ++++++++++++--- src/DOM/RequestAnimationFrame.purs | 73 ++++++++-- test/Main.js | 9 ++ test/Main.purs | 213 +++++++++++++++++++++++++++++ 6 files changed, 418 insertions(+), 41 deletions(-) create mode 100644 test/Main.js create mode 100644 test/Main.purs diff --git a/bower.json b/bower.json index 54c72c7..c5a06f6 100644 --- a/bower.json +++ b/bower.json @@ -3,7 +3,8 @@ "version": "1.0.0", "homepage": "https://github.com/CapillarySoftware/purescript-requestAnimationFrame", "authors": [ - "Isaac Shapira " + "Isaac Shapira ", + "Ryan Rempel " ], "description": "Request Animation Frame bindings and polyfill", "repository": { @@ -22,6 +23,14 @@ "package.json" ], "dependencies": { - "purescript-dom": "~0.2.0" + "purescript-dom": "^1.0.0", + "purescript-eff": "^1.0.0", + "purescript-datetime": "^1.0.0", + "purescript-prelude": "^1.0.0" + }, + "devDependencies": { + "purescript-test-unit": "^7.0.0", + "purescript-refs": "^1.0.0", + "purescript-now": "^1.0.0" } } diff --git a/docs/DOM/RequestAnimationFrame.md b/docs/DOM/RequestAnimationFrame.md index a58a5c4..4e015f9 100644 --- a/docs/DOM/RequestAnimationFrame.md +++ b/docs/DOM/RequestAnimationFrame.md @@ -2,20 +2,55 @@ This module exposes a polyfilled `requestAnimationFrame` function. +#### `Request` + +``` purescript +newtype Request +``` + +A request for a callback via `requestAnimationFrame`. You can supply this to +`cancelAnimationFrame` in order to cancel the request. + #### `requestAnimationFrame_` ``` purescript -requestAnimationFrame_ :: forall a eff. Window -> Eff (dom :: DOM | eff) a -> Eff (dom :: DOM | eff) Unit +requestAnimationFrame_ :: forall a eff. Window -> Eff (dom :: DOM | eff) a -> Eff (dom :: DOM | eff) Request ``` -Request the specified action be called on the next animation frame, specifying the `Window` object. +Request that the specified action be called on the next animation frame, specifying +the `Window` object. #### `requestAnimationFrame` ``` purescript -requestAnimationFrame :: forall a eff. Eff (dom :: DOM | eff) a -> Eff (dom :: DOM | eff) Unit +requestAnimationFrame :: forall a eff. Eff (dom :: DOM | eff) a -> Eff (dom :: DOM | eff) Request +``` + +Request that the specified action be called on the next animation frame. + +#### `requestAnimationFrameWithTime` + +``` purescript +requestAnimationFrameWithTime :: forall a eff. (Milliseconds -> Eff (dom :: DOM | eff) a) -> Eff (dom :: DOM | eff) Request +``` + +When it is time for the next animation frame, callback with the then-current +time, and immediately execute the resulting effect. + +#### `requestAnimationFrameWithTime_` + +``` purescript +requestAnimationFrameWithTime_ :: forall a eff. Window -> (Milliseconds -> Eff (dom :: DOM | eff) a) -> Eff (dom :: DOM | eff) Request +``` + +Like `requestAnimationFrameWithTime`, but you supply the `Window` object. + +#### `cancelAnimationFrame` + +``` purescript +cancelAnimationFrame :: forall eff. Request -> Eff (dom :: DOM | eff) Unit ``` -Request the specified action be called on the next animation frame. +Cancel a request. diff --git a/src/DOM/RequestAnimationFrame.js b/src/DOM/RequestAnimationFrame.js index 46c5489..937c2ef 100644 --- a/src/DOM/RequestAnimationFrame.js +++ b/src/DOM/RequestAnimationFrame.js @@ -1,26 +1,94 @@ "use strict"; -// module DOM.RequestAnimationFrame - -var requestAnimationFrame = null; - -// http://www.paulirish.com/2011/requestanimationframe-for-smart-animating/ -exports.requestAnimationFrame_ = function(window_) { - return function(action) { - - if (!requestAnimationFrame) { - requestAnimationFrame = (function() { - return window_.requestAnimationFrame || - window_.webkitRequestAnimationFrame || - window_.mozRequestAnimationFrame || - function(callback) { - window_.setTimeout(callback, 1000 / 60); - }; - })(); +// module DOM.RequestAnimationFrame + +// The polyfill is a first-class effectful export. We have to store the result +// of the polyfill somewhere, so why not on the provided window? And, then, +// it is just an effect (in Purescript terms), so we can model it that way. +// +// For testing purposes, I've made it so that this actually can run under +// Node -- you just have to unsafely coerce something to be a `Window`. +// Of course, there's no point in doing that in practice, since there's +// no particular resaon to be tied to 60 FPS on Node. (That is, you'd want +// a completely different API to set your own frame rate on Node). +// +// The polyfill is based on the following gist: +// +// https://gist.github.com/jonasfj/4438815 +// +// which, in turn, gives the following credits: +// +// http://paulirish.com/2011/requestanimationframe-for-smart-animating/ +// http://my.opera.com/emoller/blog/2011/12/20/requestanimationframe-for-smart-er-animating +// +// requestAnimationFrame polyfill by Erik Möller +// fixes from Paul Irish and Tino Zijdel +// list-based fallback implementation by Jonas Finnemann Jensen +exports.applyPolyfillIfNeeded = function (window_) { + return function () { + // We check for both, since we need both and they need to be consistent + if (!window_.requestAnimationFrame || !window_.cancelAnimationFrame) { + var vendors = ['webkit', 'moz']; + + for (var x = 0; x < vendors.length && !window_.requestAnimationFrame; ++x) { + window_.requestAnimationFrame = window_[vendors[x] + 'RequestAnimationFrame']; + + window_.cancelAnimationFrame = + window_[vendors[x] + 'CancelAnimationFrame'] || + window_[vendors[x] + 'CancelRequestAnimationFrame']; + } + + // Again, we double-check for both + if (!window_.requestAnimationFrame || !window_.cancelAnimationFrame) { + // If still not present, apply the polyfill. + var tid = null, cbs = [], nb = 0, ts = Date.now(); + + var animate = function animate () { + var i, clist = cbs, len = cbs.length; + tid = null; + ts = Date.now(); + cbs = []; + nb += clist.length; + + for (i = 0; i < len; i++) { + if (clist[i]) clist[i](ts); + } + }; + + window_.requestAnimationFrame = function (cb) { + if (tid === null) { + tid = setTimeout(animate, Math.max(0, 20 + ts - Date.now())); + } + + return cbs.push(cb) + nb; + }; + + window_.cancelAnimationFrame = function (id) { + delete cbs[id - nb - 1]; + }; + } } + }; +}; + +// The rest assume that the polyfill has already been applied, if needed +exports.requestAnimationFrameImpl = function (window_) { + return function (callback) { + return function () { + return window_.requestAnimationFrame(function (time) { + // The callback is a function from the time to an effect. So + // we call the callback to get the effect, and then we immediately + // execute it (since now is the time we promised to do that). + callback(time)(); + }); + }; + }; +}; - return function() { - return requestAnimationFrame(action); +exports.cancelAnimationFrameImpl = function (window_) { + return function (requestID) { + return function () { + window_.cancelAnimationFrame(requestID); }; - } + }; }; diff --git a/src/DOM/RequestAnimationFrame.purs b/src/DOM/RequestAnimationFrame.purs index 0d5fd21..a177642 100644 --- a/src/DOM/RequestAnimationFrame.purs +++ b/src/DOM/RequestAnimationFrame.purs @@ -1,22 +1,65 @@ -- | This module exposes a polyfilled `requestAnimationFrame` function. module DOM.RequestAnimationFrame - ( requestAnimationFrame - , requestAnimationFrame_ - ) where + ( Request + , requestAnimationFrame, requestAnimationFrame_ + , requestAnimationFrameWithTime, requestAnimationFrameWithTime_ + , cancelAnimationFrame + ) where -import Prelude +import DOM (DOM) +import DOM.HTML (window) +import DOM.HTML.Types (Window) -import Control.Monad.Eff +import Data.Time.Duration (Milliseconds(..)) +import Control.Monad.Eff (Eff) -import DOM (DOM()) -import DOM.HTML (window) -import DOM.HTML.Types (Window()) +import Prelude (Unit, bind, (>>=), pure, flip, ($), const, (<<<)) + + +-- | A request for a callback via `requestAnimationFrame`. You can supply this to +-- | `cancelAnimationFrame` in order to cancel the request. +newtype Request = Request + { win :: Window + , id :: RequestID + } + +foreign import data RequestID :: * + +foreign import applyPolyfillIfNeeded :: forall eff. Window -> Eff (dom :: DOM | eff) Unit + +foreign import requestAnimationFrameImpl :: forall a eff. Window -> (Number -> Eff (dom :: DOM | eff) a) -> Eff (dom :: DOM | eff) RequestID + +foreign import cancelAnimationFrameImpl :: forall eff. Window -> RequestID -> Eff (dom :: DOM | eff) Unit + + +-- | Request that the specified action be called on the next animation frame, specifying +-- | the `Window` object. +requestAnimationFrame_ :: forall a eff. Window -> Eff (dom :: DOM | eff) a -> Eff (dom :: DOM | eff) Request +requestAnimationFrame_ win = + requestAnimationFrameWithTime_ win <<< const + + +-- | Request that the specified action be called on the next animation frame. +requestAnimationFrame :: forall a eff. Eff (dom :: DOM | eff) a -> Eff (dom :: DOM | eff) Request +requestAnimationFrame action = + window >>= (flip requestAnimationFrameWithTime_) (const action) + + +-- | When it is time for the next animation frame, callback with the then-current +-- | time, and immediately execute the resulting effect. +requestAnimationFrameWithTime :: forall a eff. (Milliseconds -> Eff (dom :: DOM | eff) a) -> Eff (dom :: DOM | eff) Request +requestAnimationFrameWithTime func = + window >>= (flip requestAnimationFrameWithTime_) func + + +-- | Like `requestAnimationFrameWithTime`, but you supply the `Window` object. +requestAnimationFrameWithTime_ :: forall a eff. Window -> (Milliseconds -> Eff (dom :: DOM | eff) a) -> Eff (dom :: DOM | eff) Request +requestAnimationFrameWithTime_ win func = do + applyPolyfillIfNeeded win + id <- requestAnimationFrameImpl win (func <<< Milliseconds) + pure $ Request {id, win} --- | Request the specified action be called on the next animation frame, specifying the `Window` object. -foreign import requestAnimationFrame_ :: forall a eff. Window -> Eff (dom :: DOM | eff) a -> Eff (dom :: DOM | eff) Unit --- | Request the specified action be called on the next animation frame. -requestAnimationFrame :: forall a eff. Eff (dom :: DOM | eff) a -> Eff (dom :: DOM | eff) Unit -requestAnimationFrame action = do - w <- window - requestAnimationFrame_ w action +-- | Cancel a request. +cancelAnimationFrame :: forall eff. Request -> Eff (dom :: DOM | eff) Unit +cancelAnimationFrame (Request {win, id}) = cancelAnimationFrameImpl win id diff --git a/test/Main.js b/test/Main.js new file mode 100644 index 0000000..c2a61a7 --- /dev/null +++ b/test/Main.js @@ -0,0 +1,9 @@ +// We're just returning an object that doesn't do requestAnimationFrame, +// so that the polyfill will be applied. We do it effectfully so that +// we can control when a fresh polyfill happens (otherwise, a previous +// test affects when the next animation frame is due). +exports.fakeWindow = function () { + return function () { + return {}; + }; +}; diff --git a/test/Main.purs b/test/Main.purs new file mode 100644 index 0000000..7a7dbfa --- /dev/null +++ b/test/Main.purs @@ -0,0 +1,213 @@ +module Test.Main where + + +import DOM.RequestAnimationFrame +import Control.Monad.Aff (Aff, later, later') +import Control.Monad.Aff.Console (logShow) +import Control.Monad.Eff (Eff) +import Control.Monad.Eff.Class (liftEff) +import Control.Monad.Eff.Console (CONSOLE) +import Control.Monad.Eff.Now (now, NOW) +import Control.Monad.Eff.Ref (REF, newRef, writeRef, readRef, modifyRef) +import DOM (DOM) +import DOM.HTML.Types (Window) +import Data.DateTime.Instant (unInstant, Instant) +import Data.Time.Duration (Milliseconds(Milliseconds)) +import Prelude (void, Unit, bind, (>>=), ($), (<$>), negate, (>), (+), (-), (==), (<>), show) +import Test.Unit (test) +import Test.Unit.Assert (assert) +import Test.Unit.Console (TESTOUTPUT) +import Test.Unit.Main (runTest) + + +-- The requestAnimationFrame polyfill technically works on Node, so we supply a +-- fake window for testing. We could expose the actual functionality on Node, but +-- it doesn't really make sense, since why tie yourself to 60 FPS? +-- +-- This means that we're basically testing the polyfill, but that should be fine, +-- since we're pretty confident that the native functions will behave as the +-- polyfill does. +-- +-- We do this effectfully, because we want to be able to control when we have a +-- a fresh polyfill ... otherwise, we don't know how soon the next animation frame +-- might arrive (since the previous test would affect the next test). +foreign import fakeWindow :: forall eff. Eff (dom :: DOM | eff) Window + + +logTime :: forall eff. Aff (now :: NOW, console :: CONSOLE | eff) Unit +logTime = (liftEff now) >>= logShow + + +elapsed :: Instant -> Instant -> Milliseconds +elapsed a b = + (unInstant b) - (unInstant a) + + +main :: Eff (now :: NOW, dom :: DOM, ref :: REF, console :: CONSOLE, testOutput :: TESTOUTPUT) Unit +main = + runTest do + test "Basic" do + raf <- liftEff $ requestAnimationFrame_ <$> fakeWindow + testVar <- liftEff $ newRef 0 + + liftEff $ + raf (writeRef testVar 1) + + later' 20 do + result <- liftEff (readRef testVar) + assert "Didn't execute after 20 millis" (result == 1) + + test "Doesn't execute immediately" do + raf <- liftEff $ requestAnimationFrame_ <$> fakeWindow + testVar <- liftEff $ newRef 0 + + liftEff $ raf (writeRef testVar 1) + + initialResult <- liftEff $ readRef testVar + assert "Callback immediately executed" (initialResult == 0) + + later' 20 do + result <- liftEff (readRef testVar) + assert "Hasn't executed after 20 millis" (result == 1) + + test "Doesn't execute too soon" do + raf <- liftEff $ requestAnimationFrame_ <$> fakeWindow + testVar <- liftEff $ newRef 0 + + started <- liftEff now + liftEff $ raf (writeRef testVar 1) + + later do + result <- liftEff (readRef testVar) + ended <- liftEff now + assert + ( "Callback executed too soon. " <> + "But, this could fail sporadically, if it took 16 millis. " <> + "We checked after " <> show (elapsed started ended) <> + ", and we got the result: " <> show result + ) + (result == 0) + + later' 20 do + result <- liftEff (readRef testVar) + assert "Hasn't executed after 20 millis" (result == 1) + + test "Cancelling" do + raf <- liftEff $ requestAnimationFrame_ <$> fakeWindow + testVar <- liftEff $ newRef 0 + + request <- liftEff $ + raf (writeRef testVar 1) + + liftEff $ + cancelAnimationFrame request + + later' 20 do + result <- liftEff (readRef testVar) + assert "Executed even though we canceled. This can fail sporadically, depending on timing." (result == 0) + + test "Cancelling a little later" do + raf <- liftEff $ requestAnimationFrame_ <$> fakeWindow + testVar <- liftEff $ newRef 0 + + request <- liftEff $ + raf (writeRef testVar 1) + + later' 5 $ liftEff $ + cancelAnimationFrame request + + later' 20 do + result <- liftEff (readRef testVar) + assert "Executed even though we canceled. This can fail sporadically, depending on timing." (result == 0) + + test "Basically is called just once" do + raf <- liftEff $ requestAnimationFrame_ <$> fakeWindow + testVar <- liftEff $ newRef 0 + + liftEff $ + raf (modifyRef testVar (_ + 1)) + + later' 100 do + result <- liftEff (readRef testVar) + assert ("We were called " <> show result <> " times, rather than once.") (result == 1) + + test "Chaining requestAnimationFrame" do + raf <- liftEff $ requestAnimationFrame_ <$> fakeWindow + testVar <- liftEff $ newRef 0 + + liftEff $ raf do + writeRef testVar 1 + void $ raf do + writeRef testVar 2 + void $ raf do + writeRef testVar 3 + + initialResult <- liftEff $ readRef testVar + assert "Callback immediately executed" (initialResult == 0) + + later do + result <- liftEff (readRef testVar) + assert ("Callback executed too soon.") (result == 0) + + later' 10 do + result <- liftEff (readRef testVar) + assert ("Callback executed after 10 millis.") (result == 0) + + later' 20 do + result <- liftEff (readRef testVar) + assert ("One raf should have executed.") (result == 1) + + later' 20 do + result <- liftEff (readRef testVar) + assert ("Second raf should have executed.") (result == 2) + + later' 20 do + result <- liftEff (readRef testVar) + assert ("Third raf should have executed.") (result == 3) + + test "Timed" do + rafWithTime <- liftEff $ requestAnimationFrameWithTime_ <$> fakeWindow + testVar <- liftEff $ newRef (-20.0) + + liftEff $ + rafWithTime (\(Milliseconds millis) -> + writeRef testVar millis + ) + + later' 20 do + result <- liftEff $ readRef testVar + assert ("The time value " <> show result <> " wasn't sane") (result > 0.0) + + test "Timed is called just once" do + rafWithTime <- liftEff $ requestAnimationFrameWithTime_ <$> fakeWindow + testVar <- liftEff $ newRef (0) + + liftEff $ + rafWithTime (\(Milliseconds millis) -> + modifyRef testVar (_ + 1) + ) + + later' 100 do + result <- liftEff $ readRef testVar + assert ("We were called " <> show result <> " times, rather than once.") (result == 1) + + test "Multiple rafs get the same time in the callback" do + rafWithTime <- liftEff $ requestAnimationFrameWithTime_ <$> fakeWindow + testVar1 <- liftEff $ newRef (0.0) + testVar2 <- liftEff $ newRef (1.0) + + liftEff do + rafWithTime (\(Milliseconds millis) -> + writeRef testVar1 millis + ) + + later' 5 $ liftEff do + rafWithTime (\(Milliseconds millis) -> + writeRef testVar2 millis + ) + + later' 25 do + result1 <- liftEff $ readRef testVar1 + result2 <- liftEff $ readRef testVar2 + + assert ("Callbacks should be given the same time") (result1 == result2) From 37b46631a6fdc68afc142355b4856b3d4dc35d0a Mon Sep 17 00:00:00 2001 From: Ryan Rempel Date: Sat, 25 Jun 2016 20:26:38 -0500 Subject: [PATCH 2/4] Update Travis config to use latest purescript version. --- .travis.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8f42945..b81c33e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,9 @@ language: node_js sudo: false node_js: - - 0.10 + - 5.0 install: - - npm install -g purescript@0.7.2 pulp - - pulp dep update + - npm install -g purescript pulp + - bower install script: - - pulp build + - pulp test From 5eaf92027e41a0cde29e3303e6f51fa7304ae9ed Mon Sep 17 00:00:00 2001 From: Ryan Rempel Date: Sat, 25 Jun 2016 20:30:05 -0500 Subject: [PATCH 3/4] Explicitly install bower in Travis config. --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b81c33e..3799ac2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ sudo: false node_js: - 5.0 install: - - npm install -g purescript pulp + - npm install -g purescript pulp bower - bower install script: - pulp test From 0a50cb67ec23bd216684170c2f06aab110b68b26 Mon Sep 17 00:00:00 2001 From: Ryan Rempel Date: Sat, 25 Jun 2016 20:44:01 -0500 Subject: [PATCH 4/4] Try to avoid build timeouts on Travis. --- .travis.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3799ac2..4abc5d2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,10 @@ language: node_js -sudo: false -node_js: - - 5.0 +dist: trusty +sudo: required +node_js: 5 install: - npm install -g purescript pulp bower - bower install script: + - pulp build - pulp test