Current section
Files
Jump to
Current section
Files
src/tobble.gleam
//// Tobble is a table library for Gleam, which makes it as easy as
//// possible to render tables from simple output. It does provide some
//// customization options, but they are not very expansive, as Tobble does not
//// aim to be a full layout library. Rather, it aims to make it simple to make
//// beautiful output for your programs.
////
//// ```gleam
//// import gleam/io
//// import tobble
////
//// pub fn main() {
//// let assert Ok(table) =
//// tobble.builder()
//// |> tobble.add_row(["", "Output"])
//// |> tobble.add_row(["Stage 1", "Wibble"])
//// |> tobble.add_row(["Stage 2", "Wobble"])
//// |> tobble.add_row(["Stage 3", "WibbleWobble"])
//// |> tobble.build()
////
//// io.println(tobble.render(table))
//// }
//// ```
////
//// ```text
//// +---------+--------------+
//// | | Output |
//// +---------+--------------+
//// | Stage 1 | Wibble |
//// | Stage 2 | Wobble |
//// | Stage 3 | WibbleWobble |
//// +---------+--------------+
//// ```
import gleam/int
import gleam/list
import gleam/order
import gleam/result
import gleam/string
import gleam/string_tree
import gleam/yielder
import string_width
import tobble/internal/builder.{type BuilderError as InternalBuilderError}
import tobble/internal/rows
/// `Table` is the central type of Tobble. It holds the data you wish to display,
/// without regard for how you render it. These can be built using `builder`/`build`.
pub opaque type Table {
Table(rows: rows.Rows(String))
}
/// `Builder` is a type used to help you build tables. See `builder()` for more details.
pub opaque type Builder {
Builder(inner: builder.Builder)
}
pub type RenderOption {
/// Render the table with a width of, at most, the given width. Note that this
/// is best effort, and there are pathological cases where Tobble will decide
/// to render your tables slightly wider than requested (e.g. requesting a
/// width too small to even fit the table borders). By default, the table
/// width is unconstrained.
TableWidthRenderOption(width: Int)
/// Render the table where each column's text has the given width. If a width
/// less than 1 is given, this will default to 1. By default, columns are as
/// wide as the longest row within them.
ColumnWidthRenderOption(width: Int)
/// Render the table with a different style of border
/// By default, `ASCIILineType` is used.
LineTypeRenderOption(line_type: RenderLineType)
}
pub type RenderLineType {
/// Renders the table with box drawing characters for the borders.
///
/// <pre><code style="font-family: monospace;" class="language-plaintext">┌─────────┬──────────────┐
/// │ │ Output │
/// ├─────────┼──────────────┤
/// │ Stage 1 │ Wibble │
/// │ Stage 2 │ Wobble │
/// │ Stage 3 │ WibbleWobble │
/// └─────────┴──────────────┘</code></pre>
BoxDrawingCharsLineType
/// Renders the table with box drawing characters for the borders, but
/// with rounded corners.
///
/// <pre><code style="font-family: monospace;" class="language-plaintext">╭─────────┬──────────────╮
/// │ │ Output │
/// ├─────────┼──────────────┤
/// │ Stage 1 │ Wibble │
/// │ Stage 2 │ Wobble │
/// │ Stage 3 │ WibbleWobble │
/// ╰─────────┴──────────────╯</code></pre>
BoxDrawingCharsWithRoundedCornersLineType
/// Render the table with ASCII characters for the borders.
/// This is the default setting.
///
/// ```text
/// +---------+--------------+
/// | | Output |
/// +---------+--------------+
/// | Stage 1 | Wibble |
/// | Stage 2 | Wobble |
/// | Stage 3 | WibbleWobble |
/// +---------+--------------+
/// ```
ASCIILineType
}
pub type BuilderError {
/// Returned when not all rows have the same number of columns.
///
/// **Why is this an error?** It is not possible to render a rectangular
/// table if any row has a different number of columns than another.
InconsistentColumnCountError(expected: Int, got: Int)
/// Returned when attempting to build a table with no rows.
///
/// **Why is this an error?** It is somewhat unclear how to render this, and
/// is left as an error so callers can handle it
/// however is best for their application.
EmptyTableError
}
type RenderContext(o) {
RenderContext(
minimum_column_widths: List(Int),
lookup_element: fn(TableElement) -> String,
)
}
type TableElement {
HorizontalLineElement
VerticalLineElement
FourWayJunctionElement
StartJunctionElement
EndJunctionElement
TopJunctionElement
BottomJunctionElement
TopStartCornerJunctionElement
TopEndCornerJunctionElement
BottomStartCornerJunctionElement
BottomEndCornerJunctionElement
}
type HorizontalRulePosition {
TopRulePosition
CenterRulePosition
BottomRulePosition
}
type ScaledColumnWidths {
ScaledColumnWidths(widths: List(Int), extra_width: Int)
}
/// Create a new `Builder` for table generation. Once you have completed
/// adding your rows to this with `add_row`, you should call `build` to
/// generate a `Table`.
pub fn builder() -> Builder {
Builder(inner: builder.new())
}
/// Add a row to a table that is being built. Every call to `add_row` for a given
/// `Builder` must have the same number of columns, or an
/// `InconsistentColumnCountError` will be returned when `build` is called.
pub fn add_row(to builder: Builder, columns columns: List(String)) -> Builder {
builder.inner
|> builder.add_row(columns)
|> Builder()
}
/// Build a `Table` from the given `Builder`. If an invalid operation was
/// performed when constructing the `Builder`, an error will be returned.
pub fn build(with builder: Builder) -> Result(Table, BuilderError) {
builder.inner
|> builder.to_result()
|> result.map(fn(rows) { Table(rows:) })
|> result.map_error(builder_error_from_internal)
}
@internal
pub fn build_with_internal(
builder: builder.Builder,
) -> Result(Table, BuilderError) {
build(Builder(inner: builder))
}
/// Render the given table to a `String`, with the default options.
/// The output can be customized by using `render_with_options`.
///
/// # Example
///
/// ```gleam
/// let assert Ok(ttable) =
/// tobble.builder()
/// |> tobble.add_row(["", "Output"])
/// |> tobble.add_row(["Stage 1", "Wibble"])
/// |> tobble.add_row(["Stage 2", "Wobble"])
/// |> tobble.add_row(["Stage 3", "WibbleWobble"])
/// |> tobble.build()
///
/// io.println(tobble.render(table))
/// ```
///
/// ```text
/// +---------+--------------+
/// | | Output |
/// +---------+--------------+
/// | Stage 1 | Wibble |
/// | Stage 2 | Wobble |
/// | Stage 3 | WibbleWobble |
/// +---------+--------------+
/// ```
pub fn render(table table: Table) -> String {
render_with_options(table, [])
}
/// Render the given table to a `Yielder`. Each element of the `Yielder` will
/// produce a single line of output, without a trailing newline. Note that
/// options are applied in order, so if duplicate or conflicting options
/// are given, the last one will win.
///
/// # Example
///
/// ```gleam
/// let assert Ok(table) =
/// tobble.builder()
/// |> tobble.add_row(["", "Output"])
/// |> tobble.add_row(["Stage 1", "Wibble"])
/// |> tobble.add_row(["Stage 2", "Wobble"])
/// |> tobble.add_row(["Stage 3", "WibbleWobble"])
/// |> tobble.build()
///
///
/// table
/// |> tobble.render_iter(options: [])
/// |> yielder.each(io.println)
/// ```
///
/// ```text
/// +---------+--------------+
/// | | Output |
/// +---------+--------------+
/// | Stage 1 | Wibble |
/// | Stage 2 | Wobble |
/// | Stage 3 | WibbleWobble |
/// +---------+--------------+
/// ```
pub fn render_iter(
table table: Table,
options options: List(RenderOption),
) -> yielder.Yielder(String) {
default_render_context(table)
|> apply_options(options)
|> rendered_yielder(table)
}
/// Render the given table to a `String`, with extra options. Note that options
/// are applied in order, so if duplicate or conflicting options are given, the
/// last one will win.
///
///
/// ```gleam
/// let assert Ok(table) =
/// tobble.builder()
/// |> tobble.add_row(["", "Output"])
/// |> tobble.add_row(["Stage 1", "Wibble"])
/// |> tobble.add_row(["Stage 2", "Wobble"])
/// |> tobble.add_row(["Stage 3", "WibbleWobble"])
/// |> tobble.build()
///
/// io.println(
/// tobble.render_with_options(table, options: [
/// tobble.ColumnWidthRenderOption(6),
/// ]),
/// )
/// ```
///
/// ```text
/// +--------+--------+
/// | | Output |
/// +--------+--------+
/// | Stage | Wibble |
/// | 1 | |
/// | Stage | Wobble |
/// | 2 | |
/// | Stage | Wibble |
/// | 3 | Wobble |
/// +--------+--------+
/// ```
pub fn render_with_options(
table table: Table,
options options: List(RenderOption),
) -> String {
default_render_context(table)
|> apply_options(options)
|> rendered_yielder(table)
|> yielder.map(string_tree.from_string)
|> yielder.to_list()
|> string_tree.join("\n")
|> string_tree.to_string()
}
/// Convert an existing table to a list of its rows.
///
/// # Example
///
/// ```gleam
/// let assert Ok(table) =
/// tobble.builder()
/// |> tobble.add_row(["", "Output"])
/// |> tobble.add_row(["Stage 1", "Wibble"])
/// |> tobble.add_row(["Stage 2", "Wobble"])
/// |> tobble.add_row(["Stage 3", "WibbleWobble"])
/// |> tobble.build()
///
/// io.debug(tobble.to_list(table))
/// ```
///
/// ```gleam
/// [
/// ["", "Output"],
/// ["Stage 1", "Wibble"],
/// ["Stage 2", "Wobble"],
/// ["Stage 3", "WibbleWobble"]
/// ]
/// ```
pub fn to_list(table: Table) -> List(List(String)) {
rows.to_lists(table.rows)
}
fn builder_error_from_internal(error: InternalBuilderError) {
case error {
builder.InconsistentColumnCountError(expected:, got:) ->
InconsistentColumnCountError(expected:, got:)
builder.EmptyTableError -> EmptyTableError
}
}
fn rendered_yielder(
context: RenderContext(a),
table: Table,
) -> yielder.Yielder(String) {
yielder.once(fn() { render_horizontal_rule(context, TopRulePosition) })
|> yielder.append(rows_with_header_yielder(context, table.rows))
|> yielder.append(
yielder.once(fn() { render_horizontal_rule(context, BottomRulePosition) }),
)
}
fn column_lengths(rows: rows.Rows(String)) {
rows.columnwise_fold(over: rows, from: 0, with: fn(max, column) {
column
|> string.split("\n")
|> max_length(string_width.line)
|> result.unwrap(or: 0)
|> int.max(max)
})
}
fn render_horizontal_rule(
context: RenderContext(o),
position: HorizontalRulePosition,
) -> String {
let start_junction = case position {
TopRulePosition -> context.lookup_element(TopStartCornerJunctionElement)
CenterRulePosition -> context.lookup_element(StartJunctionElement)
BottomRulePosition ->
context.lookup_element(BottomStartCornerJunctionElement)
}
let middle_junction = case position {
TopRulePosition -> context.lookup_element(TopJunctionElement)
CenterRulePosition -> context.lookup_element(FourWayJunctionElement)
BottomRulePosition -> context.lookup_element(BottomJunctionElement)
}
let end_junction = case position {
TopRulePosition -> context.lookup_element(TopEndCornerJunctionElement)
CenterRulePosition -> context.lookup_element(EndJunctionElement)
BottomRulePosition -> context.lookup_element(BottomEndCornerJunctionElement)
}
let horizontal = context.lookup_element(HorizontalLineElement)
let rule =
context.minimum_column_widths
|> list.map(fn(width) {
horizontal
|> string.repeat(width + 2)
|> string_tree.from_string()
})
|> string_tree.join(middle_junction)
|> string_tree.prepend(start_junction)
|> string_tree.append(end_junction)
|> string_tree.to_string()
rule
}
fn rows_with_header_yielder(
context: RenderContext(o),
rows: rows.Rows(String),
) -> yielder.Yielder(String) {
case rows.pop_row(rows) {
// No rows, so no change needed
Error(Nil) -> yielder.empty()
Ok(#(head_row, rest_rows)) -> {
yielder.append(
header_yielder(context, head_row),
rows_yielder(context, rest_rows),
)
}
}
}
fn header_yielder(
context: RenderContext(o),
column_text: List(String),
) -> yielder.Yielder(String) {
yielder.append(
row_yielder(context, column_text),
yielder.once(fn() { render_horizontal_rule(context, CenterRulePosition) }),
)
}
fn rows_yielder(
context: RenderContext(o),
rows: rows.Rows(String),
) -> yielder.Yielder(String) {
yielder.unfold(rows, fn(remaining_rows) {
case rows.pop_row(remaining_rows) {
Error(Nil) -> yielder.Done
Ok(#(row, rest_rows)) -> {
yielder.Next(element: row_yielder(context, row), accumulator: rest_rows)
}
}
})
|> yielder.flatten()
}
fn row_yielder(
context: RenderContext(o),
column_text: List(String),
) -> yielder.Yielder(String) {
let column_lines =
list.map2(column_text, context.minimum_column_widths, fn(text, width) {
text
|> limit_text_width(to: width)
|> string.split("\n")
})
let height = column_lines |> max_length(list.length) |> result.unwrap(or: 1)
let visual_rows =
column_lines
|> list.map(fn(column) { pad_list_end(column, to: height, with: "") })
|> list.transpose()
yielder.unfold(visual_rows, fn(remaining_rows) {
case remaining_rows {
[] -> yielder.Done
[visual_row, ..rest_rows] ->
yielder.Next(
element: render_visual_row(context, visual_row),
accumulator: rest_rows,
)
}
})
}
fn render_visual_row(
context: RenderContext(o),
column_text: List(String),
) -> String {
let start_separator = context.lookup_element(VerticalLineElement) <> " "
let center_separator =
" " <> context.lookup_element(VerticalLineElement) <> " "
let end_separator = " " <> context.lookup_element(VerticalLineElement)
column_text
|> list.map2(context.minimum_column_widths, fn(column, width) {
column
|> column_text_to_width(width)
|> string_tree.from_string()
})
|> string_tree.join(center_separator)
|> string_tree.prepend(start_separator)
|> string_tree.append(end_separator)
|> string_tree.to_string()
}
fn column_text_to_width(text: String, width: Int) -> String {
text
|> limit_text_width(to: width)
|> pad_end(to: width)
}
fn limit_text_width(text: String, to width: Int) -> String {
string_width.limit(
text,
// The max number of rows is the length of the string,
// so worst case we can have one char per line
to: string_width.Size(rows: string.length(text), columns: width),
ellipsis: "",
)
}
fn pad_end(text: String, to width: Int) -> String {
string_width.align(text, align: string_width.Left, to: width, with: " ")
}
fn pad_list_end(
list list: List(a),
to to_length: Int,
with filler: a,
) -> List(a) {
let length = list.length(list)
case length >= to_length {
True -> list
False -> list.flatten([list, list.repeat(filler, to_length - length)])
}
}
fn max_length(lists: List(a), with get_length: fn(a) -> Int) -> Result(Int, Nil) {
case lists {
[] -> Error(Nil)
lists -> {
lists
|> list.fold(from: 0, with: fn(acc, lengthable) {
let length = get_length(lengthable)
case length > acc {
True -> length
False -> acc
}
})
|> Ok()
}
}
}
fn default_render_context(table: Table) -> RenderContext(b) {
RenderContext(
minimum_column_widths: column_lengths(table.rows),
lookup_element: lookup_ascii_table_element,
)
}
fn apply_options(
context: RenderContext(a),
options: List(RenderOption),
) -> RenderContext(a) {
list.fold(over: options, from: context, with: fn(context, option) {
case option {
LineTypeRenderOption(line_type) ->
apply_line_type_render_option(context, line_type)
TableWidthRenderOption(width) ->
apply_table_width_render_option(context, width)
ColumnWidthRenderOption(width) ->
apply_column_width_render_option(context, width)
}
})
}
fn apply_line_type_render_option(
context: RenderContext(a),
line_type: RenderLineType,
) -> RenderContext(a) {
case line_type {
ASCIILineType ->
RenderContext(..context, lookup_element: lookup_ascii_table_element)
BoxDrawingCharsLineType ->
RenderContext(..context, lookup_element: lookup_box_drawing_table_element)
BoxDrawingCharsWithRoundedCornersLineType ->
RenderContext(
..context,
lookup_element: lookup_box_drawing_rounded_corner_table_element,
)
}
}
fn apply_table_width_render_option(
context: RenderContext(a),
desired_width: Int,
) -> RenderContext(a) {
let desired_width =
column_content_width_for_table_width(
num_columns: list.length(context.minimum_column_widths),
desired_width:,
)
let total_original_width = int.sum(context.minimum_column_widths)
let scaled_widths =
list.map(context.minimum_column_widths, fn(column_width) {
column_width * desired_width / total_original_width
})
let total_scaled_width = int.sum(scaled_widths)
case int.compare(total_scaled_width, total_original_width) {
order.Gt | order.Eq ->
RenderContext(..context, minimum_column_widths: scaled_widths)
order.Lt -> {
let extra_width = desired_width - total_scaled_width
// Prioritize adding extra width to empty columns, to hopefully display something
let ScaledColumnWidths(widths: scaled_widths, extra_width:) =
scale_empty_columns(scaled_widths, extra_width)
// ... then redistribute what's left to the other columns
let ScaledColumnWidths(widths: scaled_widths, ..) =
redistribute_extra_width(scaled_widths, extra_width)
RenderContext(..context, minimum_column_widths: scaled_widths)
}
}
}
fn apply_column_width_render_option(
context: RenderContext(a),
desired_width: Int,
) {
RenderContext(
..context,
minimum_column_widths: list.map(context.minimum_column_widths, fn(_width) {
int.max(1, desired_width)
}),
)
}
// Turn the desired width from the option into one that includes space for table decorations
// If this is not possible, each column gets one character of content
fn column_content_width_for_table_width(
num_columns num_columns: Int,
desired_width desired_width: Int,
) -> Int {
// This is a bit hacky, but we need to account for thw width of the
// decorations on the table. The tables are known to have decorations of
// width 1, width padding on each side. So, we can determine the width taken
// up by the decorations by observing that
// +---+---+---+
// | 1 | 2 | 3 |
// +---+---+---+
//
// ...can be counted as having
// - two spaces per column (num_columns * 2), an
// - an inner division for each column (num_columns - 1)
// - two outer divisions (2)
//
let decoration_width = {
num_columns * 2 + { num_columns - 1 } + 2
}
let desired_width = desired_width - decoration_width
case desired_width <= 0 {
False -> desired_width
True -> num_columns
}
}
fn scale_empty_columns(
widths: List(Int),
allowed_extra_width: Int,
) -> ScaledColumnWidths {
let #(extra_width, new_widths) =
list.map_fold(over: widths, from: allowed_extra_width, with: fn(acc, width) {
case acc {
0 -> #(acc, width)
acc if width == 0 -> #(acc - 1, width + 1)
acc -> #(acc, width)
}
})
ScaledColumnWidths(widths: new_widths, extra_width:)
}
fn redistribute_extra_width(
widths: List(Int),
allowed_extra_width: Int,
) -> ScaledColumnWidths {
case allowed_extra_width {
0 -> ScaledColumnWidths(widths:, extra_width: 0)
allowed_extra_width -> {
let redistributed =
do_redistribute_extra_width(widths, allowed_extra_width)
redistribute_extra_width(redistributed.widths, redistributed.extra_width)
}
}
}
fn do_redistribute_extra_width(
widths: List(Int),
allowed_extra_width: Int,
) -> ScaledColumnWidths {
let #(extra_width, new_widths) =
list.map_fold(
over: widths,
from: allowed_extra_width,
with: fn(extra_width, width) {
case extra_width {
0 -> #(extra_width, width)
extra_width -> #(extra_width - 1, width + 1)
}
},
)
ScaledColumnWidths(widths: new_widths, extra_width:)
}
fn lookup_ascii_table_element(element: TableElement) -> String {
case element {
HorizontalLineElement -> "-"
VerticalLineElement -> "|"
FourWayJunctionElement -> "+"
StartJunctionElement -> "+"
EndJunctionElement -> "+"
TopJunctionElement -> "+"
BottomJunctionElement -> "+"
TopStartCornerJunctionElement -> "+"
TopEndCornerJunctionElement -> "+"
BottomStartCornerJunctionElement -> "+"
BottomEndCornerJunctionElement -> "+"
}
}
fn lookup_box_drawing_table_element(element: TableElement) -> String {
case element {
HorizontalLineElement -> "─"
VerticalLineElement -> "│"
FourWayJunctionElement -> "┼"
StartJunctionElement -> "├"
EndJunctionElement -> "┤"
TopJunctionElement -> "┬"
BottomJunctionElement -> "┴"
TopStartCornerJunctionElement -> "┌"
TopEndCornerJunctionElement -> "┐"
BottomStartCornerJunctionElement -> "└"
BottomEndCornerJunctionElement -> "┘"
}
}
fn lookup_box_drawing_rounded_corner_table_element(
element: TableElement,
) -> String {
case element {
HorizontalLineElement -> "─"
VerticalLineElement -> "│"
FourWayJunctionElement -> "┼"
StartJunctionElement -> "├"
EndJunctionElement -> "┤"
TopJunctionElement -> "┬"
BottomJunctionElement -> "┴"
TopStartCornerJunctionElement -> "╭"
TopEndCornerJunctionElement -> "╮"
BottomStartCornerJunctionElement -> "╰"
BottomEndCornerJunctionElement -> "╯"
}
}