Let's get started creating a control panel full of “knobs” to interactively tweak values in our application.
When creating a knob, two considerations are important.
The first is the type of the value you need to control.
This package currently provides knobs for numbers, booleans, enumerated choices (custom types or anything like that)
and colors out of the box, and there is a way to either transform one into another type (map
),
or to create an entirely new knob (custom
).
The second important consideration is the interface you want to provide to manipulate that value, i.e. the control itself. Many knobs offer different controls for the same type, particularly number-related ones, so pick the one that best suits your needs!
Represents one user-interactive control mapped to one value of type a
,
which this package refers to as a “knob”.
This is the base type used to create your knobs control panel!
Normally you'll have a single one of these stored in your model,
but that one knob can actually represent a group of compose
d knobs.
First up, within our app's init
let's create a Knob
and put it in the model.
The following are the functions you can use to create basic knobs that map to a single value.
float : { step : Basics.Float, initial : Basics.Float } -> Knob Basics.Float
Creates an input field knob for manually entering any floating point number.
The step
argument specifies the amount the number will increase or decrease
when pressing the up and down keys.
initial
is just the value it takes on first load.
floatConstrained : { range : ( Basics.Float, Basics.Float ), step : Basics.Float, initial : Basics.Float } -> Knob Basics.Float
Creates an input field knob for manually entering numbers within a specific range,
where range = ( min, max )
.
The step
argument specifies the amount the number will increase or decrease
when pressing the up and down keys.
initial
is just the value it takes on first load.
floatSlider : { range : ( Basics.Float, Basics.Float ), step : Basics.Float, initial : Basics.Float } -> Knob Basics.Float
Creates a slider knob useful for quickly tweaking numbers when precision is not needed.
Requires a range = ( min, max )
to set the boundaries of the slider control.
initial
is just the value it takes on first load.
The step
argument indicates the granularity of the values the slider will allow,
so a step of 1
will produce a slider that jumps between values like 1
, 2
, 3
as you slide it to the right,
whereas a step of 0.1
will produce one that allows setting values like 1.1
or 2.5
.
int : { step : Basics.Int, initial : Basics.Int } -> Knob Basics.Int
Creates an input field knob for manually entering any integer.
The step
argument specifies the amount the number will increase or decrease
when pressing the up and down keys.
initial
is just the value it takes on first load.
intConstrained : { range : ( Basics.Int, Basics.Int ), step : Basics.Int, initial : Basics.Int } -> Knob Basics.Int
Creates an input field knob for manually entering integers within a specific range.
The step
argument specifies the amount the number will increase or decrease
when pressing the up and down keys.
initial
is just the value it takes on first load.
intSlider : { range : ( Basics.Int, Basics.Int ), step : Basics.Int, initial : Basics.Int } -> Knob Basics.Int
Creates a slider knob useful for quickly tweaking integers when precision is not needed.
Requires a range = ( min, max )
to set the boundaries of the slider control.
initial
is just the value it takes on first load.
The step
argument indicates the granularity of the values the slider will allow,
so a step of 1
will produce a slider that will set on any integer value as you slide it to the right,
whereas a step of 10
will produce one that makes bigger jumps between 10
, 20
, 30
, etc.
boolCheckbox : Basics.Bool -> Knob Basics.Bool
Creates a checkbox representing a boolean value.
initial
determines whether it will be initially checked or not.
select : { options : List String, toString : a -> String, fromString : String -> a, initial : a } -> Knob a
Creates a dropdown select input for a custom type or any arbitrary value you wish.
You'll need to provide a list of String
s that represent each selectable option.
You'll also need a fromString
function that maps these strings to your type,
and the reverse toString
which converts a value of your type to one of the option strings.
Here's a simple example mapping "yes" and "no" options to Bool
values:
Knob.select
{ options = [ "yes", "no" ]
, toString =
\bool ->
case bool of
True ->
"yes"
False ->
"no"
, fromString = \string -> string == "yes"
, initial = False
}
{ red : Basics.Float
, green : Basics.Float
, blue : Basics.Float
}
Represents an RGB color value. Each channel value is a Float
between 0 and 1.
This is the type that the colorPicker
knob uses.
colorPicker : Color -> Knob Color
Creates a color picker input.
Colors are represented using a type alias Color
,
which is easily map
pable into other color formats for your convenience.
Below is an example mapping it into avh4/elm-color format.
-- We set magenta as the initial color.
Knob.colorPicker { red = 1, green = 0, blue = 1 }
-- We map it into avh4/elm-color format.
|> Knob.map (c -> Color.rgb c.red c.green c.blue)
The next step is to actually display our knob in the page.
view : (Knob a -> msg) -> Knob a -> Html msg
Converts a knob into HTML to put in your view.
You should display a single Knob
value at any which time,
so if you need multiple knobs, make sure you compose
them into a single value!
Knobs keep track of their state once they're put in the view, but for that you need to wire them up with a message, which is the first argument that this function takes.
This function produces plain HTML with no styles, so make sure you also include styles
in your page to make it display properly, or provide your own custom styles.
-- Prepare a message for your knob:
type Msg =
KnobUpdated (Knob YourType)
-- Put this as an HTML node within your view:
Knob.view KnobUpdated yourKnob
Check the documentation's readme for a full demonstration on how to wire things up.
styles : Html msg
Default styles for the knobs, provided as a <style>
tag.
Put this as a child somewhere in your view in order to use them.
You could choose not to use these default styles and instead provide your own. I recommend you check the DOM output in your browser's inspector—the structure of the HTML produced is pretty simple!
Of course, our knobs are of no use to us if we can't read the value entered by the user.
value : Knob a -> a
Extract the current value out of a knob. Use it in your view to affect what you display.
Knob.int { step = 1, initial = 5 }
|> Knob.value -- Gets `5`.
Most of the time you'll want to control multiple values. For that purpose we're going to “stack” our knobs together.
compose : (a -> b) -> Knob (a -> b)
Creates a knob that joins multiple knobs to build up a record
(or actually any data structure you want, depending on the constructor
argument you pass it!)
Pipe (|>
) the knobs into it
using stack
or stackLabel
in order to provide the arguments.
type alias Controls =
{ someNumber : Float
, anInteger : Int
}
aKnob =
Knob.compose Controls
-- This knob will map to `someNumber`:
|> Knob.stack (Knob.float { step = 1, initial = 0 })
-- This one will map to `anInteger`:
|> Knob.stack (Knob.int { step = 1, initial = 0 })
Here's how you use it to build up a different data structure, in this case a tuple. Notice that the number of arguments in the function matches the number of “stacks”.
anotherKnob =
Knob.compose (\theFloat theInt -> ( theFloat, theInt ))
|> Knob.stack (Knob.float { step = 1, initial = 0 })
|> Knob.stack (Knob.int { step = 1, initial = 0 })
stack : Knob a -> Knob (a -> b) -> Knob b
Adds a knob into a compose
knob.
See the documentation for that for an example.
This function is called “stack” because the order you compose your knobs does matter, as they will be displayed one on top of the other!
We could have a bunch of similar knobs in our panel and not know what each of them does, so let's make sure we do!
label : String -> Knob a -> Knob a
Attaches a text description next to a knob, as a way to identify what the control is for.
The following example will produce a float
knob described as “x position”.
Knob.label "x position"
(Knob.float { step = 1, initial = 0 })
stackLabel : String -> Knob a -> Knob (a -> b) -> Knob b
Convenience function that unifies the functionality of stack
and label
.
The two examples below produce the same identical result:
-- This:
Knob.stackLabel "Some label" someKnob
-- is the same as:
Knob.stack (Knob.label "Some label" someKnob)
map : (a -> b) -> Knob a -> Knob b
Analogous to List.map
or other data structures' map
function,
you can use this function to convert the value produced by a Knob.
The following example converts a knob that produces an Int
(i.e. a Knob Int
)
into one that produces a String
(i.e. a Knob String
.) This is achieved
because String.fromInt
is a function with the type Int -> String
.
Knob.int { step = 1, initial = 0 }
|> Knob.map String.fromInt
custom : { value : a, view : () -> Html (Knob a) } -> Knob a
Creates a knob for any type, using a custom HTML view that you supply.
You can use this function if there is some kind of knob you need
that is not available in this package, and can't be created by using map
over a predefined knob.
Knobs are comprised of a value
of the appropriate type,
and a view
which listens to user input
(typically the Html.Events.onInput
event)
and emits the updated knob, instead of a regular message like is normally done.
In this sense, knobs are recursive, as their view needs to construct a new knob,
typically by calling the very same constructor function that created it in the first place.
Here's how the boolCheckbox
knob would be created using custom
:
ourBoolKnob : Bool -> Knob Bool
ourBoolKnob initial =
let
view : () -> Html (Knob Bool)
view () =
Html.input
[ Html.Attributes.type_ "checkbox"
, Html.Attributes.checked initial
, Html.Events.onChecked ourBoolKnob
]
[]
in
Knob.custom
{ value = initial
, view = view
}
Notice how view
is a thunk—that is, a function that takes ()
(a placeholder value)
and returns the view.
The view is just some HTML that emits knobs instead of messages.
Take a look at the line with Html.Events.onChecked
and make note of what we're doing:
We're directly passing in ourBoolKnob
because it's a function that takes
the new "checked" value and with it constructs the knob anew.
This is how we're transforming the contained value when the user clicks.
A thing to keep in mind: For cases in which you're taking unconstrained user input, such as a text field, you can wind up making it so that the user cannot input freely. This occurs if you're parsing the input into a different type in a lossy manner.
Let's imagine we want to display a text field to map a String
to a Vegetable
type.
In the naïve case, our knob's signature could look like this, taking Vegetable
to set the initial value:
vegetableKnob : Vegetable -> Knob Vegetable
It would convert the initial value to a String
, and set that as the text field's text.
Then, upon user input, we'd parse the input String
into our type,
and use that to construct the updated knob.
The problem with this situation is that if the conversion from String
to Vegetable
and then back into String
is lossy (i.e., the result is not the same as what the user typed,)
then the user won't be able to type some things, as they'll be changing every time
they hit a key.
vegetableFromString : String -> Vegetable
vegetableFromString text =
case text of
"carrot" ->
Carrot
_ ->
Tomato
If we're using the function above to parse user input into Vegetable
,
the user may want to type "carrot" and start typing "c",
but as it's not yet a valid value, it will be parsed as Tomato
and the text field's text will be reset to whatever the string value for Tomato
is.
In order to prevent this behavior, set the unparsed input text as the text field's value
property,
and set the parsed result as the knob's value
.
This means that your knob will need to take a String
as its initial value.
vegetableKnob : String -> Knob Vegetable
vegetableKnob initial =
Knob.custom
{ value = vegetableFromString initial -- Parse here!
, view =
\() ->
Html.input
[ Html.Attributes.type_ "text"
, Html.Attributes.value initial -- No parsing
, Html.Events.onInput vegetableKnob
]
[]
}