lib/owl/progress_bar.ex

defmodule Owl.ProgressBar do
  @moduledoc ~S"""
  A live progress bar widget.

  ## Single bar

      Owl.ProgressBar.start(id: :users, label: "Creating users", total: 1000)

      Enum.each(1..100, fn _ ->
        Process.sleep(10)
        Owl.ProgressBar.inc(id: :users)
      end)

      Owl.LiveScreen.await_render()

  ## Multiple bars

      1..10
      |> Enum.map(fn index ->
        Task.async(fn ->
          range = 1..Enum.random(100..500)

          label = "Demo Progress ##{index}"

          Owl.ProgressBar.start(
            id: {:demo, index},
            label: label,
            total: range.last,
            timer: true,
            bar_width_ratio: 0.3,
            filled_symbol: "#",
            partial_symbols: []
          )

          Enum.each(range, fn _ ->
            Process.sleep(Enum.random(10..50))
            Owl.ProgressBar.inc(id: {:demo, index})
          end)
        end)
      end)
      |> Task.await_many(:infinity)

      Owl.LiveScreen.await_render()
  """
  use GenServer, restart: :transient

  @type id :: any()
  @type label :: String.t()

  @tick_interval_ms 100

  @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 """
  Starts a progress bar on `Owl.LiveScreen`.

  ## Options

  * `:id` - an id of the progress bar. Required.
  * `:label` - a label of the progress bar. Required.
  * `:total` - a total value. Required.
  * `:current` - a current value. Defaults to `0`.
  * `:bar_width_ratio` - a bar width ratio. Defaults to 0.7.
  * `:timer` - set to `true` to launch a timer and display it before the bar in format `MM:SS`. Defaults to `false`.
  * `:absolute_values` - set to `true` to show absolute values before the bar in format `current/total`. Defaults to `false`.
  * `:start_symbol` - a symbol that is rendered at the beginning of the progress bar. Defaults to `"["`.
  * `:end_symbol` - a symbol that rendered at the end of the progress bar. Defaults to `"]"`.
  * `:filled_symbol` - a symbol that use used when `current` value is big enough to fill the cell. Defaults to `"≡"`
  * `:partial_symbols` - a list of symbols that are used when `current` value is too small to render
  `filled_symbol`. Defaults to `["-", "="]`.
  * `:empty_symbol` - an empty symbol. Defaults to `" "`.
  * `:screen_width` - a width of output data. Defaults to width of the terminal or 80 symbols, if a terminal is not available.
  """
  @spec start(
          label: String.t(),
          id: id(),
          total: pos_integer(),
          timer: boolean(),
          absolute_values: boolean(),
          current: non_neg_integer(),
          bar_width_ratio: nil | float(),
          start_symbol: Owl.Data.t(),
          end_symbol: Owl.Data.t(),
          filled_symbol: Owl.Data.t(),
          partial_symbols: [Owl.Data.t()],
          empty_symbol: Owl.Data.t(),
          screen_width: pos_integer()
        ) :: DynamicSupervisor.on_start_child()
  def start(opts) do
    DynamicSupervisor.start_child(Owl.WidgetsSupervisor, {__MODULE__, opts})
  end

  @doc """
  Increases `current` value by `step`.

  When `current` value becomes equal to `total`, then progress bar terminates.

  ## Options

  * `:id` - an required identifier of the progress bar.
  * `:step` - a value by which `current` value should be increased. Defaults to 1.

  ## Examples

      Owl.ProgressBar.inc(id: "Creating users")

      Owl.ProgressBar.inc(id: "Creating users", step: 10)
  """
  @spec inc(id: id(), step: integer()) :: :ok
  def inc(opts \\ []) do
    step = opts[:step] || 1
    id = Keyword.fetch!(opts, :id)
    GenServer.cast({:via, Registry, {Owl.WidgetsRegistry, id}}, {:inc, step})
  end

  @impl true
  def init(opts) do
    total = Keyword.fetch!(opts, :total)
    label = Keyword.fetch!(opts, :label)
    timer = Keyword.get(opts, :timer, false)
    absolute_values = Keyword.get(opts, :absolute_values, false)
    filled_symbol = opts[:filled_symbol] || "≡"
    partial_symbols = opts[:partial_symbols] || ["-", "="]
    empty_symbol = opts[:empty_symbol] || " "
    start_symbol = opts[:start_symbol] || "["
    end_symbol = opts[:end_symbol] || "]"
    screen_width = opts[:screen_width]
    current = opts[:current] || 0
    bar_width_ratio = opts[:bar_width_ratio] || 0.7
    live_screen_server = opts[:live_screen_server] || Owl.LiveScreen

    start_time =
      if timer do
        Process.send_after(self(), :tick, @tick_interval_ms)
        System.monotonic_time(:millisecond)
      end

    live_screen_ref = make_ref()

    state = %{
      live_screen_ref: live_screen_ref,
      live_screen_server: live_screen_server,
      bar_width_ratio: bar_width_ratio,
      total: total,
      label: label,
      start_time: start_time,
      current: current,
      screen_width: screen_width,
      start_symbol: start_symbol,
      end_symbol: end_symbol,
      empty_symbol: empty_symbol,
      filled_symbol: filled_symbol,
      partial_symbols: partial_symbols,
      absolute_values: absolute_values
    }

    Owl.LiveScreen.add_block(live_screen_server, live_screen_ref,
      state: state,
      render: &render/1
    )

    {:ok, state}
  end

  @impl true
  def handle_cast({:inc, step}, state) do
    state = %{state | current: state.current + step}
    Owl.LiveScreen.update(state.live_screen_server, state.live_screen_ref, state)

    if state.current >= state.total do
      {:stop, :normal, state}
    else
      {:noreply, state}
    end
  end

  @impl true
  def handle_info(:tick, state) do
    if state.current < state.total do
      Process.send_after(self(), :tick, @tick_interval_ms)
      Owl.LiveScreen.update(state.live_screen_server, state.live_screen_ref, state)
    end

    {:noreply, state}
  end

  defp format_time(milliseconds) do
    ss =
      (rem(milliseconds, 60_000) / 1000)
      |> Float.round(1)
      |> to_string()
      |> String.pad_leading(4, "0")

    mm =
      milliseconds
      |> div(60_000)
      |> to_string()
      |> String.pad_leading(2, "0")

    "#{mm}:#{ss}"
  end

  @doc """
  Renders a progress bar that can be consumed by `Owl.IO.puts/2`.

  Used as a callback for blocks in `Owl.LiveScreen`.

  ## Examples

      iex> Owl.ProgressBar.render(%{
      ...>   label: "Demo",
      ...>   total: 200,
      ...>   current: 60,
      ...>   bar_width_ratio: 0.7,
      ...>   start_symbol: "[",
      ...>   end_symbol: "]",
      ...>   filled_symbol: "#",
      ...>   partial_symbols: [],
      ...>   empty_symbol: ".",
      ...>   screen_width: 40
      ...> }) |> to_string()
      "Demo [########....................]  30%"

      iex> Owl.ProgressBar.render(%{
      ...>   label: "Demo",
      ...>   total: 200,
      ...>   current: 8,
      ...>   bar_width_ratio: 0.2,
      ...>   start_symbol: "|",
      ...>   absolute_values: true,
      ...>   end_symbol: "|",
      ...>   filled_symbol: "█",
      ...>   partial_symbols: ["▏", "▎", "▍", "▌", "▋", "▊", "▉"],
      ...>   empty_symbol: " ",
      ...>   screen_width: 40,
      ...>   start_time: -576460748012758993,
      ...>   current_time: -576460748012729828
      ...> }) |> to_string()
      "Demo       8/200 00:29.2 |▍       |   4%"

      iex> Owl.ProgressBar.render(%{
      ...>   label: "Demo",
      ...>   total: 200,
      ...>   current: 8,
      ...>   bar_width_ratio: 0.7,
      ...>   start_symbol: "[",
      ...>   end_symbol: "]",
      ...>   filled_symbol: Owl.Data.tag("≡", :cyan),
      ...>   partial_symbols: [Owl.Data.tag("-", :green), Owl.Data.tag("=", :blue)],
      ...>   empty_symbol: " ",
      ...>   screen_width: 40
      ...> })|> Owl.Data.to_ansidata() |> to_string
      "Demo [\e[36m≡\e[39m\e[32m-\e[39m                          ]   4%\e[0m"

  """
  @spec render(%{
          optional(:current_time) => nil | integer(),
          optional(:start_time) => nil | integer(),
          optional(:screen_width) => nil | pos_integer(),
          optional(:absolute_values) => nil | boolean(),
          bar_width_ratio: float(),
          label: String.t(),
          total: pos_integer(),
          current: non_neg_integer(),
          start_symbol: Owl.Data.t(),
          end_symbol: Owl.Data.t(),
          filled_symbol: Owl.Data.t(),
          partial_symbols: [Owl.Data.t()],
          empty_symbol: Owl.Data.t()
        }) :: Owl.Data.t()
  def render(
        %{
          label: label,
          total: total,
          current: current,
          bar_width_ratio: bar_width_ratio,
          start_symbol: start_symbol,
          end_symbol: end_symbol,
          filled_symbol: filled_symbol,
          partial_symbols: partial_symbols,
          empty_symbol: empty_symbol
        } = params
      ) do
    screen_width = params[:screen_width] || Owl.IO.columns() || 80
    percentage_width = 5
    start_end_symbols_width = 2
    percentage = String.pad_leading("#{trunc(current / total * 100)}%", percentage_width)

    {elapsed_time_formatted, elapsed_time_formatted_width} =
      case params[:start_time] do
        nil ->
          {[], 0}

        start_time ->
          current_time = params[:current_time] || System.monotonic_time(:millisecond)
          elapsed_time = current_time - start_time
          # format_time width + 1 space = 8
          {[format_time(elapsed_time), " "], 8}
      end

    {absolute_values_formatted, absolute_values_formatted_width} =
      if params[:absolute_values] do
        total_string = to_string(total)
        total_width = String.length(total_string)

        {
          [String.pad_leading(to_string(current), total_width), "/", total_string, " "],
          # 2 = String.length("/" + " ")
          total_width * 2 + 2
        }
      else
        {[], 0}
      end

    infix = [absolute_values_formatted, elapsed_time_formatted]
    infix_width = elapsed_time_formatted_width + absolute_values_formatted_width

    bar_width = trunc(screen_width * bar_width_ratio)

    label_width =
      screen_width - bar_width - percentage_width - start_end_symbols_width - infix_width

    # Float.ceil(x, 2) is needed to handle numbers like 56.99999999999999
    progress = min(Float.ceil(current / (total / bar_width), 2), bar_width * 1.0)
    filled_blocks_integer = floor(progress)

    next_block =
      case partial_symbols do
        [] ->
          nil

        partial_symbols ->
          next_block_filling = Float.floor(progress - filled_blocks_integer, 2)

          if next_block_filling != 0 do
            idx = ceil(next_block_filling * length(partial_symbols)) - 1
            Enum.at(partial_symbols, idx)
          end
      end

    [
      String.pad_trailing(label, label_width),
      infix,
      start_symbol,
      List.duplicate(filled_symbol, filled_blocks_integer),
      case next_block do
        nil ->
          List.duplicate(empty_symbol, bar_width - filled_blocks_integer)

        next_block ->
          [next_block, List.duplicate(empty_symbol, bar_width - filled_blocks_integer - 1)]
      end,
      end_symbol,
      percentage
    ]
  end
end