├── .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 | 
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 |
--------------------------------------------------------------------------------