diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..e12bf3d
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,108 @@
+name: build
+
+on:
+ merge_group:
+ pull_request:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ nix-build:
+ name: nix-build
+ runs-on: ubuntu-latest
+ steps:
+
+ - name: checkout
+ uses: actions/checkout@v4
+
+ - name: install-nix
+ uses: DeterminateSystems/nix-installer-action@v15
+
+ - name: build-frontend
+ run: |
+ nix develop --command bash -c "cd frontend && wasm32-wasi-cabal update && exec ./build.sh --debuginfo --low-memory-unused --converge --gufa --flatten --rereloop -Oz"
+
+ ghcup-build:
+ name: ghcup-build
+ runs-on: ubuntu-latest
+ steps:
+
+ - name: install-happy
+ run: |
+ cabal path --installdir >> "$GITHUB_PATH"
+ cabal update -z
+ cabal install -z happy
+
+ - name: checkout
+ uses: actions/checkout@v4
+
+ - name: ghc-wasm-meta
+ run: |
+ pushd "$(mktemp -d)"
+ curl -f -L --retry 5 https://gitlab.haskell.org/ghc/ghc-wasm-meta/-/archive/master/ghc-wasm-meta-master.tar.gz | tar xz --strip-components=1
+ SKIP_GHC=1 ./setup.sh
+ ~/.ghc-wasm/add_to_github_path.sh
+ popd
+
+ - name: cabal
+ run: |
+ ghcup config add-release-channel https://raw.githubusercontent.com/haskell/ghcup-metadata/develop/ghcup-prereleases-0.0.8.yaml
+ ghcup install cabal --set 3.15.0.0.2024.10.3
+
+ - name: wasm32-wasi-ghc
+ run: |
+ ghcup config add-release-channel https://raw.githubusercontent.com/haskell/ghcup-metadata/develop/ghcup-cross-0.0.8.yaml
+ ghcup install ghc --set wasm32-wasi-9.10.1.20241021 -- $CONFIGURE_ARGS
+
+ - name: build-frontend
+ run: |
+ cd frontend
+ cabal \
+ --with-compiler=wasm32-wasi-ghc \
+ --with-hc-pkg=wasm32-wasi-ghc-pkg \
+ --with-hsc2hs=wasm32-wasi-hsc2hs \
+ update
+ ./build.sh --debuginfo --low-memory-unused --converge --gufa --flatten --rereloop -Oz
+
+ non-nix-build:
+ name: non-nix-build
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ steps:
+
+ - name: install-happy
+ run: |
+ cabal path --installdir >> "$GITHUB_PATH"
+ cabal update -z
+ cabal install -z happy
+
+ - name: checkout
+ uses: actions/checkout@v4
+
+ - name: ghc-wasm-meta
+ run: |
+ pushd "$(mktemp -d)"
+ curl -f -L --retry 5 https://gitlab.haskell.org/ghc/ghc-wasm-meta/-/archive/master/ghc-wasm-meta-master.tar.gz | tar xz --strip-components=1
+ ./setup.sh
+ ~/.ghc-wasm/add_to_github_path.sh
+ popd
+ env:
+ FLAVOUR: '9.10'
+
+ - name: build-frontend
+ run: |
+ cd frontend
+ ./build.sh --debuginfo --low-memory-unused --converge --gufa --flatten --rereloop -Oz
+
+ - name: upload-pages-artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: frontend/dist
+ retention-days: 90
+
+ - name: deploy-pages
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c33954f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+dist-newstyle/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..a3d114c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) Tweag I/O Limited.
+
+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.md b/README.md
new file mode 100644
index 0000000..7ab7472
--- /dev/null
+++ b/README.md
@@ -0,0 +1,62 @@
+# `ghc-wasm-reflex-examples`
+
+[![Chat on Matrix](https://matrix.to/img/matrix-badge.svg)](https://matrix.to/#/#haskell-wasm:matrix.terrorjack.com)
+
+The GHC wasm backend supports the
+[JSFFI](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/wasm.html#javascript-ffi-in-the-wasm-backend)
+feature, allowing Haskell wasm apps to interop with JavaScript
+seamlessly in the browser. This repo contains an example to
+demonstrate this ability based on the
+[`reflex`](https://reflex-frp.org/) frontend framework as well as an
+experimental [`jsaddle-wasm`](https://github.com/amesgen/jsaddle-wasm)
+library under the hood.
+
+See also:
+[`ghc-wasm-miso-examples`](https://github.com/tweag/ghc-wasm-miso-examples)
+
+## Live demo
+
+- [reflex-todomvc](https://tweag.github.io/ghc-wasm-reflex-examples/reflex-todomvc.html)
+
+## Building
+
+### With nix
+
+Within the `nix develop` shell:
+
+```sh
+cd frontend
+wasm32-wasi-cabal update
+./build.sh
+```
+
+If you pass additional arguments to `build.sh`, they will be
+redirected to `wasm-opt`, otherwise a dev build without `wasm-opt`
+will be performed.
+
+The artifacts will be available in `frontend/dist`.
+
+### Without nix
+
+You can set up the toolchain by either:
+
+- Using
+ [`ghc-wasm-meta`](https://gitlab.haskell.org/ghc/ghc-wasm-meta#getting-started-without-nix)
+ directly to set up ghc 9.10
+- Using [`ghcup`](https://www.haskell.org/ghcup/guide/#cross-support)
+ to set up ghc 9.10 (9.10.1.20241021 or later, with TemplateHaskell
+ support) and cabal >=3.14.
+
+Then:
+
+```sh
+source ~/.ghc-wasm/env
+cd frontend
+./build.sh
+```
+
+## Acknowledgements
+
+The examples are vendored and modified from the following projects:
+
+- reflex-todomvc: based on https://github.com/reflex-frp/reflex-todomvc
diff --git a/app/App.hs b/app/App.hs
new file mode 100644
index 0000000..d07f92e
--- /dev/null
+++ b/app/App.hs
@@ -0,0 +1,11 @@
+module App (start) where
+
+import GHC.Wasm.Prim
+import Language.Javascript.JSaddle (JSM)
+import Reflex.TodoMVC qualified
+
+start :: JSString -> JSM ()
+start e =
+ case fromJSString e of
+ "reflex-todomvc" -> Reflex.TodoMVC.main
+ _ -> fail "unknown example"
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
index 0000000..3f1a49c
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,10 @@
+module MyMain (main) where
+
+import App (start)
+import GHC.Wasm.Prim
+import Language.Javascript.JSaddle.Wasm qualified as JSaddle.Wasm
+
+foreign export javascript "hs_start" main :: JSString -> IO ()
+
+main :: JSString -> IO ()
+main e = JSaddle.Wasm.run $ start e
diff --git a/cabal.project b/cabal.project
new file mode 100644
index 0000000..fc31946
--- /dev/null
+++ b/cabal.project
@@ -0,0 +1,33 @@
+packages: . reflex-todomvc
+
+index-state: 2024-11-11T12:54:21Z
+
+if arch(wasm32)
+ -- Required for TemplateHaskell. When using wasm32-wasi-cabal from
+ -- ghc-wasm-meta, this is superseded by the global cabal.config.
+ shared: True
+
+ -- https://github.com/haskellari/time-compat/issues/37
+ -- Older versions of time don't build on WASM.
+ constraints: time installed
+ allow-newer: time
+
+ -- https://github.com/haskellari/splitmix/pull/73
+ source-repository-package
+ type: git
+ location: https://github.com/amesgen/splitmix
+ tag: 5f5b766d97dc735ac228215d240a3bb90bc2ff75
+
+package aeson
+ flags: -ordered-keymap
+
+-- for reflex-frp
+
+-- GHC 9.10 compat
+source-repository-package
+ type: git
+ location: https://github.com/amesgen/reflex-dom
+ tag: e43e0525d643f656a0a5b0f10e13e2a04712cd4e
+ subdir: reflex-dom-core
+
+allow-newer: dependent-sum-template:template-haskell
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..b585a4b
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,81 @@
+{
+ "nodes": {
+ "flake-utils": {
+ "inputs": {
+ "systems": "systems"
+ },
+ "locked": {
+ "lastModified": 1726560853,
+ "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
+ "type": "github"
+ },
+ "original": {
+ "owner": "numtide",
+ "repo": "flake-utils",
+ "type": "github"
+ }
+ },
+ "ghc-wasm-meta": {
+ "inputs": {
+ "flake-utils": "flake-utils",
+ "nixpkgs": "nixpkgs"
+ },
+ "locked": {
+ "host": "gitlab.haskell.org",
+ "lastModified": 1731323753,
+ "narHash": "sha256-K6uBL4TVGHl9bknKdEAevBUXmkaimNXc8H4yaB2CBBk=",
+ "owner": "ghc",
+ "repo": "ghc-wasm-meta",
+ "rev": "94337065008e8892f0188be0e636cc4b5aa4652d",
+ "type": "gitlab"
+ },
+ "original": {
+ "host": "gitlab.haskell.org",
+ "owner": "ghc",
+ "repo": "ghc-wasm-meta",
+ "type": "gitlab"
+ }
+ },
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1731139594,
+ "narHash": "sha256-IigrKK3vYRpUu+HEjPL/phrfh7Ox881er1UEsZvw9Q4=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "76612b17c0ce71689921ca12d9ffdc9c23ce40b2",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "ghc-wasm-meta": "ghc-wasm-meta"
+ }
+ },
+ "systems": {
+ "locked": {
+ "lastModified": 1681028828,
+ "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
+ "owner": "nix-systems",
+ "repo": "default",
+ "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-systems",
+ "repo": "default",
+ "type": "github"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..658e856
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,15 @@
+{
+ inputs = {
+ ghc-wasm-meta.url = "gitlab:ghc/ghc-wasm-meta?host=gitlab.haskell.org";
+ };
+ outputs = inputs: inputs.ghc-wasm-meta.inputs.flake-utils.lib.eachDefaultSystem (system:
+ let pkgs = inputs.ghc-wasm-meta.inputs.nixpkgs.legacyPackages.${system};
+ in
+ {
+ devShells.default = pkgs.mkShell {
+ packages = [
+ inputs.ghc-wasm-meta.packages.${system}.all_9_10
+ ];
+ };
+ });
+}
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..2d89aca
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+ghc_wasm_jsffi.js
diff --git a/frontend/build.sh b/frontend/build.sh
new file mode 100755
index 0000000..19d47cb
--- /dev/null
+++ b/frontend/build.sh
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+
+set -e
+
+if [[ $PWD != */frontend ]]; then
+ echo "This script is meant to be run in the frontend directory"
+ exit 1
+fi
+
+if [ $# -eq 0 ]; then
+ echo "Building for dev"
+ dev_mode=true
+else
+ echo "Building for prod"
+ dev_mode=false
+fi
+
+rm -rf dist
+mkdir dist
+cp ./*.html dist/
+
+if command -v wasm32-wasi-cabal &>/dev/null; then
+ wasm32-wasi-cabal build ghc-wasm-reflex-examples
+else
+ cabal \
+ --with-compiler=wasm32-wasi-ghc \
+ --with-hc-pkg=wasm32-wasi-ghc-pkg \
+ --with-hsc2hs=wasm32-wasi-hsc2hs \
+ build ghc-wasm-reflex-examples
+fi
+
+hs_wasm_path=$(find .. -name "*.wasm")
+
+"$(wasm32-wasi-ghc --print-libdir)"/post-link.mjs \
+ --input "$hs_wasm_path" --output ghc_wasm_jsffi.js
+
+if $dev_mode; then
+ cp "$hs_wasm_path" dist/bin.wasm
+else
+ wizer --allow-wasi --wasm-bulk-memory true --init-func _initialize -o dist/bin.wasm "$hs_wasm_path"
+ wasm-opt ${1+"$@"} dist/bin.wasm -o dist/bin.wasm
+ wasm-tools strip -o dist/bin.wasm dist/bin.wasm
+fi
+
+cp ./*.js dist
diff --git a/frontend/index.js b/frontend/index.js
new file mode 100644
index 0000000..1d5605b
--- /dev/null
+++ b/frontend/index.js
@@ -0,0 +1,22 @@
+import { WASI, OpenFile, File, ConsoleStdout } from "https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/dist/index.js";
+import ghc_wasm_jsffi from "./ghc_wasm_jsffi.js";
+
+const args = [];
+const env = [];
+const fds = [
+ new OpenFile(new File([])), // stdin
+ ConsoleStdout.lineBuffered((msg) => console.log(`[WASI stdout] ${msg}`)),
+ ConsoleStdout.lineBuffered((msg) => console.warn(`[WASI stderr] ${msg}`)),
+];
+const options = { debug: false };
+const wasi = new WASI(args, env, fds, options);
+
+const instance_exports = {};
+const { instance } = await WebAssembly.instantiateStreaming(fetch("bin.wasm"), {
+ wasi_snapshot_preview1: wasi.wasiImport,
+ ghc_wasm_jsffi: ghc_wasm_jsffi(instance_exports),
+});
+Object.assign(instance_exports, instance.exports);
+
+wasi.initialize(instance);
+await instance.exports.hs_start(globalThis.example);
diff --git a/frontend/reflex-todomvc.html b/frontend/reflex-todomvc.html
new file mode 100644
index 0000000..fcfeb2b
--- /dev/null
+++ b/frontend/reflex-todomvc.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ TodoMVC | Reflex FRP example via GHC WASM
+
+
+
+
+
+
diff --git a/ghc-wasm-reflex-examples.cabal b/ghc-wasm-reflex-examples.cabal
new file mode 100644
index 0000000..6cae952
--- /dev/null
+++ b/ghc-wasm-reflex-examples.cabal
@@ -0,0 +1,19 @@
+cabal-version: 3.0
+name: ghc-wasm-reflex-examples
+version: 0
+
+executable ghc-wasm-reflex-examples
+ main-is: Main.hs
+ build-depends:
+ , base
+ , ghc-experimental
+ , jsaddle
+ , jsaddle-wasm
+ , reflex-todomvc
+ hs-source-dirs: app
+ default-language: GHC2021
+ default-extensions: BlockArguments LambdaCase LexicalNegation OverloadedStrings RecordWildCards
+ ghc-options: -Wall -Wunused-packages -Wno-name-shadowing -Wredundant-constraints
+ other-modules:
+ App
+ ghc-options: -no-hs-main -optl-mexec-model=reactor "-optl-Wl,--export=hs_start"
diff --git a/reflex-todomvc/reflex-todomvc.cabal b/reflex-todomvc/reflex-todomvc.cabal
new file mode 100644
index 0000000..139501f
--- /dev/null
+++ b/reflex-todomvc/reflex-todomvc.cabal
@@ -0,0 +1,25 @@
+Cabal-version: 3.0
+Name: reflex-todomvc
+Version: 0.1
+Synopsis: Functional Reactive TodoMVC
+Description: An implementation of the TodoMVC specification using the Reflex-DOM functional reactive DOM library
+License: BSD-3-Clause
+License-file: LICENSE
+Author: Ryan Trinkle
+Maintainer: ryan.trinkle@gmail.com
+Stability: Experimental
+Category: FRP
+
+library
+ hs-source-dirs: src
+ build-depends:
+ base,
+ reflex,
+ ghcjs-dom == 0.9.*,
+ reflex-dom-core,
+ containers,
+ text,
+ mtl
+ exposed-modules:
+ Reflex.TodoMVC
+ ghc-options: -Wall -fwarn-tabs -funbox-strict-fields -O2
diff --git a/reflex-todomvc/src/Reflex/TodoMVC.hs b/reflex-todomvc/src/Reflex/TodoMVC.hs
new file mode 100644
index 0000000..3556574
--- /dev/null
+++ b/reflex-todomvc/src/Reflex/TodoMVC.hs
@@ -0,0 +1,322 @@
+{-# LANGUAGE ConstraintKinds #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecursiveDo #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeFamilies #-}
+module Reflex.TodoMVC where
+
+import Prelude hiding (mapM, mapM_, sequence)
+
+import Control.Monad hiding (mapM, mapM_, forM, forM_, sequence)
+import Control.Monad.Fix
+import Data.Map (Map)
+import qualified Data.Map as Map
+import Data.Foldable
+import Data.Monoid ((<>))
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as T
+
+import GHCJS.DOM.Types (JSM)
+
+import Reflex
+import Reflex.Dom.Core
+
+--------------------------------------------------------------------------------
+-- Model
+--------------------------------------------------------------------------------
+
+data Task
+ = Task { taskDescription :: Text
+ , taskCompleted :: Bool
+ }
+ deriving (Show, Eq)
+
+-- | Add a new value to a map; automatically choose an unused key
+insertNew_ :: (Enum k, Ord k) => v -> Map k v -> Map k v
+insertNew_ v m = case Map.maxViewWithKey m of
+ Nothing -> Map.singleton (toEnum 0) v
+ Just ((k, _), _) -> Map.insert (succ k) v m
+
+initialTasks :: Map Int Task
+initialTasks = Map.empty
+
+--------------------------------------------------------------------------------
+-- Filters
+--------------------------------------------------------------------------------
+
+-- | Subsets of the task list that can be selected by the user
+data Filter
+ = All -- ^ All tasks
+ | Active -- ^ Uncompleted tasks
+ | Completed -- ^ Completed tasks
+ deriving (Show, Eq)
+
+-- | Determine whether this Task should be shown when this Filter is in effect
+satisfiesFilter :: Filter -> Task -> Bool
+satisfiesFilter f = case f of
+ All -> const True
+ Active -> not . taskCompleted
+ Completed -> taskCompleted
+
+--------------------------------------------------------------------------------
+-- View
+--------------------------------------------------------------------------------
+
+main :: JSM ()
+main = mainWidgetWithCss styleCss todoMVC
+ where
+ -- WASM backend is currently lacking TH
+ styleCss = T.encodeUtf8 "html,\nbody {\n\tmargin: 0;\n\tpadding: 0;\n}\n\nbutton {\n\tmargin: 0;\n\tpadding: 0;\n\tborder: 0;\n\tbackground: none;\n\tfont-size: 100%;\n\tvertical-align: baseline;\n\tfont-family: inherit;\n\tfont-weight: inherit;\n\tcolor: inherit;\n\t-webkit-appearance: none;\n\tappearance: none;\n\t-webkit-font-smoothing: antialiased;\n\t-moz-osx-font-smoothing: grayscale;\n}\n\nbody {\n\tfont: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;\n\tline-height: 1.4em;\n\tbackground: #f5f5f5;\n\tcolor: #111111;\n\tmin-width: 230px;\n\tmax-width: 550px;\n\tmargin: 0 auto;\n\t-webkit-font-smoothing: antialiased;\n\t-moz-osx-font-smoothing: grayscale;\n\tfont-weight: 300;\n}\n\n:focus {\n\toutline: 0;\n}\n\n.hidden {\n\tdisplay: none;\n}\n\n.todoapp {\n\tbackground: #fff;\n\tmargin: 130px 0 40px 0;\n\tposition: relative;\n\tbox-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),\n\t 0 25px 50px 0 rgba(0, 0, 0, 0.1);\n}\n\n.todoapp input::-webkit-input-placeholder {\n\tfont-style: italic;\n\tfont-weight: 300;\n\tcolor: rgba(0, 0, 0, 0.4);\n}\n\n.todoapp input::-moz-placeholder {\n\tfont-style: italic;\n\tfont-weight: 300;\n\tcolor: rgba(0, 0, 0, 0.4);\n}\n\n.todoapp input::input-placeholder {\n\tfont-style: italic;\n\tfont-weight: 300;\n\tcolor: rgba(0, 0, 0, 0.4);\n}\n\n.todoapp h1 {\n\tposition: absolute;\n\ttop: -140px;\n\twidth: 100%;\n\tfont-size: 80px;\n\tfont-weight: 200;\n\ttext-align: center;\n\tcolor: #b83f45;\n\t-webkit-text-rendering: optimizeLegibility;\n\t-moz-text-rendering: optimizeLegibility;\n\ttext-rendering: optimizeLegibility;\n}\n\n.new-todo,\n.edit {\n\tposition: relative;\n\tmargin: 0;\n\twidth: 100%;\n\tfont-size: 24px;\n\tfont-family: inherit;\n\tfont-weight: inherit;\n\tline-height: 1.4em;\n\tcolor: inherit;\n\tpadding: 6px;\n\tborder: 1px solid #999;\n\tbox-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);\n\tbox-sizing: border-box;\n\t-webkit-font-smoothing: antialiased;\n\t-moz-osx-font-smoothing: grayscale;\n}\n\n.new-todo {\n\tpadding: 16px 16px 16px 60px;\n\tborder: none;\n\tbackground: rgba(0, 0, 0, 0.003);\n\tbox-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);\n}\n\n.main {\n\tposition: relative;\n\tz-index: 2;\n\tborder-top: 1px solid #e6e6e6;\n}\n\n.toggle-all {\n\twidth: 1px;\n\theight: 1px;\n\tborder: none; /* Mobile Safari */\n\topacity: 0;\n\tposition: absolute;\n\tright: 100%;\n\tbottom: 100%;\n}\n\n.toggle-all + label {\n\twidth: 60px;\n\theight: 34px;\n\tfont-size: 0;\n\tposition: absolute;\n\ttop: -52px;\n\tleft: -13px;\n\t-webkit-transform: rotate(90deg);\n\ttransform: rotate(90deg);\n}\n\n.toggle-all + label:before {\n\tcontent: '\10095';\n\tfont-size: 22px;\n\tcolor: #e6e6e6;\n\tpadding: 10px 27px 10px 27px;\n}\n\n.toggle-all:checked + label:before {\n\tcolor: #737373;\n}\n\n.todo-list {\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.todo-list li {\n\tposition: relative;\n\tfont-size: 24px;\n\tborder-bottom: 1px solid #ededed;\n}\n\n.todo-list li:last-child {\n\tborder-bottom: none;\n}\n\n.todo-list li.editing {\n\tborder-bottom: none;\n\tpadding: 0;\n}\n\n.todo-list li.editing .edit {\n\tdisplay: block;\n\twidth: calc(100% - 43px);\n\tpadding: 12px 16px;\n\tmargin: 0 0 0 43px;\n}\n\n.todo-list li.editing .view {\n\tdisplay: none;\n}\n\n.todo-list li .toggle {\n\ttext-align: center;\n\twidth: 40px;\n\t/* auto, since non-WebKit browsers doesn't support input styling */\n\theight: auto;\n\tposition: absolute;\n\ttop: 0;\n\tbottom: 0;\n\tmargin: auto 0;\n\tborder: none; /* Mobile Safari */\n\t-webkit-appearance: none;\n\tappearance: none;\n}\n\n.todo-list li .toggle {\n\topacity: 0;\n}\n\n.todo-list li .toggle + label {\n\t/*\n\t\tFirefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433\n\t\tIE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/\n\t*/\n\tbackground-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');\n\tbackground-repeat: no-repeat;\n\tbackground-position: center left;\n}\n\n.todo-list li .toggle:checked + label {\n\tbackground-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');\n}\n\n.todo-list li label {\n\tword-break: break-all;\n\tpadding: 15px 15px 15px 60px;\n\tdisplay: block;\n\tline-height: 1.2;\n\ttransition: color 0.4s;\n\tfont-weight: 400;\n\tcolor: #4d4d4d;\n}\n\n.todo-list li.completed label {\n\tcolor: #cdcdcd;\n\ttext-decoration: line-through;\n}\n\n.todo-list li .destroy {\n\tdisplay: none;\n\tposition: absolute;\n\ttop: 0;\n\tright: 10px;\n\tbottom: 0;\n\twidth: 40px;\n\theight: 40px;\n\tmargin: auto 0;\n\tfont-size: 30px;\n\tcolor: #cc9a9a;\n\tmargin-bottom: 11px;\n\ttransition: color 0.2s ease-out;\n}\n\n.todo-list li .destroy:hover {\n\tcolor: #af5b5e;\n}\n\n.todo-list li .destroy:after {\n\tcontent: '\215';\n}\n\n.todo-list li:hover .destroy {\n\tdisplay: block;\n}\n\n.todo-list li .edit {\n\tdisplay: none;\n}\n\n.todo-list li.editing:last-child {\n\tmargin-bottom: -1px;\n}\n\n.footer {\n\tpadding: 10px 15px;\n\theight: 20px;\n\ttext-align: center;\n\tfont-size: 15px;\n\tborder-top: 1px solid #e6e6e6;\n}\n\n.footer:before {\n\tcontent: '';\n\tposition: absolute;\n\tright: 0;\n\tbottom: 0;\n\tleft: 0;\n\theight: 50px;\n\toverflow: hidden;\n\tbox-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),\n\t 0 8px 0 -3px #f6f6f6,\n\t 0 9px 1px -3px rgba(0, 0, 0, 0.2),\n\t 0 16px 0 -6px #f6f6f6,\n\t 0 17px 2px -6px rgba(0, 0, 0, 0.2);\n}\n\n.todo-count {\n\tfloat: left;\n\ttext-align: left;\n}\n\n.todo-count strong {\n\tfont-weight: 300;\n}\n\n.filters {\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n\tposition: absolute;\n\tright: 0;\n\tleft: 0;\n}\n\n.filters li {\n\tdisplay: inline;\n}\n\n.filters li a {\n\tcolor: inherit;\n\tmargin: 3px;\n\tpadding: 3px 7px;\n\ttext-decoration: none;\n\tborder: 1px solid transparent;\n\tborder-radius: 3px;\n}\n\n.filters li a:hover {\n\tborder-color: rgba(175, 47, 47, 0.1);\n}\n\n.filters li a.selected {\n\tborder-color: rgba(175, 47, 47, 0.2);\n}\n\n.clear-completed,\nhtml .clear-completed:active {\n\tfloat: right;\n\tposition: relative;\n\tline-height: 20px;\n\ttext-decoration: none;\n\tcursor: pointer;\n}\n\n.clear-completed:hover {\n\ttext-decoration: underline;\n}\n\n.info {\n\tmargin: 65px auto 0;\n\tcolor: #4d4d4d;\n\tfont-size: 11px;\n\ttext-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);\n\ttext-align: center;\n}\n\n.info p {\n\tline-height: 1;\n}\n\n.info a {\n\tcolor: inherit;\n\ttext-decoration: none;\n\tfont-weight: 400;\n}\n\n.info a:hover {\n\ttext-decoration: underline;\n}\n\n/*\n\tHack to remove background from Mobile Safari.\n\tCan't use it globally since it destroys checkboxes in Firefox\n*/\n@media screen and (-webkit-min-device-pixel-ratio:0) {\n\t.toggle-all,\n\t.todo-list li .toggle {\n\t\tbackground: none;\n\t}\n\n\t.todo-list li .toggle {\n\t\theight: 40px;\n\t}\n}\n\n@media (max-width: 430px) {\n\t.footer {\n\t\theight: 50px;\n\t}\n\n\t.filters {\n\t\tbottom: 10px;\n\t}\n}\n"
+
+todoMVC
+ :: ( DomBuilder t m
+ , DomBuilderSpace m ~ GhcjsDomSpace
+ , MonadFix m
+ , MonadHold t m
+ , PostBuild t m
+ )
+ => m ()
+todoMVC = el "div" $ do
+ elAttr "section" ("class" =: "todoapp") $ do
+ mainHeader
+ rec tasks <- foldDyn ($) initialTasks $ mergeWith (.)
+ [ fmap insertNew_ newTask
+ , listModifyTasks
+ , fmap (const $ Map.filter $ not . taskCompleted) clearCompleted -- Call out the type and purpose of these things
+ ]
+ newTask <- taskEntry
+ listModifyTasks <- taskList activeFilter tasks
+ (activeFilter, clearCompleted) <- controls tasks
+ return ()
+ infoFooter
+
+-- | Display the main header
+mainHeader :: DomBuilder t m => m ()
+mainHeader = el "h1" $ text "todos"
+
+-- | Strip leading and trailing whitespace from the user's entry, and discard it if nothing remains
+stripDescription :: Text -> Maybe Text
+stripDescription d =
+ let trimmed = T.strip d
+ in if T.null trimmed
+ then Nothing
+ else Just trimmed
+
+keyCodeIs :: Key -> KeyCode -> Bool
+keyCodeIs k c = keyCodeLookup c == k
+
+-- | Display an input field; produce new Tasks when the user creates them
+taskEntry
+ :: ( DomBuilder t m
+ , MonadFix m
+ , PostBuild t m
+ , DomBuilderSpace m ~ GhcjsDomSpace
+ )
+ => m (Event t Task)
+taskEntry = el "header" $ do
+ -- Create the textbox; it will be cleared whenever the user presses enter
+ rec let newValueEntered = keypress Enter descriptionBox
+ descriptionBox <- inputElement $ def
+ & inputElementConfig_setValue .~ fmap (const "") newValueEntered
+ & inputElementConfig_elementConfig . elementConfig_initialAttributes .~
+ mconcat [ "class" =: "new-todo"
+ , "placeholder" =: "What needs to be done?"
+ , "name" =: "newTodo"
+ , "type" =: "text"
+ ]
+ -- -- Request focus on this element when the widget is done being built
+ -- schedulePostBuild $ liftIO $ focus $ _textInput_element descriptionBox
+ let -- | Get the current value of the textbox whenever the user hits enter
+ newValue = tag (current $ value descriptionBox) newValueEntered
+ -- -- Set focus when the user enters a new Task
+ -- performEvent_ $ fmap (const $ liftIO $ focus $ _textInput_element descriptionBox) newValueEntered
+ return $ fmap (\d -> Task d False) $ fmapMaybe stripDescription newValue
+
+-- | Display the user's Tasks, subject to a Filter; return requested modifications to the Task list
+taskList
+ :: ( DomBuilder t m
+ , DomBuilderSpace m ~ GhcjsDomSpace
+ , PostBuild t m
+ , MonadHold t m
+ , MonadFix m
+ , Ord k
+ )
+ => Dynamic t Filter
+ -> Dynamic t (Map k Task)
+ -> m (Event t (Map k Task -> Map k Task))
+taskList activeFilter tasks = elAttr "section" ("class" =: "main") $ do
+ let toggleAllState = all taskCompleted . Map.elems <$> tasks
+ toggleAllAttrs = ffor tasks $ \t -> "class" =: "toggle-all" <> "name" =: "toggle" <> if Map.null t then "style" =: "visibility:hidden" else mempty
+ toggleAll <- toggleInput toggleAllAttrs toggleAllState
+ elAttr "label" ("for" =: "toggle-all") $ text "Mark all as complete"
+ -- Filter the item list
+ let visibleTasks = zipDynWith (Map.filter . satisfiesFilter) activeFilter tasks
+ -- Hide the item list itself if there are no items
+ let itemListAttrs = ffor visibleTasks $ \t -> mconcat
+ [ "class" =: "todo-list"
+ , if Map.null t then "style" =: "visibility:hidden" else mempty
+ ]
+ -- Display the items
+ items <- elDynAttr "ul" itemListAttrs $ list visibleTasks todoItem
+ -- Aggregate the changes produced by the elements
+ let combineItemChanges = fmap (foldl' (.) id) . mergeList . map (\(k, v) -> fmap (flip Map.update k) v) . Map.toList
+ itemChangeEvent = fmap combineItemChanges items
+ itemChanges = switch $ current itemChangeEvent
+ return itemChanges
+
+toggleInput
+ :: ( DomBuilder t m
+ , DomBuilderSpace m ~ GhcjsDomSpace
+ , MonadFix m
+ , MonadHold t m
+ , PostBuild t m
+ )
+ => Dynamic t (Map AttributeName Text)
+ -> Dynamic t Bool
+ -> m (Event t ())
+toggleInput dynAttrs dynChecked = do
+ let attrs = (<> "class" =: "toggle") . ("type" =: "checkbox" <>) <$> dynAttrs
+ updatedAttrs = fmap Just <$> updated dynAttrs
+ updatedChecked = updated dynChecked
+ initialAttrs <- sample $ current attrs
+ initialChecked <- sample $ current dynChecked
+ domEvent Click <$> inputElement (def
+ & inputElementConfig_initialChecked .~ initialChecked
+ & inputElementConfig_setChecked .~ updatedChecked
+ & inputElementConfig_elementConfig . elementConfig_modifyAttributes .~ updatedAttrs
+ & inputElementConfig_elementConfig . elementConfig_initialAttributes .~ initialAttrs)
+
+buildCompletedCheckbox
+ :: ( DomBuilder t m
+ , DomBuilderSpace m ~ GhcjsDomSpace
+ , MonadFix m
+ , MonadHold t m
+ , PostBuild t m
+ )
+ => Dynamic t Task
+ -> Dynamic t Text
+ -> m (Event t Bool, Event t (), Event t ())
+buildCompletedCheckbox todo description = elAttr "div" ("class" =: "view") $ do
+ -- Display the todo item's completed status, and allow it to be set
+ completed <- holdUniqDyn $ fmap taskCompleted todo
+ checkboxClicked <- toggleInput (constDyn mempty) completed
+ let setCompleted = fmap not $ tag (current completed) checkboxClicked
+ -- Display the todo item's name for viewing purposes
+ (descriptionLabel, _) <- el' "label" $ dynText description
+ -- Display the button for deleting the todo item
+ (destroyButton, _) <- elAttr' "button" ("class" =: "destroy") $ return ()
+ return ( setCompleted
+ , domEvent Click destroyButton
+ , void $ domEvent Dblclick descriptionLabel
+ )
+
+-- | Display an individual todo item
+todoItem
+ :: ( DomBuilder t m
+ , DomBuilderSpace m ~ GhcjsDomSpace
+ , MonadFix m
+ , MonadHold t m
+ , PostBuild t m
+ )
+ => Dynamic t Task
+ -> m (Event t (Task -> Maybe Task))
+todoItem todo = do
+ description <- holdUniqDyn $ fmap taskDescription todo
+ rec -- Construct the attributes for our element
+ let attrs = ffor2 todo editing' $ \t e -> Map.singleton "class" $ T.unwords $ concat
+ [ [ "completed" | taskCompleted t ]
+ , [ "editing" | e ]
+ ]
+ (editing', changeTodo) <- elDynAttr "li" attrs $ do
+ (setCompleted, destroy, startEditing) <- buildCompletedCheckbox todo description
+ -- Set the current value of the editBox whenever we start editing (it's not visible in non-editing mode)
+ let setEditValue = tag (current description) $ ffilter id $ updated editing'
+ editBox <- inputElement $ def
+ & inputElementConfig_setValue .~ setEditValue
+ & inputElementConfig_elementConfig . elementConfig_initialAttributes
+ .~ ("class" =: "edit" <> "name" =: "title")
+ let -- Set the todo item's description when the user leaves the textbox or presses enter in it
+ setDescription = tag (current $ value editBox) $ leftmost
+ [ keypress Enter editBox
+ , domEvent Blur editBox
+ ]
+ -- Cancel editing (without changing the item's description) when the user presses escape in the textbox
+ cancelEdit = keypress Escape editBox
+ -- Put together all the ways the todo item can change itself
+ changeSelf = mergeWith (>=>) [ fmap (\c t -> Just $ t { taskCompleted = c }) setCompleted
+ , fmap (const $ const Nothing) destroy
+ , fmap (\d t -> fmap (\trimmed -> t { taskDescription = trimmed }) $ stripDescription d) setDescription
+ ]
+ -- Set focus on the edit box when we enter edit mode
+-- postGui <- askPostGui
+-- performEvent_ $ fmap (const $ liftIO $ void $ forkIO $ threadDelay 1000 >> postGui (liftIO $ focus $ _textInput_element editBox)) startEditing -- Without the delay, the focus doesn't take effect because the element hasn't become unhidden yet; we need to use postGui to ensure that this is threadsafe when built with GTK
+ -- Determine the current editing state; initially false, but can be modified by various events
+ editing <- holdDyn False $ leftmost [ fmap (const True) startEditing
+ , fmap (const False) setDescription
+ , fmap (const False) cancelEdit
+ ]
+ return (editing, changeSelf)
+ -- Return an event that fires whenever we change ourselves
+ return changeTodo
+
+buildActiveFilter
+ :: ( DomBuilder t m
+ , PostBuild t m
+ , MonadHold t m
+ , MonadFix m
+ )
+ => m (Dynamic t Filter)
+buildActiveFilter = elAttr "ul" ("class" =: "filters") $ do
+ rec activeFilter <- holdDyn All setFilter
+ let filterButton f = el "li" $ do
+ let buttonAttrs = ffor activeFilter $ \af -> "class" =: if f == af then "selected" else ""
+ (e, _) <- elDynAttr' "a" buttonAttrs $ text $ T.pack $ show f
+ return $ fmap (const f) (domEvent Click e)
+ allButton <- filterButton All
+ text " "
+ activeButton <- filterButton Active
+ text " "
+ completedButton <- filterButton Completed
+ let setFilter = leftmost [allButton, activeButton, completedButton]
+ return activeFilter
+
+-- | Display the control footer; return the user's currently-selected filter and an event that fires when the user chooses to clear all completed events
+controls
+ :: ( DomBuilder t m
+ , PostBuild t m
+ , MonadHold t m
+ , MonadFix m
+ )
+ => Dynamic t (Map k Task)
+ -> m (Dynamic t Filter, Event t ())
+controls tasks = do
+ -- Determine the attributes for the footer; it is invisible when there are no todo items
+ let controlsAttrs = ffor tasks $ \t -> "class" =: "footer" <> if Map.null t then "style" =: "visibility:hidden" else mempty
+ elDynAttr "footer" controlsAttrs $ do
+ -- Compute the number of completed and uncompleted tasks
+ let (tasksCompleted, tasksLeft) = splitDynPure $ ffor tasks $ \m ->
+ let completed = Map.size $ Map.filter taskCompleted m
+ in (completed, Map.size m - completed)
+ elAttr "span" ("class" =: "todo-count") $ do
+ el "strong" $ dynText $ fmap (T.pack . show) tasksLeft
+ dynText $ fmap (\n -> (if n == 1 then " item" else " items") <> " left") tasksLeft
+ activeFilter <- buildActiveFilter
+ let clearCompletedAttrs = ffor tasksCompleted $ \n -> mconcat
+ [ "class" =: "clear-completed"
+ , if n > 0 then mempty else "hidden" =: ""
+ ]
+ (clearCompletedAttrsButton, _) <- elDynAttr' "button" clearCompletedAttrs $ dynText $ ffor tasksCompleted $ \n -> "Clear completed (" <> T.pack (show n) <> ")"
+ return (activeFilter, domEvent Click clearCompletedAttrsButton)
+
+-- | Display static information about the application
+infoFooter :: DomBuilder t m => m ()
+infoFooter = elAttr "footer" ("class" =: "info") $ do
+ el "p" $ text "Click to edit a todo"
+ el "p" $ do
+ text "Written by "
+ elAttr "a" ("href" =: "https://github.com/ryantrinkle") $ text "Ryan Trinkle"
+ el "p" $ do
+ text "Part of "
+ elAttr "a" ("href" =: "http://todomvc.com") $ text "TodoMVC"