defmodule Owl.Spinner do
@moduledoc ~S"""
A spinner widget.
Simply run any long-running task using `run/2`:
Owl.Spinner.run(
fn -> Process.sleep(5_000) end,
labels: [ok: "Done", error: "Failed", processing: "Please wait..."]
)
Multiple spinners can be run simultaneously:
long_running_tasks =
Enum.map([9000, 8000, 4000, 6000], fn delay ->
fn -> Process.sleep(delay) end
end)
long_running_tasks
|> Task.async_stream(&Owl.Spinner.run/1, timeout: :infinity)
|> Stream.run()
Multiline frames are supported as well:
Owl.Spinner.run(fn -> Process.sleep(5_000) end,
frames: [
processing: [
"╔════╤╤╤╤════╗\n║ │││ \\ ║\n║ │││ O ║\n║ OOO ║",
"╔════╤╤╤╤════╗\n║ ││││ ║\n║ ││││ ║\n║ OOOO ║",
"╔════╤╤╤╤════╗\n║ / │││ ║\n║ O │││ ║\n║ OOO ║",
"╔════╤╤╤╤════╗\n║ ││││ ║\n║ ││││ ║\n║ OOOO ║"
]
]
)
### Where can I get alternative frames?
* https://github.com/blackode/elixir_cli_spinners/blob/master/lib/cli_spinners/spinners.ex
* https://www.google.com/search?q=ascii+spinners
"""
use GenServer, restart: :transient
@type id :: any()
@type label :: Owl.Data.t()
@type frame :: Owl.Data.t()
@default_processing_frames ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
@doc false
def start_link(opts) do
id = Keyword.fetch!(opts, :id)
GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {Owl.WidgetsRegistry, id}})
end
# we define child_spec just to disable doc
@doc false
def child_spec(init_arg) do
super(init_arg)
end
@doc """
Runs a spinner during execution of `process_function` and returns its result.
The spinner is started, and automatically stopped after the function returns, regardless if there was an error when executing the function.
It is a wrapper around `start/1` and `stop/1`. The only downside of `run/2` is that it is not possible to update
a label while `process_function` is executing.
If function returns `:ok` or `{:ok, value}` then spinner will be stopped with `:ok` resolution.
If function returns `:error` or `{:error, reason}` then spinner will be stopped with `:error` resolution.
## Options
* `:refresh_every` - period of changing frames. Defaults to `100`.
* `:frames` - allows to set frames for different states of spinner:
* `:processing` - list of frames which are rendered until spinner is stopped.
Defaults to `#{inspect(@default_processing_frames)}`.
* `:ok` - frame that is rendered when spinner is stopped with `:ok` resolution.
Defaults to `Owl.Data.tag("✔", :green)`.
* `:error` - frame that is rendered when spinner is stopped with `:error` resolution.
Defaults to `Owl.Data.tag("✖", :red)`.
* `:labels` - allows to set labels for different states of spinner:
* `:processing` - label that is rendered during processing. Cannot be changed during execution of `process_function`.
Defaults to `nil`.
* `:ok` - label that is rendered when spinner is stopped with `:ok` resolution. A function with arity 1 can be
passed in order to format a label based on result of `process_function`.
Defaults to `nil`.
* `:error` - label that is rendered when spinner is stopped with `:error` resolution. A function with arity 1
can be passed in order to format a label based on result of `process_function`.
Defaults to `nil`.
## Examples
Owl.Spinner.run(fn -> Process.sleep(5_000) end)
=> :ok
Owl.Spinner.run(fn -> Process.sleep(5_000) end,
frames: [
# an ASCII fish going back and forth
processing: [
">))'>",
" >))'>",
" >))'>",
" <'((<",
"<'((<"
]
]
)
=> :ok
Owl.Spinner.run(
fn ->
Process.sleep(5_000)
{:error, :oops}
end,
labels: [
error: fn reason -> "Failed: \#{inspect(reason)}" end,
processing: "Processing..."
]
)
=> {:error, :oops}
"""
@spec run(process_function :: (() -> :ok | :error | {:ok, value} | {:error, reason}),
refresh_every: non_neg_integer(),
frames: [ok: frame(), error: frame(), processing: [frame()]],
labels: [
ok: label() | (nil | value -> label() | nil) | nil,
error: label() | (nil | reason -> label()) | nil,
processing: label() | nil
]
) :: :ok | :error | {:ok, value} | {:error, reason}
when value: any, reason: any
def run(process_function, opts \\ []) do
id = make_ref()
with {:ok, _server_pid} <-
start(
opts
|> Keyword.take([:refresh_every, :live_screen_server, :frames, :labels])
|> Keyword.update(:labels, [], fn labels -> Keyword.take(labels, [:processing]) end)
|> Keyword.put(:id, id)
) do
try do
result = process_function.()
labels = Keyword.get(opts, :labels, [])
case result do
:ok ->
label = maybe_get_lazy_label(labels, :ok, nil)
stop(id: id, resolution: :ok, label: label)
{:ok, value} ->
label = maybe_get_lazy_label(labels, :ok, value)
stop(id: id, resolution: :ok, label: label)
:error ->
label = maybe_get_lazy_label(labels, :error, nil)
stop(id: id, resolution: :error, label: label)
{:error, reason} ->
label = maybe_get_lazy_label(labels, :error, reason)
stop(id: id, resolution: :error, label: label)
end
result
rescue
e ->
stop(id: id, resolution: :error)
reraise(e, __STACKTRACE__)
end
end
end
defp maybe_get_lazy_label(labels, key, value) do
case labels[key] do
callback when is_function(callback, 1) -> callback.(value)
label -> label
end
end
@doc """
Starts a new spinner.
Must be stopped manually by calling `stop/1`.
## Options
* `:id` - an id of the spinner. Required.
* `:refresh_every` - period of changing frames. Defaults to `100`.
* `:frames` - allows to set frames for different states of spinner:
* `:processing` - list of frames which are rendered until spinner is stopped.
Defaults to `#{inspect(@default_processing_frames)}`.
* `:ok` - frame that is rendered when spinner is stopped with `:ok` resolution.
Defaults to `Owl.Data.tag("✔", :green)`.
* `:error` - frame that is rendered when spinner is stopped with `:error` resolution.
Defaults to `Owl.Data.tag("✖", :red)`.
* `:labels` - allows to set labels for different states of spinner:
* `:processing` - label that is rendered during processing. Can be changed with `update_label/1`.
Defaults to `nil`.
* `:ok` - label that is rendered when spinner is stopped with `:ok` resolution.
Defaults to `nil`.
* `:error` - label that is rendered when spinner is stopped with `:error` resolution.
Defaults to `nil`.
## Example
Owl.Spinner.start(id: :my_spinner)
Process.sleep(1000)
Owl.Spinner.stop(id: :my_spinner, resolution: :ok)
"""
@spec start(
id: id(),
frames: [ok: frame(), error: frame(), processing: [frame()]],
labels: [ok: label() | nil, error: label() | nil, processing: label() | nil],
refresh_every: non_neg_integer()
) :: DynamicSupervisor.on_start_child()
def start(opts) do
DynamicSupervisor.start_child(Owl.WidgetsSupervisor, {__MODULE__, opts})
end
@doc """
Updates a label of the running spinner.
Overrides a value that is set for `:processing` state on start.
## Options
* `:id` - an id of the spinner. Required.
* `:label` - a new value of the label. Required.
## Example
Owl.Spinner.start(id: :my_spinner)
Owl.Spinner.update_label(id: :my_spinner, label: "Downloading files...")
Process.sleep(1000)
Owl.Spinner.update_label(id: :my_spinner, label: "Checking signatures...")
Process.sleep(1000)
Owl.Spinner.stop(id: :my_spinner, resolution: :ok, label: "Done")
"""
@spec update_label(id: id(), label: label()) :: :ok
def update_label(opts) do
id = Keyword.fetch!(opts, :id)
label = Keyword.fetch!(opts, :label)
GenServer.cast({:via, Registry, {Owl.WidgetsRegistry, id}}, {:update_label, label})
end
@doc """
Stops the spinner.
## Options
* `:id` - an id of the spinner. Required.
* `:resolution` - an atom `:ok` or `:error`. Determines frame and label for final rendering. Required.
* `:label` - a label for final rendering. If not set, then values that are set on spinner start will be used.
## Example
Owl.Spinner.stop(id: :my_spinner, resolution: :ok)
"""
@spec stop(id: id(), resolution: :ok | :error, label: label()) :: :ok
def stop(opts) do
id = Keyword.fetch!(opts, :id)
GenServer.call({:via, Registry, {Owl.WidgetsRegistry, id}}, {:stop, opts})
end
@impl true
def init(opts) do
frames = Keyword.get(opts, :frames, [])
processing_frames = Keyword.get(frames, :processing, @default_processing_frames)
ok_frame = Keyword.get(frames, :ok, Owl.Data.tag("✔", :green))
error_frame = Keyword.get(frames, :error, Owl.Data.tag("✖", :red))
labels = Keyword.get(opts, :labels, [])
ok_label = Keyword.get(labels, :ok)
error_label = Keyword.get(labels, :error)
processing_label = Keyword.get(labels, :processing)
refresh_every = Keyword.get(opts, :refresh_every, 100)
live_screen_server = opts[:live_screen_server] || Owl.LiveScreen
live_screen_ref = make_ref()
{current_frame, next_processing_frames} = rotate_frames(processing_frames)
Owl.LiveScreen.add_block(live_screen_server, live_screen_ref,
state: %{frame: current_frame, label: processing_label},
render: &render/1
)
Process.send_after(self(), :tick, refresh_every)
{:ok,
%{
refresh_every: refresh_every,
live_screen_ref: live_screen_ref,
live_screen_server: live_screen_server,
processing_frames: next_processing_frames,
ok_frame: ok_frame,
error_frame: error_frame,
ok_label: ok_label,
error_label: error_label,
processing_label: processing_label
}}
end
@impl true
def handle_cast({:update_label, new_value}, state) do
{:noreply, %{state | processing_label: new_value}}
end
@impl true
def handle_call({:stop, opts}, _from, state) do
{frame, label} =
case Keyword.fetch!(opts, :resolution) do
:ok -> {state.ok_frame, Keyword.get(opts, :label, state.ok_label)}
:error -> {state.error_frame, Keyword.get(opts, :label, state.error_label)}
end
Owl.LiveScreen.update(state.live_screen_server, state.live_screen_ref, %{
frame: frame,
label: label
})
Owl.LiveScreen.await_render(state.live_screen_server)
{:stop, :normal, :ok, state}
end
@impl true
def handle_info(:tick, state) do
{current_frame, next_processing_frames} = rotate_frames(state.processing_frames)
Owl.LiveScreen.update(state.live_screen_server, state.live_screen_ref, %{
frame: current_frame,
label: state.processing_label
})
Process.send_after(self(), :tick, state.refresh_every)
{:noreply, %{state | processing_frames: next_processing_frames}}
end
defp rotate_frames([head | rest]) do
{head, rest ++ [head]}
end
defp render(%{frame: frame, label: nil}), do: frame
defp render(%{frame: frame, label: label}), do: [frame, " ", label]
end