Current section
Files
Jump to
Current section
Files
src/trellis.gleam
import gleam/int
import gleam/list
import gleam/option
import gleam/string
import trellis/column.{type Align, type Column, Center, Column, Left, Right}
import trellis/style
/// A type representing a formatted table with headers and rows of data
///
/// ## Example
///
/// ```gleam
/// let table =
/// table([user1, user2, user3])
/// |> with("Name", Left, {
/// use user <- param
/// user.name
/// })
/// |> with("Age", Right, {
/// use user <- param
/// user.age |> int.to_string
/// })
/// ```
pub type Table(value) {
Table(columns: List(Column(value)), rows: List(value), style: style.Style)
}
// Helper type to store cell content split into lines
type CellContent {
CellContent(lines: List(String), width: Int, height: Int)
}
/// Creates a new table with the given rows of data
pub fn table(data rows: List(value)) -> Table(value) {
Table(columns: [], rows: rows, style: style.Line)
}
/// Set the style of the table
pub fn style(
table table: Table(value),
style style: style.Style,
) -> Table(value) {
Table(..table, style:)
}
/// Helper function to wrap a function for use in column definitions
///
/// This is a utility function that can make column definitions more readable
/// when used with partial application
pub fn param(f: fn(a) -> b) -> fn(a) -> b {
f
}
/// Adds a new column to the table definition
pub fn with(
table table: Table(value),
column column: Column(value),
) -> Table(value) {
let Table(columns:, rows: _, style: _) = table
Table(..table, columns: [column, ..columns])
}
fn cell_content(text: String, width width: option.Option(Int)) -> CellContent {
let newline_splits = string.split(text, on: "\n")
let lines = case width {
option.Some(width) -> {
list.flat_map(newline_splits, fn(line) { wrap_text(line, width) })
}
option.None -> newline_splits
}
let content_width =
list.fold(lines, 0, fn(acc, line) { int.max(acc, string.length(line)) })
let final_width = case width {
option.Some(max) -> int.min(content_width, max)
option.None -> content_width
}
CellContent(lines: lines, width: final_width, height: list.length(lines))
}
/// Calculates the required width for each column based on content
///
/// Takes into account both header width and the maximum width of values
/// in each column to determine the appropriate column widths
fn calculate_widths(
headers: List(String),
values: List(String),
widths: List(option.Option(Int)),
) -> List(Int) {
let header_cells = list.map2(headers, widths, cell_content)
let columns = list.sized_chunk(values, list.length(headers))
let transposed_columns = list.transpose(columns)
let min_column_widths =
list.map(transposed_columns, fn(col_values) {
list.fold(col_values, 0, fn(acc, val) {
int.max(acc, calculate_min_width(val))
})
})
let value_cells =
list.map2(transposed_columns, widths, fn(col_values, width) {
list.map(col_values, fn(value) { cell_content(value, width) })
})
use #(header, max_width), #(column_cells, min_width) <- list.map2(
list.zip(header_cells, widths),
list.zip(value_cells, min_column_widths),
)
let natural_width =
list.fold(column_cells, header.width, fn(acc, cell) {
int.max(acc, cell.width)
})
let width = int.max(natural_width, min_width)
case max_width {
option.Some(max_width) -> int.max(min_width, int.min(width, max_width))
option.None -> width
}
}
fn make_multi_line_row(
style style: style.Style,
aligns aligns: List(Align),
widths widths: List(Int),
cell_contents cell_contents: List(CellContent),
) -> List(String) {
let vertical = style.style(style: style).vertical
let max_height =
list.fold(cell_contents, 0, fn(acc, cell) { int.max(acc, cell.height) })
list.range(0, max_height - 1)
|> list.map(fn(line_num) {
let parts =
list.map2(list.zip(aligns, widths), cell_contents, fn(tup, cell) {
#(tup.0, tup.1, cell)
})
let line_parts =
list.map(parts, fn(part) {
let #(align, width, cell) = part
let content =
list.index_fold(cell.lines, "", fn(acc, line, i) {
case line_num == i {
True -> line
_ -> acc
}
})
let padded = case align {
Left -> pad_right(content, width)
Right -> pad_left(content, width)
Center -> pad_center(content, width)
}
" " <> padded <> " "
})
vertical <> string.join(line_parts, vertical) <> vertical
})
}
/// Converts the table to a formatted string representation
///
/// Creates a string with Unicode box-drawing characters, properly aligned content,
/// and separators between header and rows
pub fn to_string(table: Table(value)) -> String {
let Table(columns: columns, rows: rows, style: style) = table
let columns = list.reverse(columns)
let headers = list.map(columns, fn(column) { column.header })
let aligns = list.map(columns, fn(column) { column.align })
let max_widths = list.map(columns, fn(column) { column.width })
let values =
list.flat_map(rows, fn(row) {
list.map(columns, fn(col) { col.getter(row) })
})
let widths = calculate_widths(headers, values, max_widths)
let separator_top =
make_separator(style:, widths:, is_header: True, is_top: True)
let separator_header =
make_separator(style:, widths:, is_header: True, is_top: False)
let separator_bottom =
make_separator(style:, widths:, is_header: False, is_top: False)
let header_cells = list.map2(headers, max_widths, cell_content)
let header_rows =
make_multi_line_row(
style:,
aligns: list.map(list.range(1, list.length(columns)), fn(_) { Center }),
widths:,
cell_contents: header_cells,
)
let body_rows =
list.flat_map(rows, fn(row) {
let cell_contents =
list.map2(columns, max_widths, fn(col, w) {
cell_content(col.getter(row), w)
})
make_multi_line_row(style:, aligns:, widths:, cell_contents:)
})
[separator_top]
|> list.append(header_rows)
|> list.append([separator_header])
|> list.append(body_rows)
|> list.append([separator_bottom])
|> string.join("\n")
}
/// Creates a horizontal separator line for the table
fn make_separator(
style style: style.Style,
widths widths: List(Int),
is_header is_header: Bool,
is_top is_top: Bool,
) -> String {
let style.StyleGuide(
top_left:,
top_middle:,
top_right:,
middle_left:,
middle_right:,
bottom_left:,
bottom_middle:,
bottom_right:,
cross:,
horizontal:,
vertical: _,
) = style.style(style:)
let #(start, middle, end) = case is_top, is_header {
True, _ -> #(top_left, top_middle, top_right)
False, True -> #(middle_right, cross, middle_left)
False, False -> #(bottom_left, bottom_middle, bottom_right)
}
let line =
widths
|> list.fold(start, fn(acc, col) {
acc <> string.repeat(horizontal, col + 2) <> middle
})
string.slice(line, 0, string.length(line) - 1) <> end
}
/// Left-pads a string with spaces to reach the specified width
fn pad_left(text: String, width: Int) -> String {
let padding = width - string.length(text)
string.repeat(" ", padding) <> text
}
/// Right-pads a string with spaces to reach the specified width
fn pad_right(text: String, width: Int) -> String {
let padding = width - string.length(text)
text <> string.repeat(" ", padding)
}
/// Centers a string by padding both sides with spaces to reach the specified width
fn pad_center(text: String, width: Int) -> String {
let padding = width - string.length(text)
let left = padding / 2
let right = padding - left
string.repeat(" ", left) <> text <> string.repeat(" ", right)
}
fn wrap_text(text: String, max_width: Int) -> List(String) {
let min_width = calculate_min_width(text)
let effective_width = int.max(max_width, min_width)
case string.length(text) <= effective_width {
True -> [text]
False -> {
let words = string.split(text, on: " ")
do_wrap(words: words, max_width: effective_width, current: "", lines: [])
}
}
}
fn do_wrap(
words words: List(String),
max_width max_width: Int,
current current: String,
lines lines: List(String),
) -> List(String) {
case words {
[] ->
case current {
"" -> list.reverse(lines)
_ -> list.reverse([string.trim(current), ..lines])
}
[word, ..rest] -> {
let with_space = case current {
"" -> word
_ -> current <> " " <> word
}
case string.length(with_space) <= max_width {
True -> do_wrap(rest, max_width, with_space, lines)
False ->
case current {
"" -> do_wrap(rest, max_width, word, lines)
_ ->
do_wrap([word, ..rest], max_width, "", [
string.trim(current),
..lines
])
}
}
}
}
}
fn calculate_min_width(text: String) -> Int {
string.split(text, on: " ")
|> list.fold(0, fn(acc, word) { int.max(acc, string.length(word)) })
}