├── .github ├── FUNDING.yml └── workflows │ ├── image_optimizer.yml │ └── ci.yml ├── .gitignore ├── img ├── md.png ├── rst.png ├── grid.png ├── plain.png ├── pretty.png ├── simple.png ├── tab-2.png ├── tab-4.png ├── tab-8.png ├── fancy_grid.png ├── header_bold.png ├── header_plain.png ├── orientation_row.png └── orientation_column.png ├── tests ├── no_padding │ ├── plain.out │ └── simple.out ├── grid_single_row.out ├── styles │ ├── plain.out │ ├── custom.out │ ├── simple.out │ ├── md.out │ ├── rst.out │ ├── pretty.out │ ├── grid.out │ └── fancy_grid.out └── unicode │ └── cjk.out ├── v.mod ├── unicode_test.v ├── styles.v ├── LICENSE.md ├── styles_test.v ├── CHANGELOG.md ├── README.md ├── termtable_test.v └── termtable.v /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: serkonda7 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.code-workspace 2 | *.so 3 | examples/ 4 | -------------------------------------------------------------------------------- /img/md.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/md.png -------------------------------------------------------------------------------- /img/rst.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/rst.png -------------------------------------------------------------------------------- /img/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/grid.png -------------------------------------------------------------------------------- /img/plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/plain.png -------------------------------------------------------------------------------- /img/pretty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/pretty.png -------------------------------------------------------------------------------- /img/simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/simple.png -------------------------------------------------------------------------------- /img/tab-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/tab-2.png -------------------------------------------------------------------------------- /img/tab-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/tab-4.png -------------------------------------------------------------------------------- /img/tab-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/tab-8.png -------------------------------------------------------------------------------- /tests/no_padding/plain.out: -------------------------------------------------------------------------------- 1 | Name Age Sex 2 | Max 13 male 3 | Lisa 42 female 4 | -------------------------------------------------------------------------------- /img/fancy_grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/fancy_grid.png -------------------------------------------------------------------------------- /img/header_bold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/header_bold.png -------------------------------------------------------------------------------- /img/header_plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/header_plain.png -------------------------------------------------------------------------------- /tests/grid_single_row.out: -------------------------------------------------------------------------------- 1 | +-----+-----+-----+ 2 | | Foo | bar | baz | 3 | +-----+-----+-----+ 4 | -------------------------------------------------------------------------------- /img/orientation_row.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/orientation_row.png -------------------------------------------------------------------------------- /tests/no_padding/simple.out: -------------------------------------------------------------------------------- 1 | Name Age Sex 2 | ---- --- ------ 3 | Max 13 male 4 | Lisa 42 female 5 | -------------------------------------------------------------------------------- /img/orientation_column.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkonda7/termtable/HEAD/img/orientation_column.png -------------------------------------------------------------------------------- /tests/styles/plain.out: -------------------------------------------------------------------------------- 1 | Name Age Sex 2 | Max 13 male 3 | Moritz 12 male 4 | Lisa 42 female 5 | -------------------------------------------------------------------------------- /tests/unicode/cjk.out: -------------------------------------------------------------------------------- 1 | +-------+---------+ 2 | | 键 | 值值 | 3 | +-------+---------+ 4 | | V版本 | V 0.2.2 | 5 | +-------+---------+ 6 | -------------------------------------------------------------------------------- /tests/styles/custom.out: -------------------------------------------------------------------------------- 1 | Name Age Sex 2 | ===================== 3 | Max 13 male 4 | Moritz 12 male 5 | Lisa 42 female 6 | -------------------------------------------------------------------------------- /tests/styles/simple.out: -------------------------------------------------------------------------------- 1 | Name Age Sex 2 | ------ --- ------ 3 | Max 13 male 4 | Moritz 12 male 5 | Lisa 42 female 6 | -------------------------------------------------------------------------------- /tests/styles/md.out: -------------------------------------------------------------------------------- 1 | | Name | Age | Sex | 2 | |--------|-----|--------| 3 | | Max | 13 | male | 4 | | Moritz | 12 | male | 5 | | Lisa | 42 | female | 6 | -------------------------------------------------------------------------------- /tests/styles/rst.out: -------------------------------------------------------------------------------- 1 | ====== === ====== 2 | Name Age Sex 3 | ====== === ====== 4 | Max 13 male 5 | Moritz 12 male 6 | Lisa 42 female 7 | ====== === ====== 8 | -------------------------------------------------------------------------------- /tests/styles/pretty.out: -------------------------------------------------------------------------------- 1 | +--------+-----+--------+ 2 | | Name | Age | Sex | 3 | +--------+-----+--------+ 4 | | Max | 13 | male | 5 | | Moritz | 12 | male | 6 | | Lisa | 42 | female | 7 | +--------+-----+--------+ 8 | -------------------------------------------------------------------------------- /tests/styles/grid.out: -------------------------------------------------------------------------------- 1 | +--------+-----+--------+ 2 | | Name | Age | Sex | 3 | +--------+-----+--------+ 4 | | Max | 13 | male | 5 | +--------+-----+--------+ 6 | | Moritz | 12 | male | 7 | +--------+-----+--------+ 8 | | Lisa | 42 | female | 9 | +--------+-----+--------+ 10 | -------------------------------------------------------------------------------- /tests/styles/fancy_grid.out: -------------------------------------------------------------------------------- 1 | ╒════════╤═════╤════════╕ 2 | │ Name │ Age │ Sex │ 3 | ╞════════╪═════╪════════╡ 4 | │ Max │ 13 │ male │ 5 | ├────────┼─────┼────────┤ 6 | │ Moritz │ 12 │ male │ 7 | ├────────┼─────┼────────┤ 8 | │ Lisa │ 42 │ female │ 9 | ╘════════╧═════╧════════╛ 10 | -------------------------------------------------------------------------------- /v.mod: -------------------------------------------------------------------------------- 1 | Module { 2 | name: 'termtable' 3 | description: 'Simple and highly customizable library to display tables in the terminal.' 4 | author: 'Lukas Neubert ' 5 | license: 'MIT' 6 | repo_url: 'https://github.com/serkonda7/termtable' 7 | version: '0.9.0' 8 | dependencies: [] 9 | } 10 | -------------------------------------------------------------------------------- /unicode_test.v: -------------------------------------------------------------------------------- 1 | module termtable 2 | 3 | import os 4 | 5 | fn test_cjk_chars() { 6 | table := Table{ 7 | data: [ 8 | ['键', '值值'], 9 | ['V版本', 'V 0.2.2'], 10 | ] 11 | header_style: .plain 12 | } 13 | mut exp := os.read_file('${dir}/tests/unicode/cjk.out') or { panic(err) } 14 | exp = exp.trim_string_right('\n') 15 | assert table.str() == exp 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/image_optimizer.yml: -------------------------------------------------------------------------------- 1 | name: Optimize Images 2 | on: 3 | pull_request: 4 | paths: 5 | - '**.png' 6 | jobs: 7 | optimize: 8 | if: github.event.pull_request.head.repo.full_name == github.repository 9 | name: calibreapp/image-actions 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Repo 13 | uses: actions/checkout@v2 14 | - name: Compress Images 15 | uses: calibreapp/image-actions@main 16 | with: 17 | githubToken: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /styles.v: -------------------------------------------------------------------------------- 1 | module termtable 2 | 3 | pub enum Style { 4 | custom 5 | plain 6 | grid 7 | simple 8 | pretty 9 | fancy_grid 10 | md 11 | rst 12 | } 13 | 14 | pub struct Sepline { 15 | pub mut: 16 | left string 17 | right string 18 | cross string 19 | sep string 20 | } 21 | 22 | pub struct StyleConfig { 23 | pub mut: 24 | topline Sepline 25 | headerline Sepline 26 | middleline Sepline 27 | bottomline Sepline 28 | colsep string 29 | fill_padding bool = true 30 | } 31 | 32 | fn get_style_config(style Style) StyleConfig { 33 | return style_configs[style.str()] 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Lukas Neubert 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | code-style: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout V 14 | uses: actions/checkout@v2 15 | with: 16 | repository: vlang/v 17 | - name: Build V 18 | run: make 19 | - name: Checkout termtable 20 | uses: actions/checkout@v2 21 | with: 22 | path: termtable 23 | - name: vet 24 | run: ./v vet -W termtable/ 25 | - name: fmt 26 | run: | 27 | ./v fmt -diff termtable/ 28 | ./v fmt -verify termtable/ 29 | 30 | test: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout V 34 | uses: actions/checkout@v2 35 | with: 36 | repository: vlang/v 37 | - name: Build V 38 | run: make 39 | - name: Checkout termtable 40 | uses: actions/checkout@v2 41 | with: 42 | path: termtable 43 | - name: Run tests 44 | run: ./v test termtable/ 45 | - name: Run tests with warnings as errors 46 | run: ./v -W test termtable/ 47 | 48 | build: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Checkout V 52 | uses: actions/checkout@v2 53 | with: 54 | repository: vlang/v 55 | - name: Build V 56 | run: make 57 | - name: Checkout termtable 58 | uses: actions/checkout@v2 59 | with: 60 | path: termtable 61 | - name: Development build 62 | run: ./v -shared termtable/ 63 | - name: Production build 64 | run: ./v -prod -shared termtable/ 65 | -------------------------------------------------------------------------------- /styles_test.v: -------------------------------------------------------------------------------- 1 | module termtable 2 | 3 | import os 4 | 5 | fn test_table_styles() { 6 | custom_style := StyleConfig{ 7 | headerline: Sepline{ 8 | left: '' 9 | right: '' 10 | cross: '' 11 | sep: '=' 12 | } 13 | colsep: ' ' 14 | } 15 | mut table := Table{ 16 | data: [ 17 | ['Name', 'Age', 'Sex'], 18 | ['Max', '13', 'male'], 19 | ['Moritz', '12', 'male'], 20 | ['Lisa', '42', 'female'], 21 | ] 22 | header_style: .plain 23 | } 24 | for i := 0; true; i++ { 25 | s := unsafe { Style(i) } 26 | if s.str() == 'unknown enum value' { 27 | break 28 | } 29 | table.style = s 30 | if s == .custom { 31 | table.custom_style = custom_style 32 | } 33 | mut exp := os.read_file('${dir}/tests/styles/${s.str()}.out') or { panic(err) } 34 | exp = exp.trim_string_right('\n') 35 | assert table.str() == exp 36 | } 37 | } 38 | 39 | fn test_single_row_tables() { 40 | table := Table{ 41 | data: [ 42 | ['Foo', 'bar', 'baz'], 43 | ] 44 | header_style: .plain 45 | style: .grid 46 | } 47 | mut exp := os.read_file('${dir}/tests/grid_single_row.out') or { panic(err) } 48 | exp = exp.trim_string_right('\n') 49 | assert table.str() == exp 50 | } 51 | 52 | fn test_no_padding() { 53 | mut table := Table{ 54 | data: [ 55 | ['Name', 'Age', 'Sex'], 56 | ['Max', '13', 'male'], 57 | ['Lisa', '42', 'female'], 58 | ] 59 | padding: 0 60 | header_style: .plain 61 | } 62 | mut styles := []Style{} 63 | styles = [ 64 | .plain, 65 | .simple, 66 | ] 67 | for s in styles { 68 | table.style = s 69 | mut exp := os.read_file('${dir}/tests/no_padding/${s.str()}.out') or { panic(err) } 70 | exp = exp.trim_string_right('\n') 71 | assert table.str() == exp 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## 0.9.0 5 | _27 May 2021_ 6 | 7 | **Breaking** 8 | - `.github` style was renamed to `.md` to clarify it's markdown 9 | - `StyleConfig.col_sep` got renamed to `StyleConfig.colsep` and has no default value anymore 10 | 11 | **Additions** 12 | - unicode widechars (e.g. chinese chars) are now supported _(somewhat experimental)_ 13 | - predefined `.rst` style for [reStructuredText][rst-docs] 14 | - `Table`: enforce a value for the `custom_style` field when `style: .custom` is set 15 | - ci: add a build and vet steps 16 | 17 | **Changes** 18 | - ci: remove dependency on setup-vlang-action 19 | - ci: more diverse and stricter checks 20 | - keep up with latest changes in V 21 | 22 | ## 0.8.0 23 | _17 November 2020_ 24 | 25 | **Additions** 26 | - Support for definition of custom styles 27 | - Validate the given table property values to prevent V panics 28 | 29 | 30 | ## 0.7.0 31 | _16 November 2020_ 32 | 33 | **Additions** 34 | - Implement proper tab support 35 | 36 | **Fixes** 37 | - Fix behaviour of various styles with padding of zero 38 | - Do not print a header sepline for single rowed tables 39 | 40 | 41 | ## 0.6.0 42 | _09 November 2020_ 43 | 44 | **Additions** 45 | - Basic Unicode symbol support 46 | - Tab support 47 | 48 | **Fixes** 49 | - Remove seplines between rows in github style 50 | 51 | 52 | ## 0.5.0 53 | _04 November 2020_ 54 | 55 | **Additions** 56 | - Add a total of four new styles: `.simple`, `.pretty`, `.github`, `.fancy_grid` 57 | 58 | ## 0.4.0 59 | _31 October 2020_ 60 | 61 | **Additions** 62 | - Choose from a set of predefined table styles using the `style` property 63 | - Bring back plain text headers. Choose what you want with `header_style` 64 | 65 | **Changes** 66 | - Readme: small improvements and clarifications 67 | 68 | 69 | ## 0.3.0 70 | _24 October 2020_ 71 | 72 | **Additions** 73 | - Print headers in bold 74 | - New `orientation` config 75 | 76 | **Fixes** 77 | - Use the actual padding value to create the seperator line 78 | - Readme: fix import line in Usage example 79 | 80 | 81 | ## 0.2.0 82 | _23 October 2020_ 83 | 84 | **Additions** 85 | - New `align` config to control cell item alignment 86 | - New `padding` config to set the minimum space between cell separator and item 87 | - Readme: add description and sections about installation and usage 88 | - Add GH Sponsors button 89 | 90 | 91 | ## 0.1.0 92 | _22 October 2020_ 93 | 94 | **Breaking** 95 | - `Table`: replace `show()` with `str()`, so you now have to print it on your own 96 | 97 | **Additions** 98 | - Test every line of code (it was splitted into small functions to allow this) 99 | - CI workflow that checks formatting and runs tests 100 | 101 | 102 | ## 0.0.1 103 | _21 October 2020_ 104 | 105 | - Just table printing 106 | 107 | 108 | 109 | [rst-docs]: https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # V Terminal Tables 2 | ![CI](https://github.com/serkonda7/termtable/workflows/CI/badge.svg?branch=master) 3 | 4 | Simple and highly customizable library to display tables in the terminal. 5 | 6 | 7 | ## Features 8 | - Choose from seven predefined [styles](#predefined-styles) 9 | - Or create any [custom style](#creating-custom-styles) you want 10 | - [Tab support](#tabsize) 11 | - [Unicode support](test/../tests/unicode/cjk.out) 12 | 13 | 14 | ## Installation 15 | `v install serkonda7.termtable` 16 | 17 | 18 | ## Usage 19 | ```v 20 | import serkonda7.termtable as tt 21 | 22 | fn main() { 23 | data := [ 24 | ['Name', 'Age', 'Sex'], 25 | ['Max', '13', 'male'], 26 | ['Moritz', '12', 'male'], 27 | ['Lisa', '42', 'female'], 28 | ] 29 | t := tt.Table{ 30 | data: data 31 | // The following settings are optional and have these defaults: 32 | style: .grid 33 | header_style: .bold 34 | align: .left 35 | orientation: .row 36 | padding: 1 37 | tabsize: 4 38 | } 39 | println(t) 40 | } 41 | ``` 42 | 43 | 44 | ### Predefined Styles 45 | Supported values for `style: ...` are: 46 | - .grid 47 | - .pretty 48 | - .plain 49 | - .simple 50 | - .fancy_grid 51 | - .md 52 | - .rst 53 | 54 | `.grid` (default): 55 | 56 | ![](img/grid.png) 57 | 58 | `.pretty`: 59 | 60 | ![](img/pretty.png) 61 | 62 | `.plain`: 63 | 64 | ![](img/plain.png) 65 | 66 | `.simple`: 67 | 68 | ![](img/simple.png) 69 | 70 | `.fancy_grid`: 71 | 72 | ![](img/fancy_grid.png) 73 | 74 | `.md` follows the conventions of [Markdown][md-tables]. It does not add alignment colons though: 75 | 76 | ![](img/md.png) 77 | 78 | `.rst` behaves like the [reStructuredText][rst-tables] simple table format: 79 | 80 | ![](img/rst.png) 81 | 82 | 83 | ### Header Style 84 | ```v 85 | // header_style: ... 86 | ``` 87 | | `.bold (default)` | `.plain` | 88 | | :-------: | :-------: | 89 | | ![](img/header_bold.png) | ![](img/header_plain.png) | 90 | 91 | 92 | ### Alignment 93 | ```v 94 | // align: ... 95 | | Max | 13 | male | // .left (default) 96 | | Max | 13 | male | // .center 97 | | Max | 13 | male | // .right 98 | ``` 99 | 100 | 101 | ### Orientation 102 | ```v 103 | t := tt.Table{ 104 | data: [ 105 | ['Name', 'Age'], 106 | ['Max', '13'], 107 | ['Moritz', '12'], 108 | ] 109 | // orientation: ... 110 | } 111 | println(t) 112 | ``` 113 | | `.row (default)` | `.column` | 114 | | :-------: | :-------: | 115 | | ![](img/orientation_row.png) | ![](img/orientation_column.png) | 116 | 117 | 118 | ### Padding 119 | Control the count of spaces between the cell border and the item. 120 | ```v 121 | // padding: ... 122 | | Lisa | 42 | female | // 3 123 | 124 | | Lisa | 42 | female | // 1 (default) 125 | 126 | |Lisa|42|female| // 0 127 | ``` 128 | 129 | 130 | ### Tabsize 131 | ```v 132 | t := tt.Table{ 133 | data: [ 134 | ['\tName', 'Sex'], 135 | ['1.\tMax', 'male\t'], 136 | ['2. \tMoritz', '\tmale'], 137 | ] 138 | // tabsize: ... 139 | } 140 | println(t) 141 | ``` 142 | 143 | | `4 (default)` | `2` | `8` | 144 | | :-------: | :-------: | :-------: | 145 | | ![](img/tab-4.png) | ![](img/tab-2.png) | ![](img/tab-8.png) | 146 | 147 | 148 | ### Creating Custom Styles 149 | To create a custom style set the tables style property to `style: .custom` 150 | and specify `custom_style: tt.StyleConfig{...}`. 151 | 152 | #### `StyleConfig` Struct 153 | ```v 154 | topline tt.Sepline{...} 155 | headerline tt.Sepline{...} 156 | middleline tt.Sepline{...} 157 | bottomline tt.Sepline{...} 158 | colsep string 159 | fill_padding bool = true 160 | ``` 161 | 162 | #### `Sepline` Struct 163 | ```v 164 | left string 165 | right string 166 | cross string 167 | sep string 168 | ``` 169 | 170 | 171 | ## Acknowledgements 172 | - Images were made with [carbon][carbon-repo] and optimized with [image-actions][image-actions-repo] 173 | 174 | 175 | ## License 176 | Licensed under the [MIT License](LICENSE.md) 177 | 178 | 179 | 180 | [md-tables]: https://www.markdownguide.org/extended-syntax#tables 181 | [rst-tables]: https://docutils.sourceforge.io/docs/user/rst/quickref.html#tables 182 | [carbon-repo]: https://github.com/carbon-app/carbon 183 | [image-actions-repo]: https://github.com/calibreapp/image-actions 184 | -------------------------------------------------------------------------------- /termtable_test.v: -------------------------------------------------------------------------------- 1 | module termtable 2 | 3 | fn test_validate_table_properties() { 4 | tables := { 5 | 'no_data': Table{} 6 | 'small_tab': Table{ 7 | data: [['Foo\t']] 8 | tabsize: 1 9 | } 10 | 'negative_pad': Table{ 11 | data: [['Foo']] 12 | padding: -1 13 | } 14 | 'no_custom_style_cfg': Table{ 15 | data: [['Foo']] 16 | style: .custom 17 | } 18 | } 19 | error_suffixes := { 20 | 'no_data': 'Table.data should not be empty.' 21 | 'small_tab': 'tabsize should be at least 2 (got 1).' 22 | 'negative_pad': 'cannot use a negative padding (got -1).' 23 | 'no_custom_style_cfg': 'please provide a value for `custom_style` if you use `style: .custom`.' 24 | } 25 | mut errors := 0 26 | for k, t in tables { 27 | validate_table_properties(t) or { 28 | errors++ 29 | assert err.msg() == error_suffixes[k] 30 | } 31 | } 32 | assert errors == tables.len 33 | } 34 | 35 | fn test_expand_tabs() { 36 | tabs := [ 37 | ['\tName', 'Sex\t\t'], 38 | ['1.\tMax', 'male\t'], 39 | ['2. Moritz', 'male'], 40 | ] 41 | tabsizes := [4, 2] 42 | expanded_tabs := [ 43 | [ 44 | [' Name', 'Sex '], 45 | ['1. Max', 'male '], 46 | ['2. Moritz', 'male'], 47 | ], 48 | [ 49 | [' Name', 'Sex '], 50 | ['1. Max', 'male '], 51 | ['2. Moritz', 'male'], 52 | ], 53 | ] 54 | for i, ts in tabsizes { 55 | exp := expanded_tabs[i] 56 | assert expand_tabs(tabs, ts) == exp 57 | } 58 | } 59 | 60 | fn test_get_row_and_col_data() { 61 | rowdata := [ 62 | ['Name', 'Age'], 63 | ['Max', '13'], 64 | ['Moritz', '12'], 65 | ] 66 | coldata := [ 67 | ['Name', 'Max', 'Moritz'], 68 | ['Age', '13', '12'], 69 | ] 70 | mut rd, mut cd := get_row_and_col_data(rowdata, .row) 71 | assert rd == rowdata 72 | assert cd == coldata 73 | rd, cd = get_row_and_col_data(coldata, .column) 74 | assert rd == rowdata 75 | assert cd == coldata 76 | } 77 | 78 | fn test_max_column_sizes() { 79 | coldata := [ 80 | ['Name', 'Max', 'Moritz', 'Lisa'], 81 | ['Age', '13', '12', '42'], 82 | ['Sex', 'male', 'male', 'female'], 83 | ['', ''], 84 | ] 85 | colmaxes := [6, 3, 6, 0] 86 | assert max_column_sizes(coldata) == colmaxes 87 | } 88 | 89 | struct ApplyHeaderStyleInput { 90 | row []string 91 | header_style HeaderStyle 92 | orient Orientation 93 | } 94 | 95 | fn test_apply_header_style() { 96 | inputs := [ 97 | ApplyHeaderStyleInput{['spam', 'eggs'], .bold, .row}, 98 | ApplyHeaderStyleInput{['foo', 'bar', 'baz'], .plain, .row}, 99 | ApplyHeaderStyleInput{['test', 'placeholder'], .bold, .column}, 100 | ] 101 | expected := [ 102 | ['\e[1mspam\e[0m', '\e[1meggs\e[0m'], 103 | ['foo', 'bar', 'baz'], 104 | ['\e[1mtest\e[0m', 'placeholder'], 105 | ] 106 | for i, inp in inputs { 107 | exp := expected[i] 108 | assert apply_header_style(inp.row, inp.header_style, inp.orient) == exp 109 | } 110 | } 111 | 112 | struct RowSpacesInput { 113 | row []string 114 | col_sizes []int 115 | } 116 | 117 | fn test_get_row_spaces() { 118 | inputs := [ 119 | RowSpacesInput{['a', 'bc', 'def'], [3, 4, 5]}, 120 | RowSpacesInput{['foo', 'bar', 'baz'], [5, 3, 6]}, 121 | RowSpacesInput{[''], [0]}, 122 | RowSpacesInput{['🤨', '💯💯', '✌👍🐞'], [4, 5, 5]}, 123 | ] 124 | expected := [ 125 | [2, 2, 2], 126 | [2, 0, 3], 127 | [0], 128 | [2, 1, 0], 129 | ] 130 | for i, inp in inputs { 131 | exp := expected[i] 132 | assert get_row_spaces(inp.row, inp.col_sizes) == exp 133 | } 134 | } 135 | 136 | struct RowToStrInput { 137 | align Alignment 138 | padding int 139 | style Style 140 | } 141 | 142 | fn test_row_to_string() { 143 | row := ['a', 'bc', 'def'] 144 | rspace := [2, 2, 0] 145 | inp_vals := [ 146 | RowToStrInput{.left, 1, .grid}, 147 | RowToStrInput{.center, 3, .grid}, 148 | ] 149 | expected := [ 150 | '| a | bc | def |', 151 | '| a | bc | def |', 152 | ] 153 | for i, inp in inp_vals { 154 | b := get_style_config(inp.style) 155 | exp := expected[i] 156 | assert row_to_string(row, rspace, inp.align, inp.padding, b) == exp 157 | } 158 | } 159 | 160 | fn test_cell_space() { 161 | inputs := [ 162 | [2, 0], 163 | [4, 1], 164 | [5, 1], 165 | [3, 2], 166 | ] 167 | expected := [ 168 | [0, 2], 169 | [2, 2], 170 | [2, 3], 171 | [3, 0], 172 | ] 173 | for i, inp in inputs { 174 | ls, rs := cell_space(inp[0], unsafe { Alignment(inp[1]) }) 175 | assert ls == expected[i][0] 176 | assert rs == expected[i][1] 177 | } 178 | } 179 | 180 | struct CreateSeplineInput { 181 | col_sizes []int 182 | padding int 183 | style Style 184 | } 185 | 186 | fn test_create_sepline() { 187 | inputs := [ 188 | CreateSeplineInput{ 189 | col_sizes: [1, 2, 3] 190 | padding: 1 191 | style: .grid 192 | }, 193 | CreateSeplineInput{ 194 | col_sizes: [1, 4] 195 | padding: 0 196 | style: .grid 197 | }, 198 | CreateSeplineInput{ 199 | col_sizes: [2, 2] 200 | padding: 1 201 | style: .plain 202 | }, 203 | ] 204 | expected := [ 205 | ['+---+----+-----+\n', '+---+----+-----+'], 206 | ['+-+----+\n', '+-+----+'], 207 | ['', ''], 208 | ] 209 | for i, inp in inputs { 210 | b := get_style_config(inp.style) 211 | exp := expected[i] 212 | assert create_sepline(.top, inp.col_sizes, inp.padding, b) == exp[0] 213 | assert create_sepline(.header, inp.col_sizes, inp.padding, b) == exp[0] 214 | assert create_sepline(.middle, inp.col_sizes, inp.padding, b) == exp[0] 215 | assert create_sepline(.bottom, inp.col_sizes, inp.padding, b) == exp[1] 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /termtable.v: -------------------------------------------------------------------------------- 1 | module termtable 2 | 3 | import os 4 | 5 | const dir = os.dir(@FILE) 6 | 7 | const ( 8 | gridline = Sepline{ 9 | left: '+' 10 | right: '+' 11 | cross: '+' 12 | sep: '-' 13 | } 14 | style_configs = { 15 | 'grid': StyleConfig{ 16 | topline: gridline 17 | headerline: gridline 18 | middleline: gridline 19 | bottomline: gridline 20 | colsep: '|' 21 | } 22 | 'plain': StyleConfig{ 23 | colsep: ' ' 24 | } 25 | 'simple': StyleConfig{ 26 | headerline: Sepline{ 27 | cross: ' ' 28 | sep: '-' 29 | } 30 | fill_padding: false 31 | colsep: ' ' 32 | } 33 | 'pretty': StyleConfig{ 34 | topline: gridline 35 | headerline: gridline 36 | bottomline: gridline 37 | colsep: '|' 38 | } 39 | 'fancy_grid': StyleConfig{ 40 | topline: Sepline{ 41 | left: '╒' 42 | right: '╕' 43 | cross: '╤' 44 | sep: '═' 45 | } 46 | headerline: Sepline{ 47 | left: '╞' 48 | right: '╡' 49 | cross: '╪' 50 | sep: '═' 51 | } 52 | middleline: Sepline{ 53 | left: '├' 54 | right: '┤' 55 | cross: '┼' 56 | sep: '─' 57 | } 58 | bottomline: Sepline{ 59 | left: '╘' 60 | right: '╛' 61 | cross: '╧' 62 | sep: '═' 63 | } 64 | colsep: '│' 65 | } 66 | 'md': StyleConfig{ 67 | headerline: Sepline{ 68 | left: '|' 69 | right: '|' 70 | cross: '|' 71 | sep: '-' 72 | } 73 | colsep: '|' 74 | } 75 | 'rst': StyleConfig{ 76 | topline: Sepline{ 77 | left: '' 78 | right: '' 79 | cross: '' 80 | sep: '=' 81 | } 82 | headerline: Sepline{ 83 | left: '' 84 | right: '' 85 | cross: '' 86 | sep: '=' 87 | } 88 | bottomline: Sepline{ 89 | left: '' 90 | right: '' 91 | cross: '' 92 | sep: '=' 93 | } 94 | fill_padding: false 95 | } 96 | } 97 | ) 98 | 99 | pub enum HeaderStyle { 100 | plain 101 | bold 102 | } 103 | 104 | pub enum Orientation { 105 | row 106 | column 107 | } 108 | 109 | pub enum Alignment { 110 | left 111 | center 112 | right 113 | } 114 | 115 | enum SeplinePos { 116 | top 117 | header 118 | middle 119 | bottom 120 | } 121 | 122 | pub struct Table { 123 | pub mut: 124 | data [][]string 125 | style Style = .grid 126 | header_style HeaderStyle = .bold 127 | orientation Orientation = .row 128 | align Alignment = .left 129 | padding int = 1 130 | tabsize int = 4 131 | custom_style StyleConfig = StyleConfig{} 132 | } 133 | 134 | // str generates the string representation of the table. 135 | pub fn (t Table) str() string { 136 | validate_table_properties(t) or { 137 | eprintln('termtable: ${err}') 138 | exit(1) 139 | } 140 | edata := expand_tabs(t.data, t.tabsize) 141 | rowdata, coldata := get_row_and_col_data(edata, t.orientation) 142 | colmaxes := max_column_sizes(coldata) 143 | mut rowstrings := []string{} 144 | sc := if t.style == .custom { t.custom_style } else { get_style_config(t.style) } 145 | for i, row in rowdata { 146 | mut styled_row := row.clone() 147 | if t.orientation == .column || i == 0 { 148 | styled_row = apply_header_style(row, t.header_style, t.orientation) 149 | } 150 | rspace := get_row_spaces(row, colmaxes) 151 | rowstrings << row_to_string(styled_row, rspace, t.align, t.padding, sc) 152 | } 153 | topline := create_sepline(.top, colmaxes, t.padding, sc) 154 | headline := create_sepline(.header, colmaxes, t.padding, sc) 155 | sepline := create_sepline(.middle, colmaxes, t.padding, sc) 156 | bottomline := create_sepline(.bottom, colmaxes, t.padding, sc) 157 | mut final_str := topline 158 | for i, row_str in rowstrings { 159 | final_str += '${row_str}\n' 160 | if i == 0 && rowstrings.len >= 2 { 161 | final_str += headline 162 | } else if i < rowstrings.len - 1 { 163 | final_str += sepline 164 | } 165 | } 166 | final_str += bottomline 167 | return final_str.trim_space() 168 | } 169 | 170 | fn validate_table_properties(t Table) ! { 171 | if t.data == [][]string{} { 172 | return error('Table.data should not be empty.') 173 | } 174 | if t.tabsize < 2 { 175 | return error('tabsize should be at least 2 (got ${t.tabsize}).') 176 | } 177 | if t.padding < 0 { 178 | return error('cannot use a negative padding (got ${t.padding}).') 179 | } 180 | if t.style == .custom { 181 | default_sc := StyleConfig{} 182 | if t.custom_style.str() == default_sc.str() { 183 | return error('please provide a value for `custom_style` if you use `style: .custom`.') 184 | } 185 | } 186 | } 187 | 188 | fn expand_tabs(raw_data [][]string, tabsize int) [][]string { 189 | mut edata := [][]string{} 190 | for d in raw_data { 191 | mut ed := []string{} 192 | for c in d { 193 | mut ec := c.clone() 194 | tabs := ec.count('\t') 195 | for _ in 0 .. tabs { 196 | tpos := ec.index('\t') or { 0 } 197 | spaces := tabsize - (tpos % tabsize) 198 | ec = ec.replace_once('\t', ' '.repeat(spaces)) 199 | } 200 | ed << ec 201 | } 202 | edata << ed 203 | } 204 | return edata 205 | } 206 | 207 | fn get_row_and_col_data(data [][]string, orient Orientation) ([][]string, [][]string) { 208 | mut other_data := [][]string{} 209 | for i in 0 .. data[0].len { 210 | mut od := []string{} 211 | for d in data { 212 | od << d[i] 213 | } 214 | other_data << od 215 | } 216 | if orient == .row { 217 | return data, other_data 218 | } else { 219 | return other_data, data 220 | } 221 | } 222 | 223 | fn max_column_sizes(columns [][]string) []int { 224 | mut colmaxes := []int{len: columns.len, init: 0} 225 | for i, col in columns { 226 | for cell in col { 227 | len := utf8_str_visible_length(cell) 228 | if len > colmaxes[i] { 229 | colmaxes[i] = len 230 | } 231 | } 232 | } 233 | return colmaxes 234 | } 235 | 236 | fn apply_header_style(row []string, style HeaderStyle, orient Orientation) []string { 237 | if style == .plain { 238 | return row 239 | } 240 | if orient == .column { 241 | mut r := ['\e[1m${row[0]}\e[0m'] 242 | r << row[1..] 243 | return r 244 | } 245 | return row.map('\e[1m${it}\e[0m') 246 | } 247 | 248 | fn get_row_spaces(row []string, col_sizes []int) []int { 249 | mut rspace := []int{} 250 | for i, cell in row { 251 | rspace << col_sizes[i] - utf8_str_visible_length(cell) 252 | } 253 | return rspace 254 | } 255 | 256 | fn row_to_string(row []string, rspace []int, align Alignment, padding int, sc StyleConfig) string { 257 | mut final_row := row.clone() 258 | pad := ' '.repeat(padding) 259 | mut rstr := sc.colsep + pad 260 | for i, cell in final_row { 261 | sl, sr := cell_space(rspace[i], align) 262 | rstr += ' '.repeat(sl) + cell + ' '.repeat(sr) 263 | rstr += pad + sc.colsep + pad 264 | } 265 | return rstr.trim_space() 266 | } 267 | 268 | fn cell_space(total_space int, align Alignment) (int, int) { 269 | match align { 270 | .left { 271 | return 0, total_space 272 | } 273 | .center { 274 | half_space := total_space / 2 275 | sr := half_space + total_space % 2 276 | return half_space, sr 277 | } 278 | .right { 279 | return total_space, 0 280 | } 281 | } 282 | } 283 | 284 | fn create_sepline(pos SeplinePos, col_sizes []int, pad int, sc StyleConfig) string { 285 | padding := pad * 2 286 | sl_cfg := match pos { 287 | .top { sc.topline } 288 | .header { sc.headerline } 289 | .middle { sc.middleline } 290 | .bottom { sc.bottomline } 291 | } 292 | mut line := sl_cfg.left 293 | for i, cs in col_sizes { 294 | if sc.fill_padding { 295 | line += sl_cfg.sep.repeat(cs + padding) 296 | } else { 297 | line += sl_cfg.sep.repeat(cs) 298 | line += ' '.repeat(padding) 299 | } 300 | if i < col_sizes.len - 1 { 301 | line += sl_cfg.cross 302 | } 303 | } 304 | line += sl_cfg.right 305 | line = line.trim_space() 306 | if line.len == 0 { 307 | return '' 308 | } 309 | if pos != .bottom { 310 | line += '\n' 311 | } 312 | return line 313 | } 314 | --------------------------------------------------------------------------------