├── .gitignore ├── LICENSE ├── README.md ├── elm-config-ui-helper.js ├── elm.json ├── examples ├── boids │ ├── Config.elm │ ├── ConfigSchema.elm │ ├── Main.elm │ ├── README.md │ ├── elm.json │ ├── public │ │ ├── config.js │ │ ├── config.json │ │ └── index.html │ └── run.sh ├── simple │ ├── Config.elm │ ├── ConfigSchema.elm │ ├── Main.elm │ ├── elm.json │ ├── public │ │ ├── config.js │ │ ├── config.json │ │ └── index.html │ └── run.sh └── view_options │ ├── Config.elm │ ├── ConfigSchema.elm │ ├── Main.elm │ ├── elm.json │ ├── public │ ├── config.js │ ├── config.json │ └── index.html │ └── run.sh └── src ├── ConfigForm.elm └── ConfigFormGenerator.elm /.gitignore: -------------------------------------------------------------------------------- 1 | examples/**/main.js 2 | elm-stuff 3 | tmp/ 4 | compiled/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 James Gary 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elm-config-gui 2 | 3 | ## ⚠️ Note: Experimental, and likely to change! ⚠️ 4 | 5 | Have a bunch of magic numbers you want to tweak in the browser? Tired of making a `Msg` for every single field? Try `elm-config-gui`! 6 | 7 | `elm-config-gui` adds a mini-editor into the browser to let you update values (`Int`, `Float`, `String`, and `Color`) on the fly without refreshing. Check out a live example [here](https://elm-boids-demo.s3-us-west-1.amazonaws.com/index.html)! 8 | 9 | ![Screenshot of boids with elm-config-ui](https://user-images.githubusercontent.com/386075/64661773-dcba6a80-d3fa-11e9-96fa-d5013e0ae9e3.png) 10 | 11 | This package has the following features: 12 | 13 | - Mini-editor in the browser to let you update config values on the fly without refreshing 14 | - Automatically save changes to localStorage 15 | - Encodes config data to JSON so you can save in a more persistent `.json` file 16 | 17 | This module has a **javascript dependency** that sets up webcomponents for saving to localstorage and handling pointerlock for infinite dragging. It also uses a **CLI tool** for generating your `Config.elm` file. Check out the examples directory to see how it all works! 18 | 19 | This is meant to be used a dev-facing tool. Hence, there's limited customizability for things like the view. For a fully customizable editor with things like advanced validation and types, feel free to fork and modify! 20 | 21 | # Install 22 | 23 | Let's say you want a config record that looks like this: 24 | 25 | ```elm 26 | type alias Config = 27 | { headerFontSize : Int 28 | , bodyFontSize : Int 29 | , backgroundColor : Color 30 | } 31 | ``` 32 | 33 | Here are the steps to wire everything up: 34 | 35 | ## Step 1: Generate your `Config.elm` 36 | 37 | When adding a new field, such as `headerFontColor`, you'd normally have to update the `type alias Config`, add it to the form in the view, add a `Msg`, encoder, decoder, etc. Turns out there's a lot to do, which can slow down development! If you want all this generated for you, you can instead write a schema file: 38 | 39 | ```elm 40 | module ConfigSchema exposing (main) 41 | 42 | import ConfigFormGenerator exposing (Kind(..)) 43 | import Html exposing (Html) 44 | 45 | myConfigFields : List ( String, Kind ) 46 | myConfigFields = 47 | [ ( "Header Font Size", IntKind "headerFontSize" ) 48 | , ( "Body Font Size", IntKind "bodyFontSize" ) 49 | , ( "Background Color", ColorKind "backgroundColor" ) 50 | -- add more fields here 51 | ] 52 | 53 | main : Html msg 54 | main = 55 | let 56 | generatedElmCode = 57 | ConfigFormGenerator.toFile myConfigFields 58 | 59 | _ = 60 | Debug.log generatedElmCode "" 61 | in 62 | Html.text "" 63 | ``` 64 | 65 | Copy this and save it as `ConfigSchema.elm`. You can now run the following to generate a `Config.elm` file: 66 | 67 | ```sh 68 | # Compile schema file to tmp js 69 | elm make ConfigSchema.elm --output=~tmp/tmp.js > /dev/null 70 | 71 | # Run compiled js with node, which logs out generated elm code, and save to Config.elm: 72 | node ~tmp/tmp.js > Config.elm 2>/dev/null 73 | 74 | # You now have a Config.elm file! 75 | ``` 76 | 77 | This will watch for changes to `ConfigSchema.elm` and generate a `Config.elm` file with all the expanded `Config`, `empty`, and `logics` code. 78 | 79 | Check out the `run.sh` scripts in the examples to see how to set up a watcher to do this for you automatically! 80 | 81 | ## Step 2: App initialization 82 | 83 | Initialize your elm app using the `elm-config-ui-helper.js` script: 84 | 85 | ```html 86 | 87 | 88 |
89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 112 | ``` 113 | 114 | `elmConfigUiData` will contain json from your file and your localstorage. 115 | 116 | ## Step 3: Elm app integration 117 | 118 | ```elm 119 | -- import your generated Config file and the ConfigForm package 120 | import Config exposing (Config) 121 | import ConfigForm exposing (ConfigForm) 122 | 123 | -- add config and configForm to your model 124 | type alias Model = 125 | { ... 126 | -- config is your generated config record, 127 | -- which can be called like model.config.headerFontSize 128 | , config : Config 129 | -- configForm is an opaque type that is managed by ConfigForm 130 | , configForm : ConfigForm 131 | } 132 | 133 | init : Json.Encode.Value -> ( Model, Cmd Msg ) 134 | init elmConfigUiFlags = 135 | let 136 | -- Initialize your config and configForm, 137 | -- passing in defaults for any empty config fields 138 | ( config, configForm ) = 139 | ConfigForm.init 140 | { flags = elmConfigUiFlags 141 | , logics = Config.logics 142 | , emptyConfig = 143 | Config.empty 144 | { int = 1 145 | , float = 1 146 | , string = "SORRY IM NEW HERE" 147 | , bool = True 148 | , color = Color.rgba 1 0 1 1 -- hot pink! 149 | } 150 | } 151 | in 152 | ( { config = config 153 | , configForm = configForm 154 | } 155 | , Cmd.none 156 | ) 157 | 158 | type Msg 159 | = ConfigFormMsg (ConfigForm.Msg Config) 160 | | ... 161 | 162 | update : Msg -> Model -> ( Model, Cmd Msg ) 163 | update msg model = 164 | case msg of 165 | ConfigFormMsg configFormMsg -> 166 | let 167 | ( newConfig, newConfigForm ) = 168 | ConfigForm.update 169 | Config.logics 170 | model.config 171 | model.configForm 172 | configFormMsg 173 | in 174 | ( { model 175 | | config = newConfig 176 | , configForm = newConfigForm 177 | } 178 | , Cmd.none 179 | ) 180 | 181 | -- Lastly, lets add the form to the view! 182 | view : Model -> Html Msg 183 | view model = 184 | Html.div 185 | -- some nice styles to render it on the right side of the viewport 186 | [ style "padding" "12px" 187 | , style "background" "#eec" 188 | , style "border" "1px solid #444" 189 | , style "position" "absolute" 190 | , style "height" "calc(100% - 80px)" 191 | , style "right" "20px" 192 | , style "top" "20px" 193 | ] 194 | [ ConfigForm.view 195 | ConfigForm.viewOptions 196 | Config.logics 197 | model.configForm 198 | |> Html.map ConfigFormMsg 199 | 200 | -- As a developer, you'll want to save your tweaks to your config.json. 201 | -- You can copy/paste the content from this textarea to your config.json. 202 | -- Then the next time a new user loads your app, they'll see your updated config. 203 | , Html.textarea [] 204 | [ ConfigForm.encode model.configForm 205 | |> Json.Encode.encode 2 206 | |> Html.text 207 | ] 208 | ] 209 | ``` 210 | 211 | # Todo 212 | 213 | New features 214 | 215 | - Undo/redo 216 | - Reset to default 217 | - Indicator for vals that differ from file (or that are entirely new) 218 | - Save scrolltop 219 | - Fancy (or custom) kinds, like css or elm-ui attributes? 220 | 221 | Optimizations 222 | 223 | - Cleaner run script (remove duplication and tmp file?) 224 | 225 | Tests! 226 | -------------------------------------------------------------------------------- /elm-config-ui-helper.js: -------------------------------------------------------------------------------- 1 | window.ElmConfigUi = { 2 | init: function({filepath, localStorageKey, callback}) { 3 | this.localStorageKey = localStorageKey; 4 | 5 | fetch(filepath) 6 | .then(function(resp) { return resp.json() }) 7 | .then(function(fileJson) { 8 | callback({ 9 | file: fileJson, 10 | localStorage: JSON.parse(localStorage.getItem(localStorageKey)), 11 | }); 12 | }); 13 | 14 | window.customElements.define('elm-config-ui-slider', ElmConfigUiSlider); 15 | window.customElements.define('elm-config-ui-json', ElmConfigUiJson); 16 | }, 17 | }; 18 | 19 | class ElmConfigUiSlider extends HTMLElement { 20 | constructor() { 21 | return super(); 22 | } 23 | 24 | connectedCallback() { 25 | let self = this; 26 | 27 | function updatePosition(e) { 28 | self.dispatchEvent(new CustomEvent('pl', { 29 | detail: { x: e.movementX }, 30 | })); 31 | } 32 | 33 | function mouseUp(e) { 34 | document.exitPointerLock(); 35 | self.dispatchEvent(new CustomEvent('plMouseUp', e)); 36 | } 37 | 38 | self.addEventListener('mousedown', function() { 39 | self.requestPointerLock(); 40 | }); 41 | 42 | document.addEventListener('pointerlockchange', function() { 43 | if (document.pointerLockElement === self) { 44 | document.addEventListener("mousemove", updatePosition, false); 45 | document.addEventListener("mouseup", mouseUp, false); 46 | } else { 47 | document.removeEventListener("mousemove", updatePosition, false); 48 | document.removeEventListener("mouseup", mouseUp, false); 49 | } 50 | }, false); 51 | } 52 | } 53 | 54 | class ElmConfigUiJson extends HTMLElement { 55 | constructor() { 56 | return super(); 57 | } 58 | 59 | connectedCallback() { 60 | let self = this; 61 | } 62 | 63 | static get observedAttributes() { 64 | return ['data-encoded-config']; 65 | } 66 | 67 | attributeChangedCallback(name, oldValue, newValue) { 68 | console.log("localStorageKey", window.ElmConfigUi.localStorageKey); 69 | localStorage.setItem(window.ElmConfigUi.localStorageKey, newValue); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "jamesgary/elm-config-ui", 4 | "summary": "Editor and code generator for live-editing config values in the browser", 5 | "license": "MIT", 6 | "version": "1.0.0", 7 | "exposed-modules": [ 8 | "ConfigForm", 9 | "ConfigFormGenerator" 10 | ], 11 | "elm-version": "0.19.0 <= v < 0.20.0", 12 | "dependencies": { 13 | "avh4/elm-color": "1.0.0 <= v < 2.0.0", 14 | "elm/browser": "1.0.1 <= v < 2.0.0", 15 | "elm/core": "1.0.2 <= v < 2.0.0", 16 | "elm/html": "1.0.0 <= v < 2.0.0", 17 | "elm/json": "1.1.3 <= v < 2.0.0", 18 | "mpizenberg/elm-pointer-events": "4.0.1 <= v < 5.0.0", 19 | "myrho/elm-round": "1.0.4 <= v < 2.0.0", 20 | "simonh1000/elm-colorpicker": "2.0.0 <= v < 3.0.0", 21 | "y0hy0h/ordered-containers": "1.0.0 <= v < 2.0.0" 22 | }, 23 | "test-dependencies": {} 24 | } 25 | -------------------------------------------------------------------------------- /examples/boids/Config.elm: -------------------------------------------------------------------------------- 1 | -- GENERATED CODE, DO NOT EDIT BY HAND! 2 | 3 | 4 | module Config exposing (Config, empty, logics) 5 | 6 | import Color exposing (Color) 7 | import ConfigForm as ConfigForm 8 | 9 | 10 | type alias Config = 11 | { viewportWidth : Int 12 | , viewportHeight : Int 13 | , timeScale : Float 14 | , numBoids : Int 15 | , boidRad : Float 16 | , visionRange : Float 17 | , showRanges : Bool 18 | , maxSpeed : Float 19 | , momentumFactor : Float 20 | , cohesionFactor : Float 21 | , alignmentFactor : Float 22 | , separationFactor : Float 23 | , separationPower : Float 24 | , separationRangeFactor : Float 25 | , mouseFactor : Float 26 | , skyColor : Color 27 | , configTableBgColor : Color 28 | , configTableBorderWidth : Int 29 | , configTableBorderColor : Color 30 | , configTablePadding : Int 31 | } 32 | 33 | 34 | empty : ConfigForm.Defaults -> Config 35 | empty defaults = 36 | { viewportWidth = defaults.int 37 | , viewportHeight = defaults.int 38 | , timeScale = defaults.float 39 | , numBoids = defaults.int 40 | , boidRad = defaults.float 41 | , visionRange = defaults.float 42 | , showRanges = defaults.bool 43 | , maxSpeed = defaults.float 44 | , momentumFactor = defaults.float 45 | , cohesionFactor = defaults.float 46 | , alignmentFactor = defaults.float 47 | , separationFactor = defaults.float 48 | , separationPower = defaults.float 49 | , separationRangeFactor = defaults.float 50 | , mouseFactor = defaults.float 51 | , skyColor = defaults.color 52 | , configTableBgColor = defaults.color 53 | , configTableBorderWidth = defaults.int 54 | , configTableBorderColor = defaults.color 55 | , configTablePadding = defaults.int 56 | } 57 | 58 | 59 | --logics : List (ConfigForm.Logic Config) 60 | logics = 61 | [ ConfigForm.int 62 | "viewportWidth" 63 | "Viewport width (px)" 64 | .viewportWidth 65 | (\a c -> { c | viewportWidth = a }) 66 | , ConfigForm.int 67 | "viewportHeight" 68 | "Viewport height (px)" 69 | .viewportHeight 70 | (\a c -> { c | viewportHeight = a }) 71 | , ConfigForm.float 72 | "timeScale" 73 | "Time Scale" 74 | .timeScale 75 | (\a c -> { c | timeScale = a }) 76 | , ConfigForm.section 77 | "Boids" 78 | , ConfigForm.int 79 | "numBoids" 80 | "# of boids" 81 | .numBoids 82 | (\a c -> { c | numBoids = a }) 83 | , ConfigForm.float 84 | "boidRad" 85 | "Boid radius" 86 | .boidRad 87 | (\a c -> { c | boidRad = a }) 88 | , ConfigForm.float 89 | "visionRange" 90 | "Vision range" 91 | .visionRange 92 | (\a c -> { c | visionRange = a }) 93 | , ConfigForm.bool 94 | "showRanges" 95 | "Show Ranges" 96 | .showRanges 97 | (\a c -> { c | showRanges = a }) 98 | , ConfigForm.float 99 | "maxSpeed" 100 | "Max speed" 101 | .maxSpeed 102 | (\a c -> { c | maxSpeed = a }) 103 | , ConfigForm.section 104 | "Rule 0: Momentum" 105 | , ConfigForm.float 106 | "momentumFactor" 107 | "Factor" 108 | .momentumFactor 109 | (\a c -> { c | momentumFactor = a }) 110 | , ConfigForm.section 111 | "Rule 1: Cohesion" 112 | , ConfigForm.float 113 | "cohesionFactor" 114 | "Factor" 115 | .cohesionFactor 116 | (\a c -> { c | cohesionFactor = a }) 117 | , ConfigForm.section 118 | "Rule 2: Alignment" 119 | , ConfigForm.float 120 | "alignmentFactor" 121 | "Factor" 122 | .alignmentFactor 123 | (\a c -> { c | alignmentFactor = a }) 124 | , ConfigForm.section 125 | "Rule 3: Separation" 126 | , ConfigForm.float 127 | "separationFactor" 128 | "Factor" 129 | .separationFactor 130 | (\a c -> { c | separationFactor = a }) 131 | , ConfigForm.float 132 | "separationPower" 133 | "Power" 134 | .separationPower 135 | (\a c -> { c | separationPower = a }) 136 | , ConfigForm.float 137 | "separationRangeFactor" 138 | "Personal space" 139 | .separationRangeFactor 140 | (\a c -> { c | separationRangeFactor = a }) 141 | , ConfigForm.section 142 | "Rule 4: Mouse" 143 | , ConfigForm.float 144 | "mouseFactor" 145 | "Factor" 146 | .mouseFactor 147 | (\a c -> { c | mouseFactor = a }) 148 | , ConfigForm.section 149 | "Boid Visuals" 150 | , ConfigForm.color 151 | "skyColor" 152 | "Sky color" 153 | .skyColor 154 | (\a c -> { c | skyColor = a }) 155 | , ConfigForm.section 156 | "Config container" 157 | , ConfigForm.color 158 | "configTableBgColor" 159 | "BG color" 160 | .configTableBgColor 161 | (\a c -> { c | configTableBgColor = a }) 162 | , ConfigForm.int 163 | "configTableBorderWidth" 164 | "Border width" 165 | .configTableBorderWidth 166 | (\a c -> { c | configTableBorderWidth = a }) 167 | , ConfigForm.color 168 | "configTableBorderColor" 169 | "Border color" 170 | .configTableBorderColor 171 | (\a c -> { c | configTableBorderColor = a }) 172 | , ConfigForm.int 173 | "configTablePadding" 174 | "Padding" 175 | .configTablePadding 176 | (\a c -> { c | configTablePadding = a }) 177 | ] 178 | 179 | 180 | --: "" 181 | -------------------------------------------------------------------------------- /examples/boids/ConfigSchema.elm: -------------------------------------------------------------------------------- 1 | module ConfigSchema exposing (main) 2 | 3 | import ConfigFormGenerator exposing (Kind(..)) 4 | import Html exposing (Html) 5 | 6 | 7 | myConfigFields : List ( String, Kind ) 8 | myConfigFields = 9 | [ ( "Viewport width (px)", IntKind "viewportWidth" ) 10 | , ( "Viewport height (px)", IntKind "viewportHeight" ) 11 | , ( "Time Scale", FloatKind "timeScale" ) 12 | 13 | -- boids 14 | , ( "Boids", SectionKind ) 15 | , ( "# of boids", IntKind "numBoids" ) 16 | , ( "Boid radius", FloatKind "boidRad" ) 17 | , ( "Vision range", FloatKind "visionRange" ) 18 | , ( "Show Ranges", BoolKind "showRanges" ) 19 | , ( "Max speed", FloatKind "maxSpeed" ) 20 | 21 | -- rule 0: Momentum (how quick to change steering) 22 | , ( "Rule 0: Momentum", SectionKind ) 23 | , ( "Factor", FloatKind "momentumFactor" ) 24 | 25 | -- rule 1: Cohesion (Friendly gathering at center of mass) 26 | , ( "Rule 1: Cohesion", SectionKind ) 27 | , ( "Factor", FloatKind "cohesionFactor" ) 28 | 29 | -- rule 2: Alignment (conformity) 30 | , ( "Rule 2: Alignment", SectionKind ) 31 | , ( "Factor", FloatKind "alignmentFactor" ) 32 | 33 | -- rule 3: Separation (personal space) 34 | , ( "Rule 3: Separation", SectionKind ) 35 | , ( "Factor", FloatKind "separationFactor" ) 36 | , ( "Power", FloatKind "separationPower" ) 37 | , ( "Personal space", FloatKind "separationRangeFactor" ) 38 | 39 | -- rule 4: Mouse 40 | , ( "Rule 4: Mouse", SectionKind ) 41 | , ( "Factor", FloatKind "mouseFactor" ) 42 | 43 | -- visuals 44 | , ( "Boid Visuals", SectionKind ) 45 | , ( "Sky color", ColorKind "skyColor" ) 46 | 47 | -- config container 48 | , ( "Config container", SectionKind ) 49 | , ( "BG color", ColorKind "configTableBgColor" ) 50 | , ( "Border width", IntKind "configTableBorderWidth" ) 51 | , ( "Border color", ColorKind "configTableBorderColor" ) 52 | , ( "Padding", IntKind "configTablePadding" ) 53 | ] 54 | 55 | 56 | main : Html msg 57 | main = 58 | let 59 | generatedElmCode = 60 | ConfigFormGenerator.toFile myConfigFields 61 | 62 | _ = 63 | Debug.log generatedElmCode "" 64 | in 65 | Html.text "" 66 | -------------------------------------------------------------------------------- /examples/boids/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (main) 2 | 3 | import Array exposing (Array) 4 | import Array.Extra 5 | import Browser 6 | import Browser.Events 7 | import Color exposing (Color) 8 | import Config exposing (Config) 9 | import ConfigForm as ConfigForm exposing (ConfigForm) 10 | import Dict exposing (Dict) 11 | import Direction2d exposing (Direction2d) 12 | import Game.TwoD 13 | import Game.TwoD.Camera 14 | import Game.TwoD.Render 15 | import Html exposing (Html) 16 | import Html.Attributes exposing (style) 17 | import Html.Events 18 | import Html.Events.Extra.Pointer as Pointer 19 | import Json.Decode as JD 20 | import Json.Decode.Pipeline as JDP 21 | import Json.Encode as JE 22 | import List.Extra 23 | import Point2d exposing (Point2d) 24 | import Random 25 | import Random.Array 26 | import Round 27 | import Svg exposing (Svg) 28 | import Svg.Attributes 29 | import Vector2d exposing (Vector2d) 30 | 31 | 32 | port sendToPort : JD.Value -> Cmd msg 33 | 34 | 35 | main = 36 | Browser.element 37 | { init = init 38 | , view = view 39 | , update = updateResult 40 | , subscriptions = subscriptions 41 | } 42 | 43 | 44 | type alias ModelResult = 45 | Result String Model 46 | 47 | 48 | type alias Model = 49 | { config : Config 50 | , configForm : ConfigForm 51 | , boids : Array Boid 52 | , seed : Random.Seed 53 | , mousePos : Maybe Point2d 54 | , selectedBoidIndex : Maybe Int 55 | } 56 | 57 | 58 | type alias Boid = 59 | { pos : Point2d 60 | , vel : Vector2d 61 | , velForCohesion : Vector2d 62 | , velForAlignment : Vector2d 63 | , velForSeparation : Vector2d 64 | , velForMouse : Vector2d 65 | , velForMomentum : Vector2d 66 | , color : Color 67 | } 68 | 69 | 70 | type Msg 71 | = ConfigFormMsg (ConfigForm.Msg Config) 72 | | Tick Float 73 | | MouseMoved Point2d 74 | | MouseClicked Point2d 75 | | MouseLeft 76 | 77 | 78 | 79 | -- FLAGS 80 | 81 | 82 | type alias Flags = 83 | { elmConfigUiData : JE.Value 84 | , timestamp : Int 85 | } 86 | 87 | 88 | decodeFlags : JD.Decoder Flags 89 | decodeFlags = 90 | JD.succeed Flags 91 | |> JDP.required "elmConfigUiData" JD.value 92 | |> JDP.required "timestamp" JD.int 93 | 94 | 95 | 96 | -- INIT 97 | 98 | 99 | init : JE.Value -> ( ModelResult, Cmd Msg ) 100 | init jsonFlags = 101 | case JD.decodeValue decodeFlags jsonFlags of 102 | Ok flags -> 103 | let 104 | ( config, configForm ) = 105 | ConfigForm.init 106 | { flags = flags.elmConfigUiData 107 | , logics = Config.logics 108 | , emptyConfig = 109 | Config.empty 110 | { int = 1 111 | , float = 1 112 | , string = "SORRY IM NEW HERE" 113 | , bool = True 114 | , color = Color.rgba 1 0 1 1 -- hot pink! 115 | } 116 | } 117 | 118 | ( boids, seed ) = 119 | Random.step 120 | (Random.Array.array config.numBoids (boidGenerator config)) 121 | (Random.initialSeed flags.timestamp) 122 | in 123 | ( Ok 124 | { config = config 125 | , configForm = configForm 126 | , boids = boids 127 | , seed = seed 128 | , mousePos = Nothing 129 | , selectedBoidIndex = Just 0 130 | } 131 | , Cmd.none 132 | ) 133 | 134 | Err err -> 135 | ( Err (JD.errorToString err) 136 | , Cmd.none 137 | ) 138 | 139 | 140 | boidGenerator : Config -> Random.Generator Boid 141 | boidGenerator config = 142 | Random.map4 143 | (\x y angle color -> 144 | { pos = Point2d.fromCoordinates ( x, y ) 145 | , vel = 146 | ( config.maxSpeed, angle ) 147 | |> fromPolar 148 | |> Vector2d.fromComponents 149 | , velForCohesion = Vector2d.zero 150 | , velForAlignment = Vector2d.zero 151 | , velForSeparation = Vector2d.zero 152 | , velForMouse = Vector2d.zero 153 | , velForMomentum = Vector2d.zero 154 | , color = color 155 | } 156 | ) 157 | (Random.float 0 (toFloat config.viewportWidth)) 158 | (Random.float 0 (toFloat config.viewportHeight)) 159 | (Random.float 0 (2 * pi)) 160 | colorGenerator 161 | 162 | 163 | updateResult : Msg -> ModelResult -> ( ModelResult, Cmd Msg ) 164 | updateResult msg modelResult = 165 | case modelResult of 166 | Ok model -> 167 | update msg model 168 | |> Tuple.mapFirst Ok 169 | 170 | Err _ -> 171 | ( modelResult, Cmd.none ) 172 | 173 | 174 | update : Msg -> Model -> ( Model, Cmd Msg ) 175 | update msg model = 176 | case msg of 177 | Tick deltaInMilliseconds -> 178 | ( { model 179 | | boids = moveBoids model deltaInMilliseconds 180 | } 181 | , Cmd.none 182 | ) 183 | 184 | ConfigFormMsg configFormMsg -> 185 | let 186 | ( newConfig, newConfigForm ) = 187 | ConfigForm.update 188 | Config.logics 189 | model.config 190 | model.configForm 191 | configFormMsg 192 | 193 | newModel = 194 | { model 195 | | config = newConfig 196 | , configForm = newConfigForm 197 | } 198 | in 199 | ( newModel 200 | |> updateBoidCount 201 | , Cmd.none 202 | ) 203 | 204 | MouseMoved pos -> 205 | ( { model 206 | | mousePos = 207 | pos 208 | |> Point2d.coordinates 209 | |> (\( x, y ) -> ( x, toFloat model.config.viewportHeight - y )) 210 | |> Point2d.fromCoordinates 211 | |> Just 212 | } 213 | , Cmd.none 214 | ) 215 | 216 | MouseClicked pos -> 217 | ( { model 218 | | selectedBoidIndex = 219 | getBoidAt pos model 220 | } 221 | , Cmd.none 222 | ) 223 | 224 | MouseLeft -> 225 | ( { model | mousePos = Nothing } 226 | , Cmd.none 227 | ) 228 | 229 | 230 | updateBoidCount : Model -> Model 231 | updateBoidCount model = 232 | let 233 | boidDiff = 234 | model.config.numBoids - Array.length model.boids 235 | in 236 | if boidDiff > 0 then 237 | -- add more 238 | let 239 | ( newBoids, seed ) = 240 | Random.step 241 | (Random.Array.array boidDiff (boidGenerator model.config)) 242 | model.seed 243 | in 244 | { model 245 | | boids = Array.append model.boids newBoids 246 | , seed = seed 247 | } 248 | 249 | else if boidDiff < 0 then 250 | let 251 | ( decreasedBoids, newSelectedIndex ) = 252 | case model.selectedBoidIndex of 253 | Just index -> 254 | if index <= model.config.numBoids then 255 | case Array.get index model.boids of 256 | Just selectedBoid -> 257 | ( model.boids 258 | |> Array.slice 0 (model.config.numBoids - 1) 259 | |> Array.append (Array.fromList [ selectedBoid ]) 260 | , Just 0 261 | ) 262 | 263 | Nothing -> 264 | ( model.boids 265 | |> Array.slice 0 model.config.numBoids 266 | -- should never happen, so reset selectedIndex 267 | , Nothing 268 | ) 269 | 270 | else 271 | ( model.boids 272 | |> Array.slice 0 model.config.numBoids 273 | , Just index 274 | ) 275 | 276 | Nothing -> 277 | ( model.boids 278 | |> Array.slice 0 model.config.numBoids 279 | , Nothing 280 | ) 281 | in 282 | { model 283 | | boids = decreasedBoids 284 | , selectedBoidIndex = newSelectedIndex 285 | } 286 | 287 | else 288 | model 289 | 290 | 291 | getBoidAt : Point2d -> Model -> Maybe Int 292 | getBoidAt pos model = 293 | -- TODO torus 294 | model.boids 295 | |> Array.toIndexedList 296 | |> List.Extra.find 297 | (\( i, boid ) -> 298 | (boid.pos 299 | |> Point2d.squaredDistanceFrom pos 300 | ) 301 | <= (model.config.boidRad ^ 2) 302 | ) 303 | |> Maybe.map Tuple.first 304 | 305 | 306 | getHoveredBoidIndex : Model -> Maybe Int 307 | getHoveredBoidIndex model = 308 | -- TODO torus 309 | case model.mousePos of 310 | Just mousePos -> 311 | model.boids 312 | |> Array.toIndexedList 313 | |> List.Extra.find 314 | (\( i, boid ) -> 315 | (boid.pos 316 | |> Point2d.squaredDistanceFrom mousePos 317 | ) 318 | <= (model.config.boidRad ^ 2) 319 | ) 320 | |> Maybe.map Tuple.first 321 | 322 | Nothing -> 323 | Nothing 324 | 325 | 326 | moveBoids : Model -> Float -> Array Boid 327 | moveBoids model delta = 328 | model.boids 329 | |> mapOthers 330 | (moveBoid 331 | model.config 332 | model.mousePos 333 | delta 334 | ) 335 | 336 | 337 | mapOthers : (List a -> a -> b) -> Array a -> Array b 338 | mapOthers func array = 339 | -- apply a func to an item and all OTHER items in the list 340 | array 341 | |> Array.indexedMap 342 | (\i val -> 343 | let 344 | otherVals = 345 | array 346 | |> Array.Extra.removeAt i 347 | |> Array.toList 348 | in 349 | func otherVals val 350 | ) 351 | 352 | 353 | moveBoid : Config -> Maybe Point2d -> Float -> List Boid -> Boid -> Boid 354 | moveBoid config maybeMousePos delta otherBoids boid = 355 | let 356 | velFromRule : Point2d -> Float -> (List Boid -> Vector2d) -> Vector2d 357 | velFromRule pos range ruleFunc = 358 | boidsInRange 359 | ( toFloat config.viewportWidth 360 | , toFloat config.viewportHeight 361 | ) 362 | range 363 | otherBoids 364 | pos 365 | |> ruleFunc 366 | 367 | -- cohesion (center of mass) 368 | velForCohesion = 369 | velFromRule 370 | boid.pos 371 | config.visionRange 372 | (\nearbyBoids -> 373 | let 374 | centerOfMass = 375 | nearbyBoids 376 | |> List.map .pos 377 | |> Point2d.centroid 378 | in 379 | case centerOfMass of 380 | Just center -> 381 | center 382 | |> Vector2d.from boid.pos 383 | |> Vector2d.normalize 384 | |> Vector2d.scaleBy 385 | (config.cohesionFactor 386 | / toFloat (List.length nearbyBoids) 387 | ) 388 | 389 | Nothing -> 390 | Vector2d.zero 391 | ) 392 | 393 | -- alignment 394 | velForAlignment = 395 | velFromRule 396 | boid.pos 397 | config.visionRange 398 | (\nearbyBoids -> 399 | if List.isEmpty nearbyBoids then 400 | Vector2d.zero 401 | 402 | else 403 | nearbyBoids 404 | |> List.map .vel 405 | |> List.foldl Vector2d.sum Vector2d.zero 406 | |> Vector2d.scaleBy 407 | (config.alignmentFactor 408 | / toFloat (List.length nearbyBoids) 409 | ) 410 | ) 411 | 412 | -- separation 413 | velForSeparation = 414 | velFromRule 415 | boid.pos 416 | (personalSpaceRange config) 417 | (\nearbyBoids -> 418 | -- OLD ALG 419 | --let 420 | -- centerOfMassOfTooCloseBoids = 421 | -- nearbyBoids 422 | -- |> List.map .pos 423 | -- |> Point2d.centroid 424 | --in 425 | --case centerOfMassOfTooCloseBoids of 426 | -- Just center -> 427 | -- center 428 | -- |> Vector2d.from boid.pos 429 | -- --|> Vector2d.normalize 430 | -- |> Vector2d.scaleBy 431 | -- (-config.separationFactor 432 | -- / toFloat (List.length nearbyBoids) 433 | -- ) 434 | -- Nothing -> 435 | -- Vector2d.zero 436 | -- CLASSIC ALG 437 | List.foldl 438 | (\nearbyBoid tmpVec -> 439 | let 440 | dist = 441 | Vector2d.from nearbyBoid.pos boid.pos 442 | 443 | scale = 444 | -- 1 to Inf 445 | -- 1 : furthest away 446 | -- Inf : right on top 447 | (personalSpaceRange config / Vector2d.length dist) ^ config.separationPower 448 | in 449 | --Vector2d.from nearbyBoid.pos boid.pos 450 | dist 451 | |> Vector2d.normalize 452 | |> Vector2d.scaleBy scale 453 | |> Vector2d.sum tmpVec 454 | ) 455 | Vector2d.zero 456 | nearbyBoids 457 | |> Vector2d.scaleBy config.separationFactor 458 | ) 459 | 460 | -- mouse 461 | velForMouse = 462 | case ( maybeMousePos, config.mouseFactor /= 0 ) of 463 | ( Just mousePos, True ) -> 464 | let 465 | distSq = 466 | Point2d.squaredDistanceFrom boid.pos mousePos 467 | in 468 | if distSq <= config.visionRange ^ 2 then 469 | boid.pos 470 | |> Vector2d.from mousePos 471 | |> Vector2d.normalize 472 | --|> Vector2d.scaleBy (config.mouseFactor / logBase config.mouseLogBase (sqrt distSq / config.visionRange)) 473 | --|> Vector2d.scaleBy (-1 * config.mouseFactor ^ config.mouseExponent) 474 | |> Vector2d.scaleBy (-1 * config.mouseFactor) 475 | 476 | else 477 | Vector2d.zero 478 | 479 | _ -> 480 | Vector2d.zero 481 | 482 | -- momentum 483 | velForMomentum = 484 | boid.vel 485 | |> Vector2d.scaleBy config.momentumFactor 486 | 487 | -- wrap it all up 488 | allVels = 489 | [ velForCohesion 490 | , velForSeparation 491 | , velForAlignment 492 | , velForMouse 493 | , velForMomentum 494 | ] 495 | 496 | newVel = 497 | allVels 498 | |> List.foldl Vector2d.sum Vector2d.zero 499 | --|> Vector2d.scaleBy (1 / toFloat (List.length allVels)) 500 | |> (\v -> 501 | if Vector2d.length v > config.maxSpeed then 502 | v 503 | |> Vector2d.direction 504 | |> Maybe.map Direction2d.toVector 505 | |> Maybe.withDefault Vector2d.zero 506 | |> Vector2d.scaleBy config.maxSpeed 507 | 508 | else 509 | v 510 | ) 511 | 512 | ( w, h ) = 513 | ( toFloat config.viewportWidth 514 | , toFloat config.viewportHeight 515 | ) 516 | 517 | newPos = 518 | boid.pos 519 | |> Point2d.translateBy (Vector2d.scaleBy (delta / 1000) newVel) 520 | |> Point2d.coordinates 521 | |> (\( x, y ) -> 522 | ( if x < 0 then 523 | w - abs x 524 | 525 | else if x > w then 526 | x - w 527 | 528 | else 529 | x 530 | , if y < 0 then 531 | h - abs y 532 | 533 | else if y > h then 534 | y - h 535 | 536 | else 537 | y 538 | ) 539 | ) 540 | |> Point2d.fromCoordinates 541 | in 542 | { boid 543 | | pos = newPos 544 | , vel = newVel 545 | , velForCohesion = velForCohesion 546 | , velForAlignment = velForAlignment 547 | , velForSeparation = velForSeparation 548 | , velForMouse = velForMouse 549 | , velForMomentum = velForMomentum 550 | } 551 | 552 | 553 | wrappedPoses : ( Float, Float ) -> Point2d -> List Point2d 554 | wrappedPoses ( width, height ) pos = 555 | let 556 | ( x, y ) = 557 | pos 558 | |> Point2d.coordinates 559 | 560 | --wrapped values ought to sometimes be closer than original pos 561 | wrappedX = 562 | if x > (width / 2) then 563 | x - width 564 | 565 | else 566 | x + width 567 | 568 | wrappedY = 569 | if y > (height / 2) then 570 | y - height 571 | 572 | else 573 | y + height 574 | in 575 | [ pos 576 | , Point2d.fromCoordinates ( x, wrappedY ) 577 | , Point2d.fromCoordinates ( wrappedX, y ) 578 | , Point2d.fromCoordinates ( wrappedX, wrappedY ) 579 | ] 580 | 581 | 582 | boidsInRange : ( Float, Float ) -> Float -> List Boid -> Point2d -> List Boid 583 | boidsInRange viewport range boids boidPos = 584 | boids 585 | |> List.filterMap 586 | (\otherBoid -> 587 | let 588 | -- TODO perf 589 | closestPos = 590 | wrappedPoses viewport otherBoid.pos 591 | |> List.Extra.minimumBy 592 | (Point2d.squaredDistanceFrom boidPos) 593 | |> Maybe.withDefault otherBoid.pos 594 | in 595 | if Point2d.squaredDistanceFrom boidPos closestPos <= range ^ 2 then 596 | Just { otherBoid | pos = closestPos } 597 | 598 | else 599 | Nothing 600 | ) 601 | 602 | 603 | vector2dToStr : Vector2d -> String 604 | vector2dToStr v = 605 | v 606 | |> Vector2d.components 607 | |> (\( x, y ) -> 608 | [ "(" 609 | , Round.round 2 x 610 | , " , " 611 | , Round.round 2 y 612 | , ")" 613 | ] 614 | |> String.concat 615 | ) 616 | 617 | 618 | type ReceiveMsg 619 | = ConfigFormPortMsg JE.Value 620 | 621 | 622 | fromPortDecoder : JD.Decoder ReceiveMsg 623 | fromPortDecoder = 624 | JD.field "id" JD.string 625 | |> JD.andThen 626 | (\id -> 627 | case id of 628 | "CONFIG" -> 629 | JD.field "val" JD.value 630 | |> JD.map ConfigFormPortMsg 631 | 632 | str -> 633 | JD.fail ("Bad id to receiveFromPort: " ++ str) 634 | ) 635 | 636 | 637 | view : ModelResult -> Html Msg 638 | view modelResult = 639 | case modelResult of 640 | Ok model -> 641 | Html.div 642 | [ style "width" "100%" 643 | , style "height" "100%" 644 | , style "padding" "20px" 645 | , style "font-family" "sans-serif" 646 | , style "box-sizing" "border-box" 647 | ] 648 | [ viewBoidsWebGL model 649 | , viewConfig model 650 | ] 651 | 652 | Err err -> 653 | Html.text err 654 | 655 | 656 | viewConfig : Model -> Html Msg 657 | viewConfig ({ config } as model) = 658 | Html.div 659 | [ style "right" "20px" 660 | , style "top" "20px" 661 | , style "position" "absolute" 662 | , style "height" "calc(100% - 80px)" 663 | , style "font-size" "22px" 664 | ] 665 | [ Html.div 666 | [ style "padding" (pxInt config.configTablePadding) 667 | , style "overflow-y" "auto" 668 | , style "background" (Color.toCssString config.configTableBgColor) 669 | , style "border" ("1px solid " ++ Color.toCssString config.configTableBorderColor) 670 | , style "height" "100%" 671 | ] 672 | [ ConfigForm.view 673 | ConfigForm.viewOptions 674 | Config.logics 675 | model.configForm 676 | |> Html.map ConfigFormMsg 677 | , Html.textarea 678 | [ Html.Attributes.value 679 | (ConfigForm.encode 680 | model.configForm 681 | |> JE.encode 2 682 | ) 683 | ] 684 | [] 685 | ] 686 | ] 687 | 688 | 689 | viewBoidsWebGL : Model -> Html Msg 690 | viewBoidsWebGL model = 691 | let 692 | boidDiameter = 693 | 2 * model.config.boidRad 694 | 695 | ( w, h ) = 696 | ( toFloat model.config.viewportWidth 697 | , toFloat model.config.viewportHeight 698 | ) 699 | 700 | boidRenderables = 701 | model.boids 702 | |> Array.map (viewBoidWebGL model.config) 703 | |> Array.toList 704 | 705 | rangeRenderables = 706 | if model.config.showRanges then 707 | model.boids 708 | |> Array.map (viewRangeWebGL model.config) 709 | |> Array.toList 710 | 711 | else 712 | [] 713 | in 714 | Game.TwoD.renderWithOptions 715 | [ style "width" (pxFloat w) 716 | , style "height" (pxFloat h) 717 | , style "background" (Color.toCssString model.config.skyColor) 718 | , style "border" "1px solid black" 719 | , Pointer.onMove (relativePos >> MouseMoved) 720 | , Pointer.onDown (relativePos >> MouseClicked) 721 | , Pointer.onLeave (\_ -> MouseLeft) 722 | ] 723 | { time = 0 724 | , size = ( round w, round h ) 725 | , camera = 726 | Game.TwoD.Camera.fixedArea ((w - boidDiameter) * (h - boidDiameter)) ( w, h ) 727 | |> Game.TwoD.Camera.moveTo ( w / 2, h / 2 ) 728 | } 729 | (boidRenderables ++ rangeRenderables) 730 | 731 | 732 | viewBoidWebGL : Config -> Boid -> Game.TwoD.Render.Renderable 733 | viewBoidWebGL config boid = 734 | Game.TwoD.Render.shape 735 | Game.TwoD.Render.circle 736 | { color = boid.color 737 | , position = Point2d.coordinates boid.pos 738 | , size = ( config.boidRad, config.boidRad ) 739 | } 740 | 741 | 742 | viewRangeWebGL : Config -> Boid -> Game.TwoD.Render.Renderable 743 | viewRangeWebGL config boid = 744 | let 745 | rad = 746 | config.visionRange 747 | in 748 | Game.TwoD.Render.shape 749 | Game.TwoD.Render.ring 750 | { color = boid.color 751 | , position = 752 | Point2d.coordinates boid.pos 753 | |> (\( x, y ) -> 754 | ( x - rad + (config.boidRad / 2) 755 | , y - rad + (config.boidRad / 2) 756 | ) 757 | ) 758 | , size = ( 2 * rad, 2 * rad ) 759 | } 760 | 761 | 762 | toOpacity : Color -> Float 763 | toOpacity color = 764 | color 765 | |> Color.toRgba 766 | |> .alpha 767 | 768 | 769 | toOpacityString : Color -> String 770 | toOpacityString color = 771 | color 772 | |> Color.toRgba 773 | |> .alpha 774 | |> String.fromFloat 775 | 776 | 777 | relativePos : Pointer.Event -> Point2d 778 | relativePos event = 779 | event.pointer.offsetPos 780 | |> Point2d.fromCoordinates 781 | 782 | 783 | personalSpaceRange : Config -> Float 784 | personalSpaceRange config = 785 | config.boidRad * config.separationRangeFactor 786 | 787 | 788 | percFloat : Float -> String 789 | percFloat val = 790 | String.fromFloat val ++ "%" 791 | 792 | 793 | pxInt : Int -> String 794 | pxInt val = 795 | String.fromInt val ++ "px" 796 | 797 | 798 | pxFloat : Float -> String 799 | pxFloat val = 800 | String.fromFloat val ++ "px" 801 | 802 | 803 | subscriptions : ModelResult -> Sub Msg 804 | subscriptions modelResult = 805 | case modelResult of 806 | Ok model -> 807 | Sub.batch 808 | [ Browser.Events.onAnimationFrameDelta Tick 809 | ] 810 | 811 | Err _ -> 812 | Sub.none 813 | 814 | 815 | colorGenerator : Random.Generator Color 816 | colorGenerator = 817 | -- Colors from https://www.schemecolor.com/multi-color.php 818 | Random.uniform 819 | (Color.rgb255 235 102 98) 820 | [ Color.rgb255 247 177 114 821 | , Color.rgb255 247 211 126 822 | , Color.rgb255 130 200 129 823 | , Color.rgb255 29 143 148 824 | , Color.rgb255 32 61 133 825 | ] 826 | -------------------------------------------------------------------------------- /examples/boids/README.md: -------------------------------------------------------------------------------- 1 | References 2 | 3 | https://www.youtube.com/watch?v=MwLyeEWnMCY 4 | https://pdfs.semanticscholar.org/db08/51926cabc588e1cecb879ed7ce0ce4b9a298.pdf 5 | https://www.sea-of-memes.com/LetsCode26/LetsCode26.html 6 | http://algorithmicbotany.org/papers/colonization.egwnp2007.large.pdf 7 | -------------------------------------------------------------------------------- /examples/boids/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | ".", 5 | "../../src" 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "NoRedInk/elm-json-decode-pipeline": "1.0.0", 11 | "Zinggi/elm-2d-game": "4.0.0", 12 | "avh4/elm-color": "1.0.0", 13 | "elm/browser": "1.0.1", 14 | "elm/core": "1.0.2", 15 | "elm/html": "1.0.0", 16 | "elm/json": "1.1.3", 17 | "elm/random": "1.0.0", 18 | "elm/svg": "1.0.1", 19 | "elm-community/array-extra": "2.1.0", 20 | "elm-community/list-extra": "8.2.0", 21 | "elm-community/random-extra": "3.1.0", 22 | "elm-explorations/linear-algebra": "1.0.3", 23 | "ianmackenzie/elm-geometry": "1.2.1", 24 | "mdgriffith/elm-ui": "1.1.0", 25 | "mpizenberg/elm-pointer-events": "4.0.1", 26 | "myrho/elm-round": "1.0.4", 27 | "simonh1000/elm-colorpicker": "2.0.0", 28 | "y0hy0h/ordered-containers": "1.0.0" 29 | }, 30 | "indirect": { 31 | "elm/bytes": "1.0.8", 32 | "elm/file": "1.0.5", 33 | "elm/time": "1.0.0", 34 | "elm/url": "1.0.0", 35 | "elm/virtual-dom": "1.0.2", 36 | "elm-explorations/test": "1.2.1", 37 | "elm-explorations/webgl": "1.1.1", 38 | "ianmackenzie/elm-float-extra": "1.1.0", 39 | "ianmackenzie/elm-interval": "1.0.1", 40 | "ianmackenzie/elm-triangular-mesh": "1.0.2", 41 | "owanturist/elm-union-find": "1.0.0" 42 | } 43 | }, 44 | "test-dependencies": { 45 | "direct": {}, 46 | "indirect": {} 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/boids/public/config.js: -------------------------------------------------------------------------------- 1 | window.ElmConfigUi = { 2 | init: function({filepath, localStorageKey, callback}) { 3 | this.localStorageKey = localStorageKey; 4 | 5 | fetch(filepath) 6 | .then(function(resp) { return resp.json() }) 7 | .then(function(fileJson) { 8 | callback({ 9 | file: fileJson, 10 | localStorage: JSON.parse(localStorage.getItem(localStorageKey)), 11 | }); 12 | }); 13 | 14 | window.customElements.define('elm-config-ui-slider', ElmConfigUiSlider); 15 | window.customElements.define('elm-config-ui-json', ElmConfigUiJson); 16 | }, 17 | }; 18 | 19 | class ElmConfigUiSlider extends HTMLElement { 20 | constructor() { 21 | return super(); 22 | } 23 | 24 | connectedCallback() { 25 | let self = this; 26 | 27 | function updatePosition(e) { 28 | self.dispatchEvent(new CustomEvent('pl', { 29 | detail: { x: e.movementX }, 30 | })); 31 | } 32 | 33 | function mouseUp(e) { 34 | document.exitPointerLock(); 35 | self.dispatchEvent(new CustomEvent('plMouseUp', e)); 36 | } 37 | 38 | self.addEventListener('mousedown', function() { 39 | self.requestPointerLock(); 40 | }); 41 | 42 | document.addEventListener('pointerlockchange', function() { 43 | if (document.pointerLockElement === self) { 44 | document.addEventListener("mousemove", updatePosition, false); 45 | document.addEventListener("mouseup", mouseUp, false); 46 | } else { 47 | document.removeEventListener("mousemove", updatePosition, false); 48 | document.removeEventListener("mouseup", mouseUp, false); 49 | } 50 | }, false); 51 | } 52 | } 53 | 54 | class ElmConfigUiJson extends HTMLElement { 55 | constructor() { 56 | return super(); 57 | } 58 | 59 | connectedCallback() { 60 | let self = this; 61 | } 62 | 63 | static get observedAttributes() { 64 | return ['data-encoded-config']; 65 | } 66 | 67 | attributeChangedCallback(name, oldValue, newValue) { 68 | console.log("localStorageKey", window.ElmConfigUi.localStorageKey); 69 | localStorage.setItem(window.ElmConfigUi.localStorageKey, newValue); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /examples/boids/public/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "fields": { 3 | "viewportWidth": [ 4 | 791, 5 | 0 6 | ], 7 | "viewportHeight": [ 8 | 626, 9 | 0 10 | ], 11 | "timeScale": [ 12 | 1, 13 | 0 14 | ], 15 | "numBoids": [ 16 | 45, 17 | 0 18 | ], 19 | "boidRad": [ 20 | 27, 21 | 0 22 | ], 23 | "visionRange": [ 24 | 100, 25 | 0 26 | ], 27 | "showRanges": false, 28 | "maxSpeed": [ 29 | 305, 30 | 0 31 | ], 32 | "momentumFactor": [ 33 | 1, 34 | -2 35 | ], 36 | "cohesionFactor": [ 37 | 128, 38 | 0 39 | ], 40 | "alignmentFactor": [ 41 | 0.12999999999999934, 42 | -2 43 | ], 44 | "separationFactor": [ 45 | 42, 46 | -1 47 | ], 48 | "separationPower": [ 49 | 2, 50 | -2 51 | ], 52 | "separationRangeFactor": [ 53 | 1.2600000000000018, 54 | -2 55 | ], 56 | "mouseFactor": [ 57 | -105, 58 | 0 59 | ], 60 | "skyColor": { 61 | "r": 0.2943999999999999, 62 | "g": 0.9648639999999999, 63 | "b": 0.9856000000000001, 64 | "a": 1 65 | }, 66 | "configTableBgColor": { 67 | "r": 0.9758333333333333, 68 | "g": 0.942, 69 | "b": 0.6375, 70 | "a": 1 71 | }, 72 | "configTableBorderWidth": [ 73 | 1, 74 | 0 75 | ], 76 | "configTableBorderColor": { 77 | "r": 0.47730000000000006, 78 | "g": 0.3619833333333334, 79 | "b": 0.016033333333333344, 80 | "a": 1 81 | }, 82 | "configTablePadding": [ 83 | 15, 84 | 0 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /examples/boids/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Boids 6 | 7 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /examples/boids/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | CONFIG_SCHEMA_ELMFILE=ConfigSchema.elm 4 | CONFIG_ELMFILE=Config.elm 5 | TMP_JS=~/tmp/gen-config.js 6 | MAIN_ELMFILE=Main.elm 7 | SERVER_DIR=public/ 8 | MAIN_JS_OUTPUT=public/main.js 9 | 10 | GENERATE_ARGS="$CONFIG_SCHEMA_ELMFILE $TMP_JS $CONFIG_ELMFILE" 11 | 12 | # Command for generating Config.elm from ConfigSchema.elm 13 | generate_config () { 14 | CONFIG_SCHEMA_ELMFILE=$1 15 | TMP_JS=$2 16 | CONFIG_ELMFILE=$3 17 | # Use `elm make` to make an elm app that console.logs the generated Config.elm code 18 | elm make $CONFIG_SCHEMA_ELMFILE --output=$TMP_JS > /dev/null && \ 19 | # Run it with `node` to print the output and write to Config.elm 20 | node $TMP_JS > $CONFIG_ELMFILE 2>/dev/null 21 | } 22 | export -f generate_config 23 | 24 | # Generate the config initially, just in case it doesn't exist 25 | generate_config $GENERATE_ARGS 26 | 27 | # Watch for config changes 28 | chokidar $CONFIG_SCHEMA_ELMFILE --command "generate_config $GENERATE_ARGS" & 29 | 30 | # Watch for elm changes 31 | #elm-live $MAIN_ELMFILE --dir=$SERVER_DIR -- --optimize --output=$MAIN_JS_OUTPUT & 32 | elm-live $MAIN_ELMFILE --dir=$SERVER_DIR --port 8000 -- --output=$MAIN_JS_OUTPUT & 33 | 34 | wait 35 | -------------------------------------------------------------------------------- /examples/simple/Config.elm: -------------------------------------------------------------------------------- 1 | -- GENERATED CODE, DO NOT EDIT BY HAND! 2 | 3 | 4 | module Config exposing (Config, empty, logics) 5 | 6 | import Color exposing (Color) 7 | import ConfigForm as ConfigForm 8 | 9 | 10 | type alias Config = 11 | { headerFontSize : Int 12 | , bodyFontSize : Int 13 | , bgColor : Color 14 | } 15 | 16 | 17 | empty : ConfigForm.Defaults -> Config 18 | empty defaults = 19 | { headerFontSize = defaults.int 20 | , bodyFontSize = defaults.int 21 | , bgColor = defaults.color 22 | } 23 | 24 | 25 | --logics : List (ConfigForm.Logic Config) 26 | logics = 27 | [ ConfigForm.int 28 | "headerFontSize" 29 | "Header Font Size" 30 | .headerFontSize 31 | (\a c -> { c | headerFontSize = a }) 32 | , ConfigForm.int 33 | "bodyFontSize" 34 | "Body Font Size" 35 | .bodyFontSize 36 | (\a c -> { c | bodyFontSize = a }) 37 | , ConfigForm.color 38 | "bgColor" 39 | "Background Color" 40 | .bgColor 41 | (\a c -> { c | bgColor = a }) 42 | ] 43 | 44 | 45 | --: "" 46 | -------------------------------------------------------------------------------- /examples/simple/ConfigSchema.elm: -------------------------------------------------------------------------------- 1 | module ConfigSchema exposing (main) 2 | 3 | import ConfigFormGenerator exposing (Kind(..)) 4 | import Html exposing (Html) 5 | 6 | 7 | myConfigFields : List ( String, Kind ) 8 | myConfigFields = 9 | [ ( "Header Font Size", IntKind "headerFontSize" ) 10 | , ( "Body Font Size", IntKind "bodyFontSize" ) 11 | , ( "Background Color", ColorKind "bgColor" ) 12 | ] 13 | 14 | 15 | main : Html msg 16 | main = 17 | let 18 | generatedElmCode = 19 | ConfigFormGenerator.toFile myConfigFields 20 | 21 | _ = 22 | Debug.log generatedElmCode "" 23 | in 24 | Html.text "" 25 | -------------------------------------------------------------------------------- /examples/simple/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Browser 4 | import Browser.Events 5 | import Color exposing (Color) 6 | import Config exposing (Config) 7 | import ConfigForm as ConfigForm exposing (ConfigForm) 8 | import Html exposing (Html) 9 | import Html.Attributes exposing (style) 10 | import Json.Decode 11 | import Json.Decode.Pipeline 12 | import Json.Encode 13 | import Point3d exposing (Point3d) 14 | import Random 15 | import Svg exposing (Svg) 16 | import Svg.Attributes 17 | 18 | 19 | main = 20 | Browser.element 21 | { init = init 22 | , view = view 23 | , update = update 24 | , subscriptions = subscriptions 25 | } 26 | 27 | 28 | 29 | {- 30 | Your model will need a Config, which is what your app will read from to get config values. 31 | 32 | These are separate because maybe you have either a DevModel and ProdModel, where the DevModel has both Config and ConfigForm, while the ProdModel just has Config so it won't allow further configuration tweaking by the user (plus saves on js filesize). 33 | -} 34 | 35 | 36 | type alias Model = 37 | { config : Config 38 | , configForm : ConfigForm 39 | } 40 | 41 | 42 | 43 | {- 44 | Your Msg will need to support a ConfigFormMsg value. 45 | -} 46 | 47 | 48 | type Msg 49 | = ConfigFormMsg (ConfigForm.Msg Config) 50 | 51 | 52 | 53 | -- FLAGS 54 | {- 55 | Your flags should be (or contain) a json Value that you got using `ElmConfigUi.init` in your javascript. 56 | It contains the following: 57 | - config data stored in localstorage 58 | - gets persisted automatically as you tweak config values 59 | - config data stored in a file 60 | - must be saved manually and is used when a user doesn't have any config values in their localstorage 61 | -} 62 | -- INIT 63 | 64 | 65 | init : Json.Encode.Value -> ( Model, Cmd Msg ) 66 | init flags = 67 | let 68 | {- 69 | Initialize your config and configForm, passing in defaults for any empty config fields 70 | -} 71 | ( config, configForm ) = 72 | ConfigForm.init 73 | { flags = flags 74 | , logics = Config.logics 75 | , emptyConfig = 76 | Config.empty 77 | { int = 1 78 | , float = 1 79 | , string = "SORRY IM NEW HERE" 80 | , bool = True 81 | , color = Color.rgba 1 0 1 1 -- hot pink! 82 | } 83 | } 84 | in 85 | ( { config = config 86 | , configForm = configForm 87 | } 88 | , Cmd.none 89 | ) 90 | 91 | 92 | update : Msg -> Model -> ( Model, Cmd Msg ) 93 | update msg model = 94 | case msg of 95 | ConfigFormMsg configFormMsg -> 96 | let 97 | ( newConfig, newConfigForm ) = 98 | ConfigForm.update 99 | Config.logics 100 | model.config 101 | model.configForm 102 | configFormMsg 103 | in 104 | ( { model 105 | | config = newConfig 106 | , configForm = newConfigForm 107 | } 108 | , Cmd.none 109 | ) 110 | 111 | 112 | view : Model -> Html Msg 113 | view model = 114 | Html.div 115 | [ style "background" (Color.toCssString model.config.bgColor) 116 | , style "font-size" "22px" 117 | , style "padding" "20px" 118 | , style "height" "100%" 119 | , style "font-family" "sans-serif" 120 | ] 121 | [ Html.h1 122 | [ style "font-size" (String.fromInt model.config.headerFontSize ++ "px") 123 | , style "line-height" "0" 124 | ] 125 | [ Html.text "Some Header Text" ] 126 | , Html.p 127 | [ style "font-size" (String.fromInt model.config.bodyFontSize ++ "px") ] 128 | [ Html.text "I am the body text!" ] 129 | , Html.div 130 | [ style "padding" "12px" 131 | , style "background" "#eec" 132 | , style "border" "1px solid #444" 133 | , style "position" "absolute" 134 | , style "height" "calc(100% - 80px)" 135 | , style "right" "20px" 136 | , style "top" "20px" 137 | ] 138 | [ ConfigForm.view 139 | ConfigForm.viewOptions 140 | Config.logics 141 | model.configForm 142 | |> Html.map ConfigFormMsg 143 | , Html.hr [] [] 144 | , Html.text "Copy this json to config.json:" 145 | , Html.br [] [] 146 | , Html.textarea 147 | [ style "width" "100%" 148 | , style "height" "100px" 149 | , Html.Attributes.readonly True 150 | ] 151 | [ ConfigForm.encode model.configForm 152 | |> Json.Encode.encode 2 153 | |> Html.text 154 | ] 155 | ] 156 | ] 157 | 158 | 159 | subscriptions : Model -> Sub Msg 160 | subscriptions model = 161 | Sub.none 162 | -------------------------------------------------------------------------------- /examples/simple/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | ".", 5 | "../../src" 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "NoRedInk/elm-json-decode-pipeline": "1.0.0", 11 | "avh4/elm-color": "1.0.0", 12 | "elm/browser": "1.0.1", 13 | "elm/core": "1.0.2", 14 | "elm/html": "1.0.0", 15 | "elm/json": "1.1.3", 16 | "elm/random": "1.0.0", 17 | "elm/svg": "1.0.1", 18 | "elm-community/array-extra": "2.1.0", 19 | "elm-community/list-extra": "8.2.0", 20 | "elm-community/random-extra": "3.1.0", 21 | "elm-explorations/linear-algebra": "1.0.3", 22 | "ianmackenzie/elm-geometry": "1.2.1", 23 | "mdgriffith/elm-ui": "1.1.0", 24 | "mpizenberg/elm-pointer-events": "4.0.1", 25 | "myrho/elm-round": "1.0.4", 26 | "simonh1000/elm-colorpicker": "2.0.0", 27 | "y0hy0h/ordered-containers": "1.0.0" 28 | }, 29 | "indirect": { 30 | "elm/bytes": "1.0.8", 31 | "elm/file": "1.0.5", 32 | "elm/time": "1.0.0", 33 | "elm/url": "1.0.0", 34 | "elm/virtual-dom": "1.0.2", 35 | "elm-explorations/test": "1.2.1", 36 | "ianmackenzie/elm-float-extra": "1.1.0", 37 | "ianmackenzie/elm-interval": "1.0.1", 38 | "ianmackenzie/elm-triangular-mesh": "1.0.2", 39 | "owanturist/elm-union-find": "1.0.0" 40 | } 41 | }, 42 | "test-dependencies": { 43 | "direct": {}, 44 | "indirect": {} 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/simple/public/config.js: -------------------------------------------------------------------------------- 1 | window.ElmConfigUi = { 2 | init: function({filepath, localStorageKey, callback}) { 3 | this.localStorageKey = localStorageKey; 4 | 5 | fetch(filepath) 6 | .then(function(resp) { return resp.json() }) 7 | .then(function(fileJson) { 8 | callback({ 9 | file: fileJson, 10 | localStorage: JSON.parse(localStorage.getItem(localStorageKey)), 11 | }); 12 | }); 13 | 14 | window.customElements.define('elm-config-ui-slider', ElmConfigUiSlider); 15 | window.customElements.define('elm-config-ui-json', ElmConfigUiJson); 16 | }, 17 | }; 18 | 19 | class ElmConfigUiSlider extends HTMLElement { 20 | constructor() { 21 | return super(); 22 | } 23 | 24 | connectedCallback() { 25 | let self = this; 26 | 27 | function updatePosition(e) { 28 | self.dispatchEvent(new CustomEvent('pl', { 29 | detail: { x: e.movementX }, 30 | })); 31 | } 32 | 33 | function mouseUp(e) { 34 | document.exitPointerLock(); 35 | self.dispatchEvent(new CustomEvent('plMouseUp', e)); 36 | } 37 | 38 | self.addEventListener('mousedown', function() { 39 | self.requestPointerLock(); 40 | }); 41 | 42 | document.addEventListener('pointerlockchange', function() { 43 | if (document.pointerLockElement === self) { 44 | document.addEventListener("mousemove", updatePosition, false); 45 | document.addEventListener("mouseup", mouseUp, false); 46 | } else { 47 | document.removeEventListener("mousemove", updatePosition, false); 48 | document.removeEventListener("mouseup", mouseUp, false); 49 | } 50 | }, false); 51 | } 52 | } 53 | 54 | class ElmConfigUiJson extends HTMLElement { 55 | constructor() { 56 | return super(); 57 | } 58 | 59 | connectedCallback() { 60 | let self = this; 61 | } 62 | 63 | static get observedAttributes() { 64 | return ['data-encoded-config']; 65 | } 66 | 67 | attributeChangedCallback(name, oldValue, newValue) { 68 | console.log("localStorageKey", window.ElmConfigUi.localStorageKey); 69 | localStorage.setItem(window.ElmConfigUi.localStorageKey, newValue); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /examples/simple/public/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "fields": { 3 | "headerFontSize": [ 4 | 70, 5 | 1 6 | ], 7 | "bodyFontSize": [ 8 | 59, 9 | 0 10 | ], 11 | "fZZZZZZZZZZZZloatyTest": [ 12 | 0.604, 13 | -3 14 | ], 15 | "bgColor": { 16 | "r": 0.9148000000000001, 17 | "g": 0.8931279999999999, 18 | "b": 0.6052, 19 | "a": 1 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /examples/simple/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Simple Config Example 6 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /examples/simple/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Requires elm, elm-live, and chokidir, so run the following install command: 4 | # npm install --global elm elm-live@next chokidir 5 | # (use --save-dev instead of --global if you only need it locally for one project) 6 | 7 | CONFIG_SCHEMA_ELMFILE=ConfigSchema.elm 8 | CONFIG_ELMFILE=Config.elm 9 | TMP_JS=~/tmp/gen-config.js 10 | MAIN_ELMFILE=Main.elm 11 | SERVER_DIR=public/ 12 | MAIN_JS_OUTPUT=public/main.js 13 | 14 | GENERATE_ARGS="$CONFIG_SCHEMA_ELMFILE $TMP_JS $CONFIG_ELMFILE" 15 | 16 | # Command for generating Config.elm from ConfigSchema.elm 17 | generate_config () { 18 | CONFIG_SCHEMA_ELMFILE=$1 19 | TMP_JS=$2 20 | CONFIG_ELMFILE=$3 21 | # Use `elm make` to make an elm app that console.logs the generated Config.elm code 22 | elm make $CONFIG_SCHEMA_ELMFILE --output=$TMP_JS > /dev/null && \ 23 | # Run it with `node` to print the output and write to Config.elm 24 | node $TMP_JS > $CONFIG_ELMFILE 2>/dev/null 25 | } 26 | export -f generate_config 27 | 28 | # Generate the config initially, just in case it doesn't exist 29 | generate_config $GENERATE_ARGS 30 | 31 | # Watch for config changes 32 | chokidar $CONFIG_SCHEMA_ELMFILE --command "generate_config $GENERATE_ARGS" & 33 | 34 | # Watch for elm changes 35 | #elm-live $MAIN_ELMFILE --dir=$SERVER_DIR -- --optimize --output=$MAIN_JS_OUTPUT & 36 | elm-live $MAIN_ELMFILE --dir=$SERVER_DIR -- --output=$MAIN_JS_OUTPUT & 37 | 38 | wait 39 | -------------------------------------------------------------------------------- /examples/view_options/Config.elm: -------------------------------------------------------------------------------- 1 | -- GENERATED CODE, DO NOT EDIT BY HAND! 2 | 3 | 4 | module Config exposing (Config, empty, logics) 5 | 6 | import Color exposing (Color) 7 | import ConfigForm as ConfigForm 8 | 9 | 10 | type alias Config = 11 | { headerFontSize : Int 12 | , configBgColor : Color 13 | , configPaddingX : Int 14 | , configPaddingY : Int 15 | , configFontSize : Int 16 | , configRowSpacing : Int 17 | , configInputWidth : Int 18 | , configInputSpacing : Float 19 | , configLabelHighlightBgColor : Color 20 | , configSectionSpacing : Int 21 | } 22 | 23 | 24 | empty : ConfigForm.Defaults -> Config 25 | empty defaults = 26 | { headerFontSize = defaults.int 27 | , configBgColor = defaults.color 28 | , configPaddingX = defaults.int 29 | , configPaddingY = defaults.int 30 | , configFontSize = defaults.int 31 | , configRowSpacing = defaults.int 32 | , configInputWidth = defaults.int 33 | , configInputSpacing = defaults.float 34 | , configLabelHighlightBgColor = defaults.color 35 | , configSectionSpacing = defaults.int 36 | } 37 | 38 | 39 | --logics : List (ConfigForm.Logic Config) 40 | logics = 41 | [ ConfigForm.int 42 | "headerFontSize" 43 | "Header size" 44 | .headerFontSize 45 | (\a c -> { c | headerFontSize = a }) 46 | , ConfigForm.section 47 | "Config wrapper" 48 | , ConfigForm.color 49 | "configBgColor" 50 | "Background color" 51 | .configBgColor 52 | (\a c -> { c | configBgColor = a }) 53 | , ConfigForm.int 54 | "configPaddingX" 55 | "Padding X" 56 | .configPaddingX 57 | (\a c -> { c | configPaddingX = a }) 58 | , ConfigForm.int 59 | "configPaddingY" 60 | "Padding Y" 61 | .configPaddingY 62 | (\a c -> { c | configPaddingY = a }) 63 | , ConfigForm.section 64 | "Config view options" 65 | , ConfigForm.int 66 | "configFontSize" 67 | "Font Size" 68 | .configFontSize 69 | (\a c -> { c | configFontSize = a }) 70 | , ConfigForm.int 71 | "configRowSpacing" 72 | "Row Spacing" 73 | .configRowSpacing 74 | (\a c -> { c | configRowSpacing = a }) 75 | , ConfigForm.int 76 | "configInputWidth" 77 | "Input Width" 78 | .configInputWidth 79 | (\a c -> { c | configInputWidth = a }) 80 | , ConfigForm.float 81 | "configInputSpacing" 82 | "Input Spacing" 83 | .configInputSpacing 84 | (\a c -> { c | configInputSpacing = a }) 85 | , ConfigForm.color 86 | "configLabelHighlightBgColor" 87 | "Label Highlight Bg" 88 | .configLabelHighlightBgColor 89 | (\a c -> { c | configLabelHighlightBgColor = a }) 90 | , ConfigForm.int 91 | "configSectionSpacing" 92 | "Section Spacing" 93 | .configSectionSpacing 94 | (\a c -> { c | configSectionSpacing = a }) 95 | ] 96 | 97 | 98 | --: "" 99 | -------------------------------------------------------------------------------- /examples/view_options/ConfigSchema.elm: -------------------------------------------------------------------------------- 1 | module ConfigSchema exposing (main) 2 | 3 | import ConfigFormGenerator exposing (Kind(..)) 4 | import Html exposing (Html) 5 | 6 | 7 | myConfigFields : List ( String, Kind ) 8 | myConfigFields = 9 | [ ( "Header size", IntKind "headerFontSize" ) 10 | , ( "Config wrapper", SectionKind ) 11 | , ( "Background color", ColorKind "configBgColor" ) 12 | , ( "Padding X", IntKind "configPaddingX" ) 13 | , ( "Padding Y", IntKind "configPaddingY" ) 14 | , ( "Config view options", SectionKind ) 15 | , ( "Font Size", IntKind "configFontSize" ) 16 | , ( "Row Spacing", IntKind "configRowSpacing" ) 17 | , ( "Input Width", IntKind "configInputWidth" ) 18 | , ( "Input Spacing", FloatKind "configInputSpacing" ) 19 | , ( "Label Highlight Bg", ColorKind "configLabelHighlightBgColor" ) 20 | , ( "Section Spacing", IntKind "configSectionSpacing" ) 21 | ] 22 | 23 | 24 | main : Html msg 25 | main = 26 | let 27 | generatedElmCode = 28 | ConfigFormGenerator.toFile myConfigFields 29 | 30 | _ = 31 | Debug.log generatedElmCode "" 32 | in 33 | Html.text "" 34 | -------------------------------------------------------------------------------- /examples/view_options/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Browser 4 | import Browser.Events 5 | import Color exposing (Color) 6 | import Config exposing (Config) 7 | import ConfigForm as ConfigForm exposing (ConfigForm) 8 | import Html exposing (Html) 9 | import Html.Attributes exposing (style) 10 | import Json.Decode 11 | import Json.Decode.Pipeline 12 | import Json.Encode 13 | import Point3d exposing (Point3d) 14 | import Random 15 | import Svg exposing (Svg) 16 | import Svg.Attributes 17 | 18 | 19 | main = 20 | Browser.element 21 | { init = init 22 | , view = view 23 | , update = update 24 | , subscriptions = subscriptions 25 | } 26 | 27 | 28 | 29 | {- 30 | Your model will need a Config, which is what your app will read from to get config values. 31 | 32 | These are separate because maybe you have either a DevModel and ProdModel, where the DevModel has both Config and ConfigForm, while the ProdModel just has Config so it won't allow further configuration tweaking by the user (plus saves on js filesize). 33 | -} 34 | 35 | 36 | type alias Model = 37 | { config : Config 38 | , configForm : ConfigForm 39 | } 40 | 41 | 42 | 43 | {- 44 | Your Msg will need to support a ConfigFormMsg value. 45 | -} 46 | 47 | 48 | type Msg 49 | = ConfigFormMsg (ConfigForm.Msg Config) 50 | 51 | 52 | 53 | -- FLAGS 54 | {- 55 | Your flags should be (or contain) a json Value that you got using `ElmConfigUi.init` in your javascript. 56 | It contains the following: 57 | - config data stored in localstorage 58 | - gets persisted automatically as you tweak config values 59 | - config data stored in a file 60 | - must be saved manually and is used when a user doesn't have any config values in their localstorage 61 | -} 62 | -- INIT 63 | 64 | 65 | init : Json.Encode.Value -> ( Model, Cmd Msg ) 66 | init flags = 67 | let 68 | {- 69 | Initialize your config and configForm, passing in defaults for any empty config fields 70 | -} 71 | ( config, configForm ) = 72 | ConfigForm.init 73 | { flags = flags 74 | , logics = Config.logics 75 | , emptyConfig = 76 | Config.empty 77 | { int = 1 78 | , float = 1 79 | , string = "SORRY IM NEW HERE" 80 | , bool = True 81 | , color = Color.rgba 1 0 1 1 -- hot pink! 82 | } 83 | } 84 | in 85 | ( { config = config 86 | , configForm = configForm 87 | } 88 | , Cmd.none 89 | ) 90 | 91 | 92 | update : Msg -> Model -> ( Model, Cmd Msg ) 93 | update msg model = 94 | case msg of 95 | ConfigFormMsg configFormMsg -> 96 | let 97 | ( newConfig, newConfigForm ) = 98 | ConfigForm.update 99 | Config.logics 100 | model.config 101 | model.configForm 102 | configFormMsg 103 | in 104 | ( { model 105 | | config = newConfig 106 | , configForm = newConfigForm 107 | } 108 | , Cmd.none 109 | ) 110 | 111 | 112 | px : Int -> String 113 | px num = 114 | String.fromInt num ++ "px" 115 | 116 | 117 | view : Model -> Html Msg 118 | view model = 119 | Html.div 120 | [ style "font-family" "sans-serif" 121 | ] 122 | [ Html.h1 123 | [ style "font-size" (px model.config.headerFontSize) 124 | ] 125 | [ Html.text "Some Header Text" ] 126 | , viewConfig model 127 | ] 128 | 129 | 130 | viewConfig : Model -> Html Msg 131 | viewConfig { config, configForm } = 132 | Html.div 133 | [ style "padding" 134 | (px config.configPaddingX ++ " " ++ px config.configPaddingY) 135 | , style "background" (Color.toCssString config.configBgColor) 136 | , style "border" "1px solid #444" 137 | , style "position" "absolute" 138 | , style "height" "calc(100% - 80px)" 139 | , style "right" "20px" 140 | , style "top" "20px" 141 | , style "overflow-y" "scroll" 142 | ] 143 | [ ConfigForm.view 144 | (ConfigForm.viewOptions 145 | |> ConfigForm.withInputWidth config.configInputWidth 146 | |> ConfigForm.withInputSpacing config.configInputSpacing 147 | |> ConfigForm.withFontSize config.configFontSize 148 | |> ConfigForm.withLabelHighlightBgColor config.configLabelHighlightBgColor 149 | |> ConfigForm.withRowSpacing config.configRowSpacing 150 | |> ConfigForm.withSectionSpacing config.configSectionSpacing 151 | ) 152 | Config.logics 153 | configForm 154 | |> Html.map ConfigFormMsg 155 | , Html.hr [] [] 156 | , Html.text "Copy this json to config.json:" 157 | , Html.br [] [] 158 | , Html.textarea 159 | [ style "width" "100%" 160 | , style "height" "100px" 161 | , Html.Attributes.readonly True 162 | ] 163 | [ ConfigForm.encode configForm 164 | |> Json.Encode.encode 2 165 | |> Html.text 166 | ] 167 | ] 168 | 169 | 170 | subscriptions : Model -> Sub Msg 171 | subscriptions model = 172 | Sub.none 173 | -------------------------------------------------------------------------------- /examples/view_options/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | ".", 5 | "../../src" 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "NoRedInk/elm-json-decode-pipeline": "1.0.0", 11 | "avh4/elm-color": "1.0.0", 12 | "elm/browser": "1.0.1", 13 | "elm/core": "1.0.2", 14 | "elm/html": "1.0.0", 15 | "elm/json": "1.1.3", 16 | "elm/random": "1.0.0", 17 | "elm/svg": "1.0.1", 18 | "elm-community/array-extra": "2.1.0", 19 | "elm-community/list-extra": "8.2.0", 20 | "elm-community/random-extra": "3.1.0", 21 | "elm-explorations/linear-algebra": "1.0.3", 22 | "ianmackenzie/elm-geometry": "1.2.1", 23 | "mdgriffith/elm-ui": "1.1.0", 24 | "mpizenberg/elm-pointer-events": "4.0.1", 25 | "myrho/elm-round": "1.0.4", 26 | "simonh1000/elm-colorpicker": "2.0.0", 27 | "y0hy0h/ordered-containers": "1.0.0" 28 | }, 29 | "indirect": { 30 | "elm/bytes": "1.0.8", 31 | "elm/file": "1.0.5", 32 | "elm/time": "1.0.0", 33 | "elm/url": "1.0.0", 34 | "elm/virtual-dom": "1.0.2", 35 | "elm-explorations/test": "1.2.1", 36 | "ianmackenzie/elm-float-extra": "1.1.0", 37 | "ianmackenzie/elm-interval": "1.0.1", 38 | "ianmackenzie/elm-triangular-mesh": "1.0.2", 39 | "owanturist/elm-union-find": "1.0.0" 40 | } 41 | }, 42 | "test-dependencies": { 43 | "direct": {}, 44 | "indirect": {} 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /examples/view_options/public/config.js: -------------------------------------------------------------------------------- 1 | window.ElmConfigUi = { 2 | init: function({filepath, localStorageKey, callback}) { 3 | this.localStorageKey = localStorageKey; 4 | 5 | fetch(filepath) 6 | .then(function(resp) { return resp.json() }) 7 | .then(function(fileJson) { 8 | callback({ 9 | file: fileJson, 10 | localStorage: JSON.parse(localStorage.getItem(localStorageKey)), 11 | }); 12 | }); 13 | 14 | window.customElements.define('elm-config-ui-slider', ElmConfigUiSlider); 15 | window.customElements.define('elm-config-ui-json', ElmConfigUiJson); 16 | }, 17 | }; 18 | 19 | class ElmConfigUiSlider extends HTMLElement { 20 | constructor() { 21 | return super(); 22 | } 23 | 24 | connectedCallback() { 25 | let self = this; 26 | 27 | function updatePosition(e) { 28 | self.dispatchEvent(new CustomEvent('pl', { 29 | detail: { x: e.movementX }, 30 | })); 31 | } 32 | 33 | function mouseUp(e) { 34 | document.exitPointerLock(); 35 | self.dispatchEvent(new CustomEvent('plMouseUp', e)); 36 | } 37 | 38 | self.addEventListener('mousedown', function() { 39 | self.requestPointerLock(); 40 | }); 41 | 42 | document.addEventListener('pointerlockchange', function() { 43 | if (document.pointerLockElement === self) { 44 | document.addEventListener("mousemove", updatePosition, false); 45 | document.addEventListener("mouseup", mouseUp, false); 46 | } else { 47 | document.removeEventListener("mousemove", updatePosition, false); 48 | document.removeEventListener("mouseup", mouseUp, false); 49 | } 50 | }, false); 51 | } 52 | } 53 | 54 | class ElmConfigUiJson extends HTMLElement { 55 | constructor() { 56 | return super(); 57 | } 58 | 59 | connectedCallback() { 60 | let self = this; 61 | } 62 | 63 | static get observedAttributes() { 64 | return ['data-encoded-config']; 65 | } 66 | 67 | attributeChangedCallback(name, oldValue, newValue) { 68 | console.log("localStorageKey", window.ElmConfigUi.localStorageKey); 69 | localStorage.setItem(window.ElmConfigUi.localStorageKey, newValue); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /examples/view_options/public/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "fields": { 3 | "headerFontSize": [ 4 | 52, 5 | 0 6 | ], 7 | "configBgColor": { 8 | "r": 0.7834666666666668, 9 | "g": 0.9533333333333333, 10 | "b": 0.7800000000000001, 11 | "a": 1 12 | }, 13 | "configPaddingX": [ 14 | 5, 15 | 0 16 | ], 17 | "configPaddingY": [ 18 | 10, 19 | 0 20 | ], 21 | "configFontSize": [ 22 | 18, 23 | 0 24 | ], 25 | "configRowSpacing": [ 26 | 2, 27 | 0 28 | ], 29 | "configInputWidth": [ 30 | 80, 31 | 0 32 | ], 33 | "configInputSpacing": [ 34 | 1.4000000000000048, 35 | -2 36 | ], 37 | "configLabelHighlightBgColor": { 38 | "r": 0.6619999999999999, 39 | "g": 0.7640933333333333, 40 | "b": 0.9913333333333334, 41 | "a": 1 42 | }, 43 | "configSectionSpacing": [ 44 | 17, 45 | 0 46 | ] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/view_options/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | View Options Example 6 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /examples/view_options/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Requires elm, elm-live, and chokidir, so run the following install command: 4 | # npm install --global elm elm-live@next chokidir 5 | # (use --save-dev instead of --global if you only need it locally for one project) 6 | 7 | CONFIG_SCHEMA_ELMFILE=ConfigSchema.elm 8 | CONFIG_ELMFILE=Config.elm 9 | TMP_JS=~/tmp/gen-config.js 10 | MAIN_ELMFILE=Main.elm 11 | SERVER_DIR=public/ 12 | MAIN_JS_OUTPUT=public/main.js 13 | 14 | GENERATE_ARGS="$CONFIG_SCHEMA_ELMFILE $TMP_JS $CONFIG_ELMFILE" 15 | 16 | # Command for generating Config.elm from ConfigSchema.elm 17 | generate_config () { 18 | CONFIG_SCHEMA_ELMFILE=$1 19 | TMP_JS=$2 20 | CONFIG_ELMFILE=$3 21 | # Use `elm make` to make an elm app that console.logs the generated Config.elm code 22 | elm make $CONFIG_SCHEMA_ELMFILE --output=$TMP_JS > /dev/null && \ 23 | # Run it with `node` to print the output and write to Config.elm 24 | node $TMP_JS > $CONFIG_ELMFILE 2>/dev/null 25 | } 26 | export -f generate_config 27 | 28 | # Generate the config initially, just in case it doesn't exist 29 | generate_config $GENERATE_ARGS 30 | 31 | # Watch for config changes 32 | chokidar $CONFIG_SCHEMA_ELMFILE --command "generate_config $GENERATE_ARGS" & 33 | 34 | # Watch for elm changes 35 | #elm-live $MAIN_ELMFILE --dir=$SERVER_DIR -- --optimize --output=$MAIN_JS_OUTPUT & 36 | elm-live $MAIN_ELMFILE --dir=$SERVER_DIR -- --output=$MAIN_JS_OUTPUT & 37 | 38 | wait 39 | -------------------------------------------------------------------------------- /src/ConfigForm.elm: -------------------------------------------------------------------------------- 1 | module ConfigForm exposing 2 | ( ConfigForm, init, InitOptions, Defaults 3 | , Msg 4 | , update 5 | , encode 6 | , view 7 | , viewOptions, withFontSize, withRowSpacing, withInputWidth, withInputSpacing, withLabelHighlightBgColor, withSectionSpacing 8 | , int, float, string, bool, color, section 9 | ) 10 | 11 | {-| Note: The `config` in the following type signatures is a record of all your config values, like... 12 | 13 | type alias Config = 14 | { headerFontSize : Int 15 | , bodyFontSize : Int 16 | , backgroundColor : Color 17 | } 18 | 19 | Also, `Value` is shorthand for `Json.Encode.Value`. 20 | 21 | @docs ConfigForm, init, InitOptions, Defaults 22 | 23 | 24 | # Msg 25 | 26 | @docs Msg 27 | 28 | 29 | # Update 30 | 31 | @docs update 32 | 33 | 34 | # Encoding 35 | 36 | @docs encode 37 | 38 | 39 | # View 40 | 41 | @docs view 42 | 43 | 44 | # View options 45 | 46 | @docs viewOptions, withFontSize, withRowSpacing, withInputWidth, withInputSpacing, withLabelHighlightBgColor, withSectionSpacing 47 | 48 | 49 | # Used only by generated Config code 50 | 51 | @docs int, float, string, bool, color, section 52 | 53 | -} 54 | 55 | import Color exposing (Color) 56 | import ColorPicker 57 | import Dict exposing (Dict) 58 | import Html exposing (Html) 59 | import Html.Attributes exposing (style) 60 | import Html.Events 61 | import Html.Events.Extra.Pointer as Pointer 62 | import Json.Decode as JD 63 | import Json.Encode as JE 64 | import OrderedDict exposing (OrderedDict) 65 | import Round 66 | 67 | 68 | {-| ConfigForm is the state of the config form. Keep it in your model along with the `config` record. 69 | -} 70 | type ConfigForm 71 | = ConfigForm 72 | { fields : OrderedDict String Field 73 | , fileFields : Dict String Field 74 | , activeField : Maybe ( FieldState, String ) 75 | } 76 | 77 | 78 | type FieldState 79 | = Hovering 80 | | Dragging 81 | 82 | 83 | {-| Field 84 | -} 85 | type Field 86 | = IntField IntFieldData 87 | | FloatField FloatFieldData 88 | | StringField StringFieldData 89 | | BoolField BoolFieldData 90 | | ColorField ColorFieldData 91 | | SectionField String 92 | 93 | 94 | type alias IntFieldData = 95 | { val : Int 96 | , str : String 97 | , power : Int 98 | } 99 | 100 | 101 | type alias FloatFieldData = 102 | { val : Float 103 | , str : String 104 | , power : Int 105 | } 106 | 107 | 108 | type alias StringFieldData = 109 | { val : String 110 | } 111 | 112 | 113 | type alias BoolFieldData = 114 | { val : Bool 115 | } 116 | 117 | 118 | type alias ColorFieldData = 119 | { val : Color 120 | , meta : ColorFieldMeta 121 | } 122 | 123 | 124 | type ColorFieldMeta 125 | = ColorFieldMeta 126 | { state : ColorPicker.State 127 | , isOpen : Bool 128 | } 129 | 130 | 131 | {-| If a particular value isn't found from localStorage or file, then it fallbacks to these values. It might be a good idea to use wild values that are easy to spot so you can quickly replace them with real values. 132 | 133 | defaults = 134 | { int = -9999 135 | , float = -9999 136 | , string = "PLEASE REPLACE ME" 137 | , bool = True 138 | , color = Color.rgb 1 0 1 -- hot pink! 139 | } 140 | 141 | -} 142 | type alias Defaults = 143 | { int : Int 144 | , float : Float 145 | , string : String 146 | , bool : Bool 147 | , color : Color 148 | } 149 | 150 | 151 | {-| InitOptions are used to initialize your config and ConfigForm. 152 | 153 | { flags = flagsFromJavascript 154 | , logics = Config.logics 155 | , emptyConfig = Config.empty 156 | } 157 | 158 | `Config` is your generated module that was made using [ ConfigFormGenerator](ConfigFormGenerator). 159 | 160 | -} 161 | type alias InitOptions config = 162 | { flags : JE.Value 163 | , logics : List (Logic config) 164 | , emptyConfig : config 165 | } 166 | 167 | 168 | type alias Flags = 169 | { file : JE.Value 170 | , localStorage : JE.Value 171 | } 172 | 173 | 174 | {-| `init` will create both a valid `Config` and `ConfigForm`. 175 | -} 176 | init : InitOptions config -> ( config, ConfigForm ) 177 | init options = 178 | let 179 | { file, localStorage } = 180 | decodeFlags 181 | options.flags 182 | 183 | fileFields = 184 | decodeFields 185 | options.logics 186 | file 187 | 188 | localStorageFields = 189 | decodeFields 190 | options.logics 191 | localStorage 192 | 193 | mergedFields = 194 | options.logics 195 | |> List.map 196 | (\logic -> 197 | ( logic.fieldName 198 | , Dict.get logic.fieldName localStorageFields 199 | |> Maybe.withDefault 200 | (Dict.get logic.fieldName fileFields 201 | |> Maybe.withDefault 202 | (emptyField logic options.emptyConfig) 203 | ) 204 | ) 205 | ) 206 | |> OrderedDict.fromList 207 | in 208 | ( configFromFields options.logics mergedFields options.emptyConfig 209 | , ConfigForm 210 | { fields = mergedFields 211 | , fileFields = fileFields 212 | , activeField = Nothing 213 | } 214 | ) 215 | 216 | 217 | emptyField : Logic config -> config -> Field 218 | emptyField logic emptyConfig = 219 | case logic.kind of 220 | IntLogic getter setter -> 221 | IntField 222 | { val = getter emptyConfig 223 | , str = getter emptyConfig |> String.fromInt 224 | , power = 0 225 | } 226 | 227 | FloatLogic getter setter -> 228 | FloatField 229 | { val = getter emptyConfig 230 | , str = getter emptyConfig |> String.fromFloat 231 | , power = 0 232 | } 233 | 234 | StringLogic getter setter -> 235 | StringField 236 | { val = getter emptyConfig 237 | } 238 | 239 | BoolLogic getter setter -> 240 | BoolField 241 | { val = getter emptyConfig 242 | } 243 | 244 | ColorLogic getter setter -> 245 | ColorField 246 | { val = getter emptyConfig 247 | , meta = 248 | ColorFieldMeta 249 | { state = ColorPicker.empty 250 | , isOpen = False 251 | } 252 | } 253 | 254 | SectionLogic -> 255 | SectionField logic.fieldName 256 | 257 | 258 | 259 | -- STUFF NEEDED ONLY BY GENERATED CONFIG STUFF 260 | {- Logic stuff. Never persist Logic in your model! -} 261 | 262 | 263 | type alias Logic config = 264 | { fieldName : String 265 | , label : String 266 | , kind : LogicKind config 267 | } 268 | 269 | 270 | type LogicKind config 271 | = IntLogic (config -> Int) (Int -> config -> config) 272 | | FloatLogic (config -> Float) (Float -> config -> config) 273 | | StringLogic (config -> String) (String -> config -> config) 274 | | ColorLogic (config -> Color) (Color -> config -> config) 275 | | BoolLogic (config -> Bool) (Bool -> config -> config) 276 | | SectionLogic 277 | 278 | 279 | {-| Creates the logic for Int values 280 | -} 281 | int : String -> String -> (config -> Int) -> (Int -> config -> config) -> Logic config 282 | int fieldName label getter setter = 283 | { fieldName = fieldName 284 | , label = label 285 | , kind = IntLogic getter setter 286 | } 287 | 288 | 289 | {-| Creates the logic for Float values 290 | -} 291 | float : String -> String -> (config -> Float) -> (Float -> config -> config) -> Logic config 292 | float fieldName label getter setter = 293 | { fieldName = fieldName 294 | , label = label 295 | , kind = FloatLogic getter setter 296 | } 297 | 298 | 299 | {-| Creates the logic for String values 300 | -} 301 | string : String -> String -> (config -> String) -> (String -> config -> config) -> Logic config 302 | string fieldName label getter setter = 303 | { fieldName = fieldName 304 | , label = label 305 | , kind = StringLogic getter setter 306 | } 307 | 308 | 309 | {-| Creates the logic for Bool values 310 | -} 311 | bool : String -> String -> (config -> Bool) -> (Bool -> config -> config) -> Logic config 312 | bool fieldName label getter setter = 313 | { fieldName = fieldName 314 | , label = label 315 | , kind = BoolLogic getter setter 316 | } 317 | 318 | 319 | {-| Creates the logic for Color values 320 | -} 321 | color : String -> String -> (config -> Color) -> (Color -> config -> config) -> Logic config 322 | color fieldName label getter setter = 323 | { fieldName = fieldName 324 | , label = label 325 | , kind = ColorLogic getter setter 326 | } 327 | 328 | 329 | {-| Creates the logic for Section values 330 | -} 331 | section : String -> Logic config 332 | section sectionStr = 333 | { fieldName = "" 334 | , label = sectionStr 335 | , kind = SectionLogic 336 | } 337 | 338 | 339 | {-| A Msg is an opaque type for ConfigForm to communicate with your app through ConfigForm.update. 340 | -} 341 | type Msg config 342 | = ChangedConfigForm String Field 343 | | ClickedPointerLockLabel String 344 | | HoveredLabel String Bool 345 | | MouseMove Int 346 | | MouseUp 347 | 348 | 349 | {-| Encodes the current Config (with some metadata) in your ConfigForm. Usually used for both localStorage and as a .json file. 350 | -} 351 | encode : ConfigForm -> JE.Value 352 | encode (ConfigForm configForm) = 353 | JE.object 354 | [ ( "fields", encodeFields configForm.fields ) 355 | ] 356 | 357 | 358 | encodeColor : Color -> JE.Value 359 | encodeColor col = 360 | col 361 | |> Color.toRgba 362 | |> (\{ red, green, blue, alpha } -> 363 | JE.object 364 | [ ( "r", JE.float red ) 365 | , ( "g", JE.float green ) 366 | , ( "b", JE.float blue ) 367 | , ( "a", JE.float alpha ) 368 | ] 369 | ) 370 | 371 | 372 | {-| Encodes the current data of your config form to be persisted, including meta-data. This is typically used to save to localStorage. 373 | -} 374 | encodeFields : OrderedDict String Field -> JE.Value 375 | encodeFields fields = 376 | fields 377 | |> OrderedDict.toList 378 | |> List.filterMap 379 | (\( fieldName, field ) -> 380 | case encodeField field of 381 | Just json -> 382 | Just 383 | ( fieldName 384 | , json 385 | ) 386 | 387 | Nothing -> 388 | Nothing 389 | ) 390 | |> JE.object 391 | 392 | 393 | tuple2Encoder : (a -> JE.Value) -> (b -> JE.Value) -> ( a, b ) -> JE.Value 394 | tuple2Encoder enc1 enc2 ( val1, val2 ) = 395 | -- from https://stackoverflow.com/a/52676142 396 | JE.list identity [ enc1 val1, enc2 val2 ] 397 | 398 | 399 | encodeField : Field -> Maybe JE.Value 400 | encodeField field = 401 | case field of 402 | IntField data -> 403 | ( data.val, data.power ) 404 | |> tuple2Encoder JE.int JE.int 405 | |> Just 406 | 407 | FloatField data -> 408 | ( data.val, data.power ) 409 | |> tuple2Encoder JE.float JE.int 410 | |> Just 411 | 412 | StringField data -> 413 | JE.string data.val 414 | |> Just 415 | 416 | BoolField data -> 417 | JE.bool data.val 418 | |> Just 419 | 420 | ColorField data -> 421 | encodeColor data.val 422 | |> Just 423 | 424 | SectionField _ -> 425 | Nothing 426 | 427 | 428 | {-| When you receive a Config.Msg, update your `Config` and `ConfigForm` using this. It returns a new `Config` and `ConfigForm`, plus possible json to pass through ports for pointerlock. 429 | 430 | update : Msg -> Model -> ( Model, Cmd Msg ) 431 | update msg model = 432 | case msg of 433 | ConfigFormMsg configFormMsg -> 434 | let 435 | ( newConfig, newConfigForm, maybeJsonCmd ) = 436 | ConfigForm.update 437 | Config.logics 438 | model.config 439 | model.configForm 440 | configFormMsg 441 | 442 | newModel = 443 | { model 444 | | config = newConfig 445 | , configForm = newConfigForm 446 | } 447 | in 448 | ( newModel 449 | , Cmd.batch 450 | [ saveToLocalStorageCmd newModel 451 | , case maybeJsonCmd of 452 | Just jsonCmd -> 453 | sendToPort 454 | (Json.Encode.object 455 | [ ( "id", Json.Encode.string "CONFIG" ) 456 | , ( "val", jsonCmd ) 457 | ] 458 | ) 459 | 460 | Nothing -> 461 | Cmd.none 462 | ] 463 | ) 464 | 465 | -} 466 | update : List (Logic config) -> config -> ConfigForm -> Msg config -> ( config, ConfigForm ) 467 | update logics config (ConfigForm configForm) msg = 468 | case msg of 469 | ChangedConfigForm fieldName field -> 470 | let 471 | newConfigForm = 472 | configForm.fields 473 | |> OrderedDict.insert fieldName field 474 | 475 | newConfig = 476 | configFromFields logics newConfigForm config 477 | in 478 | ( newConfig 479 | , ConfigForm { configForm | fields = newConfigForm } 480 | ) 481 | 482 | ClickedPointerLockLabel fieldName -> 483 | ( config 484 | , ConfigForm { configForm | activeField = Just ( Dragging, fieldName ) } 485 | ) 486 | 487 | HoveredLabel fieldName didEnter -> 488 | ( config 489 | , ConfigForm 490 | { configForm 491 | | activeField = 492 | -- chrome triggers a mouseleave when entering pointerlock, 493 | -- so check if you're dragging first, and don't change anything if so 494 | case configForm.activeField of 495 | Just ( Dragging, _ ) -> 496 | configForm.activeField 497 | 498 | _ -> 499 | if didEnter then 500 | Just ( Hovering, fieldName ) 501 | 502 | else 503 | Nothing 504 | } 505 | ) 506 | 507 | MouseMove num -> 508 | let 509 | newConfigForm = 510 | case configForm.activeField of 511 | Just ( state, fieldName ) -> 512 | { configForm 513 | | fields = 514 | configForm.fields 515 | |> OrderedDict.update fieldName 516 | (\maybeField -> 517 | case maybeField of 518 | Just (IntField data) -> 519 | let 520 | newVal = 521 | data.val 522 | + (num * (10 ^ data.power)) 523 | in 524 | Just 525 | (IntField 526 | { data 527 | | val = newVal 528 | , str = formatPoweredInt data.power newVal 529 | } 530 | ) 531 | 532 | Just (FloatField data) -> 533 | let 534 | newVal = 535 | data.val 536 | + toFloat (num * (10 ^ data.power)) 537 | in 538 | Just 539 | (FloatField 540 | { data 541 | | val = newVal 542 | , str = formatPoweredFloat data.power newVal 543 | } 544 | ) 545 | 546 | _ -> 547 | Nothing 548 | ) 549 | } 550 | 551 | Nothing -> 552 | configForm 553 | 554 | newConfig = 555 | configFromFields 556 | logics 557 | newConfigForm.fields 558 | config 559 | in 560 | ( newConfig 561 | , ConfigForm newConfigForm 562 | ) 563 | 564 | MouseUp -> 565 | ( config 566 | , ConfigForm 567 | { configForm 568 | | activeField = 569 | case configForm.activeField of 570 | Just ( state, fieldName ) -> 571 | Just ( Hovering, fieldName ) 572 | 573 | Nothing -> 574 | Nothing 575 | } 576 | ) 577 | 578 | 579 | configFromFields : List (Logic config) -> OrderedDict String Field -> config -> config 580 | configFromFields logics configForm config = 581 | logics 582 | |> List.foldl 583 | (\logic newConfig -> 584 | let 585 | maybeField = 586 | OrderedDict.get logic.fieldName configForm 587 | in 588 | case ( maybeField, logic.kind ) of 589 | ( Just (IntField data), IntLogic getter setter ) -> 590 | setter data.val newConfig 591 | 592 | ( Just (FloatField data), FloatLogic getter setter ) -> 593 | setter data.val newConfig 594 | 595 | ( Just (StringField data), StringLogic getter setter ) -> 596 | setter data.val newConfig 597 | 598 | ( Just (BoolField data), BoolLogic getter setter ) -> 599 | setter data.val newConfig 600 | 601 | ( Just (ColorField data), ColorLogic getter setter ) -> 602 | setter data.val newConfig 603 | 604 | _ -> 605 | newConfig 606 | ) 607 | config 608 | 609 | 610 | formatPoweredInt : Int -> Int -> String 611 | formatPoweredInt power val = 612 | Round.round -power (toFloat val) 613 | 614 | 615 | formatPoweredFloat : Int -> Float -> String 616 | formatPoweredFloat power val = 617 | Round.round -power val 618 | 619 | 620 | poweredInt : Int -> Int -> Int 621 | poweredInt power val = 622 | round <| Round.roundNum -power (toFloat val) 623 | 624 | 625 | poweredFloat : Int -> Float -> Float 626 | poweredFloat power val = 627 | Round.roundNum -power val 628 | 629 | 630 | decodeFields : List (Logic config) -> JE.Value -> Dict String Field 631 | decodeFields logics json = 632 | logics 633 | |> List.filterMap 634 | (\logic -> 635 | decodeField logic json 636 | |> Maybe.map 637 | (\field -> 638 | ( logic.fieldName, field ) 639 | ) 640 | ) 641 | |> Dict.fromList 642 | 643 | 644 | decodeField : Logic config -> JE.Value -> Maybe Field 645 | decodeField logic json = 646 | case logic.kind of 647 | IntLogic getter setter -> 648 | let 649 | decoder = 650 | JD.at [ "fields", logic.fieldName ] 651 | (JD.map2 652 | Tuple.pair 653 | (JD.index 0 JD.int) 654 | (JD.index 1 JD.int) 655 | ) 656 | in 657 | case JD.decodeValue decoder json of 658 | Ok ( val, power ) -> 659 | { val = val 660 | , str = formatPoweredInt power val 661 | , power = power 662 | } 663 | |> IntField 664 | |> Just 665 | 666 | Err err -> 667 | Nothing 668 | 669 | FloatLogic getter setter -> 670 | let 671 | decoder = 672 | JD.at [ "fields", logic.fieldName ] 673 | (JD.map2 Tuple.pair 674 | (JD.index 0 JD.float) 675 | (JD.index 1 JD.int) 676 | ) 677 | in 678 | case JD.decodeValue decoder json of 679 | Ok ( val, power ) -> 680 | { val = val 681 | , str = formatPoweredFloat power val 682 | , power = power 683 | } 684 | |> FloatField 685 | |> Just 686 | 687 | Err err -> 688 | Nothing 689 | 690 | StringLogic getter setter -> 691 | let 692 | decoder = 693 | JD.at [ "fields", logic.fieldName ] JD.string 694 | in 695 | case JD.decodeValue decoder json of 696 | Ok val -> 697 | { val = val 698 | } 699 | |> StringField 700 | |> Just 701 | 702 | Err err -> 703 | Nothing 704 | 705 | BoolLogic getter setter -> 706 | let 707 | decoder = 708 | JD.at [ "fields", logic.fieldName ] JD.bool 709 | in 710 | case JD.decodeValue decoder json of 711 | Ok val -> 712 | { val = val 713 | } 714 | |> BoolField 715 | |> Just 716 | 717 | Err err -> 718 | Nothing 719 | 720 | ColorLogic getter setter -> 721 | let 722 | decoder = 723 | JD.at [ "fields", logic.fieldName ] colorValDecoder 724 | in 725 | case JD.decodeValue decoder json of 726 | Ok val -> 727 | { val = val 728 | , meta = 729 | ColorFieldMeta 730 | { state = ColorPicker.empty 731 | , isOpen = False 732 | } 733 | } 734 | |> ColorField 735 | |> Just 736 | 737 | Err err -> 738 | Nothing 739 | 740 | SectionLogic -> 741 | logic.fieldName 742 | |> SectionField 743 | |> Just 744 | 745 | 746 | 747 | -- JSON encode/decoder stuff 748 | 749 | 750 | decodeFlags : JE.Value -> Flags 751 | decodeFlags json = 752 | let 753 | decoder = 754 | JD.map2 Flags 755 | (JD.field "file" JD.value) 756 | (JD.field "localStorage" JD.value) 757 | in 758 | JD.decodeValue decoder json 759 | |> Result.withDefault 760 | { file = JE.object [] 761 | , localStorage = JE.object [] 762 | } 763 | 764 | 765 | decodeConfig : List (Logic config) -> config -> Flags -> config 766 | decodeConfig logics emptyConfig { file, localStorage } = 767 | let 768 | buildConfig json tmpConfig = 769 | logics 770 | |> List.foldl 771 | (\logic config -> 772 | case logic.kind of 773 | IntLogic getter setter -> 774 | case JD.decodeValue (JD.field logic.fieldName JD.int) json of 775 | Ok intVal -> 776 | setter intVal config 777 | 778 | Err err -> 779 | config 780 | 781 | FloatLogic getter setter -> 782 | case JD.decodeValue (JD.field logic.fieldName JD.float) json of 783 | Ok floatVal -> 784 | setter floatVal config 785 | 786 | Err err -> 787 | config 788 | 789 | StringLogic getter setter -> 790 | case JD.decodeValue (JD.field logic.fieldName JD.string) json of 791 | Ok str -> 792 | setter str config 793 | 794 | Err err -> 795 | config 796 | 797 | BoolLogic getter setter -> 798 | case JD.decodeValue (JD.field logic.fieldName JD.bool) json of 799 | Ok str -> 800 | setter str config 801 | 802 | Err err -> 803 | config 804 | 805 | ColorLogic getter setter -> 806 | case JD.decodeValue (JD.field logic.fieldName colorValDecoder) json of 807 | Ok col -> 808 | setter col config 809 | 810 | Err err -> 811 | config 812 | 813 | SectionLogic -> 814 | config 815 | ) 816 | tmpConfig 817 | in 818 | emptyConfig 819 | |> buildConfig file 820 | |> buildConfig localStorage 821 | 822 | 823 | colorValDecoder : JD.Decoder Color 824 | colorValDecoder = 825 | JD.map4 Color.rgba 826 | (JD.field "r" JD.float) 827 | (JD.field "g" JD.float) 828 | (JD.field "b" JD.float) 829 | (JD.field "a" JD.float) 830 | 831 | 832 | 833 | -- VIEW 834 | 835 | 836 | {-| View the config form. 837 | -} 838 | view : ViewOptions -> List (Logic config) -> ConfigForm -> Html (Msg config) 839 | view options logics ((ConfigForm configForm) as configFormType) = 840 | Html.div [ style "font-size" (pxInt options.fontSize) ] 841 | [ Html.table 842 | [ style "border-spacing" ("0 " ++ pxInt options.rowSpacing) 843 | ] 844 | (logics 845 | |> List.indexedMap 846 | (\i logic -> 847 | Html.tr 848 | (case configForm.activeField of 849 | Just ( state, fieldName ) -> 850 | if fieldName == logic.fieldName then 851 | [ style 852 | "background" 853 | (Color.toCssString options.labelHighlightBgColor) 854 | ] 855 | 856 | else 857 | [] 858 | 859 | Nothing -> 860 | [] 861 | ) 862 | [ viewLabel options configFormType i logic 863 | , viewChanger options configFormType i logic 864 | ] 865 | ) 866 | ) 867 | , Html.div [ Html.Attributes.id "elm-config-ui-pointerlock" ] [] 868 | , Html.node "elm-config-ui-json" 869 | [ Html.Attributes.attribute 870 | "data-encoded-config" 871 | (configForm 872 | |> ConfigForm 873 | |> encode 874 | |> JE.encode 2 875 | ) 876 | ] 877 | [] 878 | ] 879 | 880 | 881 | slider : List (Html (Msg config)) -> Html (Msg config) 882 | slider children = 883 | Html.node "elm-config-ui-slider" 884 | [ Html.Events.on "pl" 885 | (JD.at [ "detail", "x" ] JD.int 886 | |> JD.map MouseMove 887 | ) 888 | ] 889 | children 890 | 891 | 892 | viewLabel : ViewOptions -> ConfigForm -> Int -> Logic config -> Html (Msg config) 893 | viewLabel options configForm i logic = 894 | case logic.kind of 895 | StringLogic getter setter -> 896 | Html.td 897 | [] 898 | [ Html.text logic.label ] 899 | 900 | IntLogic getter setter -> 901 | Html.td 902 | (resizeAttrs options configForm logic) 903 | [ slider [ Html.text logic.label ] 904 | , powerEl options configForm logic 905 | ] 906 | 907 | FloatLogic getter setter -> 908 | Html.td 909 | (resizeAttrs options configForm logic) 910 | [ slider [ Html.text logic.label ] 911 | , powerEl options configForm logic 912 | ] 913 | 914 | BoolLogic getter setter -> 915 | Html.td 916 | [] 917 | [ Html.text logic.label ] 918 | 919 | ColorLogic getter setter -> 920 | Html.td 921 | [] 922 | [ Html.text logic.label 923 | , closeEl options configForm i logic 924 | ] 925 | 926 | SectionLogic -> 927 | Html.td 928 | [ style "font-weight" "bold" 929 | , style "padding" (pxInt options.sectionSpacing ++ " 0 5px 0") 930 | , Html.Attributes.colspan 2 931 | ] 932 | [ Html.text logic.label ] 933 | 934 | 935 | closeEl : ViewOptions -> ConfigForm -> Int -> Logic config -> Html (Msg config) 936 | closeEl options (ConfigForm configForm) i logic = 937 | let 938 | maybeCloseMsg = 939 | case OrderedDict.get logic.fieldName configForm.fields of 940 | Just (ColorField data) -> 941 | let 942 | shouldShow = 943 | case data.meta of 944 | ColorFieldMeta meta -> 945 | meta.isOpen 946 | in 947 | if shouldShow then 948 | let 949 | meta = 950 | case data.meta of 951 | ColorFieldMeta m -> 952 | m 953 | in 954 | Just 955 | (ChangedConfigForm 956 | logic.fieldName 957 | (ColorField 958 | { data 959 | | meta = 960 | ColorFieldMeta 961 | { meta 962 | | isOpen = False 963 | } 964 | } 965 | ) 966 | ) 967 | 968 | else 969 | Nothing 970 | 971 | _ -> 972 | Nothing 973 | in 974 | case maybeCloseMsg of 975 | Just msg -> 976 | Html.button 977 | [ style "background" "rgba(255,255,255,0.9)" 978 | , style "border" "1px solid rgba(0,0,0,0.9)" 979 | , style "border-radius" "4px" 980 | , style "width" (px (1.5 * toFloat options.fontSize)) 981 | , style "height" (px (1.5 * toFloat options.fontSize)) 982 | , Html.Attributes.tabindex (1 + i) 983 | , Html.Events.onClick msg 984 | ] 985 | [ Html.div 986 | [ style "padding" "3px 0 0 2px" ] 987 | [ Html.text "❌" ] 988 | ] 989 | 990 | Nothing -> 991 | Html.text "" 992 | 993 | 994 | formattedPower : Int -> String 995 | formattedPower power = 996 | let 997 | numStr = 998 | if power >= 0 then 999 | String.fromInt (10 ^ power) 1000 | 1001 | else 1002 | "0." ++ String.repeat (-1 - power) "0" ++ "1" 1003 | in 1004 | "x" ++ numStr 1005 | 1006 | 1007 | powerEl : ViewOptions -> ConfigForm -> Logic config -> Html (Msg config) 1008 | powerEl options (ConfigForm configForm) logic = 1009 | let 1010 | makePowerEl power newIncField newDecField isDownDisabled = 1011 | Html.div 1012 | [ style "position" "absolute" 1013 | , style "top" "0px" 1014 | , style "right" "0" 1015 | , style "height" "100%" 1016 | , style "box-sizing" "border-box" 1017 | , style "display" "flex" 1018 | , style "align-items" "center" 1019 | , style "padding-left" (px (0.45 * inputFieldVertPadding options)) 1020 | , style "font-size" (px (0.8 * toFloat options.fontSize)) 1021 | , style "background" (Color.toCssString options.labelHighlightBgColor) 1022 | , style "background" 1023 | ([ "linear-gradient(to right," 1024 | , "transparent," 1025 | , Color.toCssString options.labelHighlightBgColor ++ " 10%," 1026 | , Color.toCssString options.labelHighlightBgColor 1027 | ] 1028 | |> String.join " " 1029 | ) 1030 | ] 1031 | [ Html.span 1032 | [ style "padding" "5px 0" 1033 | ] 1034 | -- label 1035 | [ Html.text (formattedPower power) ] 1036 | , Html.span 1037 | [ style "font-size" (0.8 * toFloat options.fontSize |> px) 1038 | , style "top" "1px" 1039 | , Pointer.onWithOptions "pointerdown" 1040 | { stopPropagation = True 1041 | , preventDefault = True 1042 | } 1043 | (\_ -> ChangedConfigForm logic.fieldName newIncField) 1044 | , if isDownDisabled then 1045 | style "opacity" "0.4" 1046 | 1047 | else 1048 | style "cursor" "pointer" 1049 | ] 1050 | -- down btn 1051 | [ Html.text "↙️" ] 1052 | , Html.span 1053 | [ style "font-size" (0.8 * toFloat options.fontSize |> px) 1054 | , style "top" "1px" 1055 | , Pointer.onWithOptions "pointerdown" 1056 | { stopPropagation = True 1057 | , preventDefault = True 1058 | } 1059 | (\_ -> ChangedConfigForm logic.fieldName newDecField) 1060 | , style "cursor" "pointer" 1061 | ] 1062 | -- up btn 1063 | [ Html.text "↗️" ] 1064 | ] 1065 | 1066 | el = 1067 | case OrderedDict.get logic.fieldName configForm.fields of 1068 | Just (IntField data) -> 1069 | makePowerEl data.power 1070 | (IntField 1071 | { data 1072 | | power = data.power - 1 |> max 0 1073 | , str = formatPoweredInt (data.power - 1 |> max 0) data.val 1074 | , val = poweredInt (data.power - 1 |> max 0) data.val 1075 | } 1076 | ) 1077 | (IntField 1078 | { data 1079 | | power = data.power + 1 1080 | , str = formatPoweredInt (data.power + 1) data.val 1081 | , val = poweredInt (data.power + 1) data.val 1082 | } 1083 | ) 1084 | (data.power <= 0) 1085 | 1086 | Just (FloatField data) -> 1087 | makePowerEl data.power 1088 | (FloatField 1089 | { data 1090 | | power = data.power - 1 1091 | , str = formatPoweredFloat (data.power - 1) data.val 1092 | , val = poweredFloat (data.power - 1) data.val 1093 | } 1094 | ) 1095 | (FloatField 1096 | { data 1097 | | power = data.power + 1 1098 | , str = formatPoweredFloat (data.power + 1) data.val 1099 | , val = poweredFloat (data.power + 1) data.val 1100 | } 1101 | ) 1102 | False 1103 | 1104 | _ -> 1105 | Html.text "" 1106 | in 1107 | case configForm.activeField of 1108 | Just ( state, fieldName ) -> 1109 | if fieldName == logic.fieldName then 1110 | el 1111 | 1112 | else 1113 | Html.text "" 1114 | 1115 | Nothing -> 1116 | Html.text "" 1117 | 1118 | 1119 | resizeAttrs : ViewOptions -> ConfigForm -> Logic config -> List (Html.Attribute (Msg config)) 1120 | resizeAttrs options configForm logic = 1121 | [ Html.Events.onMouseEnter (HoveredLabel logic.fieldName True) 1122 | , Html.Events.onMouseLeave (HoveredLabel logic.fieldName False) 1123 | 1124 | --, Html.Events.onMouseDown (ClickedPointerLockLabel logic.fieldName) 1125 | , style "cursor" "ew-resize" 1126 | , style "height" "100%" 1127 | , style "position" "relative" 1128 | ] 1129 | 1130 | 1131 | inputFieldVertPadding : ViewOptions -> Float 1132 | inputFieldVertPadding options = 1133 | toFloat options.fontSize * options.inputSpacing 1134 | 1135 | 1136 | viewChanger : ViewOptions -> ConfigForm -> Int -> Logic config -> Html (Msg config) 1137 | viewChanger options (ConfigForm configForm) i logic = 1138 | let 1139 | defaultAttrs = 1140 | [ style "width" (pxInt options.inputWidth) 1141 | , style "height" (px (inputFieldVertPadding options)) 1142 | ] 1143 | 1144 | tabAttrs = 1145 | [ Html.Attributes.tabindex (1 + i) 1146 | ] 1147 | 1148 | incrementalAttrs strToNum wrapper data = 1149 | [ Html.Events.on "keydown" 1150 | (JD.map 1151 | (\key -> 1152 | let 1153 | maybeNewNum = 1154 | case key of 1155 | 38 -> 1156 | Just <| data.val + 1 1157 | 1158 | 40 -> 1159 | Just <| data.val - 1 1160 | 1161 | _ -> 1162 | Nothing 1163 | in 1164 | ChangedConfigForm logic.fieldName 1165 | (wrapper 1166 | (case maybeNewNum of 1167 | Just newNum -> 1168 | { data 1169 | | val = newNum 1170 | , str = strToNum newNum 1171 | } 1172 | 1173 | Nothing -> 1174 | data 1175 | ) 1176 | ) 1177 | ) 1178 | Html.Events.keyCode 1179 | ) 1180 | , style "font-variant-numeric" "tabular-nums" 1181 | ] 1182 | 1183 | maybeField = 1184 | OrderedDict.get logic.fieldName configForm.fields 1185 | 1186 | colspan = 1187 | case maybeField of 1188 | Just (SectionField _) -> 1189 | 0 1190 | 1191 | _ -> 1192 | 1 1193 | in 1194 | case maybeField of 1195 | Just (StringField data) -> 1196 | Html.td [] 1197 | [ textInputHelper 1198 | options 1199 | { label = logic.label 1200 | , valStr = data.val 1201 | , attrs = defaultAttrs ++ tabAttrs 1202 | , setterMsg = 1203 | \newStr -> 1204 | ChangedConfigForm 1205 | logic.fieldName 1206 | (StringField { data | val = newStr }) 1207 | } 1208 | ] 1209 | 1210 | Just (BoolField data) -> 1211 | Html.td [] 1212 | [ Html.input 1213 | (defaultAttrs 1214 | ++ tabAttrs 1215 | ++ [ Html.Attributes.type_ "checkbox" 1216 | , Html.Attributes.checked data.val 1217 | , Html.Events.onCheck 1218 | (\newBool -> 1219 | ChangedConfigForm 1220 | logic.fieldName 1221 | (BoolField { data | val = newBool }) 1222 | ) 1223 | ] 1224 | ) 1225 | [] 1226 | ] 1227 | 1228 | Just (IntField data) -> 1229 | Html.td [] 1230 | [ textInputHelper 1231 | options 1232 | { label = logic.label 1233 | , valStr = data.str 1234 | , attrs = 1235 | defaultAttrs 1236 | ++ tabAttrs 1237 | ++ incrementalAttrs String.fromInt IntField data 1238 | ++ (if String.toInt data.str == Nothing then 1239 | [ style "background" "1,0,0,0.3)" ] 1240 | 1241 | else 1242 | [] 1243 | ) 1244 | , setterMsg = 1245 | \newStr -> 1246 | ChangedConfigForm 1247 | logic.fieldName 1248 | <| 1249 | IntField 1250 | { data 1251 | | str = newStr 1252 | , val = 1253 | case String.toInt newStr of 1254 | Just num -> 1255 | num 1256 | 1257 | Nothing -> 1258 | data.val 1259 | } 1260 | } 1261 | ] 1262 | 1263 | Just (FloatField data) -> 1264 | Html.td [] 1265 | [ textInputHelper 1266 | options 1267 | { label = logic.label 1268 | , valStr = data.str 1269 | , attrs = 1270 | defaultAttrs 1271 | ++ tabAttrs 1272 | ++ incrementalAttrs String.fromFloat FloatField data 1273 | ++ (if String.toFloat data.str == Nothing then 1274 | [ style "background" "rgba(1,0,0,0.3)" ] 1275 | 1276 | else 1277 | [] 1278 | ) 1279 | , setterMsg = 1280 | \newStr -> 1281 | ChangedConfigForm 1282 | logic.fieldName 1283 | <| 1284 | FloatField 1285 | { data 1286 | | str = newStr 1287 | , val = 1288 | case String.toFloat newStr of 1289 | Just num -> 1290 | num 1291 | 1292 | Nothing -> 1293 | data.val 1294 | } 1295 | } 1296 | ] 1297 | 1298 | Just (ColorField data) -> 1299 | let 1300 | meta = 1301 | case data.meta of 1302 | ColorFieldMeta m -> 1303 | m 1304 | in 1305 | Html.td [] 1306 | [ if meta.isOpen then 1307 | ColorPicker.view 1308 | data.val 1309 | meta.state 1310 | |> Html.map 1311 | (\pickerMsg -> 1312 | let 1313 | ( newPickerState, newColor ) = 1314 | ColorPicker.update 1315 | pickerMsg 1316 | data.val 1317 | meta.state 1318 | in 1319 | ChangedConfigForm logic.fieldName 1320 | (ColorField 1321 | { data 1322 | | val = newColor |> Maybe.withDefault data.val 1323 | , meta = 1324 | ColorFieldMeta 1325 | { state = newPickerState 1326 | , isOpen = meta.isOpen 1327 | } 1328 | } 1329 | ) 1330 | ) 1331 | 1332 | else 1333 | Html.div 1334 | (defaultAttrs 1335 | ++ [ style "background" (Color.toCssString data.val) 1336 | , style "width" "100%" 1337 | , style "border" "1px solid rgba(0,0,0,0.3)" 1338 | , style "border-radius" "3px" 1339 | , style "box-sizing" "border-box" 1340 | , Html.Events.onMouseDown 1341 | (ChangedConfigForm 1342 | logic.fieldName 1343 | (ColorField 1344 | { data 1345 | | meta = 1346 | ColorFieldMeta 1347 | { state = meta.state 1348 | , isOpen = True 1349 | } 1350 | } 1351 | ) 1352 | ) 1353 | ] 1354 | ) 1355 | [] 1356 | ] 1357 | 1358 | Just (SectionField str) -> 1359 | Html.text "" 1360 | 1361 | Nothing -> 1362 | Html.text "" 1363 | 1364 | 1365 | textInputHelper : 1366 | ViewOptions 1367 | -> 1368 | { label : String 1369 | , valStr : String 1370 | , attrs : List (Html.Attribute (Msg config)) 1371 | , setterMsg : String -> Msg config 1372 | } 1373 | -> Html (Msg config) 1374 | textInputHelper options { label, valStr, attrs, setterMsg } = 1375 | Html.input 1376 | ([ Html.Attributes.value valStr 1377 | , Html.Events.onInput setterMsg 1378 | , style "font-size" "inherit" 1379 | , style "height" "200px" 1380 | ] 1381 | ++ attrs 1382 | ) 1383 | [] 1384 | 1385 | 1386 | 1387 | -- VIEW OPTIONS 1388 | 1389 | 1390 | {-| Options for viewing the config form. 1391 | -} 1392 | type alias ViewOptions = 1393 | { fontSize : Int 1394 | , rowSpacing : Int 1395 | , inputWidth : Int 1396 | , inputSpacing : Float 1397 | , labelHighlightBgColor : Color 1398 | , sectionSpacing : Int 1399 | } 1400 | 1401 | 1402 | {-| Default options for viewing the config form. 1403 | -} 1404 | viewOptions : ViewOptions 1405 | viewOptions = 1406 | { fontSize = 18 1407 | , rowSpacing = 2 1408 | , inputWidth = 80 1409 | , inputSpacing = 1.4 1410 | , labelHighlightBgColor = Color.rgb 0.8 0.8 1 1411 | , sectionSpacing = 10 1412 | } 1413 | 1414 | 1415 | {-| Update the font size in px. Default is 18. 1416 | -} 1417 | withFontSize : Int -> ViewOptions -> ViewOptions 1418 | withFontSize val options = 1419 | { options | fontSize = val } 1420 | 1421 | 1422 | {-| Update the row spacing in px. Default is 2. 1423 | -} 1424 | withRowSpacing : Int -> ViewOptions -> ViewOptions 1425 | withRowSpacing val options = 1426 | { options | rowSpacing = val } 1427 | 1428 | 1429 | {-| Update the width of inputs in px. Default is 80. 1430 | -} 1431 | withInputWidth : Int -> ViewOptions -> ViewOptions 1432 | withInputWidth val options = 1433 | { options | inputWidth = val } 1434 | 1435 | 1436 | {-| Update the inner spacing of inputs by a ratio of its font size. Default is 1.40. 1437 | -} 1438 | withInputSpacing : Float -> ViewOptions -> ViewOptions 1439 | withInputSpacing val options = 1440 | { options | inputSpacing = val } 1441 | 1442 | 1443 | {-| Update the row color when hovering field labels that are pointerlock-able. Default is yellow: (0.8, 0.8, 1). 1444 | -} 1445 | withLabelHighlightBgColor : Color -> ViewOptions -> ViewOptions 1446 | withLabelHighlightBgColor val options = 1447 | { options | labelHighlightBgColor = val } 1448 | 1449 | 1450 | {-| Update the extra top spacing for sections in px. Default is 20. 1451 | -} 1452 | withSectionSpacing : Int -> ViewOptions -> ViewOptions 1453 | withSectionSpacing val options = 1454 | { options | sectionSpacing = val } 1455 | 1456 | 1457 | 1458 | -- MISC INTERNAL 1459 | 1460 | 1461 | px : Float -> String 1462 | px num = 1463 | String.fromFloat num ++ "px" 1464 | 1465 | 1466 | pxInt : Int -> String 1467 | pxInt num = 1468 | String.fromInt num ++ "px" 1469 | -------------------------------------------------------------------------------- /src/ConfigFormGenerator.elm: -------------------------------------------------------------------------------- 1 | module ConfigFormGenerator exposing 2 | ( Kind(..) 3 | , toFile 4 | ) 5 | 6 | {-| Imagine being able to add a field to the config form with just one line! It can be done if you use code generation. 7 | 8 | Use `ConfigFormGenerator` in your `ConfigSchema.elm` to make a `Config.elm` file (it can be excluded from your `src/` directory if you want, since it won't be compiled directly with your other elm files): 9 | 10 | -- ConfigSchema.elm 11 | 12 | 13 | import ConfigFormGenerator exposing (Kind(..)) 14 | import Html exposing (Html) 15 | 16 | myConfigFields : List ( String, Kind ) 17 | myConfigFields = 18 | [ ( "Header Font Size", IntKind "headerFontSize" ) 19 | , ( "Body Font Size", IntKind "bodyFontSize" ) 20 | , ( "Background Color", ColorKind "bgColor" ) 21 | ] 22 | 23 | main : Html msg 24 | main = 25 | let 26 | generatedElmCode = 27 | ConfigFormGenerator.toFile myConfigFields 28 | 29 | _ = 30 | Debug.log generatedElmCode "" 31 | in 32 | Html.text "" 33 | 34 | When compiled, it makes an elm app whose sole purpose is to `console.log` the elm code needed for a `Config.elm` file. To generate it, run something like this: 35 | 36 | ```shell 37 | # Compile schema file to tmp js: 38 | elm make ConfigSchema.elm --output=~/tmp/tmp.js > /dev/null 39 | 40 | # Run compiled js with node, which logs out generated elm code, and save to Config.elm: 41 | node ~/tmp/tmp.js > Config.elm 2>/dev/null 42 | ``` 43 | 44 | 45 | # How to automate with a watcher script 46 | 47 | ```shell 48 | #!/bin/bash 49 | 50 | CONFIG_SCHEMA_ELMFILE=ConfigSchema.elm 51 | CONFIG_ELMFILE=Config.elm 52 | TMP_JS=~/tmp/gen-config.js 53 | MAIN_ELMFILE=Main.elm 54 | SERVER_DIR=public/ 55 | MAIN_JS_OUTPUT=public/js/main.js 56 | 57 | GENERATE_ARGS="$CONFIG_SCHEMA_ELMFILE $TMP_JS $CONFIG_ELMFILE" 58 | 59 | # Command for generating Config.elm from ConfigSchema.elm 60 | generate_config () { 61 | CONFIG_SCHEMA_ELMFILE=$1 62 | TMP_JS=$2 63 | CONFIG_ELMFILE=$3 64 | 65 | # Use `elm make` to make an elm app that console.logs the generated Config.elm code 66 | elm make $CONFIG_SCHEMA_ELMFILE --output=$TMP_JS > /dev/null && \ 67 | # Run it with `node` to print the output and write to Config.elm 68 | node $TMP_JS > $CONFIG_ELMFILE 2>/dev/null 69 | } 70 | export -f generate_config 71 | 72 | # Generate the config initially, just in case it doesn't exist 73 | generate_config $GENERATE_ARGS 74 | 75 | # Watch for config changes 76 | chokidar $CONFIG_SCHEMA_ELMFILE --command "generate_config $GENERATE_ARGS" & 77 | 78 | # Watch for elm changes 79 | elm-live $MAIN_ELMFILE --dir=$SERVER_DIR -- --output=$MAIN_JS_OUTPUT & 80 | 81 | wait 82 | ``` 83 | 84 | This will watch for changes to `ConfigSchema.elm` and generate a `Config.elm` file. Make sure you have the following installed, too: 85 | 86 | ```shell 87 | # (use --save-dev instead of --global if you only need it locally for one project) 88 | npm install --global elm elm-live@next chokidir 89 | ``` 90 | 91 | @docs Kind 92 | @docs toFile 93 | 94 | -} 95 | 96 | import Dict exposing (Dict) 97 | 98 | 99 | {-| Use these to define what kind of value your field is. For all values except `SectionKind`, the `String` is the field's camelCase variable name for both your `Config` record and its JSON representation, such as "headerFontSize". 100 | 101 | `SectionKind` is just for visually organizing your fields. 102 | 103 | -} 104 | type Kind 105 | = IntKind String 106 | | FloatKind String 107 | | StringKind String 108 | | BoolKind String 109 | | ColorKind String 110 | | SectionKind 111 | 112 | 113 | {-| Generates the elm code for your Config module given a list of labels and field kinds. 114 | -} 115 | toFile : List ( String, Kind ) -> String 116 | toFile data = 117 | [ header 118 | , typeAlias data 119 | , empty data 120 | , logics data 121 | , "--" 122 | ] 123 | |> String.join "\n\n\n" 124 | 125 | 126 | header : String 127 | header = 128 | """ 129 | -- GENERATED CODE, DO NOT EDIT BY HAND! 130 | 131 | 132 | module Config exposing (Config, empty, logics) 133 | 134 | import Color exposing (Color) 135 | import ConfigForm as ConfigForm 136 | """ 137 | |> String.trim 138 | 139 | 140 | typeAlias : List ( String, Kind ) -> String 141 | typeAlias data = 142 | let 143 | pre = 144 | "type alias Config =" 145 | 146 | middle = 147 | data 148 | |> List.map Tuple.second 149 | |> List.filterMap typeAliasEntry 150 | |> List.indexedMap 151 | (\i entry -> 152 | let 153 | pre_ = 154 | if i == 0 then 155 | " { " 156 | 157 | else 158 | " , " 159 | in 160 | pre_ ++ entry 161 | ) 162 | |> String.join "\n" 163 | 164 | post = 165 | " }" 166 | in 167 | [ pre 168 | , middle 169 | , post 170 | ] 171 | |> String.join "\n" 172 | 173 | 174 | typeAliasEntry : Kind -> Maybe String 175 | typeAliasEntry kind = 176 | case ( kindToFieldName kind, kindToType kind ) of 177 | ( Just fieldName, Just type_ ) -> 178 | Just (fieldName ++ " : " ++ type_) 179 | 180 | _ -> 181 | Nothing 182 | 183 | 184 | empty : List ( String, Kind ) -> String 185 | empty data = 186 | let 187 | pre = 188 | """ 189 | empty : ConfigForm.Defaults -> Config 190 | empty defaults = 191 | """ 192 | |> String.trim 193 | 194 | middle = 195 | data 196 | |> List.map Tuple.second 197 | |> List.filterMap emptyEntry 198 | |> List.indexedMap 199 | (\i entry -> 200 | let 201 | pre_ = 202 | if i == 0 then 203 | " { " 204 | 205 | else 206 | " , " 207 | in 208 | pre_ ++ entry 209 | ) 210 | |> String.join "\n" 211 | 212 | post = 213 | " }" 214 | in 215 | [ pre 216 | , middle 217 | , post 218 | ] 219 | |> String.join "\n" 220 | 221 | 222 | emptyEntry : Kind -> Maybe String 223 | emptyEntry kind = 224 | case ( kindToFieldName kind, kindToDefault kind ) of 225 | ( Just fieldName, Just default ) -> 226 | Just (fieldName ++ " = " ++ default) 227 | 228 | _ -> 229 | Nothing 230 | 231 | 232 | logics : List ( String, Kind ) -> String 233 | logics data = 234 | let 235 | pre = 236 | """ 237 | --logics : List (ConfigForm.Logic Config) 238 | logics = 239 | """ 240 | |> String.trim 241 | 242 | middle = 243 | data 244 | |> List.indexedMap 245 | (\i ( label, kind ) -> 246 | let 247 | pre_ = 248 | if i == 0 then 249 | " [ " ++ kindToLogic kind 250 | 251 | else 252 | " , " ++ kindToLogic kind 253 | 254 | args = 255 | kindToLogicArgs ( label, kind ) 256 | |> List.map (\str -> " " ++ str) 257 | in 258 | (pre_ :: args) 259 | |> String.join "\n" 260 | ) 261 | |> String.join "\n" 262 | 263 | post = 264 | " ]" 265 | in 266 | [ pre 267 | , middle 268 | , post 269 | ] 270 | |> String.join "\n" 271 | 272 | 273 | kindToType : Kind -> Maybe String 274 | kindToType kind = 275 | case kind of 276 | IntKind _ -> 277 | Just "Int" 278 | 279 | FloatKind _ -> 280 | Just "Float" 281 | 282 | StringKind _ -> 283 | Just "String" 284 | 285 | BoolKind _ -> 286 | Just "Bool" 287 | 288 | ColorKind _ -> 289 | Just "Color" 290 | 291 | SectionKind -> 292 | Nothing 293 | 294 | 295 | kindToDefault : Kind -> Maybe String 296 | kindToDefault kind = 297 | case kind of 298 | IntKind _ -> 299 | Just "defaults.int" 300 | 301 | FloatKind _ -> 302 | Just "defaults.float" 303 | 304 | StringKind _ -> 305 | Just "defaults.string" 306 | 307 | BoolKind _ -> 308 | Just "defaults.bool" 309 | 310 | ColorKind _ -> 311 | Just "defaults.color" 312 | 313 | SectionKind -> 314 | Nothing 315 | 316 | 317 | kindToLogic : Kind -> String 318 | kindToLogic kind = 319 | case kind of 320 | IntKind _ -> 321 | "ConfigForm.int" 322 | 323 | FloatKind _ -> 324 | "ConfigForm.float" 325 | 326 | StringKind _ -> 327 | "ConfigForm.string" 328 | 329 | BoolKind _ -> 330 | "ConfigForm.bool" 331 | 332 | ColorKind _ -> 333 | "ConfigForm.color" 334 | 335 | SectionKind -> 336 | "ConfigForm.section" 337 | 338 | 339 | kindToLogicArgs : ( String, Kind ) -> List String 340 | kindToLogicArgs ( label, kind ) = 341 | case kindToFieldName kind of 342 | Just fieldName -> 343 | -- need all args 344 | let 345 | fieldLine = 346 | "\"" ++ fieldName ++ "\"" 347 | 348 | labelLine = 349 | "\"" ++ label ++ "\"" 350 | 351 | getter = 352 | "." ++ fieldName 353 | 354 | setter = 355 | "(\\a c -> { c | " ++ fieldName ++ " = a })" 356 | in 357 | [ fieldLine 358 | , labelLine 359 | , getter 360 | , setter 361 | ] 362 | 363 | Nothing -> 364 | [ "\"" ++ label ++ "\"" 365 | ] 366 | 367 | 368 | kindToFieldName : Kind -> Maybe String 369 | kindToFieldName kind = 370 | case kind of 371 | IntKind str -> 372 | Just str 373 | 374 | FloatKind str -> 375 | Just str 376 | 377 | StringKind str -> 378 | Just str 379 | 380 | BoolKind str -> 381 | Just str 382 | 383 | ColorKind str -> 384 | Just str 385 | 386 | SectionKind -> 387 | Nothing 388 | --------------------------------------------------------------------------------