Simplify playground code and add snippets. (#136)

* Add instructions for how to run the playground locally.
* Make it easier to select backend in the elm code.
* Use scrolling for long scripts.
* Fix bug, send code to backend on connect, don't auto play on code changes.
* Compile and cache snippets.
This commit is contained in:
David Himmelstrup 2020-08-22 10:35:25 +08:00 committed by GitHub
commit c1c4996e63
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 219 additions and 203 deletions

View file

@ -82,7 +82,11 @@ jobs:
- name: Playground - name: Playground
run: | run: |
CWD=`pwd` CWD=`pwd`
cd reanimate-playground/viewer-elm stack build
cd reanimate-playground
stack build
stack exec --cwd ../ playground snippets reanimate-playground/snippets > viewer-elm/dist/snippets.js
cd viewer-elm
npm install npm install
npm run build npm run build
mkdir $CWD/hpc/playground mkdir $CWD/hpc/playground

View file

@ -18,3 +18,22 @@ Test against:
# How to run locally:
## Backend
```
stack build
stack exec --cwd ../ playground
```
## Frontend
By default the frontend will use the backend running at reanimate.clozecards.com.
To switch to a local backend, change 'backend' in Main.elm to 'Local'.
```
cd viewer-elm
npm install
npm run dev-server
```

View file

@ -1,3 +1,2 @@
cradle: cradle:
stack: stack: {component: "playground"}
component: "playground"

View file

@ -23,6 +23,7 @@ executable playground
githash -any, githash -any,
time -any, time -any,
haskell-src-exts -any, haskell-src-exts -any,
temporary,
websockets -any, websockets -any,
warp, warp,
wai-app-static wai-app-static

View file

@ -34,6 +34,7 @@ import System.Directory
import System.Environment import System.Environment
import System.Exit import System.Exit
import System.FilePath import System.FilePath
import System.IO.Temp
import System.IO import System.IO
import System.Process import System.Process
import System.Timeout import System.Timeout
@ -83,6 +84,22 @@ main = do
args <- getArgs args <- getArgs
case args of case args of
["test"] -> putStrLn "Test OK" ["test"] -> putStrLn "Test OK"
["snippets", folder] -> do
files <- sort <$> getDirectoryContents folder
ghci <- takeMVar (backendGhci backend)
snippets <- mapM (genSnippet ghci)
[ folder </> file
| file <- files, takeExtension file == ".hs" ]
putStr "const snippets = "
putStrLn $
"[" ++ intercalate ","
[ "{" ++
"\"title\": " ++ show title ++ "," ++
"\"url\": " ++ show url ++ "," ++
"\"code\": " ++ show inp ++
"}"
| (title, url, inp) <- snippets ] ++
"];"
[] -> do [] -> do
root <- cacheDir root <- cacheDir
tid <- forkIO $ run 10162 (staticApp $ defaultWebAppSettings root) tid <- forkIO $ run 10162 (staticApp $ defaultWebAppSettings root)
@ -91,6 +108,23 @@ main = do
hPutStrLn stderr "Invalid arguments" hPutStrLn stderr "Invalid arguments"
exitWith (ExitFailure 1) exitWith (ExitFailure 1)
genSnippet :: Ghci -> FilePath -> IO (String, String, Text)
genSnippet ghci path = do
inp <- T.readFile path
let ParseOk m = parseHaskell inp
h = sourceHash m
withHaskellFile m $ \hsFile -> do
_ <- reqGhcOutput ghci $ ":load " ++ hsFile
out <- reqGhcOutput ghci "Reanimate.duration animation"
let dur = read (unlines out) :: Double
frames = round (dur * fromIntegral frameRate) :: Int
url = "https://reanimate.clozecards.com/" ++ h ++ "/" ++ show (frames `div` 2) ++ ".svg"
title = takeWhileEnd (/= '_') (takeBaseName path)
return (title, url, inp)
where
takeWhileEnd f = reverse . takeWhile f . reverse
opts :: ConnectionOptions opts :: ConnectionOptions
opts = defaultConnectionOptions opts = defaultConnectionOptions
{ connectionCompressionOptions = PermessageDeflateCompression defaultPermessageDeflate } { connectionCompressionOptions = PermessageDeflateCompression defaultPermessageDeflate }
@ -189,10 +223,10 @@ requestRender backend render = do
newGhci :: IO Ghci newGhci :: IO Ghci
newGhci = do newGhci = do
let fastProc = proc "stack" ["exec", "ghci", "--rts-options="++memoryLimit] let fastProc = proc "stack" ["exec", "ghci", "--rts-options="++memoryLimit]
(fastGhci, _loads) <- startGhciProcess fastProc (\_stream msg -> putStrLn msg) (fastGhci, _loads) <- startGhciProcess fastProc (\_stream msg -> hPutStrLn stderr msg)
void $ exec fastGhci "import qualified Reanimate" void $ reqGhcOutput fastGhci "import qualified Reanimate"
void $ exec fastGhci "import qualified Reanimate.Render as Reanimate" void $ reqGhcOutput fastGhci "import qualified Reanimate.Render as Reanimate"
return fastGhci return fastGhci
newBackend :: IO Backend newBackend :: IO Backend
@ -201,8 +235,7 @@ newBackend = do
queue <- newEmptyMVar queue <- newEmptyMVar
tid <- forkIO $ forever $ do tid <- forkIO $ forever $ do
req <- takeMVar queue req <- takeMVar queue
guardWanted req $ do guardWanted req $ withHaskellFile (renderCode req) $ \hs -> do
hs <- writeHaskellFile (renderCode req)
ghci <- readMVar ghciRef ghci <- readMVar ghciRef
guardGhci req ghci (":load " ++ hs) $ \_ -> guardWanted req $ guardGhci req ghci (":load " ++ hs) $ \_ -> guardWanted req $
guardGhci req ghci "Reanimate.duration animation" $ \out -> do guardGhci req ghci "Reanimate.duration animation" $ \out -> do
@ -247,8 +280,9 @@ cacheDir = do
createDirectoryIfMissing True root createDirectoryIfMissing True root
return root return root
writeHaskellFile :: Module SrcSpanInfo -> IO FilePath withHaskellFile :: Module SrcSpanInfo -> (FilePath -> IO a) -> IO a
writeHaskellFile m = do withHaskellFile m action = withSystemTempFile "playground.hs" $ \target h -> do
hClose h
T.writeFile target "{-# LANGUAGE OverloadedStrings #-}\n" T.writeFile target "{-# LANGUAGE OverloadedStrings #-}\n"
T.appendFile target "module Animation where\n" T.appendFile target "module Animation where\n"
T.appendFile target "import Reanimate\n" T.appendFile target "import Reanimate\n"
@ -262,9 +296,7 @@ writeHaskellFile m = do
T.appendFile target "import Control.Lens\n" T.appendFile target "import Control.Lens\n"
T.appendFile target "import Codec.Picture.Types\n" T.appendFile target "import Codec.Picture.Types\n"
T.appendFile target $ T.pack $ prettyPrint m T.appendFile target $ T.pack $ prettyPrint m
return target action target
where
target = "playground.hs"
splitGhciOutput :: Ghci -> String -> IO ([String], [String]) splitGhciOutput :: Ghci -> String -> IO ([String], [String])
splitGhciOutput ghci cmd = do splitGhciOutput ghci cmd = do
@ -276,6 +308,13 @@ splitGhciOutput ghci cmd = do
Stderr -> modifyIORef err (++[msg]) Stderr -> modifyIORef err (++[msg])
(,) <$> readIORef err <*> readIORef out (,) <$> readIORef err <*> readIORef out
reqGhcOutput :: Ghci -> String -> IO [String]
reqGhcOutput ghci cmd = do
(err, out) <- splitGhciOutput ghci cmd
unless (null err) $
error (unlines err)
return out
logMsg :: String -> IO () logMsg :: String -> IO ()
logMsg msg = do logMsg msg = do
now <- getCurrentTime now <- getCurrentTime

View file

@ -22,6 +22,7 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.0/css/bulma.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.0/css/bulma.min.css">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<script src="snippets.js"></script>
<script src="playground.js"></script> <script src="playground.js"></script>
</head> </head>
@ -41,10 +42,6 @@
<div class="grid"> <div class="grid">
<div class="edit-box"> <div class="edit-box">
<select class="column-header">
<option>Example 1</option>
<option>Example 2</option>
</select>
<div id="editor"></div> <div id="editor"></div>
</div> </div>
<div class="gutter-column-1"></div> <div class="gutter-column-1"></div>
@ -60,7 +57,10 @@
<img id="seek1" src="skip_next-white-48dp.svg"> <img id="seek1" src="skip_next-white-48dp.svg">
<img id="seek10" src="forward_10-white-48dp.svg"> <img id="seek10" src="forward_10-white-48dp.svg">
</div> </div>
<img id="help" src="help_outline-white-48dp.svg"> <div>
<img id="examples" src="collections-white-48dp.svg">
<img id="help" src="help_outline-white-48dp.svg">
</div>
</div> </div>
</div> </div>
<div id="elm"></div> <div id="elm"></div>
@ -85,6 +85,45 @@
<button class="modal-close is-large" area-label="close"></button> <button class="modal-close is-large" area-label="close"></button>
</div> </div>
<div id="examples-modal" class="modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Reanimate Examples</p>
<button class="delete" aria-label="close"></button>
</header>
<section class="modal-card-body">
<!--
Title
Url
Code
-->
<div class="snippet-container">
<!-- <div>
<span>drawBox</span>
<img src="https://reanimate.clozecards.com/EwyRzmx8zxM/30.svg">
</div>
<div>
<span>drawCircle</span>
<img src="https://reanimate.clozecards.com/EwyRzmx8zxM/50.svg">
</div>
<div>2</div>
<div>3</div>
<div>4</div>
<div>5</div>
<div>6</div>
<div>7</div>
<div>8</div>
<div>9</div>
<div>10</div> -->
</div>
</section>
<!-- <footer class="modal-card-foot">
</footer> -->
</div>
<button class="modal-close is-large" area-label="close"></button>
</div>
<script type="text/javascript"> <script type="text/javascript">
var app = playgroundInit('elm'); var app = playgroundInit('elm');
@ -95,54 +134,69 @@
lineWrapping: true lineWrapping: true
}); });
app.newCode(myCodeMirror.getValue());
myCodeMirror.on('change', function () { myCodeMirror.on('change', function () {
document.querySelector('.media-buttons').classList.remove('paused'); document.querySelector('.media-buttons').classList.remove('paused');
app.play();
app.newCode(myCodeMirror.getValue()); app.newCode(myCodeMirror.getValue());
}); });
document.getElementById('play').onclick = function() { renderSnippets(document.querySelector('.snippet-container'), myCodeMirror);
document.getElementById('play').onclick = function () {
document.querySelector('.media-buttons').classList.toggle('paused'); document.querySelector('.media-buttons').classList.toggle('paused');
app.play(); app.play();
}; };
document.getElementById('pause').onclick = function() { document.getElementById('pause').onclick = function () {
document.querySelector('.media-buttons').classList.toggle('paused'); document.querySelector('.media-buttons').classList.toggle('paused');
app.pause(); app.pause();
}; };
document.getElementById('seek1').onclick = function() { document.getElementById('seek1').onclick = function () {
document.querySelector('.media-buttons').classList.add('paused'); document.querySelector('.media-buttons').classList.add('paused');
app.pause(); app.pause();
app.seek1(); app.seek1();
}; };
document.getElementById('seek10').onclick = function() { document.getElementById('seek10').onclick = function () {
document.querySelector('.media-buttons').classList.add('paused'); document.querySelector('.media-buttons').classList.add('paused');
app.pause(); app.pause();
app.seek10(); app.seek10();
}; };
document.getElementById('seek-1').onclick = function() { document.getElementById('seek-1').onclick = function () {
document.querySelector('.media-buttons').classList.add('paused'); document.querySelector('.media-buttons').classList.add('paused');
app.pause(); app.pause();
app.seek_1(); app.seek_1();
}; };
document.getElementById('seek-10').onclick = function() { document.getElementById('seek-10').onclick = function () {
document.querySelector('.media-buttons').classList.add('paused'); document.querySelector('.media-buttons').classList.add('paused');
app.pause(); app.pause();
app.seek_10(); app.seek_10();
}; };
document.getElementById('help').onclick = function() { document.getElementById('help').onclick = function () {
document.querySelector('#help-modal').classList.add('is-active'); document.querySelector('#help-modal').classList.add('is-active');
}; };
document.querySelector('#help-modal .modal-background').onclick = function() { document.querySelector('#help-modal .modal-background').onclick = function () {
document.querySelector('#help-modal').classList.remove('is-active'); document.querySelector('#help-modal').classList.remove('is-active');
}; };
document.querySelector('#help-modal .modal-close').onclick = function() { document.querySelector('#help-modal .modal-close').onclick = function () {
document.querySelector('#help-modal').classList.remove('is-active'); document.querySelector('#help-modal').classList.remove('is-active');
}; };
document.querySelector('#help-modal .delete').onclick = function() { document.querySelector('#help-modal .delete').onclick = function () {
document.querySelector('#help-modal').classList.remove('is-active'); document.querySelector('#help-modal').classList.remove('is-active');
}; };
document.getElementById('examples').onclick = function () {
document.querySelector('#examples-modal').classList.add('is-active');
};
document.querySelector('#examples-modal .modal-background').onclick = function () {
document.querySelector('#examples-modal').classList.remove('is-active');
};
document.querySelector('#examples-modal .modal-close').onclick = function () {
document.querySelector('#examples-modal').classList.remove('is-active');
};
document.querySelector('#examples-modal .delete').onclick = function () {
document.querySelector('#examples-modal').classList.remove('is-active');
};
</script> </script>
<script> <script>
Split({ // gutters specified in options Split({ // gutters specified in options

View file

@ -3,11 +3,13 @@ const backend = "149.56.132.163";
function playgroundInit(elt) { function playgroundInit(elt) {
var mySockets = {}; var mySockets = {};
var frames = {}; var frames = {};
var lastScript = "";
function sendSocketCommand(wat) { function sendSocketCommand(wat) {
if (wat.cmd == "connect") { if (wat.cmd == "connect") {
socket = new WebSocket(wat.address); socket = new WebSocket(wat.address);
socket.onopen = function (event) { socket.onopen = function (event) {
socket.send(lastScript);
app.ports.receiveSocketMsg.send({ app.ports.receiveSocketMsg.send({
name: wat.name, name: wat.name,
msg: "data", msg: "data",
@ -42,7 +44,9 @@ function playgroundInit(elt) {
socket.onclose = connectionFailedHandler; socket.onclose = connectionFailedHandler;
mySockets[wat.name] = socket; mySockets[wat.name] = socket;
} else if (wat.cmd == "send") { } else if (wat.cmd == "send") {
mySockets[wat.name].send(wat.content); if( mySockets[wat.name].readyState === mySockets[wat.name].OPEN ) {
mySockets[wat.name].send(wat.content);
}
} else if (wat.cmd == "close") { } else if (wat.cmd == "close") {
mySockets[wat.name].close(); mySockets[wat.name].close();
delete mySockets[wat.name]; delete mySockets[wat.name];
@ -73,7 +77,26 @@ function playgroundInit(elt) {
app.ports.receiveControlMsg.send('seek-10'); app.ports.receiveControlMsg.send('seek-10');
}, },
newCode: function(code) { newCode: function(code) {
lastScript = code;
app.ports.receiveEditorMsg.send(code); app.ports.receiveEditorMsg.send(code);
} }
}; };
} }
function renderSnippets(elt, myCodeMirror) {
for(var i=0;i<snippets.length;i++) {
const div = document.createElement("div");
const title = document.createElement("span");
const img = document.createElement("img");
const code = snippets[i].code;
title.innerHTML = snippets[i].title;
img.src = snippets[i].url;
div.appendChild(title)
div.appendChild(img)
div.onclick = function () {
myCodeMirror.setValue(code);
document.querySelector('#examples-modal').classList.remove('is-active');
};
elt.appendChild(div)
}
}

View file

@ -0,0 +1 @@
const snippets = [{"title": "Composition","url": "https://reanimate.clozecards.com/ATcv1$xjG06/30.svg","code": "animation :: Animation\nanimation = docEnv $\n drawBox `parA` drawCircle\n\n"},{"title": "Color Maps","url": "https://reanimate.clozecards.com/HPNi7PpImrc/15.svg","code": "animation :: Animation\nanimation = docEnv $ staticFrame 1 $\n showColorMap parula\n"}];

View file

@ -106,6 +106,7 @@ pre {
} }
.edit-box #editor { .edit-box #editor {
flex: 1 1 auto; flex: 1 1 auto;
max-height: 100vh;
} }
.edit-box .app { .edit-box .app {
flex: 1 1 auto; flex: 1 1 auto;
@ -121,6 +122,27 @@ pre {
cursor: pointer; cursor: pointer;
} }
.snippet-container {
display: flex;
justify-content: space-around;
flex-wrap: wrap;
}
.snippet-container > div {
width: 16em;
height: 9em;
margin: 1em;
position: relative;
}
.snippet-container > div > span {
position: absolute;
display: block;
background-color: rgba(255,255,255,0.3);
padding: 0.2em;
}
.snippet-container > div > img {
border: 1px solid black;
}
.github-corner:hover .octo-arm { .github-corner:hover .octo-arm {
animation: octocat-wave 560ms ease-in-out animation: octocat-wave 560ms ease-in-out
} }

View file

@ -14,27 +14,37 @@ import List
import Platform.Sub import Platform.Sub
import Ports import Ports
import Task import Task
import Time
import WebSocket import WebSocket
type Backend
= Production
| Local
backend : Backend
backend =
Production
wsBackend : String wsBackend : String
wsBackend = wsBackend =
"wss://reanimate.clozecards.com/ws/" case backend of
Production ->
"wss://reanimate.clozecards.com/ws/"
Local ->
"ws://localhost:10161/"
--wsBackend = "ws://localhost:10161/"
webBackend : String webBackend : String
webBackend = webBackend =
"https://reanimate.clozecards.com/" case backend of
Production ->
"https://reanimate.clozecards.com/"
Local ->
"http://localhost:10162/"
-- webBackend = "http://localhost:10162/"
-- backend = "localhost"
main : Program () Model Msg main : Program () Model Msg
@ -53,8 +63,6 @@ subscriptions model =
[ Ports.receiveSocketMsg (WebSocket.receive MessageReceived) [ Ports.receiveSocketMsg (WebSocket.receive MessageReceived)
, Ports.receiveEditorMsg Change , Ports.receiveEditorMsg Change
, Ports.receiveControlMsg parseControlMsg , Ports.receiveControlMsg parseControlMsg
-- , Keyboard.downs KeyPressed
, case model of , case model of
Animating { player } -> Animating { player } ->
case player of case player of
@ -103,8 +111,6 @@ type Msg
| Pause | Pause
| Play | Play
| Seek Int | Seek Int
| KeyPressed Keyboard.RawKey
| ToggleHelp
| NoOp | NoOp
| Change String | Change String
@ -123,7 +129,6 @@ type alias Animation =
, frameIndex : Int , frameIndex : Int
, player : Player , player : Player
, bestFrame : Maybe String , bestFrame : Maybe String
, showingHelp : Bool
, frameDeltas : List Float , frameDeltas : List Float
} }
@ -135,7 +140,6 @@ initAnimation frameCount =
, frameIndex = 0 , frameIndex = 0
, player = Playing 0 , player = Playing 0
, bestFrame = Nothing , bestFrame = Nothing
, showingHelp = False
, frameDeltas = Fps.init , frameDeltas = Fps.init
} }
@ -259,17 +263,11 @@ update msg model =
MessageReceived result -> MessageReceived result ->
( processResult result model, Cmd.none ) ( processResult result model, Cmd.none )
KeyPressed rawKey ->
( model, processKeyPress rawKey model )
Pause -> Pause ->
( updateAnimation (\animation -> { animation | player = Paused }) model ( updateAnimation (\animation -> { animation | player = Paused }) model
, blurPlayOrPause , blurPlayOrPause
) )
ToggleHelp ->
( updateAnimation (\animation -> { animation | showingHelp = not animation.showingHelp }) model, Cmd.none )
AttemptReconnect -> AttemptReconnect ->
( model, connectCommand ) ( model, connectCommand )
@ -390,19 +388,15 @@ view model =
Html.text "Connected" Html.text "Connected"
Compiling -> Compiling ->
-- TODO it would be nice to have some progress indication (at least animated spinner or something) -- it would be nice to have some progress indication
-- (at least animated spinner or something)
Html.text "Compiling ..." Html.text "Compiling ..."
Problem problem -> Problem problem ->
problemView problem problemView problem
Animating { frameCount, frames, frameIndex, player, bestFrame, showingHelp, frameDeltas } -> Animating { bestFrame } ->
case player of frameView bestFrame
Paused ->
frameView bestFrame frameIndex frameCount frames showingHelp frameDeltas True
Playing _ ->
frameView bestFrame frameIndex frameCount frames showingHelp frameDeltas False
] ]
] ]
@ -419,32 +413,9 @@ framesPerMillisecond =
0.03 0.03
playControls : Bool -> Html Msg
playControls paused =
Html.div [ class "media-controls" ]
[ Html.button [ class "button", onClick (Seek -10), disabled (not paused), title "10 frames back" ] [ Html.text "<<" ]
, Html.button [ class "button", onClick (Seek -1), disabled (not paused), title "1 frame back" ] [ Html.text "<" ]
, if paused then
Html.button [ class "button", onClick Play, id playOrPauseId ] [ Html.text "Play" ]
else frameView : Maybe String -> Html Msg
Html.button [ class "button", onClick Pause, id playOrPauseId ] [ Html.text "Pause" ] frameView bestFrame =
, Html.button [ class "button", onClick (Seek 1), disabled (not paused), title "1 frame forward" ] [ Html.text ">" ]
, Html.button [ class "button", onClick (Seek 10), disabled (not paused), title "10 frames forward" ] [ Html.text ">>" ]
]
mkLink : String -> Html Msg
mkLink svgUrl =
Html.node "link"
[ Attr.rel "prefetch"
, Attr.href (webBackend ++ svgUrl)
]
[]
frameView : Maybe String -> Int -> Int -> Frames -> Bool -> List Float -> Bool -> Html Msg
frameView bestFrame frameIndex frameCount frames showingHelp frameDeltas isPaused =
let let
image = image =
case bestFrame of case bestFrame of
@ -453,66 +424,9 @@ frameView bestFrame frameIndex frameCount frames showingHelp frameDeltas isPause
Nothing -> Nothing ->
Html.text "" Html.text ""
frameCountStr =
String.fromInt frameCount
digitCount =
String.length frameCountStr
progressView =
let
receivedFrames =
Dict.size frames
in
if receivedFrames /= frameCount then
progressBar receivedFrames frameCount
else
Html.text ""
helpView =
if showingHelp then
helpModal
else
Html.button [ class "help-button button", onClick ToggleHelp ] [ Html.text "?" ]
bar =
Html.div [ class "bar" ]
[ playControls isPaused
, Html.span []
[ Html.text <|
" Frame: "
++ String.padLeft digitCount '0' (String.fromInt (frameIndex + 1))
++ " / "
++ frameCountStr
-- ++ (if isPaused then
-- " "
-- else
-- Fps.showAverage frameDeltas
-- )
]
, progressView
, helpView
]
in in
Html.div [ class "viewer" ] Html.div [ class "viewer" ]
[ bar [ image
, image
]
progressBar : Int -> Int -> Html msg
progressBar receivedFrames frameCount =
Html.label []
[ Html.span [] [ Html.text " | Loading frames " ]
, Html.progress
[ value (String.fromInt receivedFrames)
, Attr.max (String.fromInt frameCount)
]
[]
] ]
@ -535,63 +449,3 @@ problemView problem =
UnexpectedMessage problemDescription -> UnexpectedMessage problemDescription ->
Html.text ("Unexpected message: " ++ problemDescription) Html.text ("Unexpected message: " ++ problemDescription)
processKeyPress : RawKey -> Model -> Cmd Msg
processKeyPress rawKey model =
Keyboard.oneOf [ Keyboard.navigationKey, Keyboard.whitespaceKey ] rawKey
|> Maybe.andThen
(\key ->
case key of
Keyboard.ArrowDown ->
Just (Seek -10)
Keyboard.ArrowUp ->
Just (Seek 10)
Keyboard.ArrowRight ->
Just (Seek 1)
Keyboard.ArrowLeft ->
Just (Seek -1)
Keyboard.Spacebar ->
case model of
Animating { player } ->
case player of
Playing _ ->
Just Pause
Paused ->
Just Play
_ ->
Nothing
_ ->
Nothing
)
|> Maybe.map (Task.succeed >> Task.perform identity)
|> Maybe.withDefault Cmd.none
helpModal : Html Msg
helpModal =
let
explainKey key legend =
Html.tr []
[ Html.td [] [ Html.b [] [ Html.text key ] ]
, Html.td [] [ Html.text legend ]
]
in
Html.div [ class "help-dialog" ]
[ Html.h2 [ style "margin-top" "0px" ] [ Html.text "Keyboard shortcuts" ]
, Html.button [ class "help-button button", onClick ToggleHelp ] [ Html.text "X" ]
, Html.table []
[ explainKey "SPACEBAR" "Pause / Play animation"
, explainKey "ARROW LEFT" " Move back 1 frame"
, explainKey "ARROW RIGHT" " Move forward 1 frame"
, explainKey "ARROW UP" " Move forward 10 frames"
, explainKey "ARROW DOWN" " Move back 10 frames"
]
]