Packages
kino
0.12.1
0.19.0
0.18.0
0.17.0
0.16.1
0.16.0
0.15.3
0.15.2
0.15.1
0.15.0
0.14.2
0.14.1
0.14.0
0.13.2
0.13.1
0.13.0
0.12.3
0.12.2
0.12.1
0.12.0
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.1
0.8.0
0.7.0
0.6.2
0.6.1
0.6.0
retired
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.1
0.3.0
0.2.3
0.2.2
0.2.1
0.2.0
0.1.3
0.1.2
0.1.1
0.1.0
Interactive widgets for Livebook
Current section
Files
Jump to
Current section
Files
lib/kino/input.ex
defmodule Kino.Input do
@moduledoc """
Various input elements for entering data.
## Examples
First, create an input and make sure it is rendered,
either by placing it at the end of a code cell or by
explicitly rendering it with `Kino.render/1`.
input = Kino.Input.text("Name")
Then read the value after the input has been rendered:
name = Kino.Input.read(input)
All inputs are shared by default: once you change the input,
your changes will be immediately replicated to all users
reading the notebook. Use `Kino.Control.form/2` if you want
each user to have their own input.
## Async API
You can subscribe to input changes or use the `Stream`
API for event feed. See the `Kino.Control` module for
more details.
"""
defstruct [:ref, :id, :destination, :attrs]
@opaque t :: %__MODULE__{
ref: Kino.Output.ref(),
id: String.t(),
destination: Process.dest(),
attrs: map()
}
defp new(attrs) do
token = Kino.Bridge.generate_token()
persistent_id = {token, attrs} |> :erlang.phash2() |> Integer.to_string()
ref = Kino.Output.random_ref()
subscription_manager = Kino.SubscriptionManager.cross_node_name()
Kino.Bridge.reference_object(ref, self())
Kino.Bridge.monitor_object(ref, subscription_manager, {:clear_topic, ref})
%__MODULE__{ref: ref, id: persistent_id, destination: subscription_manager, attrs: attrs}
end
@doc false
def duplicate(input) do
input.attrs
|> Map.drop([:ref, :id, :destination])
|> new()
end
@doc """
Creates a new text input.
## Options
* `:default` - the initial input value. Defaults to `""`
* `:debounce` - determines when input changes are emitted. When
set to `:blur`, the change propagates when the user leaves the
input. When set to a non-negative number of milliseconds, the
change propagates after the specified delay. Defaults to `:blur`
"""
@spec text(String.t(), keyword()) :: t()
def text(label, opts \\ []) when is_binary(label) and is_list(opts) do
default = opts |> Keyword.get(:default, "") |> to_string()
debounce = Keyword.get(opts, :debounce, :blur)
assert_debounce_value!(debounce)
new(%{type: :text, label: label, default: default, debounce: debounce})
end
@doc """
Creates a new multiline text input.
## Options
* `:default` - the initial input value. Defaults to `""`
* `:monospace` - whether to use a monospace font inside the textarea.
Defaults to `false`
* `:debounce` - determines when input changes are emitted. When
set to `:blur`, the change propagates when the user leaves the
input. When set to a non-negative number of milliseconds, the
change propagates after the specified delay. Defaults to `:blur`
"""
@spec textarea(String.t(), keyword()) :: t()
def textarea(label, opts \\ []) when is_binary(label) and is_list(opts) do
default = opts |> Keyword.get(:default, "") |> to_string()
monospace = Keyword.get(opts, :monospace, false)
debounce = Keyword.get(opts, :debounce, :blur)
assert_debounce_value!(debounce)
new(%{
type: :textarea,
label: label,
default: default,
monospace: monospace,
debounce: debounce
})
end
@doc """
Creates a new password input.
This is similar to text input, except the content is not
visible by default.
## Options
* `:default` - the initial input value. Defaults to `""`
* `:debounce` - determines when input changes are emitted. When
set to `:blur`, the change propagates when the user leaves the
input. When set to a non-negative number of milliseconds, the
change propagates after the specified delay. Defaults to `:blur`
"""
@spec password(String.t(), keyword()) :: t()
def password(label, opts \\ []) when is_binary(label) and is_list(opts) do
default = opts |> Keyword.get(:default, "") |> to_string()
debounce = Keyword.get(opts, :debounce, :blur)
assert_debounce_value!(debounce)
new(%{type: :password, label: label, default: default, debounce: debounce})
end
@doc """
Creates a new number input.
The input value is can be either a number or `nil`.
## Options
* `:default` - the initial input value. Defaults to `nil`
* `:debounce` - determines when input changes are emitted. When
set to `:blur`, the change propagates when the user leaves the
input. When set to a non-negative number of milliseconds, the
change propagates after the specified delay. Defaults to `:blur`
"""
@spec number(String.t(), keyword()) :: t()
def number(label, opts \\ []) when is_binary(label) and is_list(opts) do
default = Keyword.get(opts, :default, nil)
debounce = Keyword.get(opts, :debounce, :blur)
assert_default_value!(default, "be either number or nil", fn value ->
is_nil(value) or is_number(value)
end)
assert_debounce_value!(debounce)
new(%{type: :number, label: label, default: default, debounce: debounce})
end
defp assert_default_value!(value, message, check) do
unless check.(value) do
raise ArgumentError, "expected :default to #{message}, got: #{inspect(value)}"
end
end
defp assert_debounce_value!(value) do
unless value == :blur or (is_number(value) and value >= 0) do
raise ArgumentError,
~s/expected :debounce to be :blur or a non-negative number, got: #{inspect(value)}/
end
end
@doc """
Creates a new URL input.
The input value can be either a valid URL string or `nil`.
## Options
* `:default` - the initial input value. Defaults to `nil`
* `:debounce` - determines when input changes are emitted. When
set to `:blur`, the change propagates when the user leaves the
input. When set to a non-negative number of milliseconds, the
change propagates after the specified delay. Defaults to `:blur`
"""
@spec url(String.t(), keyword()) :: t()
def url(label, opts \\ []) when is_binary(label) and is_list(opts) do
default = Keyword.get(opts, :default, nil)
debounce = Keyword.get(opts, :debounce, :blur)
assert_default_value!(default, "be either string or nil", fn value ->
is_nil(value) or is_binary(value)
end)
assert_debounce_value!(debounce)
new(%{type: :url, label: label, default: default, debounce: debounce})
end
@doc """
Creates a new select input.
The input expects a list of options in the form `[{value, label}]`,
where `value` is an arbitrary term and `label` is a descriptive
string.
## Options
* `:default` - the initial input value. Defaults to the first
value from the given list of options
## Examples
Kino.Input.select("Language", [en: "English", fr: "Français"])
Kino.Input.select("Language", [{1, "One"}, {2, "Two"}, {3, "Three"}])
"""
@spec select(String.t(), list({value :: term(), label :: String.t()}), keyword()) :: t()
def select(label, options, opts \\ [])
when is_binary(label) and is_list(options) and is_list(opts) do
if options == [] do
raise ArgumentError, "expected at least one option, got: []"
end
options = Enum.map(options, fn {key, val} -> {key, to_string(val)} end)
values = Enum.map(options, &elem(&1, 0))
default = Keyword.get_lazy(opts, :default, fn -> hd(values) end)
if default not in values do
raise ArgumentError,
"expected :default to be either of #{Enum.map_join(values, ", ", &inspect/1)}, got: #{inspect(default)}"
end
new(%{type: :select, label: label, options: options, default: default})
end
@doc """
Creates a new checkbox.
The input value can be either `true` or `false`.
## Options
* `:default` - the initial input value. Defaults to `false`
"""
@spec checkbox(String.t(), keyword()) :: t()
def checkbox(label, opts \\ []) when is_binary(label) and is_list(opts) do
default = Keyword.get(opts, :default, false)
assert_default_value!(default, "be a boolean", &is_boolean/1)
new(%{type: :checkbox, label: label, default: default})
end
@doc """
Creates a new slider input.
The input value can be either float in the configured range.
## Options
* `:default` - the initial input value. Defaults to the
minimum value
* `:min` - the minimum value
* `:max` - the maximum value
* `:step` - the slider increment
* `:debounce` - determines when input changes are emitted. When
set to a non-negative number of milliseconds, the change propagates
after the specified delay. Defaults to `250`
"""
@spec range(String.t(), keyword()) :: t()
def range(label, opts \\ []) when is_binary(label) and is_list(opts) do
min = Keyword.get(opts, :min, 0)
max = Keyword.get(opts, :max, 100)
step = Keyword.get(opts, :step, 1)
default = Keyword.get(opts, :default, min)
# In Safari range input is blurred as soon as it's clicked,
# so we don't support blur as debounce for this input
debounce = Keyword.get(opts, :debounce, 250)
if min >= max do
raise ArgumentError,
"expected :min to be less than :max, got: #{inspect(min)} and #{inspect(max)}"
end
if step <= 0 do
raise ArgumentError, "expected :step to be positive, got: #{inspect(step)}"
end
assert_default_value!(default, "be a number", &is_number/1)
if default < min or default > max do
raise ArgumentError,
"expected :default to be between :min and :max, got: #{inspect(default)}"
end
unless is_number(debounce) and debounce >= 0 do
raise ArgumentError,
~s/expected :debounce to be a non-negative number, got: #{inspect(debounce)}/
end
new(%{
type: :range,
label: label,
default: default,
min: min,
max: max,
step: step,
debounce: debounce
})
end
@doc """
Creates a new datetime input.
The input is editable in user-local time zone, however the value
is always read in UTC as a `%NaiveDateTime{}` struct.
## Options
* `:default` - the initial input value. Defaults to `nil`
* `:min` - the minimum datetime value (in UTC)
* `:max` - the maximum datetime value (in UTC)
"""
@spec utc_datetime(String.t(), keyword()) :: t()
def utc_datetime(label, opts \\ []) when is_binary(label) and is_list(opts) do
min = Keyword.get(opts, :min, nil) |> truncate_datetime()
max = Keyword.get(opts, :max, nil) |> truncate_datetime()
default = Keyword.get(opts, :default, nil) |> truncate_datetime()
if min && max && NaiveDateTime.compare(min, max) == :gt do
raise ArgumentError,
"expected a non-empty range, but :min (#{inspect(min)}) is after :max (#{inspect(max)})"
end
assert_default_value!(
default,
"be %NaiveDateTime{} or nil",
&(is_struct(&1, NaiveDateTime) or &1 == nil)
)
if min && default && NaiveDateTime.compare(default, min) == :lt do
raise ArgumentError,
"invalid :default, #{inspect(default)} is before :min (#{inspect(min)})"
end
if max && default && NaiveDateTime.compare(default, max) == :gt do
raise ArgumentError,
"invalid :default, #{inspect(default)} is after :max (#{inspect(max)})"
end
new(%{
type: :utc_datetime,
label: label,
default: default,
min: min,
max: max
})
end
defp truncate_datetime(nil), do: nil
defp truncate_datetime(datetime) do
datetime
|> NaiveDateTime.truncate(:second)
|> Map.replace!(:second, 0)
end
@doc """
Creates a new time input.
The input is editable in user-local time zone, however the value
is always read in UTC as a `%Time{}` struct.
## Options
* `:default` - the initial input value. Defaults to `nil`
* `:min` - the minimum time value (in UTC)
* `:max` - the maximum time value (in UTC)
"""
@spec utc_time(String.t(), keyword()) :: t()
def utc_time(label, opts \\ []) when is_binary(label) and is_list(opts) do
min = Keyword.get(opts, :min, nil) |> truncate_time()
max = Keyword.get(opts, :max, nil) |> truncate_time()
default = Keyword.get(opts, :default, nil) |> truncate_time()
if min && max && Time.compare(min, max) == :gt do
raise ArgumentError,
"expected a non-empty range, but :min (#{inspect(min)}) is after :max (#{inspect(max)})"
end
assert_default_value!(default, "be %Time{} or nil", &(is_struct(&1, Time) or &1 == nil))
if min && default && Time.compare(default, min) == :lt do
raise ArgumentError,
"invalid :default, #{inspect(default)} is before :min (#{inspect(min)})"
end
if max && default && Time.compare(default, max) == :gt do
raise ArgumentError,
"invalid :default, #{inspect(default)} is after :max (#{inspect(max)})"
end
new(%{
type: :utc_time,
label: label,
default: default,
min: min,
max: max
})
end
defp truncate_time(nil), do: nil
defp truncate_time(time) do
time
|> Time.truncate(:second)
|> Map.replace!(:second, 0)
end
@doc """
Creates a new date input.
The input is read as a `%Date{}` struct.
## Options
* `:default` - the initial input value. Defaults to `nil`
* `:min` - the minimum date value
* `:max` - the maximum date value
"""
@spec date(String.t(), keyword()) :: t()
def date(label, opts \\ []) when is_binary(label) and is_list(opts) do
min = Keyword.get(opts, :min, nil)
max = Keyword.get(opts, :max, nil)
default = Keyword.get(opts, :default, nil)
if min && max && Date.compare(min, max) == :gt do
raise ArgumentError,
"expected a non-empty range, but :min (#{inspect(min)}) is after :max (#{inspect(max)})"
end
assert_default_value!(default, "be %Date{} or nil", &(is_struct(&1, Date) or &1 == nil))
if min && default && Date.compare(default, min) == :lt do
raise ArgumentError,
"invalid :default, #{inspect(default)} is before :min (#{inspect(min)})"
end
if max && default && Date.compare(default, max) == :gt do
raise ArgumentError,
"invalid :default, #{inspect(default)} is after :max (#{inspect(max)})"
end
new(%{
type: :date,
label: label,
default: default,
min: min,
max: max
})
end
@doc """
Creates a new color input.
The input value can be a hex color string.
## Options
* `:default` - the initial input value. Defaults to `#6583FF`
* `:debounce` - determines when input changes are emitted. When
set to `:blur`, the change propagates when the user leaves the
input. When set to a non-negative number of milliseconds, the
change propagates after the specified delay. Defaults to `:blur`
"""
@spec color(String.t(), keyword()) :: t()
def color(label, opts \\ []) when is_binary(label) and is_list(opts) do
default = Keyword.get(opts, :default, "#6583FF")
debounce = Keyword.get(opts, :debounce, :blur)
assert_default_value!(default, "be a string", &is_binary/1)
assert_debounce_value!(debounce)
new(%{type: :color, label: label, default: default, debounce: debounce})
end
@doc """
Creates a new image input.
The input value is a map, with an image file and metadata:
%{
file_ref: term(),
height: pos_integer(),
width: pos_integer(),
format: :rgb | :png | :jpeg
}
Note that the value can also be `nil`, if no image is selected.
The file path can then be accessed using `file_path/1`.
> #### Warning {: .warning}
>
> The image input is shared by default: once you upload a image,
> the image will be replicated to all users reading the notebook.
> Use `Kino.Control.form/2` if you want each user to have a distinct
> image upload with an explicit submission button.
## Options
* `:format` - the format to read the image as, either of:
* `:rgb` (default) - the binary includes raw pixel values, each
encoded as a single byte in the HWC order. Such binary can be
directly converted to an `Nx` tensor, with no additional decoding
* `:png`
* `:jpeg` (or `:jpg`)
* `:size` - the size to fit the image into, given as `{height, width}`
* `:fit` - the strategy of fitting the image into `:size`, either of:
* `:contain` (default) - resizes the image, such that it fits in
a box of `:size`, but preserving the aspect ratio. The resulting
image can be smaller or equal to `:size`
* `:match` - resizes the image to `:size`, with no respect for
aspect ratio
* `:pad` - same as `:contain`, but pads the image to match `:size`
exactly
* `:crop` - resizes the image, such that one edge fits in `:size`
and the other overflows, then center-crops the image to match
`:size` exactly
"""
@spec image(String.t(), keyword()) :: t()
def image(label, opts \\ []) do
format =
case Keyword.get(opts, :format, :rgb) do
:rgb ->
:rgb
:png ->
:png
:jpeg ->
:jpeg
:jpg ->
:jpeg
other ->
raise ArgumentError,
"expected :format to be either of :rgb, :png or :jpeg/:jpg, got: #{inspect(other)}"
end
size = Keyword.get(opts, :size, nil)
fit = Keyword.get(opts, :fit, :contain)
unless fit in [:match, :contain, :pad, :crop] do
raise ArgumentError,
"expected :fit to be either of :contain, :match, :pad or :crop, got: #{inspect(fit)}"
end
new(%{type: :image, label: label, default: nil, size: size, format: format, fit: fit})
end
@doc """
Creates a new audio input.
The input value is a map, with an audio file and metadata:
%{
file_ref: term(),
num_channels: pos_integer(),
sampling_rate: pos_integer(),
format: :pcm_f32 | :wav
}
Note that the value can also be `nil`, if no audio is selected.
The file path can then be accessed using `file_path/1`.
> #### Warning {: .warning}
>
> The audio input is shared by default: once you upload an audio,
> the audio will be replicated to all users reading the notebook.
> Use `Kino.Control.form/2` if you want each user to have a distinct
> audio upload with an explicit submission button.
## Options
* `:format` - the format to read the audio as, either of:
* `:pcm_f32` (default) - the PCM (32-bit float) format. Note that
the binary uses native system endianness. Such binary can be
directly converted to an `Nx` tensor, with no additional decoding
* `:wav`
* `:sampling_rate` - the sampling rate (samples per second) of
the audio data. Defaults to `48_000`
"""
@spec audio(String.t(), keyword()) :: t()
def audio(label, opts \\ []) do
format = Keyword.get(opts, :format, :pcm_f32)
sampling_rate = Keyword.get(opts, :sampling_rate, 48_000)
unless format in [:pcm_f32, :wav] do
raise ArgumentError,
"expected :format to be either of :pcm_f32 or :wav, got: #{inspect(format)}"
end
new(%{type: :audio, label: label, default: nil, format: format, sampling_rate: sampling_rate})
end
@doc """
Creates a new file input.
The input value is a map, with a file and metadata:
%{
file_ref: term(),
client_name: String.t()
}
Note that the value can also be `nil`, if no file is selected.
The file path can then be accessed using `file_path/1`.
> #### Warning {: .warning}
>
> The file input is shared by default: once you upload a file,
> the file will be replicated to all users reading the notebook.
> Use `Kino.Control.form/2` if you want each user to have a distinct
> file upload with an explicit submission button.
## Considerations
Note that a file may be deleted in certain cases, specifically:
* when the file is reuploaded
* when used with a form and the uploading user leaves
* when the input is removed
The deletion is not immediate and you are unlikely to run into this
in practice, however theoretically `file_path/1` may point to a
non-existing file.
## Options
* `:accept` - the list of accepted file types (either extensions
or MIME types) or `:any`. Defaults to `:any`
## Examples
To read the content of currently uploaded file we would do:
# [Cell 1]
input = Kino.Input.file("File")
# [Cell 2]
value = Kino.Input.read(input)
path = Kino.Input.file_path(value.file_ref)
File.read!(path)
And here's how we could process an asynchronous form submission:
# [Cell 1]
form = Kino.Control.form([file: Kino.Input.file("File")], submit: "Send")
# [Cell 2]
form
|> Kino.Control.stream()
|> Kino.listen(fn event ->
path = Kino.Input.file_path(event.data.file.file_ref)
content = File.read!(path)
IO.inspect(content)
end)
"""
@spec file(String.t(), keyword()) :: t()
def file(label, opts \\ []) when is_binary(label) and is_list(opts) do
accept = Keyword.get(opts, :accept, :any)
case accept do
:any ->
:ok
[_ | _] ->
:ok
other ->
raise ArgumentError, "expected :accept to be a non-empty list, got: #{inspect(other)}"
end
new(%{type: :file, label: label, default: nil, accept: accept})
end
@doc """
Synchronously reads the current input value.
## Examples
input =
Kino.Input.text("Name")
|> Kino.render()
Kino.Input.read(input)
"""
@spec read(t()) :: term()
def read(%Kino.Input{} = input) do
case Kino.Bridge.get_input_value(input.id) do
{:ok, value} ->
value
{:error, reason} ->
raise "failed to read input value, reason: #{inspect(reason)}"
end
end
@doc """
Returns file path for the given file identifier.
"""
@spec file_path(file_ref) :: String.t() when file_ref: {:file, id :: String.t()}
def file_path({:file, file_id} = file_ref) do
case Kino.Bridge.get_file_path(file_ref) do
{:ok, path} ->
path
{:error, _reason} ->
# Return a non-existing path for consistency
Path.join([System.tmp_dir!(), "nonexistent", file_id])
end
end
end
defimpl Enumerable, for: Kino.Input do
def reduce(input, acc, fun), do: Enumerable.reduce(Kino.Control.stream([input]), acc, fun)
def member?(_input, _value), do: {:error, __MODULE__}
def count(_input), do: {:error, __MODULE__}
def slice(_input), do: {:error, __MODULE__}
end