lib/boxen.ex

defmodule Boxen do
  @moduledoc """
  Port of [boxen](https://github.com/sindresorhus/boxen) library for elixir.

  ## Usage

  ```elixir
  # Most simple usage
  Boxen.boxify("Hello, world")
  # => {:ok, string} | {:error, string}

  # with title
  Boxen.boxify("Hello, world", title: "Message")

  # with option
  Boxen.boxify("Hello, world")
  ```

  ### Helper

  The `Boxen.boxify` function returns a tuple `{:ok, string}` or `{:error, string}`. To actually the box, you have to print the string(`IO.puts`).

  This library has a helper function `Boxen.Helpers.print_output/1` that prints the boxified text.

  ```elixir
  Boxen.boxify("Hello, world") |> Boxen.Helpers.print_output
  # => Boxified output | {:error, string}
  ```

  ## Options

  As a keyword list.

  ### title

  Adds title for the box. If width is not provided & title's width is greater than text's width, then box width will be equal to title's width.

  Type: `string` | `nil`. Default: `nil`

  Example:

  ```elixir
  Boxen.boxify("hello world", title: "Something")
  ```

  ### padding

  Adds padding inside the box.

  Type: `map` | `integer`. Default: 0

  For map, the map should contain `top`, `bottom`, `left` and `right` value as atom.

  Example

  ```elixir
  # Integer
  Boxen.boxify("hello world", padding: 1)

  # Map
  Boxen.boxify("hello world", padding: %{top: 1, bottom: 1, left: 2, right: 2})
  ```

  It is not necessary to pass all the options inside the map. You can just pass `%{top: 1, bottom: 1}`, and the rest will have the default value of 0.

  ### margin

  Adds margin outside the box. Default: 0

  Type: `map` | `integer`

  For map, the map should contain `top`, `bottom`, `left` and `right` value as atom.

  Example:

  ```elixir
  # Integer
  Boxen.boxify("hello world", margin: 1)

  # Map
  Boxen.boxify("hello world", margin: %{top: 1, bottom: 1, left: 2, right: 2})
  ```

  It is not necessary to pass all the options inside the map. You can just pass `%{top: 1, bottom: 1}`, and the rest will have the default value of 0.

  ### text_alignment

  Alignment of text inside the box.

  Type: `atom`

  Value: `:left`(default) | `:right` | `:center`

  Example:

  ```elixir
  Boxen.boxify("hello \nworld \nelixir is awesome", text_alignment: :center)
  ```

  ### title_alignment

  Alignment of title on the top of the box.

  Type: `atom`

  Value: `:left`(default) | `:right` | `:center`

  Example:

  ```elixir
  Boxen.boxify("hello world", title: "Message", title_alignment: :center)
  ```

  ### box

  The box type to show the text content in. Default is `:single`.

  Type: `atom` | `map`

  Value(taken directly from Boxen library):

  - `:fallback`: Just blank space
  - `:single`

  ```
  ┌───┐
  │foo│
  └───┘
  ```

  - `:double`

  ```
  ╔═══╗
  ║foo║
  ╚═══╝
  ```

  - `:round`

  ```
  ╭───╮
  │foo│
  ╰───╯
  ```

  - `:bold`

  ```
  ┏━━━┓
  ┃foo┃
  ┗━━━┛
  ```

  - `:single_double`

  ```
  ╓───╖
  ║foo║
  ╙───╜
  ```

  - `:double_single`

  ```
  ╒═══╕
  │foo│
  ╘═══╛
  ```

  - `:classic`

  ```
  +---+
  |foo|
  +---+
  ```

  - `:arrow`

  ```
  ↘↓↓↓↙
  →foo←
  ↗↑↑↑↖
  ```

  Example:

  ```elixir
  Boxen.boxify("Hello world", box: :double)
  ```

  You can also add your own box through this option. Pass a map containing the following data:

  ```
  top_left,
  top,
  top_right,
  right,
  bottom_right,
  bottom,
  bottom_left,
  left
  ```

  It is not necessary to pass all the options inside the map. You can just pass `%{top: 1, bottom: 1}`, and the rest will have the default to blank spaces.

  Example:

  ```elixir
  Boxen.boxify("Hello world", box: %{top_left: "->", ....})
  ```

  ### width

  Set width for the box.

  Type: `integer`

  Example:

  ```elixir
  Boxen.boxify("hello world", width: 4)
  ```

  If width is less than given text, then the text is hard wrapped to the given width.

  ### border_color

  Sets the color of the border.

  Type: `string`

  The value should be an ANSI escape sequence for color.

  Example(using [IO.ANSI](https://hexdocs.pm/elixir/IO.ANSI.html) module to generate color):

  ```elixir
  red = IO.ANSI.red() #=> "\e[31m"
  Boxen.boxify("hello world", border_color: red)
  ```

  ### text_color

  Sets the color for the whole text.

  Type: `string`

  The value should be an ANSI escape sequence for color.

  Example(using [IO.ANSI](https://hexdocs.pm/elixir/IO.ANSI.html) module to generate color):

  ```elixir
  blue = IO.ANSI.blue() #=> "\e[34m"
  Boxen.boxify("hello world", text_color: blue)
  ```

  ## Custom coloring

  If you want more granular control over color, you can provide your own text with ANSI escape sequence embedded in it. Same applies for title.

  Example:

  ```elixir
  text = IO.ANSI.format([:blue, "hello, ", :cyan, "elixir"]) |> IO.chardata_to_string #=> "\e[34mhello, \e[36melixir\e[0m"

  Boxen.boxify(text)
  ```

  ## Acknowledgments

  Thanks to [Sindre Sorhus](https://github.com/sindresorhus) and the contributors of [boxen](https://github.com/sindresorhus/boxen) library. This library is inspired by boxen, and as such, almost all functions are re-written in elixir(with some minor changes here and there).

  """

  alias Boxen.{Boxes, Helpers, WrapText, Helpers.Validate}

  @padding " "
  @borders_width 2
  @newline "\n"

  @type opts() :: [
          box: atom() | map(),
          title: String.t(),
          title_alignment: :left | :right | :center,
          text_alignment: :left | :right | :center,
          padding: integer(),
          margin: integer(),
          width: non_neg_integer(),
          border_color: String.t(),
          text_color: String.t()
        ]

  @doc ~S"""
   Function to boxify a given text.

   Options:
   - `:box`: Type of box
   - `:title`: Title
   - `:title_alignment`: Alignment of title. Possible values: `:left` | `:right` | `:center`
   - `:text_alignment`: Alignment of text inside the box. Possible values: `:left` | `:right` | `:center`
   - `:padding`: Padding inside the box.
   - `:margin`: Margin outside the box.
   - `:width`: Width of the box.
   - `:border_color`: Color of borders.
   - `:text_color`: Text color.
  """
  @spec boxify(input_text :: String.t(), opts :: opts()) ::
          {:ok, String.t()} | {:error, String.t()}
  def boxify(input_text, opts \\ []) do
    # TODO: think about replacing if else blocks with pattern matched functions

    box = Keyword.get(opts, :box, :single)
    title = Keyword.get(opts, :title, nil)
    title_alignment = Keyword.get(opts, :title_alignment, :left)
    text_alignment = Keyword.get(opts, :text_alignment, :left)
    padding = Keyword.get(opts, :padding, 0)
    margin = Keyword.get(opts, :margin, 0)
    width = Keyword.get(opts, :width, nil)
    border_color = Keyword.get(opts, :border_color, nil)
    text_color = Keyword.get(opts, :text_color, nil)

    with {:ok, input_text} <- Validate.input_text(input_text),
         {:ok, box} <- Validate.box(box),
         {:ok, title} <- Validate.title(title),
         {:ok, title_alignment} <- Validate.title_alignment(title_alignment),
         {:ok, text_alignment} <- Validate.text_alignment(text_alignment),
         {:ok, padding} <- Validate.padding(padding),
         {:ok, margin} <- Validate.margin(margin),
         {:ok, width} <- Validate.width(width),
         {:ok, border_color} <-
           Validate.border_color(border_color),
         {:ok, text_color} <- Validate.text_color(text_color) do
      padding = set_map_value(padding)
      margin = set_map_value(margin)

      {width, margin, title} =
        determine_dimension(input_text,
          padding: padding,
          margin: margin,
          width: width,
          title: title
        )

      padding = prevent_padding_overflow(width, padding)

      input_text =
        input_text
        |> apply_text_color(text_color)
        |> make_text(width, padding, text_alignment)

      box = set_box(box) |> set_border_color(border_color)

      {:ok,
       box_content(box, input_text,
         width: width,
         title: title,
         title_alignment: title_alignment,
         margin: margin,
         border_color: border_color
       )}
    else
      error -> error
    end
  end

  defp determine_dimension(text, opts) do
    padding = Keyword.get(opts, :padding)
    margin = Keyword.get(opts, :margin)
    width = Keyword.get(opts, :width)
    title = Keyword.get(opts, :title)
    columns = Helpers.get_terminal_columns()

    width_override? = width != nil
    max_width = columns - margin.left - margin.right - @borders_width

    widest =
      Helpers.widest_line(WrapText.wrap(Helpers.strip_ansi(text), columns - @borders_width)) +
        padding.left +
        padding.right

    title =
      if title do
        if width_override? do
          title = String.slice(title, 0, max(0, width - 2))
          if String.length(title) != 0, do: " #{title} ", else: title
        else
          title = String.slice(title, 0, max(0, max_width - 2))
          if String.length(title) != 0, do: " #{title} ", else: title
        end
      else
        title
      end

    title_width = if title, do: Helpers.text_representation_length(title), else: 0
    width = if !width_override? && title_width > widest, do: title_width, else: width
    width = if width, do: width, else: widest

    margin_change =
      if !width_override? && (margin.left != 0 && margin.right != 0) && width > max_width do
        space_for_margins = columns - width - @borders_width
        multiplier = space_for_margins / margin.left + margin.right
        margin_left = max(0, floor(margin.left * multiplier))
        margin_right = max(0, floor(margin.right * multiplier))
        Map.merge(margin, %{left: margin_left, right: margin_right})
      else
        margin
      end

    width =
      if !width_override? do
        min(width, columns - @borders_width - margin_change.left - margin_change.right)
      else
        width
      end

    {width, margin_change, title}
  end

  defp make_title(title, placeholder, alignment, border_color) do
    title_width = Helpers.text_representation_length(title)
    # removes ANSI color codes from placeholder
    placeholder = Helpers.strip_ansi(placeholder)
    placeholder_width = String.length(placeholder)
    placeholder = String.slice(placeholder, title_width, placeholder_width)
    reset = if border_color, do: IO.ANSI.reset(), else: ""
    border_color = if border_color, do: border_color, else: ""

    case alignment do
      :left ->
        title <> border_color <> placeholder <> reset

      :right ->
        border_color <> placeholder <> reset <> title

      _ ->
        placeholder_width = String.length(placeholder)

        if rem(placeholder_width, 2) == 1 do
          placeholder =
            String.slice(
              placeholder,
              Integer.floor_div(placeholder_width, 2),
              placeholder_width
            )

          left_placeholder =
            border_color <> String.slice(placeholder, 1, placeholder_width) <> reset

          right_placeholder = border_color <> placeholder <> reset

          left_placeholder <> title <> right_placeholder
        else
          placeholder =
            String.slice(
              placeholder,
              div(placeholder_width, 2),
              placeholder_width
            )

          placeholder = border_color <> placeholder <> reset

          placeholder <> title <> placeholder
        end
    end
  end

  defp make_text(text, width, padding, text_alignment) do
    text = Helpers.ansi_align_text(text, text_alignment)
    lines = String.split(text, @newline)
    text_width = Helpers.widest_line(text)
    max = width - padding.left - padding.right

    lines =
      if text_width > max do
        Enum.reduce(lines, [], fn line, acc ->
          aligned_lines =
            line
            |> WrapText.wrap(max)
            |> Helpers.ansi_align_text(text_alignment)
            |> String.split("\n")

          longest_length =
            Enum.reduce(aligned_lines, 0, fn text, width ->
              max(width, Helpers.text_representation_length(text))
            end)

          lines =
            Enum.map(aligned_lines, fn line ->
              case text_alignment do
                :right -> String.duplicate(@padding, max - longest_length) <> line
                :center -> String.duplicate(@padding, div(max - longest_length, 2)) <> line
                _ -> line
              end
            end)

          acc ++ lines
        end)
      else
        lines
      end

    lines =
      if text_width < max do
        case text_alignment do
          :right ->
            Enum.map(lines, fn line ->
              String.duplicate(@padding, max - text_width) <> line
            end)

          :center ->
            Enum.map(lines, fn line ->
              String.duplicate(@padding, div(max - text_width, 2)) <> line
            end)

          _ ->
            lines
        end
      else
        lines
      end

    padding_left = String.duplicate(@padding, padding.left)
    padding_right = String.duplicate(@padding, padding.right)

    lines =
      lines
      |> Enum.map(fn line -> padding_left <> line <> padding_right end)
      |> Enum.map(fn line ->
        # never go less than 0
        remaining_width = max(width - Helpers.text_representation_length(line), 0)
        line <> String.duplicate(@padding, remaining_width)
      end)

    # Add top padding
    lines =
      if padding.top > 0 do
        padded_lines =
          Enum.map(1..padding.top, fn _ ->
            String.duplicate(@padding, width)
          end)

        padded_lines ++ lines
      else
        lines
      end

    # Add bottom padding
    lines =
      if padding.bottom > 0 do
        padded_lines =
          Enum.map(1..padding.bottom, fn _ ->
            String.duplicate(@padding, width)
          end)

        lines ++ padded_lines
      else
        lines
      end

    Enum.join(lines, @newline)
  end

  defp prevent_padding_overflow(width, padding) do
    remaining_width = width - padding.left - padding.right

    if remaining_width <= 0 do
      Map.merge(padding, %{left: 0, right: 0})
    else
      padding
    end
  end

  defp box_content(box, text, opts) do
    content_width = Keyword.get(opts, :width)
    margin = Keyword.get(opts, :margin)
    title = Keyword.get(opts, :title)
    title_alignment = Keyword.get(opts, :title_alignment)
    border_color = Keyword.get(opts, :border_color)
    columns = Helpers.get_terminal_columns()

    margin_top = String.duplicate(@newline, margin.top)
    margin_bottom = String.duplicate(@newline, margin.bottom)
    margin_left = String.duplicate(@padding, margin.left)

    title =
      if title do
        placeholder = String.duplicate(box.top, content_width)
        make_title(title, placeholder, title_alignment, border_color)
      else
        String.duplicate(box.top, content_width)
      end

    top = margin_top <> margin_left <> box.top_left <> title <> box.top_right

    bottom =
      margin_left <>
        box.bottom_left <>
        String.duplicate(box.bottom, content_width) <> box.bottom_right <> margin_bottom

    line_separator =
      if content_width + @borders_width + margin.left >= columns do
        ""
      else
        "\n"
      end

    middle =
      text
      |> String.split(@newline)
      |> Enum.map(fn line -> margin_left <> box.left <> line <> box.right end)
      |> Enum.join(line_separator)

    Enum.join([top, middle, bottom], line_separator)
  end

  defp apply_border_color(box, color) do
    %Boxen.Box{
      top_left: color <> box.top_left <> IO.ANSI.reset(),
      top: color <> box.top <> IO.ANSI.reset(),
      top_right: color <> box.top_right <> IO.ANSI.reset(),
      right: color <> box.right <> IO.ANSI.reset(),
      bottom_right: color <> box.bottom_right <> IO.ANSI.reset(),
      bottom: color <> box.bottom <> IO.ANSI.reset(),
      bottom_left: color <> box.bottom_left <> IO.ANSI.reset(),
      left: color <> box.left <> IO.ANSI.reset()
    }
  end

  defp apply_text_color(text, color) do
    if color do
      # text_color overrides colors already present in text
      text = Helpers.strip_ansi(text)
      color <> text <> IO.ANSI.reset()
    else
      text
    end
  end

  defp set_map_value(val) when is_number(val) do
    %{top: val, right: val * 3, bottom: val, left: val * 3}
  end

  defp set_map_value(val) when is_map(val) do
    default_val = %{top: 0, right: 0, bottom: 0, left: 0}
    Map.merge(default_val, val)
  end

  defp set_box(box) when is_map(box), do: Boxes.setup_box(box)
  defp set_box(box), do: Boxes.get_box(box)

  defp set_border_color(box, color) when is_binary(color), do: apply_border_color(box, color)
  defp set_border_color(box, _), do: box
end