Output tabular data with IO.ANSI

1k views Asked by At

I would like to render a 2-dimensional list to a nice tabular output, using an ANSI escape sequences to control the formatting.

So given this data:

data = [
  [ 100, 20, 30 ],
  [ 20, 10, 20 ],
  [ 50, 400, 20 ]
]

I would like to output something like this:

100  20   30
20   10   20
50   400  20

Many Thanks

4

There are 4 answers

1
Patrick Oscity On BEST ANSWER

Here's a more elaborate solution that works with arbitrarily wide values:

defmodule Table do
  def format(rows, opts \\ []) do
    padding = Keyword.get(opts, :padding, 1)
    rows = stringify(rows)
    widths = rows |> transpose |> column_widths
    rows |> pad_cells(widths, padding) |> join_rows
  end

  defp pad_cells(rows, widths, padding) do
    Enum.map rows, fn row ->
      for {val, width} <- Enum.zip(row, widths) do
        String.ljust(val, width + padding)
      end
    end
  end

  def join_rows(rows) do
    rows |> Enum.map(&Enum.join/1) |> Enum.join("\n")
  end

  defp stringify(rows) do
    Enum.map rows, fn row ->
      Enum.map(row, &to_string/1)
    end
  end

  defp column_widths(columns) do
    Enum.map columns, fn column ->
      column |> Enum.map(&String.length/1) |> Enum.max
    end
  end

  # http://stackoverflow.com/questions/23705074
  defp transpose([[]|_]), do: []
  defp transpose(rows) do
    [Enum.map(rows, &hd/1) | transpose(Enum.map(rows, &tl/1))]
  end
end

Use it like this:

Table.format(data, padding: 2) |> IO.puts

This prints:

100  20   30
20   10   20
50   400  20
1
Szymon Jeż On

In the "Programming Elixir" book by Dave Thomas there is a similar to what you need exercise todo:

write the code to format the data into columns, like the sample output at the start of the chapter:

 #   | Created at           | Title                                             
-----+----------------------+-------------------------------------------------- 
2722 | 2014-08-27T04:33:39Z | Added first draft of File.mv for moving files aro 
2728 | 2014-08-29T14:28:30Z | Should elixir (and other applications) have stick
-----+----------------------+--------------------------------------------------

and there is a place where readers can post their solutions to this exercise where you can find and pick what suites you. You will need to modify your code though to make it work with your input data structure (an array of arrays, so a matrix) but that should be easy. If you have any trouble doing, this just ask.

BTW here is my solution I wrote while reading the book:

defmodule Issues.TableFormatter do

  import Enum, only: [map: 2, max: 1, zip: 2, join: 2]

  @header ~w(number created_at title)
  @header_column_separator "-+-"

  # TODO: Refactor; try to find more uses for stdlib functions
  def print_table(rows, header \\ @header) do
    table = rows |> to_table(header)
    header = header |> map(&String.Chars.to_string/1) # the headers needs now to be a string
    columns_widths = [header | table] |> columns_widths

    hr = for _ <- 1..(length(header)), do: "-"

    hr     |> print_row(columns_widths, @header_column_separator)
    header |> print_row(columns_widths)
    hr     |> print_row(columns_widths, @header_column_separator)
    table  |> map &(print_row(&1, columns_widths))
    hr     |> print_row(columns_widths, @header_column_separator)
  end

  def to_table(list_of_dicts, header) do
    list_of_dicts
    |> select_keys(header)
    |> map(fn (dict) ->
      dict
      |> Dict.values
      |> map(&String.Chars.to_string/1)
    end)
  end

  def columns_widths(table) do
    table
    |> Matrix.transpose
    |> map(fn (cell) ->
      cell
      |> map(&String.length/1)
      |> max
    end)
  end

  def select_keys(dict, keys) do
    for entry <- dict do
      {dict1, _} = Dict.split(entry, keys)
      dict1
    end
  end

  def print_row(row, column_widths, separator \\ " | ") do
    padding = separator |> String.to_char_list |> List.first # Hack
    IO.puts  row
    |> zip(column_widths)
    |> map(fn ({ cell, column_width }) ->
      String.ljust(cell, column_width, padding)
    end)
    |> join(separator)
  end
end

Treat all this as inspiration not a direct solution to your problem. This may also be much more that what your needs are, but being able quickly format some tabular data in a generic way and print them in your terminal can be very handy in the future for you.

1
Gazler On

You could achieve the spacing with the tab character if each item is less than 7 chars long:

iex> Enum.each(data, fn (x) -> Enum.join(x, "\t") |> IO.puts end)
100     20      30
20      10      20
50      400     20
:ok
0
Fred the Magic Wonder Dog On

There is a printf library for Elixir that makes this kind of thing simpler if you aren't conversant in Erlang string formating.

https://github.com/parroty/exprintf