aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorgingerBill <gingerBill@users.noreply.github.com>2023-03-29 15:08:33 +0100
committerGitHub <noreply@github.com>2023-03-29 15:08:33 +0100
commit2f771bee7bed7282e84ce0cf16825a9cf625f994 (patch)
tree8f47fe922902e089e0f679885cdc65671361f87f
parentae5214c1f256433c0d5fe718ea377335ffec2f24 (diff)
parent8862f9118b7efa79a09e3b397f517f16ed342016 (diff)
Merge pull request #2412 from oskarnp/text_table
text/table: Initial implementation
-rw-r--r--core/text/table/doc.odin100
-rw-r--r--core/text/table/table.odin384
-rw-r--r--core/text/table/utility.odin13
3 files changed, 497 insertions, 0 deletions
diff --git a/core/text/table/doc.odin b/core/text/table/doc.odin
new file mode 100644
index 000000000..9b5c1f932
--- /dev/null
+++ b/core/text/table/doc.odin
@@ -0,0 +1,100 @@
+/*
+ package table implements ascii/markdown/html/custom rendering of tables.
+
+ ---
+
+ Custom rendering example:
+
+ ```odin
+ tbl := init(&Table{})
+ padding(tbl, 0, 1)
+ row(tbl, "A_LONG_ENUM", "= 54,", "// A comment about A_LONG_ENUM")
+ row(tbl, "AN_EVEN_LONGER_ENUM", "= 1,", "// A comment about AN_EVEN_LONGER_ENUM")
+ build(tbl)
+ for row in 0..<tbl.nr_rows {
+ for col in 0..<tbl.nr_cols {
+ write_table_cell(stdio_writer(), tbl, row, col)
+ }
+ io.write_byte(stdio_writer(), '\n')
+ }
+ ```
+
+ This outputs:
+ ```
+ A_LONG_ENUM = 54, // A comment about A_LONG_ENUM
+ AN_EVEN_LONGER_ENUM = 1, // A comment about AN_EVEN_LONGER_ENUM
+ ```
+
+ ---
+
+ ASCII rendering example:
+
+ ```odin
+ tbl := init(&Table{})
+ defer destroy(tbl)
+
+ caption(tbl, "This is a table caption and it is very long")
+
+ padding(tbl, 1, 1) // Left/right padding of cells
+
+ header(tbl, "AAAAAAAAA", "B")
+ header(tbl, "C") // Appends to previous header row. Same as if done header("AAAAAAAAA", "B", "C") from start.
+
+ // Create a row with two values. Since there are three columns the third
+ // value will become the empty string.
+ //
+ // NOTE: header() is not allowed anymore after this.
+ row(tbl, 123, "foo")
+
+ // Use `format()` if you need custom formatting. This will allocate into
+ // the arena specified at init.
+ row(tbl,
+ format(tbl, "%09d", 5),
+ format(tbl, "%.6f", 6.28318530717958647692528676655900576))
+
+ // A row with zero values is allowed as long as a previous row or header
+ // exist. The value and alignment of each cell can then be set
+ // individually.
+ row(tbl)
+ set_cell_value_and_alignment(tbl, last_row(tbl), 0, "a", .Center)
+ set_cell_value(tbl, last_row(tbl), 1, "bbb")
+ set_cell_value(tbl, last_row(tbl), 2, "c")
+
+ // Headers are regular cells, too. Use header_row() as row index to modify
+ // header cells.
+ set_cell_alignment(tbl, header_row(tbl), 1, .Center) // Sets alignment of 'B' column to Center.
+ set_cell_alignment(tbl, header_row(tbl), 2, .Right) // Sets alignment of 'C' column to Right.
+
+ build(tbl)
+
+ write_ascii_table(stdio_writer(), tbl)
+ write_markdown_table(stdio_writer(), tbl)
+ ```
+
+ This outputs:
+ ```
+ +-----------------------------------------------+
+ | This is a table caption and it is very long |
+ +------------------+-----------------+----------+
+ | AAAAAAAAA | B | C |
+ +------------------+-----------------+----------+
+ | 123 | foo | |
+ | 000000005 | 6.283185 | |
+ | a | bbb | c |
+ +------------------+-----------------+----------+
+ ```
+
+ and
+
+ ```
+ | AAAAAAAAA | B | C |
+ |:-----------------|:---------------:|---------:|
+ | 123 | foo | |
+ | 000000005 | 6.283185 | |
+ | a | bbb | c |
+ ```
+
+ respectively.
+*/
+
+package text_table
diff --git a/core/text/table/table.odin b/core/text/table/table.odin
new file mode 100644
index 000000000..df93ee44e
--- /dev/null
+++ b/core/text/table/table.odin
@@ -0,0 +1,384 @@
+/*
+ Copyright 2023 oskarnp <oskarnp@proton.me>
+ Made available under Odin's BSD-3 license.
+
+ List of contributors:
+ oskarnp: Initial implementation.
+*/
+
+package text_table
+
+import "core:io"
+import "core:os"
+import "core:fmt"
+import "core:mem"
+import "core:mem/virtual"
+import "core:runtime"
+import "core:strings"
+
+Cell :: struct {
+ text: string,
+ alignment: Cell_Alignment,
+}
+
+Cell_Alignment :: enum {
+ Left,
+ Center,
+ Right,
+}
+
+Table :: struct {
+ lpad, rpad: int, // Cell padding (left/right)
+ cells: [dynamic]Cell,
+ caption: string,
+ nr_rows, nr_cols: int,
+ has_header_row: bool,
+ table_allocator: runtime.Allocator, // Used for allocating cells/colw
+ format_allocator: runtime.Allocator, // Used for allocating Cell.text when applicable
+
+ dirty: bool, // True if build() needs to be called before rendering
+
+ // The following are computed on build()
+ colw: [dynamic]int, // Width of each column (including padding, excluding borders)
+ tblw: int, // Width of entire table (including padding, excluding borders)
+}
+
+init :: proc{init_with_allocator, init_with_virtual_arena, init_with_mem_arena}
+
+init_with_allocator :: proc(tbl: ^Table, format_allocator := context.temp_allocator, table_allocator := context.allocator) -> ^Table {
+ tbl.table_allocator = table_allocator
+ tbl.cells = make([dynamic]Cell, tbl.table_allocator)
+ tbl.colw = make([dynamic]int, tbl.table_allocator)
+ tbl.format_allocator = format_allocator
+ return tbl
+}
+init_with_virtual_arena :: proc(tbl: ^Table, format_arena: ^virtual.Arena, table_allocator := context.allocator) -> ^Table {
+ return init_with_allocator(tbl, virtual.arena_allocator(format_arena), table_allocator)
+}
+init_with_mem_arena :: proc(tbl: ^Table, format_arena: ^mem.Arena, table_allocator := context.allocator) -> ^Table {
+ return init_with_allocator(tbl, mem.arena_allocator(format_arena), table_allocator)
+}
+
+destroy :: proc(tbl: ^Table) {
+ free_all(tbl.format_allocator)
+ delete(tbl.cells)
+ delete(tbl.colw)
+}
+
+caption :: proc(tbl: ^Table, value: string) {
+ tbl.caption = value
+ tbl.dirty = true
+}
+
+padding :: proc(tbl: ^Table, lpad, rpad: int) {
+ tbl.lpad = lpad
+ tbl.rpad = rpad
+ tbl.dirty = true
+}
+
+get_cell :: proc(tbl: ^Table, row, col: int, loc := #caller_location) -> ^Cell {
+ assert(col >= 0 && col < tbl.nr_cols, "cell column out of range", loc)
+ assert(row >= 0 && row < tbl.nr_rows, "cell row out of range", loc)
+ resize(&tbl.cells, tbl.nr_cols * tbl.nr_rows)
+ return &tbl.cells[row*tbl.nr_cols + col]
+}
+
+set_cell_value_and_alignment :: proc(tbl: ^Table, row, col: int, value: string, alignment: Cell_Alignment) {
+ cell := get_cell(tbl, row, col)
+ cell.text = format(tbl, "%v", value)
+ cell.alignment = alignment
+ tbl.dirty = true
+}
+
+set_cell_value :: proc(tbl: ^Table, row, col: int, value: any, loc := #caller_location) {
+ cell := get_cell(tbl, row, col, loc)
+ switch val in value {
+ case nil:
+ cell.text = ""
+ case string:
+ cell.text = string(val)
+ case cstring:
+ cell.text = string(val)
+ case:
+ cell.text = format(tbl, "%v", val)
+ if cell.text == "" {
+ fmt.eprintf("{} text/table: format() resulted in empty string (arena out of memory?)\n", loc)
+ }
+ }
+ tbl.dirty = true
+}
+
+set_cell_alignment :: proc(tbl: ^Table, row, col: int, alignment: Cell_Alignment, loc := #caller_location) {
+ cell := get_cell(tbl, row, col, loc)
+ cell.alignment = alignment
+ tbl.dirty = true
+}
+
+format :: proc(tbl: ^Table, _fmt: string, args: ..any, loc := #caller_location) -> string {
+ context.allocator = tbl.format_allocator
+ return fmt.aprintf(fmt = _fmt, args = args)
+}
+
+header :: proc(tbl: ^Table, values: ..any, loc := #caller_location) {
+ if (tbl.has_header_row && tbl.nr_rows != 1) || (!tbl.has_header_row && tbl.nr_rows != 0) {
+ panic("Cannot add headers after rows have been added", loc)
+ }
+
+ if tbl.nr_rows == 0 {
+ tbl.nr_rows += 1
+ tbl.has_header_row = true
+ }
+
+ col := tbl.nr_cols
+ tbl.nr_cols += len(values)
+ for val in values {
+ set_cell_value(tbl, header_row(tbl), col, val, loc)
+ col += 1
+ }
+
+ tbl.dirty = true
+}
+
+row :: proc(tbl: ^Table, values: ..any, loc := #caller_location) {
+ if tbl.nr_cols == 0 {
+ if len(values) == 0 {
+ panic("Cannot create row without values unless knowing amount of columns in advance")
+ } else {
+ tbl.nr_cols = len(values)
+ }
+ }
+ tbl.nr_rows += 1
+ for col in 0..<tbl.nr_cols {
+ val := values[col] if col < len(values) else nil
+ set_cell_value(tbl, last_row(tbl), col, val)
+ }
+ tbl.dirty = true
+}
+
+last_row :: proc(tbl: ^Table) -> int {
+ return tbl.nr_rows - 1
+}
+
+header_row :: proc(tbl: ^Table) -> int {
+ return 0 if tbl.has_header_row else -1
+}
+
+first_row :: proc(tbl: ^Table) -> int {
+ return header_row(tbl)+1 if tbl.has_header_row else 0
+}
+
+build :: proc(tbl: ^Table) {
+ tbl.dirty = false
+
+ resize(&tbl.colw, tbl.nr_cols)
+ mem.zero_slice(tbl.colw[:])
+
+ for row in 0..<tbl.nr_rows {
+ for col in 0..<tbl.nr_cols {
+ cell := get_cell(tbl, row, col)
+ if w := len(cell.text) + tbl.lpad + tbl.rpad; w > tbl.colw[col] {
+ tbl.colw[col] = w
+ }
+ }
+ }
+
+ colw_sum := 0
+ for v in tbl.colw {
+ colw_sum += v
+ }
+
+ tbl.tblw = max(colw_sum, len(tbl.caption) + tbl.lpad + tbl.rpad)
+
+ // Resize columns to match total width of table
+ remain := tbl.tblw-colw_sum
+ for col := 0; remain > 0; col = (col + 1) % tbl.nr_cols {
+ tbl.colw[col] += 1
+ remain -= 1
+ }
+
+ return
+}
+
+write_html_table :: proc(w: io.Writer, tbl: ^Table) {
+ if tbl.dirty {
+ build(tbl)
+ }
+
+ io.write_string(w, "<table>\n")
+ if tbl.caption != "" {
+ io.write_string(w, "<caption>")
+ io.write_string(w, tbl.caption)
+ io.write_string(w, "</caption>\n")
+ }
+
+ align_attribute :: proc(cell: ^Cell) -> string {
+ switch cell.alignment {
+ case .Left: return ` align="left"`
+ case .Center: return ` align="center"`
+ case .Right: return ` align="right"`
+ }
+ unreachable()
+ }
+
+ if tbl.has_header_row {
+ io.write_string(w, "<thead>\n")
+ io.write_string(w, " <tr>\n")
+ for col in 0..<tbl.nr_cols {
+ cell := get_cell(tbl, header_row(tbl), col)
+ io.write_string(w, " <th")
+ io.write_string(w, align_attribute(cell))
+ io.write_string(w, ">")
+ io.write_string(w, cell.text)
+ io.write_string(w, "</th>\n")
+ }
+ io.write_string(w, " </tr>\n")
+ io.write_string(w, "</thead>\n")
+ }
+
+ io.write_string(w, "<tbody>\n")
+ for row in 0..<tbl.nr_rows {
+ if tbl.has_header_row && row == header_row(tbl) {
+ continue
+ }
+ io.write_string(w, " <tr>\n")
+ for col in 0..<tbl.nr_cols {
+ cell := get_cell(tbl, row, col)
+ io.write_string(w, " <td")
+ io.write_string(w, align_attribute(cell))
+ io.write_string(w, ">")
+ io.write_string(w, cell.text)
+ io.write_string(w, "</td>\n")
+ }
+ io.write_string(w, " </tr>\n")
+ }
+ io.write_string(w, " </tbody>\n")
+
+ io.write_string(w, "</table>\n")
+}
+
+write_ascii_table :: proc(w: io.Writer, tbl: ^Table) {
+ if tbl.dirty {
+ build(tbl)
+ }
+
+ write_caption_separator :: proc(w: io.Writer, tbl: ^Table) {
+ io.write_byte(w, '+')
+ write_byte_repeat(w, tbl.tblw + tbl.nr_cols - 1, '-')
+ io.write_byte(w, '+')
+ io.write_byte(w, '\n')
+ }
+
+ write_table_separator :: proc(w: io.Writer, tbl: ^Table) {
+ for col in 0..<tbl.nr_cols {
+ if col == 0 {
+ io.write_byte(w, '+')
+ }
+ write_byte_repeat(w, tbl.colw[col], '-')
+ io.write_byte(w, '+')
+ }
+ io.write_byte(w, '\n')
+ }
+
+ if tbl.caption != "" {
+ write_caption_separator(w, tbl)
+ io.write_byte(w, '|')
+ write_text_align(w, tbl.tblw - tbl.lpad - tbl.rpad + tbl.nr_cols - 1,
+ tbl.lpad, tbl.rpad, tbl.caption, .Center)
+ io.write_byte(w, '|')
+ io.write_byte(w, '\n')
+ }
+
+ write_table_separator(w, tbl)
+ for row in 0..<tbl.nr_rows {
+ for col in 0..<tbl.nr_cols {
+ if col == 0 {
+ io.write_byte(w, '|')
+ }
+ write_table_cell(w, tbl, row, col)
+ io.write_byte(w, '|')
+ }
+ io.write_byte(w, '\n')
+ if tbl.has_header_row && row == header_row(tbl) {
+ write_table_separator(w, tbl)
+ }
+ }
+ write_table_separator(w, tbl)
+}
+
+// Renders table according to GitHub Flavored Markdown (GFM) specification
+write_markdown_table :: proc(w: io.Writer, tbl: ^Table) {
+ // NOTE(oskar): Captions or colspans are not supported by GFM as far as I can tell.
+
+ if tbl.dirty {
+ build(tbl)
+ }
+
+ for row in 0..<tbl.nr_rows {
+ for col in 0..<tbl.nr_cols {
+ cell := get_cell(tbl, row, col)
+ if col == 0 {
+ io.write_byte(w, '|')
+ }
+ write_text_align(w, tbl.colw[col] - tbl.lpad - tbl.rpad, tbl.lpad, tbl.rpad, cell.text,
+ .Center if tbl.has_header_row && row == header_row(tbl) else .Left)
+ io.write_string(w, "|")
+ }
+ io.write_byte(w, '\n')
+
+ if tbl.has_header_row && row == header_row(tbl) {
+ for col in 0..<tbl.nr_cols {
+ cell := get_cell(tbl, row, col)
+ if col == 0 {
+ io.write_byte(w, '|')
+ }
+ switch cell.alignment {
+ case .Left:
+ io.write_byte(w, ':')
+ write_byte_repeat(w, max(1, tbl.colw[col]-1), '-')
+ case .Center:
+ io.write_byte(w, ':')
+ write_byte_repeat(w, max(1, tbl.colw[col]-2), '-')
+ io.write_byte(w, ':')
+ case .Right:
+ write_byte_repeat(w, max(1, tbl.colw[col]-1), '-')
+ io.write_byte(w, ':')
+ }
+ io.write_byte(w, '|')
+ }
+ io.write_byte(w, '\n')
+ }
+ }
+}
+
+write_byte_repeat :: proc(w: io.Writer, n: int, b: byte) {
+ for _ in 0..<n {
+ io.write_byte(w, b)
+ }
+}
+
+write_table_cell :: proc(w: io.Writer, tbl: ^Table, row, col: int) {
+ if tbl.dirty {
+ build(tbl)
+ }
+ cell := get_cell(tbl, row, col)
+ write_text_align(w, tbl.colw[col]-tbl.lpad-tbl.rpad, tbl.lpad, tbl.rpad, cell.text, cell.alignment)
+}
+
+write_text_align :: proc(w: io.Writer, colw, lpad, rpad: int, text: string, alignment: Cell_Alignment) {
+ write_byte_repeat(w, lpad, ' ')
+ switch alignment {
+ case .Left:
+ io.write_string(w, text)
+ write_byte_repeat(w, colw - len(text), ' ')
+ case .Center:
+ pad := colw - len(text)
+ odd := pad & 1 != 0
+ write_byte_repeat(w, pad/2, ' ')
+ io.write_string(w, text)
+ write_byte_repeat(w, pad/2 + 1 if odd else pad/2, ' ')
+ case .Right:
+ write_byte_repeat(w, colw - len(text), ' ')
+ io.write_string(w, text)
+ }
+ write_byte_repeat(w, rpad, ' ')
+}
diff --git a/core/text/table/utility.odin b/core/text/table/utility.odin
new file mode 100644
index 000000000..0e56fd968
--- /dev/null
+++ b/core/text/table/utility.odin
@@ -0,0 +1,13 @@
+package text_table
+
+import "core:io"
+import "core:os"
+import "core:strings"
+
+stdio_writer :: proc() -> io.Writer {
+ return io.to_writer(os.stream_from_handle(os.stdout))
+}
+
+strings_builder_writer :: proc(b: ^strings.Builder) -> io.Writer {
+ return strings.to_writer(b)
+}