defmodule IO.ANSI.Table.Spec do @moduledoc """ A table spec struct and functions for the IO ANSI Table app. The table spec struct contains the following fields: * initial fields: - `spec_name`, - `headers`, - `align_specs`, - `bell`, - `count`, - `header_fixes`, - `margins`, - `max_width`, - `sort_specs`, - `sort_symbols`, - `style` * derived fields: - `align_attrs`, - `headings`, - `left_margin`, - `sort_attrs` * data dependent fields: - `column_widths` - `rows` """ use PersistConfig alias __MODULE__ alias IO.ANSI.Plus, as: ANSI alias IO.ANSI.Table.{Column, Header, LineType, Row, Style} @default_bell get_env(:default_bell) @default_count get_env(:default_count) @default_margins get_env(:default_margins) @default_max_width get_env(:default_max_width) @default_sort_symbols get_env(:default_sort_symbols) @default_style get_env(:default_style) @enforce_keys [ # initial fields :spec_name, :headers, :align_specs, :bell, :count, :header_fixes, :margins, :max_width, :sort_specs, :sort_symbols, :style ] defstruct [ # initial fields :spec_name, :headers, :align_specs, :bell, :count, :header_fixes, :margins, :max_width, :sort_specs, :sort_symbols, :style, # derived fields align_attrs: [], headings: [], left_margin: "", sort_attrs: [], # data dependent fields column_widths: [], rows: [] ] @typedoc "A table spec struct for the IO ANSI Table app" @type t :: %Spec{ # initial fields spec_name: String.t(), headers: [Header.t(), ...], align_specs: [Header.align_spec()], bell: boolean, count: integer, header_fixes: %{optional(String.t() | Regex.t()) => String.t()}, margins: Keyword.t(), max_width: pos_integer, sort_specs: [Header.sort_spec()], sort_symbols: [Header.sort_symbol()], style: Style.t(), # derived fields align_attrs: [Header.align_attr()], headings: [String.t()], left_margin: String.t(), sort_attrs: [Header.sort_attr()], # data dependent fields column_widths: [Column.width()], rows: [Row.t()] } @doc """ Creates a table spec struct from `headers` and `options`. """ @spec new([Header.t(), ...], Keyword.t()) :: t def new([_ | _] = headers, options \\ []) when is_list(options) do %Spec{ spec_name: spec_name(options), headers: headers, align_specs: Keyword.get(options, :align_specs, []), bell: Keyword.get(options, :bell, @default_bell), count: Keyword.get(options, :count, @default_count), header_fixes: Keyword.get(options, :header_fixes, %{}), margins: Keyword.merge(@default_margins, options[:margins] || []), max_width: Keyword.get(options, :max_width, @default_max_width), sort_specs: Keyword.get(options, :sort_specs, []), sort_symbols: Keyword.merge(@default_sort_symbols, options[:sort_symbols] || []), style: Keyword.get(options, :style, @default_style) } end @doc """ Updates the derived fields of `spec`. """ @spec develop(t) :: t def develop(%Spec{} = spec) do spec |> Spec.AlignAttrs.derive_and_put() |> Spec.Headings.derive_and_put() |> Spec.LeftMargin.derive_and_put() |> Spec.SortAttrs.derive_and_put() end @doc """ Gets the table spec server name in `options`. Defaults to the current app name expressed as a string. ## Examples iex> alias IO.ANSI.Table.Spec iex> Spec.spec_name(style: :light, count: 9, spec_name: "github_issues") "github_issues" iex> alias IO.ANSI.Table.Spec iex> Spec.spec_name([]) "io_ansi_table" """ @spec spec_name(Keyword.t()) :: String.t() def spec_name(options) when is_list(options) do case options[:spec_name] do nil -> case :application.get_application() do {:ok, app} -> app # Thanks to `use PersistConfig`... :undefined -> @app end |> to_string() spec_name when is_binary(spec_name) -> spec_name end end @doc """ Writes data from `maps` to `:stdio` per table `spec` and `options`. """ @spec write_table(t, [Access.container()], Keyword.t()) :: :ok def write_table(%Spec{} = spec, maps, options \\ []) when is_list(maps) and is_list(options) do spec |> put(:bell, options[:bell]) |> put(:count, options[:count]) |> put(:max_width, options[:max_width]) |> put(:style, options[:style]) |> Spec.Rows.derive_and_put(maps) |> Spec.ColumnWidths.derive_and_put() |> write_table() end ## Private functions @spec put(t, atom, Keyword.value()) :: t defp put(spec, _key, nil), do: spec defp put(spec, key, value), do: Map.put(spec, key, value) @spec top_margin(t) :: String.t() defp top_margin(%Spec{margins: margins} = _spec) do case margins[:top] do # Move the cursor up N lines: \e[A... n when n <= -1 -> ANSI.cursor_up(-n) n -> String.duplicate("\n", n) end end @spec bottom_margin(t) :: String.t() defp bottom_margin(%Spec{margins: margins} = _spec) do case margins[:bottom] do n when n >= 0 -> String.duplicate("\n", n) _ -> "" end end @spec write_table(t) :: :ok defp write_table(spec) do top_margin(spec) |> IO.write() Style.line_types(spec.style) |> Enum.each(&LineType.write_lines(&1, spec)) bottom_margin(spec) |> IO.write() IO.write(if spec.bell, do: "\a", else: "") end end