defmodule TermTable do def render(rows, options \\ :empty) do IO.puts build(rows, options) end def build(data, _options \\ :empty) do max_lengths_by_index = max_lengths_by_index(data) { tbody, last_row_lengths, _ } = do_build(data, max_lengths_by_index) tbottom = do_separator(last_row_lengths) tbody <> tbottom <> "\n" end defp do_build(rows, max_lengths_by_index) do rows |> Enum.with_index |> Enum.reduce({"", [], false}, fn({cols, row_idx}, acc) -> { previous_row_output, previous_row_cell_lengths, sep_preceding } = acc if cols == [:separator] do { previous_row_output, previous_row_cell_lengths, true } else { row_output, row_cell_lengths, _last_index } = cols |> Enum.reduce({ "|", [], -1}, fn(i, acc) -> { output, cell_lengths, last_index } = acc { i, opts } = if is_tuple(i), do: i, else: { i, [] } colspan = Keyword.get(opts, :colspan, 1) align = Keyword.get(opts, :align, :left) index = last_index + 1 cell = adjust(max_lengths_by_index, i, index, align, colspan) cell_length = String.length(cell) new_cell_lengths = cell_lengths ++ [ cell_length ] output = output <> cell <> "|" last_index = index + colspan - 1 { output, new_cell_lengths, last_index } end) if sep_preceding || row_idx == 0 do separator = separator(previous_row_cell_lengths, row_cell_lengths) { previous_row_output <> separator <> "\n" <> row_output <> "\n", row_cell_lengths, false } else { previous_row_output <> row_output <> "\n", row_cell_lengths, false } end end end) end defp separator(prev_lengths, curr_lengths) do { a, b } = { length(prev_lengths), length(curr_lengths) } lengths = if a > b, do: prev_lengths, else: curr_lengths do_separator(lengths) end defp do_separator(lengths) do fun = fn(x, acc) -> acc <> String.ljust("", x, ?-) <> "+" end lengths |> Enum.reduce("+", fun) end defp adjust(max_lengths_by_index, content, col_idx, align, colspan) do range = col_idx..(col_idx + colspan - 1) required_length = Enum.reduce range, 0, fn(idx, acc) -> acc + max_lengths_by_index[idx] end # correction for spaces between cols required_length = required_length + (colspan - 1) * 3 content = to_string(content) aligned = case align do :left -> String.ljust(content, required_length) :right -> String.rjust(content, required_length) :center -> right_adjust = div(required_length + String.length(content), 2) right_adjusted = String.rjust(content, right_adjust) String.ljust(right_adjusted, required_length) end " " <> aligned <> " " end defp max_lengths_by_index(data) do fun = fn(el, acc) -> { lengths_by_index, _last_index } = lengths_by_index(el) Enum.reduce lengths_by_index, acc, fn({k, v}, acc) -> if acc[k] == nil || lengths_by_index[k] > acc[k] do Map.put(acc, k, v) else acc end end end data |> Enum.reduce(%{}, fun) end defp lengths_by_index(cols) do cols |> Enum.reduce({%{}, -1}, &length_for_element/2) end defp length_for_element({i, opts}, acc) do { acc, last_index } = acc width = Keyword.get(opts, :width) colspan = Keyword.get(opts, :colspan) unless width do width = String.length(to_string(i)) end index = last_index + 1 last_index = if colspan, do: index + colspan - 1, else: index acc = Map.put acc, index, width { acc, last_index } end defp length_for_element(i, acc) do { acc, prev_index } = acc index = prev_index + 1 acc = Map.put acc, index, String.length(to_string(i)) { acc, index } end end