{-# options_haddock not-home #-} module Myxine.Page ( -- * #Top# Creating Interactive Pages {-| To create an interactive page, we need to build a 'Page'. A 'Page' is a handle to a running page in the browser, providing a stateful typed mapping between the view and interactions in the browser page and the types and actions available within Haskell. To create a 'Page', we use 'runPage': @ 'Myxine.Page.runPage' :: 'Direct.PageLocation' -> model -> ('Myxine.WithinPage' => model -> ('Direct.PageContent', 'Handlers' model)) -> IO ('Myxine.Page.Page' model) @ The beginning of a typical @myxine-client@ app looks something like: @ data Model = ... -- the model that defines the page's current state do page <- 'runPage' location (pure initialModel) ('reactive' . component) finalModel <- 'waitPage' page where location :: 'PageLocation' location = 'pagePort' 1123 <> 'pagePath' \'/\' -- where to connect to the server initialModel :: Model initialModel = ... -- the initial state of the model component :: 'WithinPage' => 'Reactive' Model component = ... -- how to render the model and listen for events that would update it @ To describe the interactive behavior of the page, we need to define: * __@location@:__ the 'pagePath' and 'pagePort' to connect to the Myxine server. Use 'mempty' to use the default port and the root path. See the section on [page locations](#Locations). * __@initialModel@:__ the starting value for the model of the page, which can be any Haskell data type of your choice. * __@component@:__ an interleaved description in the 'Reactive' monad explaining both how to render the current state of the model as HTML, and how to handle events that occur within that selfsame HTML by updating the model and performing IO. See also the sections on [building reactive pages](#Building) and [manipulating pages](#Manipulating). -} -- ** #NoReactive# Using 'Page' without 'Reactive' {-| Although the 'Reactive' abstraction is typically the most convenient way to build a 'Page', the 'runPage' abstraction is not bound to a specific way of formatting HTML 'Direct.PageContent' or gathering a set of 'Handlers'. Instead, as noted above, 'runPage' takes as input any function of type @'Myxine.WithinPage' => model -> ('Direct.PageContent', 'Handlers' model)@. We provide the 'Reactive'-built pages to 'runPage' by /evaluating/ them using: @ 'reactive' :: 'Reactive' model -> model -> ('Direct.PageContent', 'Handlers' model) @ This might not always suit your desires, though, and that's precisely why it's not baked in. You are free to construct 'PageContent' using 'pageTitle' and 'pageBody', and to construct 'Handlers' using 'Myxine.Handlers.onEvent' and '<>', avoiding the 'Reactive' abstraction altogether if you so choose. -} -- * #Pages# Creating and Waiting For Pages Page, runPage, waitPage, stopPage -- * #Locations# Specifying Page Locations {-| If you are building a single-page application using Myxine, and you don't intend to share its address space, you don't need to change the default settings for the 'PageLocation': 'mempty' will do. However, the Myxine server will gladly host your page at any path you desire; just use 'pagePath' to specify. Similarly, use 'pagePort' to specify if the Myxine server is running on a different port than its default of 1123. -} , PageLocation, pagePath, pagePort -- * #Building# Building Reactive Pages {-| When using the 'Reactive' DSL for building pages, it's intended that you import it alongside the 'Text.Blaze.Html5' and 'Text.Blaze.Html5.Attributes' modules from the [blaze-html](https://hackage.haskell.org/package/blaze-html), which provide the HTML combinators required for building markup. While nothing about this module hard-codes the use of [lenses](https://hackage.haskell.org/package/lens), it is often useful in the body of handlers given to 'on' to manipulate the model state using lens combinators, in particular the stateful 'Control.Lens.Setter..=' and friends. This library is designed to play well with the following import structure: @ import Text.Blaze.Html5 ((!)) import qualified Text.Blaze.Html5 as H import qualified Text.Blaze.Html5.Attributes as A import Control.Monad.IO.Class import Control.Monad.State import Control.Monad.Reader import Control.Lens import Myxine @ __A small example:__ Here's a simple 'Reactive' page that uses some of the combinators in this library, as well as the Lens combinators 'Control.Lens._1', 'Control.Lens._2', 'Control.Lens.^.', 'Control.Lens..=', 'Control.Lens.+=', and 'Control.Lens.*='. In this demonstration, you can see how the '@@' and 'on' functions work together to allow you to define event handlers scoped to specific regions of the page: handlers defined via 'on' receive only events from within the region delineated by the enclosing '@@'. As such, clicking on the background does not increment the counter, but clicking on the button does. @ main :: IO () main = do page <- 'runPage' 'mempty' (0, False) ('reactive' component) print =<< 'waitPage' page component :: Reactive (Integer, Bool) component = do model <- 'ask' H.div ! A.style ("background: " '<>' if model 'Control.Lens.^.' 'Control.Lens._2' then "red" else "green") '@@' do 'on' 'MouseOver' $ \\_ -> 'Control.Lens._2' 'Control.Lens..=' True 'on' 'MouseOut' $ \\_ -> 'Control.Lens._2' 'Control.Lens..=' False H.button ! A.style "margin: 20pt" '@@' do 'on' 'Click' $ \\'MouseEvent'{shiftKey = False} -> 'Control.Lens._1' 'Control.Lens.+=' 1 'on' 'Click' $ \\'MouseEvent'{shiftKey = True} -> 'Control.Lens._1' 'Control.Lens.*=' 2 'markup' $ do H.span ! A.style "font-size: 20pt" $ H.string (show (model 'Control.Lens.^.' 'Control.Lens._1')) @ -} , module Myxine.Reactive -- * #Evaluating# Evaluating JavaScript {-| The functions 'eval' and 'evalBlock' evaluate some 'JavaScript' in the context of the current 'Page' and return a deserialized Haskell type (inferred from the use site), or throw a 'JsException' containing a human-readable string describing any error that occurred. For instance, here's how we would query the width of the browser window on every 'Resize' event: @ windowWidth :: 'WithinPage' => 'Reactive' Int windowWidth = do currentWidth <- ask 'markup' currentWidth 'on' 'Resize' $ \\_ -> do width <- 'eval' "window.innerWidth" 'Control.Monad.State.put' width @ __Possible errors__ (which manifest as 'JsException's): * Any exception in the given JavaScript * Invalid JSON response for the result type inferred (use 'JSON.Value' if you don't know what shape of data you're waiting to receive). __Further caveats:__ * JavaScript @undefined@ is translated to @null@ in the results * Return types are limited to those which can be serialized via [JSON.stringify](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify), which does not work for cyclic objects (like @window@, @document@, and all DOM nodes), and may fail to serialize some properties for other non-scalar values. If you want to return a non-scalar value like a list or dictionary, construct it explicitly yourself by copying from the fields of the object you're interested in. * You're evaluating an arbitrary string as JavaScript, which means there are no guarantees about type safety or purity. * It is possible that you could break the Myxine server code running in the page that makes it update properly, or hang the page by passing a non-terminating piece of code. * Any modifications you make to the DOM will be immediately overwritten on the next re-draw of the page. Don't do this. * If there are multiple browser windows pointed at the same page, and the result of your query differs between them, it's nondeterministic which result you get back. -} , WithinPage, eval, evalBlock -- * #Manipulating# Manipulating Running Pages {-| Once a page is running, the only way to interact with its contents is via its 'Page' handle (unless you use the methods in 'Myxine.Direct', but it is /strongly discouraged/ to mix the two different abstractions: you will almost certainly confuse yourself a lot). A 'Page' whose behavior relies solely on user interactions within the browser doesn't need any of these functions: these are the mechanism by which external information can be used to modify the model of a page, and thereby update the GUI to reflect the model. Keep in mind that Myxine, like most GUIs, is an inherently concurrent system. This interface reflects that: in between direct modifications of the model with 'modifyPage' and its friends, the model may change arbitrarily due to event handlers (or other threads) taking actions upon it. However, it's guaranteed that any single modification is atomic, and that sequences of modifications are not re-ordered (although there may be things that happen in between them). -} , modifyPage, modifyPageIO, setPage, getPage ) where import Control.Monad import Control.Monad.IO.Class import Control.Exception (SomeException, finally, throwIO, catch) import qualified Data.Aeson as JSON import Control.Concurrent import Control.Concurrent.Async import Data.List.NonEmpty (nonEmpty) import Data.Text (Text) import Data.Foldable import qualified Unsafe.Coerce as Unsafe import Myxine.Direct import Myxine.Reactive import Myxine.Handlers -- | A handle to a running 'Page'. Create this using 'runPage', and wait for its -- eventual result using 'waitPage'. In between, you can interact with it using -- the rest of the functions in this module, such as 'modifyPage', 'stopPage', -- etc. data Page model = Page { pageActions :: !(Chan (PageAction model)) , pageFinished :: !(MVar (Either SomeException model)) , pageLocation :: !PageLocation } -- | An action to be performed in the page context. Either a call to stop the -- page, or an effectful function to be performed on the model of the page. data PageAction model = StopPage | PageAction !(WithinPage => model -> IO model) -- | Run an interactive page, returning a handle 'Page' through which it can be -- interacted further, via the functions in this module (e.g. 'waitPage', -- 'modifyPage', etc.). -- -- This function takes as input a 'PageLocation', an initial model, and a pure -- function from the current state of the page's model to a rendered HTML view -- of the page in its entirety, and the new set of 'Handlers' for page events. A -- handler can modify the model of the page, and perform arbitrary 'IO' actions, -- including evaluating JavaScript in the page using 'eval'. After each all -- pertinent handlers for an event are dispatched, the page is re-rendered to -- the browser. -- -- This function itself is non-blocking: it immediately kicks off threads to -- start running the page. It will not throw exceptions by itself. All -- exceptions thrown by page threads (such as issues with connecting to the -- server) are deferred until a call to 'waitPage'. -- -- __Important:__ Because the GHC runtime does not wait for all threads to -- finish when ending the main thread, you probably need to use 'waitPage' to -- make sure your program stays alive to keep processing events. -- -- Typical use of this function embeds a 'Reactive' page by using the 'reactive' -- function to adapt it as the last argument (but this is not the only way to -- use it, see [Using Page without Reactive](#NoReactive)): -- -- @ -- 'runPage' location (pure initialModel) ('reactive' component) -- @ runPage :: forall model. PageLocation {- ^ The location of the 'Page' (built using 'pagePort' and/or 'pagePath'). -} -> (WithinPage => IO model) {- ^ An IO action to return the initial @model@ for the 'Page'. Note that this is in a 'WithinPage' context and therefore can use 'eval' and 'evalBlock'. -} -> (WithinPage => model -> (PageContent, Handlers model)) {- ^ A function to draw the @model@ as some rendered 'Direct.PageContent' and produce the set of 'Handlers' for events on that new view of the page. Note that this is in a 'WithinPage' context and therefore can use 'eval' and 'evalBlock'. -} -> IO (Page model) {- ^ A 'Page' handle to permit further interaction with the running page -} runPage pageLocation getInitialModel drawAndHandle = do pageActions :: Chan (PageAction model) <- newChan currentUpdate :: Chan (Either PageEvent model) <- newChan frames :: Chan PageContent <- newChan eventLists :: Chan (Maybe EventList) <- newChan pageFinished :: MVar (Either SomeException model) <- newEmptyMVar let inPage :: (WithinPage => a) -> a inPage = withPageContext (WithinPageContext pageLocation) -- The stream of events from the page nextEvent <- events pageLocation -- Renders the page: renderThread <- forkIO $ forever (update pageLocation . Dynamic =<< readChan frames) -- Polls for the next event: pollThread <- forkIO $ onLatest (writeChan currentUpdate . Left) $ maybe (forever (threadDelay maxBound)) nextEvent <$> readChan eventLists -- Handles events and sends update actions to the model thread: updateThread <- forkIO $ let loop handlers = do writeChan eventLists $ SomeEvents <$> nonEmpty (handledEvents handlers) readChan currentUpdate >>= \case Left event -> do writeChan pageActions (PageAction (handle handlers event)) loop handlers Right model -> do let (content, handlers') = inPage (drawAndHandle model) writeChan frames content loop handlers' in catch @SomeException (loop mempty) (putMVar pageFinished . Left) -- Processes update actions to the model and sends to update thread: _modelThread <- forkIO $ let loop model = readChan pageActions >>= \case StopPage -> putMVar pageFinished (Right model) PageAction action -> do model' <- inPage (action model) writeChan currentUpdate (Right model') loop model' in finally (catch @SomeException (loop =<< inPage getInitialModel) (putMVar pageFinished . Left)) (traverse_ killThread [renderThread, pollThread, updateThread]) -- Kick off the cycle by "updating" the intial model writeChan pageActions (PageAction pure) pure Page{..} -- | Race each action in a "stream" of actions against the arrival of the next -- action, feeding the completed results into a function, but canceling actions -- that take longer to produce a value than it takes to get the next action. In -- other words, a sequential pre-emptible work queue. onLatest :: (a -> IO ()) -> IO (IO a) -> IO () onLatest action rest = go =<< rest where go first = race first rest >>= \case Left a -> do action a next <- rest go next Right preempt -> do go preempt -- | Wait for a 'Page' to finish executing and return its resultant @model@, or -- re-throw any exception the page encountered. -- -- This function may throw 'Req.HttpException' if it cannot connect to a running -- instance of the server. Additionally, it will also re-throw any exception -- that was raised by user code running within an event handler or -- model-modifying action. waitPage :: Page model -> IO model waitPage Page{pageFinished} = do result <- readMVar pageFinished either throwIO pure result -- | Politely request a 'Page' to shut down. This is non-blocking: to get the -- final model of the 'Page', follow 'stopPage' with a call to 'waitPage'. -- -- Before the page is stopped, all events and modifications which were pending -- at the time of this command will be processed. stopPage :: Page model -> IO () stopPage Page{pageActions, pageFinished} = do running <- isEmptyMVar pageFinished when running $ writeChan pageActions StopPage -- | Modify the model of the page with a pure function, and update the view in -- the browser to reflect the new model. -- -- This function is non-blocking; the page view may not yet have been updated by -- the time it returns. modifyPage :: Page model -> (model -> model) -> IO () modifyPage page f = do running <- isEmptyMVar (pageFinished page) when running $ modifyPageIO page (pure . f) -- | Modify the model of the page, potentially doing arbitrary other effects in -- the 'IO' monad, then re-draw the page to the browser. The functions 'eval' -- and 'evalBlock' are available for evaluating JavaScript within the context of -- the current page. -- -- This function is non-blocking; the page view may not yet have been updated by -- the time it returns. modifyPageIO :: Page model -> (WithinPage => model -> IO model) -> IO () modifyPageIO Page{pageActions} action = writeChan pageActions (PageAction action) -- | Set the model of the page to a particular value, and update the view in the -- browser to reflect the new model. -- -- This function is non-blocking; the page view may not yet have been updated by -- the time it returns. setPage :: Page model -> model -> IO () setPage page = modifyPage page . const -- | Get the current model of the page, blocking until it is retrieved. -- -- __Note:__ it is not guaranteed that the model returned by this function is -- "fresh" by the time you act upon it. That is: -- -- @ -- 'getPage' page >>= 'setPage' page -- @ -- -- __is not the same as__ -- -- @ -- 'modifyPage' id -- @ -- -- This is because some other thread (notably, an event handler thread) could -- have changed the page in between the call to 'getPage' and 'setPage'. As a -- result, you probably don't want to use this function, except perhaps as a way -- to extract intermediate reports on the value of the page. getPage :: Page model -> IO model getPage page = do v <- newEmptyMVar modifyPageIO page \model -> do putMVar v model pure model takeMVar v -- Borrowing a technique from Data.Reflection: -- | The @WithinPage@ constraint, when it is present, enables the use of the -- 'eval' and 'evalBlock' functions. Only in the body of a call to 'runPage' or -- 'modifyPageIO' is there a canonical current page, and it's a type error to -- use these functions anywhere else. class WithinPage where -- Acquire the JavaScript evaluation context from the ambient page. This -- function cannot be called directly; use 'eval' or 'evalBlock' to evaluate -- JavaScript within a page. withinPageContext :: WithinPageContext -- Specialized version of Data.Reflection.Gift: newtype WithinPageGift r = WithinPageGift (WithinPage => r) -- Specialized version of Data.Reflection.give: withPageContext :: forall r. WithinPageContext -> (WithinPage => r) -> r withPageContext c k = Unsafe.unsafeCoerce (WithinPageGift k :: WithinPageGift r) c {-# NOINLINE withPageContext #-} -- A "handle" that closes over the page location newtype WithinPageContext = WithinPageContext PageLocation eval, evalBlock :: (WithinPage, JSON.FromJSON a, MonadIO m) => Text -> m a -- | Evaluate a JavaScript __expression__ in the context of the current page. -- The given text is automatically wrapped in a @return@ statement before being -- evaluated in the browser. -- -- If you try to call 'eval' outside of a call to 'runPage' or 'modifyPageIO', -- you'll get a type error like the following: -- -- > • No instance for WithinPage arising from a use of ‘eval’ -- > • In a stmt of a 'do' block: x <- eval @Int "1 + 1" -- -- This means that you called it in some non-'Page' context, like in the -- main function of your program. eval expression = let WithinPageContext location = withinPageContext in liftIO (evaluateJs location (JsExpression expression)) -- | Evaluate a JavaScript __block__ in the context of the current page. Unlike -- 'eval', the given text is /not/ automatically wrapped in a @return@ -- statement, which means that you can evaluate multi-line statements, but you -- must provide your own @return@. -- -- If you try to call 'evalBlock' outside of a call to 'runPage' or -- 'modifyPageIO', you'll get a type error like the following: -- -- > • No instance for WithinPage arising from a use of ‘evalBlock’ -- > • In a stmt of a 'do' block: x <- evalBlock @Int "return 1;" -- -- This means that you called it in some non-'Page' context, like in the -- main function of your program. evalBlock block = let WithinPageContext location = withinPageContext in liftIO (evaluateJs location (JsBlock block))