Current section
Files
Jump to
Current section
Files
src/trellis.gleam
import gleam/int
import gleam/list
import gleam/string
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)
}
/// A type representing a column in the table with header, alignment, and value getter
pub type Column(value) {
Column(header: String, align: Align, getter: fn(value) -> String)
}
/// Alignment options for column content
pub type Align {
Left
Right
Center
}
/// 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),
header header: String,
align align: Align,
getter getter: fn(value) -> String,
) -> Table(value) {
let Table(columns:, rows: _, style: _) = table
Table(..table, columns: [Column(header, align, getter), ..columns])
}
/// 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)) -> List(Int) {
let lengths = list.sized_chunk(values, list.length(headers))
list.map2(headers, list.transpose(lengths), fn(header, col_values) {
let max_value_length =
list.fold(col_values, 0, fn(acc, val) { int.max(acc, string.length(val)) })
int.max(string.length(header), max_value_length)
})
}
/// 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:, rows:, style:) = table
let columns = list.reverse(columns)
let headers = list.map(columns, fn(col) { col.header })
let aligns = list.map(columns, fn(col) { col.align })
let values =
list.flat_map(rows, fn(row) {
list.map(columns, fn(col) { col.getter(row) })
})
let widths = calculate_widths(headers, values)
let separator_top =
make_separator(style:, widths:, is_header: True, is_top: True)
let header_row =
make_row(
style,
list.map(list.range(1, list.length(columns)), fn(_) { Center }),
widths,
headers,
)
let separator_header =
make_separator(style:, widths:, is_header: True, is_top: False)
let body_rows =
list.map(rows, fn(row) {
let row_values = list.map(columns, fn(col) { col.getter(row) })
make_row(style, aligns, widths, row_values)
})
let separator_bottom =
make_separator(style:, widths:, is_header: False, is_top: False)
[separator_top, header_row, 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
}
/// Creates a row in the table with properly aligned content
fn make_row(
style: style.Style,
aligns: List(Align),
widths: List(Int),
values: List(String),
) -> String {
let vertical = style.style(style:).vertical
let row =
{
use #(align, width), value <- list.map2(
{
use align, width <- list.map2(aligns, widths)
#(align, width)
},
values,
)
let padded = case align {
Left -> pad_right(value, width)
Right -> pad_left(value, width)
Center -> pad_center(value, width)
}
" " <> padded <> " "
}
|> string.join(vertical)
vertical <> row <> vertical
}
/// 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)
}