Tablet (tablet v0.3.1)

View Source

A tiny tabular data renderer

This module renders tabular data as text for output to the console or any where else. Give it data in either of the following common tabular data shapes:

# List of matching maps (atom or string keys)
data = [
  %{"id" => 1, "name" => "Puck"},
  %{"id" => 2, "name" => "Nick Bottom"}
]

# List of matching key-value lists
data = [
  [{"id", 1}, {"name", "Puck"}],
  [{"id", 2}, {"name", "Nick Bottom"}]
]

Then call Tablet.puts/2:

Tablet.puts(data)
#=> id  name
#=> 1   Puck
#=> 2   Nick Bottom

While this shows a table with minimal styling, it's possible to create fancier tables with colors, borders and more.

Here are some of Tablet's features:

  • Kino.DataTable-inspired API for ease of switching between Livebook and console output
  • Automatic column sizing
  • Multi-column wrapping for tables with many rows and few columns
  • Data eliding for long strings
  • Customizable data formatting and styling
  • Unicode support for emojis and other wide characters
  • IO.ANSI.ansidata/0 throughout
  • Small. No runtime dependencies.

While seemingly an implementation detail, Tablet's use of IO.ANSI.ansidata/0 allows a lot of flexibility in adding color and style to rendering. See IO.ANSI and the section below to learn more about this cool feature if you haven't used it before.

Example

Here's a more involved example:

iex> data = [
...>   %{planet: "Mercury", orbital_period: 88},
...>   %{planet: "Venus", orbital_period: 224.701},
...>   %{planet: "Earth", orbital_period: 365.256},
...>   %{planet: "Mars", orbital_period: 686.971}
...> ]
iex> formatter = fn
...>   :__header__, :planet -> {:ok, "Planet"}
...>   :__header__, :orbital_period -> {:ok, "Orbital Period"}
...>   :orbital_period, value -> {:ok, "#{value} days"}
...>   _, _ -> :default
...> end
iex> Tablet.render(data, keys: [:planet, :orbital_period], formatter: formatter)
...>    |> IO.ANSI.format(false)
...>    |> IO.chardata_to_string()
"Planet   Orbital Period
" <>
"Mercury  88 days       
" <>
"Venus    224.701 days  
" <>
"Earth    365.256 days  
" <>
"Mars     686.971 days  
"

Note that normally you'd call IO.ANSI.format/2 without passing false to get colorized output and also call IO.puts/2 to write to a terminal.

Data formatting and column headers

Tablet naively converts data values and constructs column headers to IO.ANSI.ansidata/0. This may not be what you want. To customize this, pass a 2-arity function using the :formatter option. That function takes the key and value as arguments and should return {:ok, ansidata}. The special key :__header__ is passed when constructing header row. Return :default to use the default conversion.

Styling

Various table output styles are supported by supplying a :style function. The following are included:

  • compact/3 - a minimal table style with underlined headers (default)
  • markdown/3 - GitHub-flavored markdown table style

Ansidata

Tablet takes advantage of IO.ANSI.ansidata/0 everywhere. This makes it easy to apply styling, colorization, and other transformations. However, it can be hard to read. It's highly recommended to either call simplify/1 to simplify the output for review or to call IO.ANSI.format/2 and then IO.puts/2 to print it.

In a nutshell, IO.ANSI.ansidata/0 lets you create lists of strings to print and intermix atoms like :red or :blue to indicate where ANSI escape sequences should be inserted if supported. Tablet actually doesn't know what any of the atoms means and passes them through. Elixir's IO.ANSI module does all of the work. If fact, if you find IO.ANSI too limited, then you could use an alternative like bunt and include atoms like :chartreuse which its formatter will understand.

Summary

Types

Column width values

Row-oriented data

Data formatter callback function

Justification for padding ansidata

An atom or string key that identifies a data column

Line rendering context

Row rendering callback function

One row of data represented as a list of column ID, data tuples

One row of data represented in a map

Style function callback

t()

Table renderer state

Functions: Utilities

Fit ansidata into the specified number of characters

Convenience function for simplifying ansidata

Calculate the size of ansidata when rendered

Types

column_width()

@type column_width() :: pos_integer() | :default | :minimum | :expand

Column width values

Column widths may be passed via the :column_widths options. The following values may also be specified:

  • :default - use the :default_column_width. This is the same as not specifying the column width
  • :minimum - make the column minimally fit the widest data element
  • :expand - expand the column so that the table is as wide as the console

When multiple keys have the :expand, they'll be allocated equal space.

data()

@type data() :: [matching_map()] | [matching_key_value_list()]

Row-oriented data

formatter()

@type formatter() :: (key(), any() -> {:ok, IO.ANSI.ansidata()} | :default)

Data formatter callback function

This function is used for conversion of tabular data to IO.ANSI.ansidata/0. The special key :__header__ is passed when formatting the column titles.

The callback should return {:ok, ansidata} or :default.

justification()

@type justification() :: :left | :right | :center

Justification for padding ansidata

key()

@type key() :: atom() | String.t()

An atom or string key that identifies a data column

line_context()

@type line_context() :: %{
  section: :header | :body | :footer,
  row: non_neg_integer(),
  slice: non_neg_integer()
}

Line rendering context

The context is a simple map with fields that Tablet adds for conveying the section and row number that it's on. Row numbers start at 0. For normally rendered tables (:wrap_across unset or set to 1), the row number corresponds to row in the input data. For multi-column tables, the row is the left-most row in the group of rows that are rendered together.

The :slice field indicates which line is being rendered within the row. For single line rows, it will be 0. For multi-line rows, it will be 0 for the first line, then 1, etc.

Note that the line rendering function can output many lines of text per one input line. This is useful for adding borders.

line_renderer()

@type line_renderer() :: (t(), line_context(), [IO.ANSI.ansidata()] ->
                      IO.ANSI.ansidata())

Row rendering callback function

Tablet makes calls to the styling function for each line in the table starting with the header, then the rows (0 to N-1), and finally the footer. The second parameter is the line_context/0 with position details.

The third parameter is a list of IO.ANSI.ansidata/0 values. When rendering multi-column tables (:wrap_across set to greater than 1), each item in the list corresponds to a set of columns. If your styling function doesn't care about multi-column tables, then call List.flatten/1 on the parameter.

The return value is always IO.ANSI.ansidata/0. It should contain a final new line since Tablet doesn't add anything. Multiple lines can be returned if borders or more room for text is needed.

When writing styling functions, it's recommended to pattern matching on the context. Most of the time, you'll just need to know whether you're in the :header section or dealing with data rows. The context contains enough information to do more complicated things like match on even or odd lines and more if needed.

matching_key_value_list()

@type matching_key_value_list() :: [{key(), any()}]

One row of data represented as a list of column ID, data tuples

matching_map()

@type matching_map() :: %{required(key()) => any()}

One row of data represented in a map

style_function()

@type style_function() :: (t() -> t())

Style function callback

Tablet calls this function after processing user options. The style function can modify anything in Tablet's state or wrap functions or do whatever it wants to adjust the output.

Options are passed via the :style_options option which is included in the parameter.

For most styles, the callback should set at least:

  • :line_renderer - a function that processes data for one line to the final output
  • :style_padding - horizontal padding map.
    • :edge - number of characters added on the left and right edges of the table
    • :cell - number of characters added between two cells
    • :multi_column - number of characters added between multi-column border cells

t()

@type t() :: %Tablet{
  column_widths: %{required(key()) => column_width()},
  data: [matching_map()],
  default_column_width: non_neg_integer() | :minimum | :expand,
  default_row_height: pos_integer() | :minimum,
  formatter: formatter(),
  keys: nil | [key()],
  line_renderer: line_renderer(),
  name: IO.ANSI.ansidata(),
  style: atom() | style_function(),
  style_options: keyword(),
  style_padding: %{
    edge: non_neg_integer(),
    cell: non_neg_integer(),
    multi_column: non_neg_integer()
  },
  total_width: non_neg_integer(),
  wrap_across: pos_integer()
}

Table renderer state

Fields:

  • :data - data rows
  • :column_widths - a map of keys to their desired column widths. See column_width/0.
  • :keys - a list of keys to include in the table for each record. The order is reflected in the rendered table. Optional
  • :default_row_height - number of rows or :minimum to set based on cell contents. Defaults to :minimum
  • :default_column_width - column width to use when unspecified in :column_widths. Defaults to :minimum
  • :formatter - a function to format the data in the table. The default is to convert everything to strings.
  • :line_renderer - a function that processes data for one line to the final output
  • :name - the name or table title. This can be any IO.ANSI.ansidata/0 value.
  • :style - one of the built-in styles or a function to style the table. The default is :compact.
  • :style_options - styling options. See style documentation for details.
  • :style_padding - horizontal padding map
    • :edge - number of characters added on the left and right edges of the table
    • :cell - number of characters added between two cells
    • :multi_column - number of characters added between multi-column border cells
  • :total_width - the width of the console for use when expanding columns. The default is 0 to autodetect.
  • :wrap_across - the number of columns to wrap across in multi-column mode. The default is 1.

Functions: Core

puts(data, options \\ [])

@spec puts(
  data(),
  keyword()
) :: :ok

Print a table to the console

Call this to quickly print tabular data to the console.

This supports all of the options from render/2.

Additional options:

  • :ansi_enabled? - force ANSI output. If unset, the terminal setting is used.

render(data, options \\ [])

@spec render(
  data(),
  keyword()
) :: IO.ANSI.ansidata()

Render a table as IO.ANSI.ansidata/0

This formats tabular data and returns it in a form that can be run through IO.ANSI.format/2 for expansion of ANSI escape codes and then written to an IO device.

Options:

  • :column_widths - a map of keys to their desired column widths. See column_width/0.
  • :data - tabular data
  • :default_column_width - default column width in characters
  • :formatter - if passing non-ansidata, supply a function to apply custom formatting
  • :keys - a list of keys to include in the table for each record. The order is reflected in the rendered table. Optional
  • :name - the name or table title. This can be any IO.ANSI.ansidata/0 value. Not used by default style.
  • :style - see t:style/0 for details on styling tables
  • :total_width - the total width of the table if any of the :column_widths is :expand. Defaults to the console width if needed.
  • :wrap_across - the number of columns to wrap across in multi-column mode

Functions: Utilities

compute_column_widths(data, options \\ [])

@spec compute_column_widths(
  data(),
  keyword()
) :: %{required(key()) => pos_integer()}

Compute column widths

This function is useful if you need to render more than one table with the same keys and want column widths to stay the same. It takes the same options as render/2. It returns a fully resolved version of the :column_widths option that can be passed to future calls to render/2 and puts/2.

fit(ansidata, arg, justification)

Fit ansidata into the specified number of characters

This function is useful for styling output to fit data into a cell.

simplify(ansidata)

@spec simplify(IO.ANSI.ansidata()) :: IO.ANSI.ansidata()

Convenience function for simplifying ansidata

This is useful when debugging or checking output for unit tests. It flattens the list, combines strings, and removes redundant ANSI codes.

visual_size(ansidata)

@spec visual_size(IO.ANSI.ansidata()) :: {non_neg_integer(), pos_integer()}

Calculate the size of ansidata when rendered

The return value is the width and height.

Examples

iex> ansidata = ["Hello, ", :red, "world", :default_color, "!"]
iex> Tablet.visual_size(ansidata)
{13, 1}