Initial QSfera import
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
|
||||
|
||||
# folders
|
||||
.idea
|
||||
.vscode
|
||||
/tmp
|
||||
/lab
|
||||
dev.sh
|
||||
*csv2table
|
||||
_test/
|
||||
*.test
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
# See for configurations: https://golangci-lint.run/usage/configuration/
|
||||
version: 2
|
||||
# See: https://golangci-lint.run/usage/formatters/
|
||||
formatters:
|
||||
default: none
|
||||
enable:
|
||||
- gofmt # https://pkg.go.dev/cmd/gofmt
|
||||
- gofumpt # https://github.com/mvdan/gofumpt
|
||||
|
||||
settings:
|
||||
gofmt:
|
||||
simplify: true # Simplify code: gofmt with `-s` option.
|
||||
|
||||
gofumpt:
|
||||
# Module path which contains the source code being formatted.
|
||||
# Default: ""
|
||||
module-path: github.com/olekukonko/tablewriter # Should match with module in go.mod
|
||||
# Choose whether to use the extra rules.
|
||||
# Default: false
|
||||
extra-rules: true
|
||||
|
||||
# See: https://golangci-lint.run/usage/linters/
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- staticcheck
|
||||
- govet
|
||||
- gocritic
|
||||
# - unused # TODO: There are many unused functions, should I directly remove those ?
|
||||
- ineffassign
|
||||
- unconvert
|
||||
- mirror
|
||||
- usestdlibvars
|
||||
- loggercheck
|
||||
- exptostd
|
||||
- godot
|
||||
- perfsprint
|
||||
|
||||
# See: https://golangci-lint.run/usage/false-positives/
|
||||
exclusion:
|
||||
# paths:
|
||||
# rules:
|
||||
|
||||
settings:
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- "-SA1019" # disabled because it warns about deprecated: kept for compatibility will be removed soon
|
||||
- "-ST1019" # disabled because it warns about deprecated: kept for compatibility will be removed soon
|
||||
- "-ST1021" # disabled because it warns to have comment on exported packages
|
||||
- "-ST1000" # disabled because it warns to have comment on exported functions
|
||||
- "-ST1020" # disabled because it warns to have at least one file in a package should have a package comment
|
||||
|
||||
godot:
|
||||
period: false
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (C) 2014 by Oleku Konko
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+3280
File diff suppressed because it is too large
Load Diff
+1103
File diff suppressed because it is too large
Load Diff
+466
@@ -0,0 +1,466 @@
|
||||
ASCII Table Writer
|
||||
=========
|
||||
|
||||
[](https://github.com/olekukonko/tablewriter/actions?query=workflow%3Aci)
|
||||
[](https://sourcegraph.com/github.com/olekukonko/tablewriter)
|
||||
[](https://godoc.org/github.com/olekukonko/tablewriter)
|
||||
|
||||
|
||||
## Important Notice: Modernization in Progress
|
||||
|
||||
The `tablewriter` package is being modernized on the `prototype` branch with generics, streaming support, and a modular design, targeting `v0.2.0`. Until this is released:
|
||||
|
||||
**For Production Use**: Use the stable version `v0.0.5`:
|
||||
```bash
|
||||
go get github.com/olekukonko/tablewriter@v0.0.5
|
||||
```
|
||||
|
||||
####
|
||||
|
||||
For Development Preview: Try the in-progress version (unstable)
|
||||
|
||||
```bash
|
||||
go get github.com/olekukonko/tablewriter@master
|
||||
```
|
||||
|
||||
#### Features
|
||||
|
||||
- Automatic Padding
|
||||
- Support Multiple Lines
|
||||
- Supports Alignment
|
||||
- Support Custom Separators
|
||||
- Automatic Alignment of numbers & percentage
|
||||
- Write directly to http , file etc via `io.Writer`
|
||||
- Read directly from CSV file
|
||||
- Optional row line via `SetRowLine`
|
||||
- Normalise table header
|
||||
- Make CSV Headers optional
|
||||
- Enable or disable table border
|
||||
- Set custom footer support
|
||||
- Optional identical cells merging
|
||||
- Set custom caption
|
||||
- Optional reflowing of paragraphs in multi-line cells.
|
||||
|
||||
#### Example 1 - Basic
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"A", "The Good", "500"},
|
||||
[]string{"B", "The Very very Bad Man", "288"},
|
||||
[]string{"C", "The Ugly", "120"},
|
||||
[]string{"D", "The Gopher", "800"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Name", "Sign", "Rating"})
|
||||
|
||||
for _, v := range data {
|
||||
table.Append(v)
|
||||
}
|
||||
table.Render() // Send output
|
||||
```
|
||||
|
||||
##### Output 1
|
||||
|
||||
```
|
||||
+------+-----------------------+--------+
|
||||
| NAME | SIGN | RATING |
|
||||
+------+-----------------------+--------+
|
||||
| A | The Good | 500 |
|
||||
| B | The Very very Bad Man | 288 |
|
||||
| C | The Ugly | 120 |
|
||||
| D | The Gopher | 800 |
|
||||
+------+-----------------------+--------+
|
||||
```
|
||||
|
||||
#### Example 2 - Without Border / Footer / Bulk Append
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "2233", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "2233", "$54.95"},
|
||||
[]string{"1/4/2014", "February Hosting", "2233", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "2233", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetFooter([]string{"", "", "Total", "$146.93"}) // Add Footer
|
||||
table.EnableBorder(false) // Set Border to false
|
||||
table.AppendBulk(data) // Add Bulk Data
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 2
|
||||
|
||||
```
|
||||
|
||||
DATE | DESCRIPTION | CV2 | AMOUNT
|
||||
-----------+--------------------------+-------+----------
|
||||
1/1/2014 | Domain name | 2233 | $10.98
|
||||
1/1/2014 | January Hosting | 2233 | $54.95
|
||||
1/4/2014 | February Hosting | 2233 | $51.00
|
||||
1/4/2014 | February Extra Bandwidth | 2233 | $30.00
|
||||
-----------+--------------------------+-------+----------
|
||||
TOTAL | $146 93
|
||||
--------+----------
|
||||
|
||||
```
|
||||
|
||||
#### Example 3 - CSV
|
||||
|
||||
```go
|
||||
table, _ := tablewriter.NewCSV(os.Stdout, "testdata/test_info.csv", true)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT) // Set Alignment
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 3
|
||||
|
||||
```
|
||||
+----------+--------------+------+-----+---------+----------------+
|
||||
| FIELD | TYPE | NULL | KEY | DEFAULT | EXTRA |
|
||||
+----------+--------------+------+-----+---------+----------------+
|
||||
| user_id | smallint(5) | NO | PRI | NULL | auto_increment |
|
||||
| username | varchar(10) | NO | | NULL | |
|
||||
| password | varchar(100) | NO | | NULL | |
|
||||
+----------+--------------+------+-----+---------+----------------+
|
||||
```
|
||||
|
||||
#### Example 4 - Custom Separator
|
||||
|
||||
```go
|
||||
table, _ := tablewriter.NewCSV(os.Stdout, "testdata/test.csv", true)
|
||||
table.SetRowLine(true) // Enable row line
|
||||
|
||||
// Change table lines
|
||||
table.SetCenterSeparator("*")
|
||||
table.SetColumnSeparator("╪")
|
||||
table.SetRowSeparator("-")
|
||||
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 4
|
||||
|
||||
```
|
||||
*------------*-----------*---------*
|
||||
╪ FIRST NAME ╪ LAST NAME ╪ SSN ╪
|
||||
*------------*-----------*---------*
|
||||
╪ John ╪ Barry ╪ 123456 ╪
|
||||
*------------*-----------*---------*
|
||||
╪ Kathy ╪ Smith ╪ 687987 ╪
|
||||
*------------*-----------*---------*
|
||||
╪ Bob ╪ McCornick ╪ 3979870 ╪
|
||||
*------------*-----------*---------*
|
||||
```
|
||||
|
||||
#### Example 5 - Markdown Format
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "2233", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "2233", "$54.95"},
|
||||
[]string{"1/4/2014", "February Hosting", "2233", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "2233", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
|
||||
table.SetCenterSeparator("|")
|
||||
table.AppendBulk(data) // Add Bulk Data
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 5
|
||||
|
||||
```
|
||||
| DATE | DESCRIPTION | CV2 | AMOUNT |
|
||||
|----------|--------------------------|------|--------|
|
||||
| 1/1/2014 | Domain name | 2233 | $10.98 |
|
||||
| 1/1/2014 | January Hosting | 2233 | $54.95 |
|
||||
| 1/4/2014 | February Hosting | 2233 | $51.00 |
|
||||
| 1/4/2014 | February Extra Bandwidth | 2233 | $30.00 |
|
||||
```
|
||||
|
||||
#### Example 6 - Identical cells merging
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "1234", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "2345", "$54.95"},
|
||||
[]string{"1/4/2014", "February Hosting", "3456", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "4567", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetFooter([]string{"", "", "Total", "$146.93"})
|
||||
table.SetAutoMergeCells(true)
|
||||
table.SetRowLine(true)
|
||||
table.AppendBulk(data)
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 6
|
||||
|
||||
```
|
||||
+----------+--------------------------+-------+---------+
|
||||
| DATE | DESCRIPTION | CV2 | AMOUNT |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/1/2014 | Domain name | 1234 | $10.98 |
|
||||
+ +--------------------------+-------+---------+
|
||||
| | January Hosting | 2345 | $54.95 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/4/2014 | February Hosting | 3456 | $51.00 |
|
||||
+ +--------------------------+-------+---------+
|
||||
| | February Extra Bandwidth | 4567 | $30.00 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| TOTAL | $146 93 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
```
|
||||
|
||||
#### Example 7 - Identical cells merging (specify the column index to merge)
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "1234", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "1234", "$10.98"},
|
||||
[]string{"1/4/2014", "February Hosting", "3456", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "4567", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetFooter([]string{"", "", "Total", "$146.93"})
|
||||
table.SetAutoMergeCellsByColumnIndex([]int{2, 3})
|
||||
table.SetRowLine(true)
|
||||
table.AppendBulk(data)
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 7
|
||||
|
||||
```
|
||||
+----------+--------------------------+-------+---------+
|
||||
| DATE | DESCRIPTION | CV2 | AMOUNT |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/1/2014 | Domain name | 1234 | $10.98 |
|
||||
+----------+--------------------------+ + +
|
||||
| 1/1/2014 | January Hosting | | |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/4/2014 | February Hosting | 3456 | $51.00 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| 1/4/2014 | February Extra Bandwidth | 4567 | $30.00 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
| TOTAL | $146.93 |
|
||||
+----------+--------------------------+-------+---------+
|
||||
```
|
||||
|
||||
#### Table with color
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"1/1/2014", "Domain name", "2233", "$10.98"},
|
||||
[]string{"1/1/2014", "January Hosting", "2233", "$54.95"},
|
||||
[]string{"1/4/2014", "February Hosting", "2233", "$51.00"},
|
||||
[]string{"1/4/2014", "February Extra Bandwidth", "2233", "$30.00"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Date", "Description", "CV2", "Amount"})
|
||||
table.SetFooter([]string{"", "", "Total", "$146.93"}) // Add Footer
|
||||
table.EnableBorder(false) // Set Border to false
|
||||
|
||||
table.SetHeaderColor(tablewriter.Colors{tablewriter.Bold, tablewriter.BgGreenColor},
|
||||
tablewriter.Colors{tablewriter.FgHiRedColor, tablewriter.Bold, tablewriter.BgBlackColor},
|
||||
tablewriter.Colors{tablewriter.BgRedColor, tablewriter.FgWhiteColor},
|
||||
tablewriter.Colors{tablewriter.BgCyanColor, tablewriter.FgWhiteColor})
|
||||
|
||||
table.SetColumnColor(tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiBlackColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiRedColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiBlackColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgBlackColor})
|
||||
|
||||
table.SetFooterColor(tablewriter.Colors{}, tablewriter.Colors{},
|
||||
tablewriter.Colors{tablewriter.Bold},
|
||||
tablewriter.Colors{tablewriter.FgHiRedColor})
|
||||
|
||||
table.AppendBulk(data)
|
||||
table.Render()
|
||||
```
|
||||
|
||||
#### Table with color Output
|
||||
|
||||

|
||||
|
||||
#### Example - 8 Table Cells with Color
|
||||
|
||||
Individual Cell Colors from `func Rich` take precedence over Column Colors
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"Test1Merge", "HelloCol2 - 1", "HelloCol3 - 1", "HelloCol4 - 1"},
|
||||
[]string{"Test1Merge", "HelloCol2 - 2", "HelloCol3 - 2", "HelloCol4 - 2"},
|
||||
[]string{"Test1Merge", "HelloCol2 - 3", "HelloCol3 - 3", "HelloCol4 - 3"},
|
||||
[]string{"Test2Merge", "HelloCol2 - 4", "HelloCol3 - 4", "HelloCol4 - 4"},
|
||||
[]string{"Test2Merge", "HelloCol2 - 5", "HelloCol3 - 5", "HelloCol4 - 5"},
|
||||
[]string{"Test2Merge", "HelloCol2 - 6", "HelloCol3 - 6", "HelloCol4 - 6"},
|
||||
[]string{"Test2Merge", "HelloCol2 - 7", "HelloCol3 - 7", "HelloCol4 - 7"},
|
||||
[]string{"Test3Merge", "HelloCol2 - 8", "HelloCol3 - 8", "HelloCol4 - 8"},
|
||||
[]string{"Test3Merge", "HelloCol2 - 9", "HelloCol3 - 9", "HelloCol4 - 9"},
|
||||
[]string{"Test3Merge", "HelloCol2 - 10", "HelloCol3 -10", "HelloCol4 - 10"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Col1", "Col2", "Col3", "Col4"})
|
||||
table.SetFooter([]string{"", "", "Footer3", "Footer4"})
|
||||
table.EnableBorder(false)
|
||||
|
||||
table.SetHeaderColor(tablewriter.Colors{tablewriter.Bold, tablewriter.BgGreenColor},
|
||||
tablewriter.Colors{tablewriter.FgHiRedColor, tablewriter.Bold, tablewriter.BgBlackColor},
|
||||
tablewriter.Colors{tablewriter.BgRedColor, tablewriter.FgWhiteColor},
|
||||
tablewriter.Colors{tablewriter.BgCyanColor, tablewriter.FgWhiteColor})
|
||||
|
||||
table.SetColumnColor(tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiBlackColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiRedColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgHiBlackColor},
|
||||
tablewriter.Colors{tablewriter.Bold, tablewriter.FgBlackColor})
|
||||
|
||||
table.SetFooterColor(tablewriter.Colors{}, tablewriter.Colors{},
|
||||
tablewriter.Colors{tablewriter.Bold},
|
||||
tablewriter.Colors{tablewriter.FgHiRedColor})
|
||||
|
||||
colorData1 := []string{"TestCOLOR1Merge", "HelloCol2 - COLOR1", "HelloCol3 - COLOR1", "HelloCol4 - COLOR1"}
|
||||
colorData2 := []string{"TestCOLOR2Merge", "HelloCol2 - COLOR2", "HelloCol3 - COLOR2", "HelloCol4 - COLOR2"}
|
||||
|
||||
for i, row := range data {
|
||||
if i == 4 {
|
||||
table.Rich(colorData1, []tablewriter.Colors{tablewriter.Colors{}, tablewriter.Colors{tablewriter.Normal, tablewriter.FgCyanColor}, tablewriter.Colors{tablewriter.Bold, tablewriter.FgWhiteColor}, tablewriter.Colors{}})
|
||||
table.Rich(colorData2, []tablewriter.Colors{tablewriter.Colors{tablewriter.Normal, tablewriter.FgMagentaColor}, tablewriter.Colors{}, tablewriter.Colors{tablewriter.Bold, tablewriter.BgRedColor}, tablewriter.Colors{tablewriter.FgHiGreenColor, tablewriter.Italic, tablewriter.BgHiCyanColor}})
|
||||
}
|
||||
table.Append(row)
|
||||
}
|
||||
|
||||
table.SetAutoMergeCells(true)
|
||||
table.Render()
|
||||
|
||||
```
|
||||
|
||||
##### Table cells with color Output
|
||||
|
||||

|
||||
|
||||
#### Example 9 - Set table caption
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
[]string{"A", "The Good", "500"},
|
||||
[]string{"B", "The Very very Bad Man", "288"},
|
||||
[]string{"C", "The Ugly", "120"},
|
||||
[]string{"D", "The Gopher", "800"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Name", "Sign", "Rating"})
|
||||
table.SetCaption(true, "Movie ratings.")
|
||||
|
||||
for _, v := range data {
|
||||
table.Append(v)
|
||||
}
|
||||
table.Render() // Send output
|
||||
```
|
||||
|
||||
Note: Caption text will wrap with total width of rendered table.
|
||||
|
||||
##### Output 9
|
||||
|
||||
```
|
||||
+------+-----------------------+--------+
|
||||
| NAME | SIGN | RATING |
|
||||
+------+-----------------------+--------+
|
||||
| A | The Good | 500 |
|
||||
| B | The Very very Bad Man | 288 |
|
||||
| C | The Ugly | 120 |
|
||||
| D | The Gopher | 800 |
|
||||
+------+-----------------------+--------+
|
||||
Movie ratings.
|
||||
```
|
||||
|
||||
#### Example 10 - Set NoWhiteSpace and TablePadding option
|
||||
|
||||
```go
|
||||
data := [][]string{
|
||||
{"node1.example.com", "Ready", "compute", "1.11"},
|
||||
{"node2.example.com", "Ready", "compute", "1.11"},
|
||||
{"node3.example.com", "Ready", "compute", "1.11"},
|
||||
{"node4.example.com", "NotReady", "compute", "1.11"},
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.SetHeader([]string{"Name", "Status", "Role", "Version"})
|
||||
table.SetAutoWrapText(false)
|
||||
table.SetAutoFormatHeaders(true)
|
||||
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetAlignment(tablewriter.ALIGN_LEFT)
|
||||
table.SetCenterSeparator("")
|
||||
table.SetColumnSeparator("")
|
||||
table.SetRowSeparator("")
|
||||
table.SetHeaderLine(false)
|
||||
table.EnableBorder(false)
|
||||
table.SetTablePadding("\t") // pad with tabs
|
||||
table.SetNoWhiteSpace(true)
|
||||
table.AppendBulk(data) // Add Bulk Data
|
||||
table.Render()
|
||||
```
|
||||
|
||||
##### Output 10
|
||||
|
||||
```
|
||||
NAME STATUS ROLE VERSION
|
||||
node1.example.com Ready compute 1.11
|
||||
node2.example.com Ready compute 1.11
|
||||
node3.example.com Ready compute 1.11
|
||||
node4.example.com NotReady compute 1.11
|
||||
```
|
||||
|
||||
#### Render table into a string
|
||||
|
||||
Instead of rendering the table to `io.Stdout` you can also render it into a string. Go 1.10 introduced the
|
||||
`strings.Builder` type which implements the `io.Writer` interface and can therefore be used for this task. Example:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"fmt"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
)
|
||||
|
||||
func main() {
|
||||
tableString := &strings.Builder{}
|
||||
table := tablewriter.NewWriter(tableString)
|
||||
|
||||
/*
|
||||
* Code to fill the table
|
||||
*/
|
||||
|
||||
table.Render()
|
||||
|
||||
fmt.Println(tableString.String())
|
||||
}
|
||||
```
|
||||
|
||||
#### TODO
|
||||
|
||||
- ~~Import Directly from CSV~~ - `done`
|
||||
- ~~Support for `SetFooter`~~ - `done`
|
||||
- ~~Support for `SetBorder`~~ - `done`
|
||||
- ~~Support table with uneven rows~~ - `done`
|
||||
- ~~Support custom alignment~~
|
||||
- General Improvement & Optimisation
|
||||
- `NewHTML` Parse table from HTML
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
recursive = true
|
||||
output_file = "all.txt"
|
||||
extensions = [".go"]
|
||||
exclude_dirs = [
|
||||
"_examples", "_readme", "_lab","_tmp","pkg","lab","cmd","test.txt","tmp",
|
||||
"_readme","pkg","renderer"
|
||||
]
|
||||
exclude_files = ["README.md","README_LEGACY.md","MIGRATION.md","test.hcl","csv.go"]
|
||||
use_gitignore = true
|
||||
detailed = true
|
||||
+1105
File diff suppressed because it is too large
Load Diff
+94
@@ -0,0 +1,94 @@
|
||||
package tablewriter
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// NewCSV Start A new table by importing from a CSV file
|
||||
// Takes io.Writer and csv File name
|
||||
func NewCSV(writer io.Writer, fileName string, hasHeader bool, opts ...Option) (*Table, error) {
|
||||
// Open the CSV file
|
||||
file, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
// Log implicitly handled by NewTable if logger is configured via opts
|
||||
return nil, err // Return nil *Table on error
|
||||
}
|
||||
defer file.Close() // Ensure file is closed
|
||||
|
||||
// Create a CSV reader
|
||||
csvReader := csv.NewReader(file)
|
||||
|
||||
// Delegate to NewCSVReader, passing through the options
|
||||
return NewCSVReader(writer, csvReader, hasHeader, opts...)
|
||||
}
|
||||
|
||||
// NewCSVReader Start a New Table Writer with csv.Reader
|
||||
// This enables customisation such as reader.Comma = ';'
|
||||
// See http://golang.org/src/pkg/encoding/csv/reader.go?s=3213:3671#L94
|
||||
func NewCSVReader(writer io.Writer, csvReader *csv.Reader, hasHeader bool, opts ...Option) (*Table, error) {
|
||||
// Create a new table instance using the modern API and provided options.
|
||||
// Options configure the table's appearance and behavior (renderer, borders, etc.).
|
||||
t := NewTable(writer, opts...) // Logger setup happens here if WithLogger/WithDebug is passed
|
||||
|
||||
// Process header row if specified
|
||||
if hasHeader {
|
||||
headers, err := csvReader.Read()
|
||||
if err != nil {
|
||||
// Handle EOF specifically: means the CSV was empty or contained only an empty header line.
|
||||
if err == io.EOF {
|
||||
t.logger.Debug("NewCSVReader: CSV empty or only header found (EOF after header read attempt).")
|
||||
// Return the table configured by opts, but without data/header.
|
||||
// It's ready for Render() which will likely output nothing or just borders if configured.
|
||||
return t, nil
|
||||
}
|
||||
// Log other read errors
|
||||
t.logger.Errorf("NewCSVReader: Error reading CSV header: %v", err)
|
||||
return nil, err // Return nil *Table on critical read error
|
||||
}
|
||||
|
||||
// Check if the read header is genuinely empty (e.g., a blank line in the CSV)
|
||||
isEmptyHeader := true
|
||||
for _, h := range headers {
|
||||
if h != "" {
|
||||
isEmptyHeader = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isEmptyHeader {
|
||||
t.Header(headers) // Use the Table method to set the header data
|
||||
t.logger.Debugf("NewCSVReader: Header set from CSV: %v", headers)
|
||||
} else {
|
||||
t.logger.Debug("NewCSVReader: Read an empty header line, skipping setting table header.")
|
||||
}
|
||||
}
|
||||
|
||||
// Process data rows
|
||||
rowCount := 0
|
||||
for {
|
||||
record, err := csvReader.Read()
|
||||
if err == io.EOF {
|
||||
break // Reached the end of the CSV data
|
||||
}
|
||||
if err != nil {
|
||||
// Log other read errors during data processing
|
||||
t.logger.Errorf("NewCSVReader: Error reading CSV record: %v", err)
|
||||
return nil, err // Return nil *Table on critical read error
|
||||
}
|
||||
|
||||
// Append the record to the table's internal buffer (for batch rendering).
|
||||
// The Table.Append method handles conversion and storage.
|
||||
if appendErr := t.Append(record); appendErr != nil {
|
||||
t.logger.Errorf("NewCSVReader: Error appending record #%d: %v", rowCount+1, appendErr)
|
||||
// Decide if append error is fatal. For now, let's treat it as fatal.
|
||||
return nil, appendErr
|
||||
}
|
||||
rowCount++
|
||||
}
|
||||
t.logger.Debugf("NewCSVReader: Finished reading CSV. Appended %d data rows.", rowCount)
|
||||
|
||||
// Return the configured and populated table instance, ready for Render() call.
|
||||
return t, nil
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
package tablewriter
|
||||
|
||||
import (
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// WithBorders configures the table's border settings by updating the renderer's border configuration.
|
||||
// This function is deprecated and will be removed in a future version.
|
||||
//
|
||||
// Deprecated: Use [WithRendition] to configure border settings for renderers that support
|
||||
// [tw.Renditioning], or update the renderer's [tw.RenderConfig] directly via its Config() method.
|
||||
// This function has no effect if no renderer is set on the table.
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// table.Options(WithBorders(tw.Border{Top: true, Bottom: true}))
|
||||
// // New (recommended)
|
||||
// table.Options(WithRendition(tw.Rendition{Borders: tw.Border{Top: true, Bottom: true}}))
|
||||
//
|
||||
// Parameters:
|
||||
// - borders: The [tw.Border] configuration to apply to the renderer's borders.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// An [Option] that updates the renderer's border settings if a renderer is set.
|
||||
// Logs a debug message if debugging is enabled and a renderer is present.
|
||||
func WithBorders(borders tw.Border) Option {
|
||||
return func(target *Table) {
|
||||
if target.renderer != nil {
|
||||
cfg := target.renderer.Config()
|
||||
cfg.Borders = borders
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithBorders applied to Table: %+v", borders)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Behavior is an alias for [tw.Behavior] to configure table behavior settings.
|
||||
// This type is deprecated and will be removed in a future version.
|
||||
//
|
||||
// Deprecated: Use [tw.Behavior] directly to configure settings such as auto-hiding empty
|
||||
// columns, trimming spaces, or controlling header/footer visibility.
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// var b tablewriter.Behavior = tablewriter.Behavior{AutoHide: tw.On}
|
||||
// // New (recommended)
|
||||
// var b tw.Behavior = tw.Behavior{AutoHide: tw.On}
|
||||
type Behavior tw.Behavior
|
||||
|
||||
// Settings is an alias for [tw.Settings] to configure renderer settings.
|
||||
// This type is deprecated and will be removed in a future version.
|
||||
//
|
||||
// Deprecated: Use [tw.Settings] directly to configure renderer settings, such as
|
||||
// separators and line styles.
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// var s tablewriter.Settings = tablewriter.Settings{Separator: "|"}
|
||||
// // New (recommended)
|
||||
// var s tw.Settings = tw.Settings{Separator: "|"}
|
||||
type Settings tw.Settings
|
||||
|
||||
// WithRendererSettings updates the renderer's settings, such as separators and line styles.
|
||||
// This function is deprecated and will be removed in a future version.
|
||||
//
|
||||
// Deprecated: Use [WithRendition] to update renderer settings for renderers that implement
|
||||
// [tw.Renditioning], or configure the renderer's [tw.Settings] directly via its
|
||||
// [tw.Renderer.Config] method. This function has no effect if no renderer is set.
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// table.Options(WithRendererSettings(tw.Settings{Separator: "|"}))
|
||||
// // New (recommended)
|
||||
// table.Options(WithRendition(tw.Rendition{Settings: tw.Settings{Separator: "|"}}))
|
||||
//
|
||||
// Parameters:
|
||||
// - settings: The [tw.Settings] configuration to apply to the renderer.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// An [Option] that updates the renderer's settings if a renderer is set.
|
||||
// Logs a debug message if debugging is enabled and a renderer is present.
|
||||
func WithRendererSettings(settings tw.Settings) Option {
|
||||
return func(target *Table) {
|
||||
if target.renderer != nil {
|
||||
cfg := target.renderer.Config()
|
||||
cfg.Settings = settings
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRendererSettings applied to Table: %+v", settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAlignment sets the text alignment for footer cells within the formatting configuration.
|
||||
// This method is deprecated and will be removed in the next version.
|
||||
//
|
||||
// Deprecated: Use [FooterConfigBuilder.Alignment] with [AlignmentConfigBuilder.WithGlobal]
|
||||
// or [AlignmentConfigBuilder.WithPerColumn] to configure footer alignments.
|
||||
// Alternatively, apply a complete [tw.CellAlignment] configuration using
|
||||
// [WithFooterAlignmentConfig].
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// builder.Footer().Formatting().WithAlignment(tw.AlignRight)
|
||||
// // New (recommended)
|
||||
// builder.Footer().Alignment().WithGlobal(tw.AlignRight)
|
||||
// // Or
|
||||
// table.Options(WithFooterAlignmentConfig(tw.CellAlignment{Global: tw.AlignRight}))
|
||||
//
|
||||
// Parameters:
|
||||
// - align: The [tw.Align] value to set for footer cells. Valid values are
|
||||
// [tw.AlignLeft], [tw.AlignRight], [tw.AlignCenter], and [tw.AlignNone].
|
||||
// Invalid alignments are ignored.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// The [FooterFormattingBuilder] instance for method chaining.
|
||||
func (ff *FooterFormattingBuilder) WithAlignment(align tw.Align) *FooterFormattingBuilder {
|
||||
if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone {
|
||||
return ff
|
||||
}
|
||||
ff.config.Alignment = align
|
||||
return ff
|
||||
}
|
||||
|
||||
// WithAlignment sets the text alignment for header cells within the formatting configuration.
|
||||
// This method is deprecated and will be removed in the next version.
|
||||
//
|
||||
// Deprecated: Use [HeaderConfigBuilder.Alignment] with [AlignmentConfigBuilder.WithGlobal]
|
||||
// or [AlignmentConfigBuilder.WithPerColumn] to configure header alignments.
|
||||
// Alternatively, apply a complete [tw.CellAlignment] configuration using
|
||||
// [WithHeaderAlignmentConfig].
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// builder.Header().Formatting().WithAlignment(tw.AlignCenter)
|
||||
// // New (recommended)
|
||||
// builder.Header().Alignment().WithGlobal(tw.AlignCenter)
|
||||
// // Or
|
||||
// table.Options(WithHeaderAlignmentConfig(tw.CellAlignment{Global: tw.AlignCenter}))
|
||||
//
|
||||
// Parameters:
|
||||
// - align: The [tw.Align] value to set for header cells. Valid values are
|
||||
// [tw.AlignLeft], [tw.AlignRight], [tw.AlignCenter], and [tw.AlignNone].
|
||||
// Invalid alignments are ignored.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// The [HeaderFormattingBuilder] instance for method chaining.
|
||||
func (hf *HeaderFormattingBuilder) WithAlignment(align tw.Align) *HeaderFormattingBuilder {
|
||||
if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone {
|
||||
return hf
|
||||
}
|
||||
hf.config.Alignment = align
|
||||
return hf
|
||||
}
|
||||
|
||||
// WithAlignment sets the text alignment for row cells within the formatting configuration.
|
||||
// This method is deprecated and will be removed in the next version.
|
||||
//
|
||||
// Deprecated: Use [RowConfigBuilder.Alignment] with [AlignmentConfigBuilder.WithGlobal]
|
||||
// or [AlignmentConfigBuilder.WithPerColumn] to configure row alignments.
|
||||
// Alternatively, apply a complete [tw.CellAlignment] configuration using
|
||||
// [WithRowAlignmentConfig].
|
||||
//
|
||||
// Example migration:
|
||||
//
|
||||
// // Old (deprecated)
|
||||
// builder.Row().Formatting().WithAlignment(tw.AlignLeft)
|
||||
// // New (recommended)
|
||||
// builder.Row().Alignment().WithGlobal(tw.AlignLeft)
|
||||
// // Or
|
||||
// table.Options(WithRowAlignmentConfig(tw.CellAlignment{Global: tw.AlignLeft}))
|
||||
//
|
||||
// Parameters:
|
||||
// - align: The [tw.Align] value to set for row cells. Valid values are
|
||||
// [tw.AlignLeft], [tw.AlignRight], [tw.AlignCenter], and [tw.AlignNone].
|
||||
// Invalid alignments are ignored.
|
||||
//
|
||||
// Returns:
|
||||
//
|
||||
// The [RowFormattingBuilder] instance for method chaining.
|
||||
func (rf *RowFormattingBuilder) WithAlignment(align tw.Align) *RowFormattingBuilder {
|
||||
if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone {
|
||||
return rf
|
||||
}
|
||||
rf.config.Alignment = align
|
||||
return rf
|
||||
}
|
||||
|
||||
// WithTableMax sets the maximum width of the entire table in characters.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
// The width constrains the table's rendering, potentially causing text wrapping or truncation
|
||||
// based on the configuration's wrapping settings (e.g., tw.WrapTruncate).
|
||||
// If debug logging is enabled via WithDebug(true), the applied width is logged.
|
||||
//
|
||||
// Deprecated: Use WithMaxWidth instead, which provides the same functionality with a clearer name
|
||||
// and consistent naming across the package. For example:
|
||||
//
|
||||
// tablewriter.NewTable(os.Stdout, tablewriter.WithMaxWidth(80))
|
||||
func WithTableMax(width int) Option {
|
||||
return func(target *Table) {
|
||||
if width < 0 {
|
||||
return
|
||||
}
|
||||
target.config.MaxWidth = width
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTableMax applied to Table: %v", width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: use WithEastAsian instead.
|
||||
// WithCondition provides a way to set a custom global runewidth.Condition
|
||||
// that will be used for all subsequent display width calculations by the twwidth (twdw) package.
|
||||
//
|
||||
// The runewidth.Condition object allows for more fine-grained control over how rune widths
|
||||
// are determined, beyond just toggling EastAsianWidth. This could include settings for
|
||||
// ambiguous width characters or other future properties of runewidth.Condition.
|
||||
func WithCondition(cond *runewidth.Condition) Option {
|
||||
return func(target *Table) {
|
||||
twwidth.SetCondition(cond)
|
||||
}
|
||||
}
|
||||
+991
@@ -0,0 +1,991 @@
|
||||
package tablewriter
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/pkg/twcache"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// Option defines a function type for configuring a Table instance.
|
||||
type Option func(target *Table)
|
||||
|
||||
// WithAutoHide enables or disables automatic hiding of columns with empty data rows.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithAutoHide(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.AutoHide = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithAutoHide applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithColumnMax sets a global maximum column width for the table in streaming mode.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithColumnMax(width int) Option {
|
||||
return func(target *Table) {
|
||||
if width < 0 {
|
||||
return
|
||||
}
|
||||
target.config.Widths.Global = width
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithColumnMax applied to Table: %v", width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithMaxWidth sets a global maximum table width for the table.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithMaxWidth(width int) Option {
|
||||
return func(target *Table) {
|
||||
if width < 0 {
|
||||
return
|
||||
}
|
||||
target.config.MaxWidth = width
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTableMax applied to Table: %v", width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithWidths sets per-column widths for the table.
|
||||
// Negative widths are removed, and the change is logged if debugging is enabled.
|
||||
func WithWidths(width tw.CellWidth) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Widths = width
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithColumnWidths applied to Table: %v", width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithColumnWidths sets per-column widths for the table.
|
||||
// Negative widths are removed, and the change is logged if debugging is enabled.
|
||||
func WithColumnWidths(widths tw.Mapper[int, int]) Option {
|
||||
return func(target *Table) {
|
||||
for k, v := range widths {
|
||||
if v < 0 {
|
||||
delete(widths, k)
|
||||
}
|
||||
}
|
||||
target.config.Widths.PerColumn = widths
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithColumnWidths applied to Table: %v", widths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithConfig applies a custom configuration to the table by merging it with the default configuration.
|
||||
func WithConfig(cfg Config) Option {
|
||||
return func(target *Table) {
|
||||
target.config = mergeConfig(defaultConfig(), cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// WithDebug enables or disables debug logging and adjusts the logger level accordingly.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithDebug(debug bool) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Debug = debug
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooter sets the table footers by calling the Footer method.
|
||||
func WithFooter(footers []string) Option {
|
||||
return func(target *Table) {
|
||||
target.Footer(footers)
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterConfig applies a full footer configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterConfig(config tw.CellConfig) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer = config
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithFooterConfig applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterAlignmentConfig applies a footer alignment configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterAlignmentConfig(alignment tw.CellAlignment) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Alignment = alignment
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterAlignmentConfig applied to Table: %+v", alignment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: Use a ConfigBuilder with .Footer().CellMerging().WithMode(...) instead.
|
||||
// This option will be removed in a future version.
|
||||
func WithFooterMergeMode(mergeMode int) Option {
|
||||
return func(target *Table) {
|
||||
if mergeMode < tw.MergeNone || mergeMode > tw.MergeHierarchical {
|
||||
return
|
||||
}
|
||||
target.config.Footer.Merging.Mode = mergeMode
|
||||
target.config.Footer.Formatting.MergeMode = mergeMode
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterMergeMode applied to Table: %v", mergeMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterAutoWrap sets the wrapping behavior for footer cells.
|
||||
// Invalid wrap modes are ignored, and the change is logged if debugging is enabled.
|
||||
func WithFooterAutoWrap(wrap int) Option {
|
||||
return func(target *Table) {
|
||||
if wrap < tw.WrapNone || wrap > tw.WrapBreak {
|
||||
return
|
||||
}
|
||||
target.config.Footer.Formatting.AutoWrap = wrap
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterAutoWrap applied to Table: %v", wrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterFilter sets the filter configuration for footer cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterFilter(filter tw.CellFilter) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Filter = filter
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithFooterFilter applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterCallbacks sets the callback configuration for footer cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterCallbacks(callbacks tw.CellCallbacks) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Callbacks = callbacks
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithFooterCallbacks applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterPaddingPerColumn sets per-column padding for footer cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterPaddingPerColumn(padding []tw.Padding) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Padding.PerColumn = padding
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterPaddingPerColumn applied to Table: %+v", padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterMaxWidth sets the maximum content width for footer cells.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithFooterMaxWidth(maxWidth int) Option {
|
||||
return func(target *Table) {
|
||||
if maxWidth < 0 {
|
||||
return
|
||||
}
|
||||
target.config.Footer.ColMaxWidths.Global = maxWidth
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterMaxWidth applied to Table: %v", maxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeader sets the table headers by calling the Header method.
|
||||
func WithHeader(headers []string) Option {
|
||||
return func(target *Table) {
|
||||
target.Header(headers)
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderAlignment sets the text alignment for header cells.
|
||||
// Invalid alignments are ignored, and the change is logged if debugging is enabled.
|
||||
func WithHeaderAlignment(align tw.Align) Option {
|
||||
return func(target *Table) {
|
||||
if align != tw.AlignLeft && align != tw.AlignRight && align != tw.AlignCenter && align != tw.AlignNone {
|
||||
return
|
||||
}
|
||||
target.config.Header.Alignment.Global = align
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderAlignment applied to Table: %v", align)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderAutoWrap sets the wrapping behavior for header cells.
|
||||
// Invalid wrap modes are ignored, and the change is logged if debugging is enabled.
|
||||
func WithHeaderAutoWrap(wrap int) Option {
|
||||
return func(target *Table) {
|
||||
if wrap < tw.WrapNone || wrap > tw.WrapBreak {
|
||||
return
|
||||
}
|
||||
target.config.Header.Formatting.AutoWrap = wrap
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderAutoWrap applied to Table: %v", wrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: Use a ConfigBuilder with .Header().CellMerging().WithMode(...) instead.
|
||||
// This option will be removed in a future version.
|
||||
func WithHeaderMergeMode(mergeMode int) Option {
|
||||
return func(target *Table) {
|
||||
if mergeMode < tw.MergeNone || mergeMode > tw.MergeHierarchical {
|
||||
return
|
||||
}
|
||||
target.config.Header.Merging.Mode = mergeMode
|
||||
target.config.Header.Formatting.MergeMode = mergeMode
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderMergeMode applied to Table: %v", mergeMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderFilter sets the filter configuration for header cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderFilter(filter tw.CellFilter) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Filter = filter
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithHeaderFilter applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderCallbacks sets the callback configuration for header cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderCallbacks(callbacks tw.CellCallbacks) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Callbacks = callbacks
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithHeaderCallbacks applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderPaddingPerColumn sets per-column padding for header cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderPaddingPerColumn(padding []tw.Padding) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Padding.PerColumn = padding
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderPaddingPerColumn applied to Table: %+v", padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderMaxWidth sets the maximum content width for header cells.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithHeaderMaxWidth(maxWidth int) Option {
|
||||
return func(target *Table) {
|
||||
if maxWidth < 0 {
|
||||
return
|
||||
}
|
||||
target.config.Header.ColMaxWidths.Global = maxWidth
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderMaxWidth applied to Table: %v", maxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowAlignment sets the text alignment for row cells.
|
||||
// Invalid alignments are ignored, and the change is logged if debugging is enabled.
|
||||
func WithRowAlignment(align tw.Align) Option {
|
||||
return func(target *Table) {
|
||||
if err := align.Validate(); err != nil {
|
||||
return
|
||||
}
|
||||
target.config.Row.Alignment.Global = align
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowAlignment applied to Table: %v", align)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowAutoWrap sets the wrapping behavior for row cells.
|
||||
// Invalid wrap modes are ignored, and the change is logged if debugging is enabled.
|
||||
func WithRowAutoWrap(wrap int) Option {
|
||||
return func(target *Table) {
|
||||
if wrap < tw.WrapNone || wrap > tw.WrapBreak {
|
||||
return
|
||||
}
|
||||
target.config.Row.Formatting.AutoWrap = wrap
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowAutoWrap applied to Table: %v", wrap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deprecated: Use a ConfigBuilder with .Row().CellMerging().WithMode(...) instead.
|
||||
// This option will be removed in a future version.
|
||||
func WithRowMergeMode(mergeMode int) Option {
|
||||
return func(target *Table) {
|
||||
if mergeMode < tw.MergeNone || mergeMode > tw.MergeHierarchical {
|
||||
return
|
||||
}
|
||||
target.config.Row.Merging.Mode = mergeMode
|
||||
target.config.Row.Formatting.MergeMode = mergeMode
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowMergeMode applied to Table: %v", mergeMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowFilter sets the filter configuration for row cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowFilter(filter tw.CellFilter) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Filter = filter
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithRowFilter applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowCallbacks sets the callback configuration for row cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowCallbacks(callbacks tw.CellCallbacks) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Callbacks = callbacks
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithRowCallbacks applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowPaddingPerColumn sets per-column padding for row cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowPaddingPerColumn(padding []tw.Padding) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Padding.PerColumn = padding
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowPaddingPerColumn applied to Table: %+v", padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderAlignmentConfig applies a header alignment configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderAlignmentConfig(alignment tw.CellAlignment) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Alignment = alignment
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderAlignmentConfig applied to Table: %+v", alignment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderConfig applies a full header configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderConfig(config tw.CellConfig) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header = config
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithHeaderConfig applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLogger sets a custom logger for the table and updates the renderer if present.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithLogger(logger *ll.Logger) Option {
|
||||
return func(target *Table) {
|
||||
target.logger = logger
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithLogger applied to Table.")
|
||||
if target.renderer != nil {
|
||||
target.renderer.Logger(target.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRenderer sets a custom renderer for the table and attaches the logger if present.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRenderer(f tw.Renderer) Option {
|
||||
return func(target *Table) {
|
||||
target.renderer = f
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRenderer applied to Table: %T", f)
|
||||
f.Logger(target.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowConfig applies a full row configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowConfig(config tw.CellConfig) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row = config
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithRowConfig applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowAlignmentConfig applies a row alignment configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowAlignmentConfig(alignment tw.CellAlignment) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Alignment = alignment
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowAlignmentConfig applied to Table: %+v", alignment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowMaxWidth sets the maximum content width for row cells.
|
||||
// Negative values are ignored, and the change is logged if debugging is enabled.
|
||||
func WithRowMaxWidth(maxWidth int) Option {
|
||||
return func(target *Table) {
|
||||
if maxWidth < 0 {
|
||||
return
|
||||
}
|
||||
target.config.Row.ColMaxWidths.Global = maxWidth
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowMaxWidth applied to Table: %v", maxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStreaming applies a streaming configuration to the table by merging it with the existing configuration.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithStreaming(c tw.StreamConfig) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Stream = mergeStreamConfig(target.config.Stream, c)
|
||||
if target.logger != nil {
|
||||
target.logger.Debug("Option: WithStreaming applied to Table.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStringer sets a custom stringer function for converting row data and clears the stringer cache.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithStringer(stringer interface{}) Option {
|
||||
return func(t *Table) {
|
||||
t.stringer = stringer
|
||||
t.stringerCache = twcache.NewLRU[reflect.Type, reflect.Value](tw.DefaultCacheStringCapacity)
|
||||
if t.logger != nil {
|
||||
t.logger.Debug("Stringer updated, cache cleared")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStringerCache enables the default LRU caching for the stringer function.
|
||||
// It initializes the cache with a default capacity if one does not already exist.
|
||||
func WithStringerCache() Option {
|
||||
return func(t *Table) {
|
||||
// Initialize default cache if strictly necessary (nil),
|
||||
// or if you want to ensure the default implementation is used.
|
||||
if t.stringerCache == nil {
|
||||
// NewLRU returns (Instance, error). We ignore the error here assuming capacity > 0.
|
||||
cache := twcache.NewLRU[reflect.Type, reflect.Value](tw.DefaultCacheStringCapacity)
|
||||
t.stringerCache = cache
|
||||
}
|
||||
|
||||
if t.logger != nil {
|
||||
t.logger.Debug("Option: WithStringerCache enabled (Default LRU)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithStringerCacheCustom enables caching for the stringer function using a specific implementation.
|
||||
// Passing nil disables caching entirely.
|
||||
func WithStringerCacheCustom(cache twcache.Cache[reflect.Type, reflect.Value]) Option {
|
||||
return func(t *Table) {
|
||||
if cache == nil {
|
||||
t.stringerCache = nil
|
||||
if t.logger != nil {
|
||||
t.logger.Debug("Option: WithStringerCacheCustom called with nil (Caching Disabled)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Set the custom cache and enable the flag
|
||||
t.stringerCache = cache
|
||||
|
||||
if t.logger != nil {
|
||||
t.logger.Debug("Option: WithStringerCacheCustom enabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrimSpace sets whether leading and trailing spaces are automatically trimmed.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithTrimSpace(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.TrimSpace = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTrimSpace applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrimTab sets whether leading and trailing tab characters are automatically trimmed.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithTrimTab(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.TrimTab = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTrimTab applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithTrimLine sets whether empty visual lines within a cell are trimmed.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithTrimLine(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.TrimLine = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithTrimLine applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderAutoFormat enables or disables automatic formatting for header cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderAutoFormat(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Formatting.AutoFormat = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderAutoFormat applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterAutoFormat enables or disables automatic formatting for footer cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterAutoFormat(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Footer.Formatting.AutoFormat = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterAutoFormat applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRowAutoFormat enables or disables automatic formatting for row cells.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRowAutoFormat(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Row.Formatting.AutoFormat = state
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRowAutoFormat applied to Table: %v", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaderControl sets the control behavior for the table header.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithHeaderControl(control tw.Control) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.Header = control
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithHeaderControl applied to Table: %v", control)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithFooterControl sets the control behavior for the table footer.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithFooterControl(control tw.Control) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior.Footer = control
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithFooterControl applied to Table: %v", control)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAlignment sets the default column alignment for the header, rows, and footer.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithAlignment(alignment tw.Alignment) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Alignment.PerColumn = alignment
|
||||
target.config.Row.Alignment.PerColumn = alignment
|
||||
target.config.Footer.Alignment.PerColumn = alignment
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithAlignment applied to Table: %+v", alignment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithBehavior applies a behavior configuration to the table.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithBehavior(behavior tw.Behavior) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Behavior = behavior
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithBehavior applied to Table: %+v", behavior)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithPadding sets the global padding for the header, rows, and footer.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithPadding(padding tw.Padding) Option {
|
||||
return func(target *Table) {
|
||||
target.config.Header.Padding.Global = padding
|
||||
target.config.Row.Padding.Global = padding
|
||||
target.config.Footer.Padding.Global = padding
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithPadding applied to Table: %+v", padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRendition allows updating the active renderer's rendition configuration
|
||||
// by merging the provided rendition.
|
||||
// If the renderer does not implement tw.Renditioning, a warning is logged.
|
||||
// Logs the change if debugging is enabled.
|
||||
func WithRendition(rendition tw.Rendition) Option {
|
||||
return func(target *Table) {
|
||||
if target.renderer == nil {
|
||||
if target.logger != nil {
|
||||
target.logger.Warn("Option: WithRendition: No renderer set on table.")
|
||||
}
|
||||
return
|
||||
}
|
||||
if ru, ok := target.renderer.(tw.Renditioning); ok {
|
||||
ru.Rendition(rendition)
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRendition: Applied to renderer via Renditioning.SetRendition(): %+v", rendition)
|
||||
}
|
||||
} else if target.logger != nil {
|
||||
target.logger.Warnf("Option: WithRendition: Current renderer type %T does not implement tw.Renditioning. Rendition may not be applied as expected.", target.renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithEastAsian configures the global East Asian width calculation setting.
|
||||
// - state=tw.On: Enables East Asian width calculations. CJK and ambiguous characters
|
||||
// are typically measured as double width.
|
||||
// - state=tw.Off: Disables East Asian width calculations. Characters are generally
|
||||
// measured as single width, subject to Unicode standards.
|
||||
//
|
||||
// This setting affects all subsequent display width calculations using the twdw package.
|
||||
func WithEastAsian(state tw.State) Option {
|
||||
return func(target *Table) {
|
||||
if state.Enabled() {
|
||||
twwidth.SetEastAsian(true)
|
||||
}
|
||||
if state.Disabled() {
|
||||
twwidth.SetEastAsian(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithSymbols sets the symbols used for drawing table borders and separators.
|
||||
// The symbols are applied to the table's renderer configuration, if a renderer is set.
|
||||
// If no renderer is set (target.renderer is nil), this option has no effect. .
|
||||
func WithSymbols(symbols tw.Symbols) Option {
|
||||
return func(target *Table) {
|
||||
if target.renderer != nil {
|
||||
cfg := target.renderer.Config()
|
||||
cfg.Symbols = symbols
|
||||
|
||||
if ru, ok := target.renderer.(tw.Renditioning); ok {
|
||||
ru.Rendition(cfg)
|
||||
if target.logger != nil {
|
||||
target.logger.Debugf("Option: WithRendition: Applied to renderer via Renditioning.SetRendition(): %+v", cfg)
|
||||
}
|
||||
} else if target.logger != nil {
|
||||
target.logger.Warnf("Option: WithRendition: Current renderer type %T does not implement tw.Renditioning. Rendition may not be applied as expected.", target.renderer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithCounters enables line counting by wrapping the table's writer.
|
||||
// If a custom counter (that implements tw.Counter) is provided, it will be used.
|
||||
// If the provided counter is nil, a default tw.LineCounter will be used.
|
||||
// The final count can be retrieved via the table.Lines() method after Render() is called.
|
||||
func WithCounters(counters ...tw.Counter) Option {
|
||||
return func(target *Table) {
|
||||
// Iterate through the provided counters and add any non-nil ones.
|
||||
for _, c := range counters {
|
||||
if c != nil {
|
||||
target.counters = append(target.counters, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithLineCounter enables the default line counter.
|
||||
// A new instance of tw.LineCounter is added to the table's list of counters.
|
||||
// The total count can be retrieved via the table.Lines() method after Render() is called.
|
||||
func WithLineCounter() Option {
|
||||
return func(target *Table) {
|
||||
// Important: Create a new instance so tables don't share counters.
|
||||
target.counters = append(target.counters, &tw.LineCounter{})
|
||||
}
|
||||
}
|
||||
|
||||
// defaultConfig returns a default Config with sensible settings for headers, rows, footers, and behavior.
|
||||
func defaultConfig() Config {
|
||||
return Config{
|
||||
MaxWidth: 0,
|
||||
Header: tw.CellConfig{
|
||||
Formatting: tw.CellFormatting{
|
||||
AutoWrap: tw.WrapTruncate,
|
||||
AutoFormat: tw.On,
|
||||
MergeMode: tw.MergeNone,
|
||||
},
|
||||
Merging: tw.CellMerging{
|
||||
Mode: tw.MergeNone,
|
||||
},
|
||||
Padding: tw.CellPadding{
|
||||
Global: tw.PaddingDefault,
|
||||
},
|
||||
Alignment: tw.CellAlignment{
|
||||
Global: tw.AlignCenter,
|
||||
PerColumn: []tw.Align{},
|
||||
},
|
||||
},
|
||||
Row: tw.CellConfig{
|
||||
Formatting: tw.CellFormatting{
|
||||
AutoWrap: tw.WrapNormal,
|
||||
AutoFormat: tw.Off,
|
||||
MergeMode: tw.MergeNone,
|
||||
},
|
||||
Merging: tw.CellMerging{
|
||||
Mode: tw.MergeNone,
|
||||
},
|
||||
Padding: tw.CellPadding{
|
||||
Global: tw.PaddingDefault,
|
||||
},
|
||||
Alignment: tw.CellAlignment{
|
||||
Global: tw.AlignLeft,
|
||||
PerColumn: []tw.Align{},
|
||||
},
|
||||
},
|
||||
Footer: tw.CellConfig{
|
||||
Formatting: tw.CellFormatting{
|
||||
AutoWrap: tw.WrapNormal,
|
||||
AutoFormat: tw.Off,
|
||||
MergeMode: tw.MergeNone,
|
||||
},
|
||||
Merging: tw.CellMerging{
|
||||
Mode: tw.MergeNone,
|
||||
},
|
||||
Padding: tw.CellPadding{
|
||||
Global: tw.PaddingDefault,
|
||||
},
|
||||
Alignment: tw.CellAlignment{
|
||||
Global: tw.AlignRight,
|
||||
PerColumn: []tw.Align{},
|
||||
},
|
||||
},
|
||||
Stream: tw.StreamConfig{
|
||||
Enable: false,
|
||||
StrictColumns: false,
|
||||
},
|
||||
Debug: false,
|
||||
Behavior: tw.Behavior{
|
||||
AutoHide: tw.Off,
|
||||
TrimSpace: tw.On,
|
||||
TrimTab: tw.On,
|
||||
TrimLine: tw.On,
|
||||
Structs: tw.Struct{
|
||||
AutoHeader: tw.Off,
|
||||
Tags: []string{"json", "db"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// mergeCellConfig merges a source CellConfig into a destination CellConfig, prioritizing non-default source values.
|
||||
// It handles deep merging for complex fields like padding and callbacks.
|
||||
func mergeCellConfig(dst, src tw.CellConfig) tw.CellConfig {
|
||||
if src.Formatting.Alignment != tw.Empty {
|
||||
dst.Formatting.Alignment = src.Formatting.Alignment
|
||||
}
|
||||
|
||||
if src.Formatting.AutoWrap != 0 {
|
||||
dst.Formatting.AutoWrap = src.Formatting.AutoWrap
|
||||
}
|
||||
if src.ColMaxWidths.Global != 0 {
|
||||
dst.ColMaxWidths.Global = src.ColMaxWidths.Global
|
||||
}
|
||||
|
||||
// Handle merging of the new CellMerging struct and the deprecated MergeMode
|
||||
if src.Merging.Mode != 0 {
|
||||
dst.Merging.Mode = src.Merging.Mode
|
||||
dst.Formatting.MergeMode = src.Merging.Mode
|
||||
} else if src.Formatting.MergeMode != 0 {
|
||||
dst.Merging.Mode = src.Formatting.MergeMode
|
||||
dst.Formatting.MergeMode = src.Formatting.MergeMode
|
||||
}
|
||||
|
||||
if src.Merging.ByColumnIndex != nil {
|
||||
dst.Merging.ByColumnIndex = src.Merging.ByColumnIndex.Clone()
|
||||
}
|
||||
|
||||
dst.Formatting.AutoFormat = src.Formatting.AutoFormat
|
||||
|
||||
if src.Padding.Global.Paddable() {
|
||||
dst.Padding.Global = src.Padding.Global
|
||||
}
|
||||
|
||||
if len(src.Padding.PerColumn) > 0 {
|
||||
if dst.Padding.PerColumn == nil {
|
||||
dst.Padding.PerColumn = make([]tw.Padding, len(src.Padding.PerColumn))
|
||||
} else if len(src.Padding.PerColumn) > len(dst.Padding.PerColumn) {
|
||||
dst.Padding.PerColumn = append(dst.Padding.PerColumn, make([]tw.Padding, len(src.Padding.PerColumn)-len(dst.Padding.PerColumn))...)
|
||||
}
|
||||
for i, pad := range src.Padding.PerColumn {
|
||||
if pad.Paddable() {
|
||||
dst.Padding.PerColumn[i] = pad
|
||||
}
|
||||
}
|
||||
}
|
||||
if src.Callbacks.Global != nil {
|
||||
dst.Callbacks.Global = src.Callbacks.Global
|
||||
}
|
||||
if len(src.Callbacks.PerColumn) > 0 {
|
||||
if dst.Callbacks.PerColumn == nil {
|
||||
dst.Callbacks.PerColumn = make([]func(), len(src.Callbacks.PerColumn))
|
||||
} else if len(src.Callbacks.PerColumn) > len(dst.Callbacks.PerColumn) {
|
||||
dst.Callbacks.PerColumn = append(dst.Callbacks.PerColumn, make([]func(), len(src.Callbacks.PerColumn)-len(dst.Callbacks.PerColumn))...)
|
||||
}
|
||||
for i, cb := range src.Callbacks.PerColumn {
|
||||
if cb != nil {
|
||||
dst.Callbacks.PerColumn[i] = cb
|
||||
}
|
||||
}
|
||||
}
|
||||
if src.Filter.Global != nil {
|
||||
dst.Filter.Global = src.Filter.Global
|
||||
}
|
||||
if len(src.Filter.PerColumn) > 0 {
|
||||
if dst.Filter.PerColumn == nil {
|
||||
dst.Filter.PerColumn = make([]func(string) string, len(src.Filter.PerColumn))
|
||||
} else if len(src.Filter.PerColumn) > len(dst.Filter.PerColumn) {
|
||||
dst.Filter.PerColumn = append(dst.Filter.PerColumn, make([]func(string) string, len(src.Filter.PerColumn)-len(dst.Filter.PerColumn))...)
|
||||
}
|
||||
for i, filter := range src.Filter.PerColumn {
|
||||
if filter != nil {
|
||||
dst.Filter.PerColumn[i] = filter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge Alignment
|
||||
if src.Alignment.Global != tw.Empty {
|
||||
dst.Alignment.Global = src.Alignment.Global
|
||||
}
|
||||
|
||||
if len(src.Alignment.PerColumn) > 0 {
|
||||
if dst.Alignment.PerColumn == nil {
|
||||
dst.Alignment.PerColumn = make([]tw.Align, len(src.Alignment.PerColumn))
|
||||
} else if len(src.Alignment.PerColumn) > len(dst.Alignment.PerColumn) {
|
||||
dst.Alignment.PerColumn = append(dst.Alignment.PerColumn, make([]tw.Align, len(src.Alignment.PerColumn)-len(dst.Alignment.PerColumn))...)
|
||||
}
|
||||
for i, align := range src.Alignment.PerColumn {
|
||||
if align != tw.Skip {
|
||||
dst.Alignment.PerColumn[i] = align
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(src.ColumnAligns) > 0 {
|
||||
if dst.ColumnAligns == nil {
|
||||
dst.ColumnAligns = make([]tw.Align, len(src.ColumnAligns))
|
||||
} else if len(src.ColumnAligns) > len(dst.ColumnAligns) {
|
||||
dst.ColumnAligns = append(dst.ColumnAligns, make([]tw.Align, len(src.ColumnAligns)-len(dst.ColumnAligns))...)
|
||||
}
|
||||
for i, align := range src.ColumnAligns {
|
||||
if align != tw.Skip {
|
||||
dst.ColumnAligns[i] = align
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(src.ColMaxWidths.PerColumn) > 0 {
|
||||
if dst.ColMaxWidths.PerColumn == nil {
|
||||
dst.ColMaxWidths.PerColumn = make(map[int]int)
|
||||
}
|
||||
for k, v := range src.ColMaxWidths.PerColumn {
|
||||
if v != 0 {
|
||||
dst.ColMaxWidths.PerColumn[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// mergeConfig merges a source Config into a destination Config, prioritizing non-default source values.
|
||||
// It performs deep merging for complex types like Header, Row, Footer, and Stream.
|
||||
func mergeConfig(dst, src Config) Config {
|
||||
if src.MaxWidth != 0 {
|
||||
dst.MaxWidth = src.MaxWidth
|
||||
}
|
||||
|
||||
dst.Debug = src.Debug || dst.Debug
|
||||
dst.Behavior.AutoHide = src.Behavior.AutoHide
|
||||
dst.Behavior.TrimSpace = src.Behavior.TrimSpace
|
||||
dst.Behavior.TrimTab = src.Behavior.TrimTab
|
||||
dst.Behavior.Compact = src.Behavior.Compact
|
||||
dst.Behavior.Header = src.Behavior.Header
|
||||
dst.Behavior.Footer = src.Behavior.Footer
|
||||
dst.Behavior.Footer = src.Behavior.Footer
|
||||
|
||||
dst.Behavior.Structs.AutoHeader = src.Behavior.Structs.AutoHeader
|
||||
|
||||
// check lent of tags
|
||||
if len(src.Behavior.Structs.Tags) > 0 {
|
||||
dst.Behavior.Structs.Tags = src.Behavior.Structs.Tags
|
||||
}
|
||||
|
||||
if src.Widths.Global != 0 {
|
||||
dst.Widths.Global = src.Widths.Global
|
||||
}
|
||||
if len(src.Widths.PerColumn) > 0 {
|
||||
if dst.Widths.PerColumn == nil {
|
||||
dst.Widths.PerColumn = make(map[int]int)
|
||||
}
|
||||
for k, v := range src.Widths.PerColumn {
|
||||
if v != 0 {
|
||||
dst.Widths.PerColumn[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dst.Header = mergeCellConfig(dst.Header, src.Header)
|
||||
dst.Row = mergeCellConfig(dst.Row, src.Row)
|
||||
dst.Footer = mergeCellConfig(dst.Footer, src.Footer)
|
||||
dst.Stream = mergeStreamConfig(dst.Stream, src.Stream)
|
||||
|
||||
return dst
|
||||
}
|
||||
|
||||
// mergeStreamConfig merges a source StreamConfig into a destination StreamConfig, prioritizing non-default source values.
|
||||
func mergeStreamConfig(dst, src tw.StreamConfig) tw.StreamConfig {
|
||||
if src.Enable {
|
||||
dst.Enable = true
|
||||
}
|
||||
|
||||
dst.StrictColumns = src.StrictColumns
|
||||
return dst
|
||||
}
|
||||
|
||||
// padLine pads a line to the specified column count by appending empty strings as needed.
|
||||
func padLine(line []string, numCols int) []string {
|
||||
if len(line) >= numCols {
|
||||
return line
|
||||
}
|
||||
padded := make([]string, numCols)
|
||||
copy(padded, line)
|
||||
for i := len(line); i < numCols; i++ {
|
||||
padded[i] = tw.Empty
|
||||
}
|
||||
return padded
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package twcache
|
||||
|
||||
// Cache defines a generic interface for a key-value storage with type constraints on keys and values.
|
||||
// The keys must be of a type that supports comparison.
|
||||
// Add inserts a new key-value pair, potentially evicting an item if necessary.
|
||||
// Get retrieves a value associated with the given key, returning a boolean to indicate if the key was found.
|
||||
// Purge clears all items from the cache.
|
||||
type Cache[K comparable, V any] interface {
|
||||
Add(key K, value V) (evicted bool)
|
||||
Get(key K) (value V, ok bool)
|
||||
Purge()
|
||||
}
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
package twcache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// EvictCallback is a function called when an entry is evicted.
|
||||
// This includes evictions during Purge or Resize operations.
|
||||
type EvictCallback[K comparable, V any] func(key K, value V)
|
||||
|
||||
// LRU is a thread-safe, generic LRU cache with a fixed size.
|
||||
// It has zero dependencies, high performance, and full features.
|
||||
type LRU[K comparable, V any] struct {
|
||||
size int
|
||||
items map[K]*entry[K, V]
|
||||
head *entry[K, V] // Most Recently Used
|
||||
tail *entry[K, V] // Least Recently Used
|
||||
onEvict EvictCallback[K, V]
|
||||
|
||||
mu sync.Mutex
|
||||
hits atomic.Int64
|
||||
misses atomic.Int64
|
||||
}
|
||||
|
||||
// entry represents a single item in the LRU linked list.
|
||||
// It holds the key, value, and pointers to prev/next entries.
|
||||
type entry[K comparable, V any] struct {
|
||||
key K
|
||||
value V
|
||||
prev *entry[K, V]
|
||||
next *entry[K, V]
|
||||
}
|
||||
|
||||
// NewLRU creates a new LRU cache with the given size.
|
||||
// Returns nil if size <= 0, acting as a disabled cache.
|
||||
// Caps size at 100,000 for reasonableness.
|
||||
func NewLRU[K comparable, V any](size int) *LRU[K, V] {
|
||||
return NewLRUEvict[K, V](size, nil)
|
||||
}
|
||||
|
||||
// NewLRUEvict creates a new LRU cache with an eviction callback.
|
||||
// The callback is optional and called on evictions.
|
||||
// Returns nil if size <= 0.
|
||||
func NewLRUEvict[K comparable, V any](size int, onEvict EvictCallback[K, V]) *LRU[K, V] {
|
||||
if size <= 0 {
|
||||
return nil // nil = disabled cache (fast path in hot code)
|
||||
}
|
||||
if size > 100_000 {
|
||||
size = 100_000 // reasonable upper bound
|
||||
}
|
||||
return &LRU[K, V]{
|
||||
size: size,
|
||||
items: make(map[K]*entry[K, V], size),
|
||||
onEvict: onEvict,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOrCompute retrieves a value or computes it if missing.
|
||||
// Ensures no double computation under concurrency.
|
||||
// Ideal for expensive computations like twwidth.
|
||||
func (c *LRU[K, V]) GetOrCompute(key K, compute func() V) V {
|
||||
if c == nil || c.size <= 0 {
|
||||
return compute()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if e, ok := c.items[key]; ok {
|
||||
c.moveToFront(e)
|
||||
c.hits.Add(1)
|
||||
c.mu.Unlock()
|
||||
return e.value
|
||||
}
|
||||
|
||||
c.misses.Add(1)
|
||||
value := compute() // expensive work only on real miss
|
||||
|
||||
// Double-check: someone might have added it while computing
|
||||
if e, ok := c.items[key]; ok {
|
||||
e.value = value
|
||||
c.moveToFront(e)
|
||||
c.mu.Unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
// Evict if needed
|
||||
if len(c.items) >= c.size {
|
||||
c.removeOldest()
|
||||
}
|
||||
|
||||
e := &entry[K, V]{key: key, value: value}
|
||||
c.addToFront(e)
|
||||
c.items[key] = e
|
||||
c.mu.Unlock()
|
||||
return value
|
||||
}
|
||||
|
||||
// Get retrieves a value by key if it exists.
|
||||
// Returns the value and true if found, else zero and false.
|
||||
// Updates the entry to most recently used.
|
||||
func (c *LRU[K, V]) Get(key K) (V, bool) {
|
||||
if c == nil || c.size <= 0 {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
c.misses.Add(1)
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
c.hits.Add(1)
|
||||
c.moveToFront(e)
|
||||
return e.value, true
|
||||
}
|
||||
|
||||
// Add inserts or updates a key-value pair.
|
||||
// Evicts the oldest if cache is full.
|
||||
// Returns true if an eviction occurred.
|
||||
func (c *LRU[K, V]) Add(key K, value V) (evicted bool) {
|
||||
if c == nil || c.size <= 0 {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if e, ok := c.items[key]; ok {
|
||||
e.value = value
|
||||
c.moveToFront(e)
|
||||
return false
|
||||
}
|
||||
|
||||
if len(c.items) >= c.size {
|
||||
c.removeOldest()
|
||||
evicted = true
|
||||
}
|
||||
|
||||
e := &entry[K, V]{key: key, value: value}
|
||||
c.addToFront(e)
|
||||
c.items[key] = e
|
||||
return evicted
|
||||
}
|
||||
|
||||
// Remove deletes a key from the cache.
|
||||
// Returns true if the key was found and removed.
|
||||
func (c *LRU[K, V]) Remove(key K) bool {
|
||||
if c == nil || c.size <= 0 {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
e, ok := c.items[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
c.removeNode(e)
|
||||
delete(c.items, key)
|
||||
return true
|
||||
}
|
||||
|
||||
// Purge clears all entries from the cache.
|
||||
// Calls onEvict for each entry if set.
|
||||
// Resets hit/miss counters.
|
||||
func (c *LRU[K, V]) Purge() {
|
||||
if c == nil || c.size <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.onEvict != nil {
|
||||
for key, e := range c.items {
|
||||
c.onEvict(key, e.value)
|
||||
}
|
||||
}
|
||||
c.items = make(map[K]*entry[K, V], c.size)
|
||||
c.head = nil
|
||||
c.tail = nil
|
||||
c.hits.Store(0)
|
||||
c.misses.Store(0)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Len returns the current number of items in the cache.
|
||||
func (c *LRU[K, V]) Len() int {
|
||||
if c == nil || c.size <= 0 {
|
||||
return 0
|
||||
}
|
||||
c.mu.Lock()
|
||||
n := len(c.items)
|
||||
c.mu.Unlock()
|
||||
return n
|
||||
}
|
||||
|
||||
// Cap returns the maximum capacity of the cache.
|
||||
func (c *LRU[K, V]) Cap() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return c.size
|
||||
}
|
||||
|
||||
// HitRate returns the cache hit ratio (0.0 to 1.0).
|
||||
// Based on hits / (hits + misses).
|
||||
func (c *LRU[K, V]) HitRate() float64 {
|
||||
h := c.hits.Load()
|
||||
m := c.misses.Load()
|
||||
total := h + m
|
||||
if total == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return float64(h) / float64(total)
|
||||
}
|
||||
|
||||
// RemoveOldest removes and returns the least recently used item.
|
||||
// Returns key, value, and true if an item was removed.
|
||||
// Calls onEvict if set.
|
||||
func (c *LRU[K, V]) RemoveOldest() (key K, value V, ok bool) {
|
||||
if c == nil || c.size <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.tail == nil {
|
||||
return
|
||||
}
|
||||
|
||||
key = c.tail.key
|
||||
value = c.tail.value
|
||||
|
||||
c.removeOldest()
|
||||
return key, value, true
|
||||
}
|
||||
|
||||
// moveToFront moves an entry to the front (MRU position).
|
||||
func (c *LRU[K, V]) moveToFront(e *entry[K, V]) {
|
||||
if c.head == e {
|
||||
return
|
||||
}
|
||||
c.removeNode(e)
|
||||
c.addToFront(e)
|
||||
}
|
||||
|
||||
// addToFront adds an entry to the front of the list.
|
||||
func (c *LRU[K, V]) addToFront(e *entry[K, V]) {
|
||||
e.prev = nil
|
||||
e.next = c.head
|
||||
if c.head != nil {
|
||||
c.head.prev = e
|
||||
}
|
||||
c.head = e
|
||||
if c.tail == nil {
|
||||
c.tail = e
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// removeNode removes an entry from the linked list.
|
||||
func (c *LRU[K, V]) removeNode(e *entry[K, V]) {
|
||||
if e.prev != nil {
|
||||
e.prev.next = e.next
|
||||
} else {
|
||||
c.head = e.next
|
||||
}
|
||||
if e.next != nil {
|
||||
e.next.prev = e.prev
|
||||
} else {
|
||||
c.tail = e.prev
|
||||
}
|
||||
e.prev = nil
|
||||
e.next = nil
|
||||
}
|
||||
|
||||
// removeOldest removes the tail entry (LRU).
|
||||
// Calls onEvict if set and deletes from map.
|
||||
func (c *LRU[K, V]) removeOldest() {
|
||||
if c.tail == nil {
|
||||
return
|
||||
}
|
||||
e := c.tail
|
||||
if c.onEvict != nil {
|
||||
c.onEvict(e.key, e.value)
|
||||
}
|
||||
c.removeNode(e)
|
||||
delete(c.items, e.key)
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
// Copyright 2014 Oleku Konko All rights reserved.
|
||||
// Use of this source code is governed by a MIT
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This module is a Table Writer API for the Go Programming Language.
|
||||
// The protocols were written in pure Go and works on windows and unix systems
|
||||
|
||||
package twwarp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/clipperhouse/uax29/v2/graphemes"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
)
|
||||
|
||||
const (
|
||||
nl = "\n"
|
||||
sp = " "
|
||||
)
|
||||
|
||||
const defaultPenalty = 1e5
|
||||
|
||||
func SplitWords(s string) []string {
|
||||
words := make([]string, 0, len(s)/5)
|
||||
var wordBegin int
|
||||
wordPending := false
|
||||
for i, c := range s {
|
||||
if unicode.IsSpace(c) {
|
||||
if wordPending {
|
||||
words = append(words, s[wordBegin:i])
|
||||
wordPending = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !wordPending {
|
||||
wordBegin = i
|
||||
wordPending = true
|
||||
}
|
||||
}
|
||||
if wordPending {
|
||||
words = append(words, s[wordBegin:])
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
// WrapString wraps s into a paragraph of lines of length lim, with minimal
|
||||
// raggedness.
|
||||
func WrapString(s string, lim int) ([]string, int) {
|
||||
if s == sp {
|
||||
return []string{sp}, lim
|
||||
}
|
||||
words := SplitWords(s)
|
||||
if len(words) == 0 {
|
||||
return []string{""}, lim
|
||||
}
|
||||
var lines []string
|
||||
max := 0
|
||||
for _, v := range words {
|
||||
max = twwidth.Width(v)
|
||||
if max > lim {
|
||||
lim = max
|
||||
}
|
||||
}
|
||||
for _, line := range WrapWords(words, 1, lim, defaultPenalty) {
|
||||
lines = append(lines, strings.Join(line, sp))
|
||||
}
|
||||
return lines, lim
|
||||
}
|
||||
|
||||
// WrapStringWithSpaces wraps a string into lines of a specified display width while preserving
|
||||
// leading and trailing spaces. It splits the input string into words, condenses internal multiple
|
||||
// spaces to a single space, and wraps the content to fit within the given width limit, measured
|
||||
// using Unicode-aware display width. The function is used in the logging library to format log
|
||||
// messages for consistent output. It returns the wrapped lines as a slice of strings and the
|
||||
// adjusted width limit, which may increase if a single word exceeds the input limit. Thread-safe
|
||||
// as it does not modify shared state.
|
||||
func WrapStringWithSpaces(s string, lim int) ([]string, int) {
|
||||
if len(s) == 0 {
|
||||
return []string{""}, lim
|
||||
}
|
||||
if strings.TrimSpace(s) == "" { // All spaces
|
||||
if twwidth.Width(s) <= lim {
|
||||
return []string{s}, twwidth.Width(s)
|
||||
}
|
||||
// For very long all-space strings, "wrap" by truncating to the limit.
|
||||
if lim > 0 {
|
||||
substring, _ := stringToDisplayWidth(s, lim)
|
||||
return []string{substring}, lim
|
||||
}
|
||||
return []string{""}, lim
|
||||
}
|
||||
|
||||
var leadingSpaces, trailingSpaces, coreContent string
|
||||
firstNonSpace := strings.IndexFunc(s, func(r rune) bool { return !unicode.IsSpace(r) })
|
||||
leadingSpaces = s[:firstNonSpace]
|
||||
lastNonSpace := strings.LastIndexFunc(s, func(r rune) bool { return !unicode.IsSpace(r) })
|
||||
trailingSpaces = s[lastNonSpace+1:]
|
||||
coreContent = s[firstNonSpace : lastNonSpace+1]
|
||||
|
||||
if coreContent == "" {
|
||||
return []string{leadingSpaces + trailingSpaces}, lim
|
||||
}
|
||||
|
||||
words := SplitWords(coreContent)
|
||||
if len(words) == 0 {
|
||||
return []string{leadingSpaces + trailingSpaces}, lim
|
||||
}
|
||||
|
||||
var lines []string
|
||||
currentLim := lim
|
||||
|
||||
maxCoreWordWidth := 0
|
||||
for _, v := range words {
|
||||
w := twwidth.Width(v)
|
||||
if w > maxCoreWordWidth {
|
||||
maxCoreWordWidth = w
|
||||
}
|
||||
}
|
||||
|
||||
if maxCoreWordWidth > currentLim {
|
||||
currentLim = maxCoreWordWidth
|
||||
}
|
||||
|
||||
wrappedWordLines := WrapWords(words, 1, currentLim, defaultPenalty)
|
||||
|
||||
for i, lineWords := range wrappedWordLines {
|
||||
joinedLine := strings.Join(lineWords, sp)
|
||||
finalLine := leadingSpaces + joinedLine
|
||||
if i == len(wrappedWordLines)-1 { // Last line
|
||||
finalLine += trailingSpaces
|
||||
}
|
||||
lines = append(lines, finalLine)
|
||||
}
|
||||
return lines, currentLim
|
||||
}
|
||||
|
||||
// stringToDisplayWidth returns a substring of s that has a display width
|
||||
// as close as possible to, but not exceeding, targetWidth.
|
||||
// It returns the substring and its actual display width.
|
||||
func stringToDisplayWidth(s string, targetWidth int) (substring string, actualWidth int) {
|
||||
if targetWidth <= 0 {
|
||||
return "", 0
|
||||
}
|
||||
|
||||
var currentWidth int
|
||||
var endIndex int // Tracks the byte index in the original string
|
||||
|
||||
g := graphemes.FromString(s)
|
||||
for g.Next() {
|
||||
grapheme := g.Value()
|
||||
graphemeWidth := twwidth.Width(grapheme)
|
||||
|
||||
if currentWidth+graphemeWidth > targetWidth {
|
||||
break
|
||||
}
|
||||
|
||||
currentWidth += graphemeWidth
|
||||
endIndex = g.End()
|
||||
}
|
||||
return s[:endIndex], currentWidth
|
||||
}
|
||||
|
||||
// WrapWords is the low-level line-breaking algorithm, useful if you need more
|
||||
// control over the details of the text wrapping process. For most uses,
|
||||
// WrapString will be sufficient and more convenient.
|
||||
//
|
||||
// WrapWords splits a list of words into lines with minimal "raggedness",
|
||||
// treating each rune as one unit, accounting for spc units between adjacent
|
||||
// words on each line, and attempting to limit lines to lim units. Raggedness
|
||||
// is the total error over all lines, where error is the square of the
|
||||
// difference of the length of the line and lim. Too-long lines (which only
|
||||
// happen when a single word is longer than lim units) have pen penalty units
|
||||
// added to the error.
|
||||
func WrapWords(words []string, spc, lim, pen int) [][]string {
|
||||
n := len(words)
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
lengths := make([]int, n)
|
||||
for i := 0; i < n; i++ {
|
||||
lengths[i] = twwidth.Width(words[i])
|
||||
}
|
||||
nbrk := make([]int, n)
|
||||
cost := make([]int, n)
|
||||
for i := range cost {
|
||||
cost[i] = math.MaxInt32
|
||||
}
|
||||
remainderLen := lengths[n-1] // Uses updated lengths
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if i < n-1 {
|
||||
remainderLen += spc + lengths[i]
|
||||
}
|
||||
if remainderLen <= lim {
|
||||
cost[i] = 0
|
||||
nbrk[i] = n
|
||||
continue
|
||||
}
|
||||
phraseLen := lengths[i]
|
||||
for j := i + 1; j < n; j++ {
|
||||
if j > i+1 {
|
||||
phraseLen += spc + lengths[j-1]
|
||||
}
|
||||
d := lim - phraseLen
|
||||
c := d*d + cost[j]
|
||||
if phraseLen > lim {
|
||||
c += pen // too-long lines get a worse penalty
|
||||
}
|
||||
if c < cost[i] {
|
||||
cost[i] = c
|
||||
nbrk[i] = j
|
||||
}
|
||||
}
|
||||
}
|
||||
var lines [][]string
|
||||
i := 0
|
||||
for i < n {
|
||||
lines = append(lines, words[i:nbrk[i]])
|
||||
i = nbrk[i]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// getLines decomposes a multiline string into a slice of strings.
|
||||
func getLines(s string) []string {
|
||||
return strings.Split(s, nl)
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package twwidth
|
||||
|
||||
import "github.com/olekukonko/tablewriter/pkg/twcache"
|
||||
|
||||
// widthCache stores memoized results of Width calculations to improve performance.
|
||||
var widthCache *twcache.LRU[cacheKey, int]
|
||||
|
||||
type cacheKey struct {
|
||||
eastAsian bool
|
||||
str string
|
||||
}
|
||||
|
||||
// SetCacheCapacity changes the cache size dynamically
|
||||
// If capacity <= 0, disables caching entirely
|
||||
func SetCacheCapacity(capacity int) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if capacity <= 0 {
|
||||
widthCache = nil // nil = fully disabled
|
||||
return
|
||||
}
|
||||
|
||||
newCache := twcache.NewLRU[cacheKey, int](capacity)
|
||||
widthCache = newCache
|
||||
}
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
/*
|
||||
Package twwidth provides intelligent East Asian width detection.
|
||||
|
||||
In 2025/2026, most modern terminal emulators (VSCode, Windows Terminal, iTerm2,
|
||||
Alacritty) and modern monospace fonts (Hack, Fira Code, Cascadia Code) treat
|
||||
box-drawing characters as Single Width, regardless of the underlying OS Locale.
|
||||
|
||||
Detection Logic (in order of priority):
|
||||
- RUNEWIDTH_EASTASIAN environment variable (explicit user override)
|
||||
- Force Legacy Mode (programmatic override for backward compatibility)
|
||||
- Modern environment detection (VSCode, Windows Terminal, etc. -> Narrow)
|
||||
- Locale-based detection (CJK locales in traditional terminals -> Wide)
|
||||
|
||||
This prioritization ensures that:
|
||||
- Users can always override behavior using RUNEWIDTH_EASTASIAN
|
||||
- Modern development environments work correctly by default
|
||||
- Traditional CJK terminals maintain compatibility via locale checks
|
||||
|
||||
Examples:
|
||||
|
||||
// Force narrow borders (for Hack font in zh_CN)
|
||||
RUNEWIDTH_EASTASIAN=0 go run .
|
||||
|
||||
// Force wide borders (for legacy CJK terminals)
|
||||
RUNEWIDTH_EASTASIAN=1 go run .
|
||||
*/
|
||||
package twwidth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Environment Variable Constants
|
||||
const (
|
||||
EnvLCAll = "LC_ALL"
|
||||
EnvLCCtype = "LC_CTYPE"
|
||||
EnvLang = "LANG"
|
||||
EnvRuneWidthEastAsian = "RUNEWIDTH_EASTASIAN"
|
||||
EnvTerm = "TERM"
|
||||
EnvTermProgram = "TERM_PROGRAM"
|
||||
EnvTermProgramWsl = "TERM_PROGRAM_WSL"
|
||||
EnvWTProfile = "WT_PROFILE_ID" // Windows Terminal
|
||||
EnvConEmuANSI = "ConEmuANSI" // ConEmu
|
||||
EnvAlacritty = "ALACRITTY_LOG" // Alacritty
|
||||
EnvVTEVersion = "VTE_VERSION" // GNOME/VTE
|
||||
)
|
||||
|
||||
const (
|
||||
overwriteOn = "override_on"
|
||||
overwriteOff = "override_off"
|
||||
|
||||
envModern = "modern_env"
|
||||
envCjk = "locale_cjk"
|
||||
envAscii = "default_ascii"
|
||||
)
|
||||
|
||||
// CJK Language Codes (Prefixes)
|
||||
// Covers ISO 639-1 (2-letter) and common full names used in some systems.
|
||||
var cjkPrefixes = []string{
|
||||
"zh", "ja", "ko", // Standard: Chinese, Japanese, Korean
|
||||
"chi", "zho", // ISO 639-2/B and T for Chinese
|
||||
"jpn", "kor", // ISO 639-2 for Japanese, Korean
|
||||
"chinese", "japanese", "korean", // Full names (rare but possible in some legacy systems)
|
||||
}
|
||||
|
||||
// CJK Region Codes
|
||||
// Checks for specific regions that imply CJK font usage (e.g., en_HK).
|
||||
var cjkRegions = map[string]bool{
|
||||
"cn": true, // China
|
||||
"tw": true, // Taiwan
|
||||
"hk": true, // Hong Kong
|
||||
"mo": true, // Macau
|
||||
"jp": true, // Japan
|
||||
"kr": true, // South Korea
|
||||
"kp": true, // North Korea
|
||||
"sg": true, // Singapore (Often uses CJK fonts)
|
||||
}
|
||||
|
||||
// Modern environments that should use narrow borders (1-width box chars)
|
||||
var modernEnvironments = map[string]bool{
|
||||
// Terminal programs
|
||||
"vscode": true, "visual studio code": true,
|
||||
"iterm.app": true, "iterm2": true,
|
||||
"windows terminal": true, "windowsterminal": true,
|
||||
"alacritty": true, "kitty": true,
|
||||
"hyper": true, "tabby": true, "terminus": true, "fluentterminal": true,
|
||||
"warp": true, "ghostty": true, "rio": true,
|
||||
"jetbrains-jediterm": true,
|
||||
|
||||
// Terminal types (TERM signatures)
|
||||
"xterm-kitty": true, "xterm-ghostty": true, "wezterm": true,
|
||||
}
|
||||
|
||||
var (
|
||||
eastAsianOnce sync.Once
|
||||
eastAsianVal bool
|
||||
|
||||
// Legacy override control
|
||||
// Renamed to cfgMu to avoid conflict with width.go's mu
|
||||
cfgMu sync.RWMutex
|
||||
forceLegacyEastAsian = false
|
||||
)
|
||||
|
||||
type Enviroment struct {
|
||||
GOOS string `json:"goos"`
|
||||
LC_ALL string `json:"lc_all"`
|
||||
LC_CTYPE string `json:"lc_ctype"`
|
||||
LANG string `json:"lang"`
|
||||
RUNEWIDTH_EASTASIAN string `json:"runewidth_eastasian"`
|
||||
TERM string `json:"term"`
|
||||
TERM_PROGRAM string `json:"term_program"`
|
||||
}
|
||||
|
||||
// State captures the calculated internal state.
|
||||
type State struct {
|
||||
NormalizedLocale string `json:"normalized_locale"`
|
||||
IsCJKLocale bool `json:"is_cjk_locale"`
|
||||
IsModernEnv bool `json:"is_modern_env"`
|
||||
LegacyOverrideMode bool `json:"legacy_override_mode"`
|
||||
}
|
||||
|
||||
// Detection aggregates all debug information regarding East Asian width detection.
|
||||
type Detection struct {
|
||||
AutoUseEastAsian bool `json:"auto_use_east_asian"`
|
||||
DetectionMode string `json:"detection_mode"`
|
||||
Raw Enviroment `json:"raw"`
|
||||
Derived State `json:"derived"`
|
||||
}
|
||||
|
||||
// EastAsianForceLegacy forces the detection logic to ignore modern environment checks.
|
||||
// It relies solely on Locale detection. This is useful for applications that need
|
||||
// strict backward compatibility.
|
||||
//
|
||||
// Note: This does NOT override RUNEWIDTH_EASTASIAN. User environment variables take precedence.
|
||||
// This should be called before the first table render.
|
||||
func EastAsianForceLegacy(force bool) {
|
||||
cfgMu.Lock()
|
||||
defer cfgMu.Unlock()
|
||||
forceLegacyEastAsian = force
|
||||
}
|
||||
|
||||
// EastAsianDetect checks the environment variables to determine if
|
||||
// East Asian width calculations should be enabled.
|
||||
func EastAsianDetect() bool {
|
||||
eastAsianOnce.Do(func() {
|
||||
eastAsianVal = detectEastAsian()
|
||||
})
|
||||
return eastAsianVal
|
||||
}
|
||||
|
||||
// EastAsianConservative is a stricter version that only defaults to Narrow
|
||||
// if the terminal is definitely known to be modern (e.g. VSCode, iTerm2).
|
||||
// It avoids heuristics like checking "xterm" in the TERM variable.
|
||||
func EastAsianConservative() bool {
|
||||
// Check overrides first
|
||||
if val, found := checkOverrides(); found {
|
||||
return val
|
||||
}
|
||||
|
||||
// Stricter modern environment detection
|
||||
if isConservativeModernEnvironment() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fall back to locale
|
||||
return checkLocale()
|
||||
}
|
||||
|
||||
// EastAsianMode returns the decision path used for the current environment.
|
||||
// Useful for debugging why a specific width was chosen.
|
||||
func EastAsianMode() string {
|
||||
// Check override
|
||||
if val, found := checkOverrides(); found {
|
||||
if val {
|
||||
return overwriteOn
|
||||
}
|
||||
return overwriteOff
|
||||
}
|
||||
|
||||
cfgMu.RLock()
|
||||
legacy := forceLegacyEastAsian
|
||||
cfgMu.RUnlock()
|
||||
|
||||
if legacy {
|
||||
if checkLocale() {
|
||||
return envCjk
|
||||
}
|
||||
return envAscii
|
||||
}
|
||||
|
||||
if isModernEnvironment() {
|
||||
return envModern
|
||||
}
|
||||
|
||||
if checkLocale() {
|
||||
return envCjk
|
||||
}
|
||||
|
||||
return envAscii
|
||||
}
|
||||
|
||||
// Debugging returns detailed information about the detection decision.
|
||||
// Useful for users to include in Github issues.
|
||||
func Debugging() Detection {
|
||||
locale := getNormalizedLocale()
|
||||
|
||||
cfgMu.RLock()
|
||||
legacy := forceLegacyEastAsian
|
||||
cfgMu.RUnlock()
|
||||
|
||||
return Detection{
|
||||
AutoUseEastAsian: EastAsianDetect(),
|
||||
DetectionMode: EastAsianMode(),
|
||||
Raw: Enviroment{
|
||||
GOOS: runtime.GOOS,
|
||||
LC_ALL: os.Getenv(EnvLCAll),
|
||||
LC_CTYPE: os.Getenv(EnvLCCtype),
|
||||
LANG: os.Getenv(EnvLang),
|
||||
RUNEWIDTH_EASTASIAN: os.Getenv(EnvRuneWidthEastAsian),
|
||||
TERM: os.Getenv(EnvTerm),
|
||||
TERM_PROGRAM: os.Getenv(EnvTermProgram),
|
||||
},
|
||||
Derived: State{
|
||||
NormalizedLocale: locale,
|
||||
IsCJKLocale: isCJKLocale(locale),
|
||||
IsModernEnv: isModernEnvironment(),
|
||||
LegacyOverrideMode: legacy,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// detectEastAsian evaluates the environment and locale settings to determine if East Asian width rules should apply.
|
||||
func detectEastAsian() bool {
|
||||
// User Override check (Highest Priority)
|
||||
if val, found := checkOverrides(); found {
|
||||
return val
|
||||
}
|
||||
|
||||
// Force Legacy Mode check
|
||||
cfgMu.RLock()
|
||||
isLegacy := forceLegacyEastAsian
|
||||
cfgMu.RUnlock()
|
||||
|
||||
if isLegacy {
|
||||
// Legacy mode ignores modern environment checks,
|
||||
// relying solely on locale.
|
||||
return checkLocale()
|
||||
}
|
||||
|
||||
// Modern Environment Detection
|
||||
// If modern, we assume Single Width (return false)
|
||||
if isModernEnvironment() {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Locale Fallback
|
||||
return checkLocale()
|
||||
}
|
||||
|
||||
// checkOverrides looks for RUNEWIDTH_EASTASIAN
|
||||
func checkOverrides() (bool, bool) {
|
||||
if rw := os.Getenv(EnvRuneWidthEastAsian); rw != "" {
|
||||
rw = strings.ToLower(rw)
|
||||
if rw == "0" || rw == "off" || rw == "false" || rw == "no" {
|
||||
return false, true
|
||||
}
|
||||
if rw == "1" || rw == "on" || rw == "true" || rw == "yes" {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// checkLocale performs the string analysis on LANG/LC_ALL
|
||||
func checkLocale() bool {
|
||||
locale := getNormalizedLocale()
|
||||
if locale == "" {
|
||||
return false
|
||||
}
|
||||
return isCJKLocale(locale)
|
||||
}
|
||||
|
||||
// isModernEnvironment performs comprehensive checks for modern terminal capabilities.
|
||||
func isModernEnvironment() bool {
|
||||
// Check TERM_PROGRAM (Most reliable)
|
||||
if termProg := os.Getenv(EnvTermProgram); termProg != "" {
|
||||
termProgLower := strings.ToLower(termProg)
|
||||
if modernEnvironments[termProgLower] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check WSL specific variable
|
||||
if os.Getenv(EnvTermProgramWsl) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows Specifics
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows Terminal
|
||||
if os.Getenv(EnvWTProfile) != "" {
|
||||
return true
|
||||
}
|
||||
// ConEmu/Cmder
|
||||
if os.Getenv(EnvConEmuANSI) == "ON" {
|
||||
return true
|
||||
}
|
||||
// Modern Windows console (Windows 10+) check via TERM
|
||||
if term := os.Getenv(EnvTerm); term != "" {
|
||||
termLower := strings.ToLower(term)
|
||||
if strings.Contains(termLower, "xterm") ||
|
||||
strings.Contains(termLower, "vt") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VTE-based terminals (GNOME Terminal, Tilix, etc.)
|
||||
if os.Getenv(EnvVTEVersion) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for Alacritty specifically
|
||||
if os.Getenv(EnvAlacritty) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check TERM for modern terminal signatures
|
||||
if term := os.Getenv(EnvTerm); term != "" {
|
||||
termLower := strings.ToLower(term)
|
||||
// Specific modern terminals often put their name in TERM
|
||||
if modernEnvironments[termLower] {
|
||||
return true
|
||||
}
|
||||
// Heuristics for standard modern-capable descriptors
|
||||
if strings.Contains(termLower, "xterm") && !strings.Contains(termLower, "xterm-mono") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(termLower, "screen") ||
|
||||
strings.Contains(termLower, "tmux") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isConservativeModernEnvironment performs strict checks only for known modern terminals.
|
||||
func isConservativeModernEnvironment() bool {
|
||||
termProg := strings.ToLower(os.Getenv(EnvTermProgram))
|
||||
|
||||
// Allow-list of definitely modern terminals
|
||||
switch termProg {
|
||||
case "vscode", "visual studio code":
|
||||
return true
|
||||
case "iterm.app", "iterm2":
|
||||
return true
|
||||
case "windows terminal", "windowsterminal":
|
||||
return true
|
||||
case "alacritty", "wezterm", "kitty", "ghostty":
|
||||
return true
|
||||
case "warp", "tabby", "hyper":
|
||||
return true
|
||||
}
|
||||
|
||||
// Windows Terminal via specific Env
|
||||
if os.Getenv(EnvWTProfile) != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isCJKLocale determines if a given locale string corresponds to a CJK (Chinese, Japanese, Korean) language or region.
|
||||
func isCJKLocale(locale string) bool {
|
||||
// Check Language Prefix
|
||||
for _, prefix := range cjkPrefixes {
|
||||
if strings.HasPrefix(locale, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check Regions
|
||||
parts := strings.Split(locale, "_")
|
||||
if len(parts) > 1 {
|
||||
for _, part := range parts[1:] {
|
||||
if cjkRegions[part] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getNormalizedLocale returns the normalized locale by inspecting environment variables LC_ALL, LC_CTYPE, and LANG.
|
||||
func getNormalizedLocale() string {
|
||||
var locale string
|
||||
if loc := os.Getenv(EnvLCAll); loc != "" {
|
||||
locale = loc
|
||||
} else if loc := os.Getenv(EnvLCCtype); loc != "" {
|
||||
locale = loc
|
||||
} else if loc := os.Getenv(EnvLang); loc != "" {
|
||||
locale = loc
|
||||
}
|
||||
|
||||
// Fast fail for empty or standard C/POSIX locales
|
||||
if locale == "" || locale == "C" || locale == "POSIX" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Strip encoding and modifiers
|
||||
if idx := strings.IndexByte(locale, '.'); idx != -1 {
|
||||
locale = locale[:idx]
|
||||
}
|
||||
if idx := strings.IndexByte(locale, '@'); idx != -1 {
|
||||
locale = locale[:idx]
|
||||
}
|
||||
|
||||
return strings.ToLower(locale)
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
package twwidth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Tab rune
|
||||
|
||||
const (
|
||||
TabWidthDefault = 8
|
||||
TabString Tab = '\t'
|
||||
)
|
||||
|
||||
// IsTab returns true if t equals the default tab.
|
||||
func (t Tab) IsTab() bool {
|
||||
return t == TabString
|
||||
}
|
||||
|
||||
func (t Tab) Byte() byte {
|
||||
return byte(t)
|
||||
}
|
||||
|
||||
func (t Tab) Rune() rune {
|
||||
return rune(t)
|
||||
}
|
||||
|
||||
func (t Tab) String() string {
|
||||
return string(t)
|
||||
}
|
||||
|
||||
// IsTab returns true if r is a tab rune.
|
||||
func IsTab(r rune) bool {
|
||||
return r == TabString.Rune()
|
||||
}
|
||||
|
||||
type Tabinal struct {
|
||||
once sync.Once
|
||||
width int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (t *Tabinal) String() string {
|
||||
return TabString.String()
|
||||
}
|
||||
|
||||
// Size returns the current tab width, default if unset.
|
||||
func (t *Tabinal) Size() int {
|
||||
t.once.Do(t.init)
|
||||
|
||||
t.mu.RLock()
|
||||
w := t.width
|
||||
t.mu.RUnlock()
|
||||
|
||||
if w <= 0 {
|
||||
return TabWidthDefault
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// SetWidth sets the tab width if valid (1–32).
|
||||
func (t *Tabinal) SetWidth(w int) {
|
||||
if w <= 0 || w > 32 {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.width = w
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
func (t *Tabinal) init() {
|
||||
w := t.detect()
|
||||
t.mu.Lock()
|
||||
t.width = w
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// detect determines tab width using env, editorconfig, project, or term.
|
||||
func (t *Tabinal) detect() int {
|
||||
if w := envInt("TABWIDTH"); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
if w := envInt("TS"); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
if w := envInt("VIM_TABSTOP"); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
if w := editorConfigTabWidth(); w > 0 {
|
||||
return w
|
||||
}
|
||||
if w := projectHeuristic(); w > 0 {
|
||||
return w
|
||||
}
|
||||
if w := termHeuristic(); w > 0 {
|
||||
return w
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func editorConfigTabWidth() int {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
for dir != "" && dir != "/" && dir != "." {
|
||||
path := filepath.Join(dir, ".editorconfig")
|
||||
if w := parseEditorConfig(path); w > 0 {
|
||||
return clamp(w)
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseEditorConfig reads tab_width or indent_size from a file.
|
||||
func parseEditorConfig(path string) int {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
inMatch := false
|
||||
globalWidth := 0
|
||||
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
pattern := line[1 : len(line)-1]
|
||||
inMatch = pattern == "*"
|
||||
|
||||
knownExts := []string{".go", ".py", ".js", ".ts", ".java", ".rs"}
|
||||
for _, ext := range knownExts {
|
||||
if strings.Contains(pattern, ext) {
|
||||
inMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !inMatch && globalWidth == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
|
||||
switch key {
|
||||
case "tab_width":
|
||||
if w, err := strconv.Atoi(val); err == nil && w > 0 {
|
||||
if inMatch {
|
||||
return clamp(w)
|
||||
}
|
||||
if globalWidth == 0 {
|
||||
globalWidth = w
|
||||
}
|
||||
}
|
||||
case "indent_size":
|
||||
if val == "tab" {
|
||||
continue
|
||||
}
|
||||
if w, err := strconv.Atoi(val); err == nil && w > 0 {
|
||||
if inMatch {
|
||||
return clamp(w)
|
||||
}
|
||||
if globalWidth == 0 {
|
||||
globalWidth = w
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return globalWidth
|
||||
}
|
||||
|
||||
// projectHeuristic returns 4 for known project types.
|
||||
func projectHeuristic() int {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
indicators := []string{
|
||||
"go.mod", "go.sum",
|
||||
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
||||
"setup.py", "pyproject.toml", "requirements.txt", "Pipfile",
|
||||
"pom.xml", "build.gradle", "build.gradle.kts",
|
||||
"Cargo.toml",
|
||||
"composer.json",
|
||||
}
|
||||
|
||||
for _, indicator := range indicators {
|
||||
if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil {
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
patterns := []string{"*.go", "*.py", "*.js", "*.ts", "*.java", "*.rs"}
|
||||
for _, pattern := range patterns {
|
||||
if matches, _ := filepath.Glob(filepath.Join(dir, pattern)); len(matches) > 0 {
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// termHeuristic returns a default width based on the TERM variable.
|
||||
func termHeuristic() int {
|
||||
termEnv := strings.ToLower(os.Getenv("TERM"))
|
||||
if termEnv == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
if strings.Contains(termEnv, "vt52") {
|
||||
return 2
|
||||
}
|
||||
|
||||
if strings.Contains(termEnv, "xterm") ||
|
||||
strings.Contains(termEnv, "screen") ||
|
||||
strings.Contains(termEnv, "tmux") ||
|
||||
strings.Contains(termEnv, "linux") ||
|
||||
strings.Contains(termEnv, "ansi") ||
|
||||
strings.Contains(termEnv, "rxvt") {
|
||||
return TabWidthDefault
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func clamp(w int) int {
|
||||
if w <= 0 {
|
||||
return 0
|
||||
}
|
||||
if w > 32 {
|
||||
return 32
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
var (
|
||||
globalTab *Tabinal
|
||||
globalTabOnce sync.Once
|
||||
)
|
||||
|
||||
// TabInstance returns the singleton Tabinal.
|
||||
func TabInstance() *Tabinal {
|
||||
globalTabOnce.Do(func() {
|
||||
globalTab = &Tabinal{}
|
||||
})
|
||||
return globalTab
|
||||
}
|
||||
|
||||
// TabWidth returns the detected global tab width.
|
||||
func TabWidth() int {
|
||||
return TabInstance().Size()
|
||||
}
|
||||
|
||||
// SetTabWidth sets the global tab width.
|
||||
func SetTabWidth(w int) {
|
||||
TabInstance().SetWidth(w)
|
||||
}
|
||||
|
||||
func envInt(k string) int {
|
||||
v := os.Getenv(k)
|
||||
w, _ := strconv.Atoi(v)
|
||||
return w
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
package twwidth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/clipperhouse/displaywidth"
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/olekukonko/tablewriter/pkg/twcache"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheCapacity = 8192
|
||||
|
||||
cachePrefix = "0:"
|
||||
cacheEastAsianPrefix = "1:"
|
||||
)
|
||||
|
||||
// Options allows for configuring width calculation on a per-call basis.
|
||||
type Options struct {
|
||||
EastAsianWidth bool
|
||||
|
||||
// Explicitly force box drawing chars to be narrow
|
||||
// regardless of EastAsianWidth setting.
|
||||
ForceNarrowBorders bool
|
||||
}
|
||||
|
||||
// globalOptions holds the global displaywidth configuration, including East Asian width settings.
|
||||
var globalOptions Options
|
||||
|
||||
// mu protects access to globalOptions for thread safety.
|
||||
var mu sync.Mutex
|
||||
|
||||
// ansi is a compiled regular expression for stripping ANSI escape codes from strings.
|
||||
var ansi = Filter()
|
||||
|
||||
func init() {
|
||||
isEastAsian := EastAsianDetect()
|
||||
|
||||
cond := runewidth.NewCondition()
|
||||
cond.EastAsianWidth = isEastAsian
|
||||
|
||||
globalOptions = Options{
|
||||
EastAsianWidth: isEastAsian,
|
||||
|
||||
// Auto-enable ForceNarrowBorders for edge cases.
|
||||
// If EastAsianWidth is ON (e.g. forced via Env Var), but we detect
|
||||
// a modern environment, we might technically want to narrow borders
|
||||
// while keeping text wide.
|
||||
ForceNarrowBorders: isEastAsian && isModernEnvironment(),
|
||||
}
|
||||
|
||||
widthCache = twcache.NewLRU[cacheKey, int](cacheCapacity)
|
||||
}
|
||||
|
||||
// Display calculates the visual width of a string using a specific runewidth.Condition.
|
||||
// Deprecated: use WidthWithOptions with the new twwidth.Options struct instead.
|
||||
// This function is kept for backward compatibility.
|
||||
func Display(cond *runewidth.Condition, str string) int {
|
||||
opts := Options{EastAsianWidth: cond.EastAsianWidth}
|
||||
return WidthWithOptions(str, opts)
|
||||
}
|
||||
|
||||
// Filter compiles and returns a regular expression for matching ANSI escape sequences,
|
||||
// including CSI (Control Sequence Introducer) and OSC (Operating System Command) sequences.
|
||||
// The returned regex can be used to strip ANSI codes from strings.
|
||||
func Filter() *regexp.Regexp {
|
||||
regESC := "\x1b" // ASCII escape character
|
||||
regBEL := "\x07" // ASCII bell character
|
||||
|
||||
// ANSI string terminator: either ESC+\ or BEL
|
||||
regST := "(" + regexp.QuoteMeta(regESC+"\\") + "|" + regexp.QuoteMeta(regBEL) + ")"
|
||||
// Control Sequence Introducer (CSI): ESC[ followed by parameters and a final byte
|
||||
regCSI := regexp.QuoteMeta(regESC+"[") + "[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]"
|
||||
// Operating System Command (OSC): ESC] followed by arbitrary content until a terminator
|
||||
regOSC := regexp.QuoteMeta(regESC+"]") + ".*?" + regST
|
||||
|
||||
// Combine CSI and OSC patterns into a single regex
|
||||
return regexp.MustCompile("(" + regCSI + "|" + regOSC + ")")
|
||||
}
|
||||
|
||||
// GetCacheStats returns current cache statistics
|
||||
func GetCacheStats() (size, capacity int, hitRate float64) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if widthCache == nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
return widthCache.Len(), widthCache.Cap(), widthCache.HitRate()
|
||||
}
|
||||
|
||||
// IsEastAsian returns the current East Asian width setting.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if twdw.IsEastAsian() {
|
||||
// // Handle East Asian width characters
|
||||
// }
|
||||
func IsEastAsian() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return globalOptions.EastAsianWidth
|
||||
}
|
||||
|
||||
// SetCondition sets the global East Asian width setting based on a runewidth.Condition.
|
||||
// Deprecated: use SetOptions with the new twwidth.Options struct instead.
|
||||
// This function is kept for backward compatibility.
|
||||
func SetCondition(cond *runewidth.Condition) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
newEastAsianWidth := cond.EastAsianWidth
|
||||
if globalOptions.EastAsianWidth != newEastAsianWidth {
|
||||
globalOptions.EastAsianWidth = newEastAsianWidth
|
||||
widthCache.Purge()
|
||||
}
|
||||
}
|
||||
|
||||
// SetEastAsian enables or disables East Asian width handling globally.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// twdw.SetEastAsian(true) // Enable East Asian width handling
|
||||
func SetEastAsian(enable bool) {
|
||||
SetOptions(Options{EastAsianWidth: enable})
|
||||
}
|
||||
|
||||
// SetForceNarrow to preserve the new flag, or create a new setter
|
||||
func SetForceNarrow(enable bool) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
globalOptions.ForceNarrowBorders = enable
|
||||
widthCache.Purge() // Clear cache because widths might change
|
||||
}
|
||||
|
||||
// SetOptions sets the global options for width calculation.
|
||||
// This function is thread-safe.
|
||||
func SetOptions(opts Options) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if globalOptions.EastAsianWidth != opts.EastAsianWidth || globalOptions.ForceNarrowBorders != opts.ForceNarrowBorders {
|
||||
globalOptions = opts
|
||||
widthCache.Purge()
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate shortens a string to fit within a specified visual width, optionally
|
||||
// appending a suffix (e.g., "..."). It preserves ANSI escape sequences and adds
|
||||
// a reset sequence (\x1b[0m) if needed to prevent formatting bleed. The function
|
||||
// respects the global East Asian width setting and is thread-safe.
|
||||
//
|
||||
// If maxWidth is negative, an empty string is returned. If maxWidth is zero and
|
||||
// a suffix is provided, the suffix is returned. If the string's visual width is
|
||||
// less than or equal to maxWidth, the string (and suffix, if provided and fits)
|
||||
// is returned unchanged.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// s := twdw.Truncate("Hello\x1b[31mWorld", 5, "...") // Returns "Hello..."
|
||||
// s = twdw.Truncate("Hello", 10) // Returns "Hello"
|
||||
func Truncate(s string, maxWidth int, suffix ...string) string {
|
||||
if maxWidth < 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
suffixStr := strings.Join(suffix, "")
|
||||
sDisplayWidth := Width(s) // Uses global cached Width
|
||||
suffixDisplayWidth := Width(suffixStr) // Uses global cached Width
|
||||
|
||||
// Case 1: Original string is visually empty.
|
||||
if sDisplayWidth == 0 {
|
||||
// If suffix is provided and fits within maxWidth (or if maxWidth is generous)
|
||||
if len(suffixStr) > 0 && suffixDisplayWidth <= maxWidth {
|
||||
return suffixStr
|
||||
}
|
||||
// If s has ANSI codes (len(s)>0) but maxWidth is 0, can't display them.
|
||||
if maxWidth == 0 && len(s) > 0 {
|
||||
return ""
|
||||
}
|
||||
return s // Returns "" or original ANSI codes
|
||||
}
|
||||
|
||||
// Case 2: maxWidth is 0, but string has content. Cannot display anything.
|
||||
if maxWidth == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Case 3: String fits completely or fits with suffix.
|
||||
// Here, maxWidth is the total budget for the line.
|
||||
if sDisplayWidth <= maxWidth {
|
||||
// If the string contains ANSI, we must ensure it ends with a reset
|
||||
// to prevent bleeding, even if we don't truncate.
|
||||
safeS := s
|
||||
if strings.Contains(s, "\x1b") && !strings.HasSuffix(s, "\x1b[0m") {
|
||||
safeS += "\x1b[0m"
|
||||
}
|
||||
|
||||
if len(suffixStr) == 0 { // No suffix.
|
||||
return safeS
|
||||
}
|
||||
// Suffix is provided. Check if s + suffix fits.
|
||||
if sDisplayWidth+suffixDisplayWidth <= maxWidth {
|
||||
return safeS + suffixStr
|
||||
}
|
||||
// s fits, but s + suffix is too long. Return s (with reset if needed).
|
||||
return safeS
|
||||
}
|
||||
|
||||
// Case 4: String needs truncation (sDisplayWidth > maxWidth).
|
||||
// maxWidth is the total budget for the final string (content + suffix).
|
||||
mu.Lock()
|
||||
currentOpts := globalOptions
|
||||
mu.Unlock()
|
||||
|
||||
// Special case for EastAsianDetect true: if only suffix fits, return suffix.
|
||||
// This was derived from previous test behavior.
|
||||
if len(suffixStr) > 0 && currentOpts.EastAsianWidth {
|
||||
provisionalContentWidth := maxWidth - suffixDisplayWidth
|
||||
if provisionalContentWidth == 0 { // Exactly enough space for suffix only
|
||||
return suffixStr
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the budget for the content part, reserving space for the suffix.
|
||||
targetContentForIteration := maxWidth
|
||||
if len(suffixStr) > 0 {
|
||||
targetContentForIteration -= suffixDisplayWidth
|
||||
}
|
||||
|
||||
// If content budget is negative, means not even suffix fits (or no suffix and no space).
|
||||
// However, if only suffix fits, it should be handled.
|
||||
if targetContentForIteration < 0 {
|
||||
// Can we still fit just the suffix?
|
||||
if len(suffixStr) > 0 && suffixDisplayWidth <= maxWidth {
|
||||
if strings.Contains(s, "\x1b[") {
|
||||
return "\x1b[0m" + suffixStr
|
||||
}
|
||||
return suffixStr
|
||||
}
|
||||
return "" // Cannot fit anything.
|
||||
}
|
||||
|
||||
var contentBuf bytes.Buffer
|
||||
var currentContentDisplayWidth int
|
||||
var ansiSeqBuf bytes.Buffer
|
||||
inAnsiSequence := false
|
||||
ansiWrittenToContent := false
|
||||
|
||||
for _, r := range s {
|
||||
if r == '\x1b' {
|
||||
inAnsiSequence = true
|
||||
ansiSeqBuf.Reset()
|
||||
ansiSeqBuf.WriteRune(r)
|
||||
} else if inAnsiSequence {
|
||||
ansiSeqBuf.WriteRune(r)
|
||||
seqBytes := ansiSeqBuf.Bytes()
|
||||
seqLen := len(seqBytes)
|
||||
terminated := false
|
||||
if seqLen >= 2 {
|
||||
introducer := seqBytes[1]
|
||||
switch introducer {
|
||||
case '[':
|
||||
if seqLen >= 3 && r >= 0x40 && r <= 0x7E {
|
||||
terminated = true
|
||||
}
|
||||
case ']':
|
||||
if r == '\x07' {
|
||||
terminated = true
|
||||
} else if seqLen > 1 && seqBytes[seqLen-2] == '\x1b' && r == '\\' { // Check for ST: \x1b\
|
||||
terminated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if terminated {
|
||||
inAnsiSequence = false
|
||||
contentBuf.Write(ansiSeqBuf.Bytes())
|
||||
ansiWrittenToContent = true
|
||||
ansiSeqBuf.Reset()
|
||||
}
|
||||
} else { // Normal character
|
||||
runeDisplayWidth := calculateRunewidth(r, currentOpts)
|
||||
if targetContentForIteration == 0 { // No budget for content at all
|
||||
break
|
||||
}
|
||||
if currentContentDisplayWidth+runeDisplayWidth > targetContentForIteration {
|
||||
break
|
||||
}
|
||||
contentBuf.WriteRune(r)
|
||||
currentContentDisplayWidth += runeDisplayWidth
|
||||
}
|
||||
}
|
||||
|
||||
result := contentBuf.String()
|
||||
|
||||
// Determine if we need to append a reset sequence to prevent color bleeding.
|
||||
// This is needed if we wrote any ANSI codes or if the input had active codes
|
||||
// that we might have cut off or left open.
|
||||
needsReset := false
|
||||
if (ansiWrittenToContent || (inAnsiSequence && strings.Contains(s, "\x1b["))) && (currentContentDisplayWidth > 0 || ansiWrittenToContent) {
|
||||
if !strings.HasSuffix(result, "\x1b[0m") {
|
||||
needsReset = true
|
||||
}
|
||||
} else if currentContentDisplayWidth > 0 && strings.Contains(result, "\x1b[") && !strings.HasSuffix(result, "\x1b[0m") && strings.Contains(s, "\x1b[") {
|
||||
needsReset = true
|
||||
}
|
||||
|
||||
if needsReset {
|
||||
result += "\x1b[0m"
|
||||
}
|
||||
|
||||
// Suffix is added if provided.
|
||||
if len(suffixStr) > 0 {
|
||||
result += suffixStr
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Width calculates the visual width of a string using the global cache for performance.
|
||||
// It excludes ANSI escape sequences and accounts for the global East Asian width setting.
|
||||
// This function is thread-safe.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// width := twdw.Width("Hello\x1b[31mWorld") // Returns 10
|
||||
func Width(str string) int {
|
||||
// Fast path ASCII (Optimization)
|
||||
if len(str) == 1 && str[0] < 0x80 {
|
||||
// Treat tab as special case even in fast path
|
||||
if IsTab(rune(str[0])) {
|
||||
return TabWidth()
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
currentOpts := globalOptions
|
||||
mu.Unlock()
|
||||
|
||||
key := cacheKey{
|
||||
eastAsian: currentOpts.EastAsianWidth,
|
||||
str: str,
|
||||
}
|
||||
|
||||
// Check Cache (Optimization)
|
||||
if w, found := widthCache.Get(key); found {
|
||||
return w
|
||||
}
|
||||
|
||||
//stripped := ansi.ReplaceAllLiteralString(str, "")
|
||||
calculatedWidth := 0
|
||||
|
||||
for _, r := range strip(str) {
|
||||
calculatedWidth += calculateRunewidth(r, currentOpts)
|
||||
}
|
||||
|
||||
// Store in Cache
|
||||
widthCache.Add(key, calculatedWidth)
|
||||
return calculatedWidth
|
||||
}
|
||||
|
||||
// WidthNoCache calculates the visual width of a string without using the global cache.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// width := twdw.WidthNoCache("Hello\x1b[31mWorld") // Returns 10
|
||||
func WidthNoCache(str string) int {
|
||||
// This function's behavior is equivalent to a one-shot calculation
|
||||
// using the current global options. The WidthWithOptions function
|
||||
// does not interact with the cache, thus fulfilling the requirement.
|
||||
mu.Lock()
|
||||
opts := globalOptions
|
||||
mu.Unlock()
|
||||
return WidthWithOptions(str, opts)
|
||||
}
|
||||
|
||||
// WidthWithOptions calculates the visual width of a string with specific options,
|
||||
// bypassing the global settings and cache. This is useful for one-shot calculations
|
||||
// where global state is not desired.
|
||||
func WidthWithOptions(str string, opts Options) int {
|
||||
// stripped := ansi.ReplaceAllLiteralString(str, "")
|
||||
calculatedWidth := 0
|
||||
for _, r := range strip(str) {
|
||||
calculatedWidth += calculateRunewidth(r, opts)
|
||||
}
|
||||
return calculatedWidth
|
||||
}
|
||||
|
||||
// calculateRunewidth calculates the width of a single rune based on the provided options.
|
||||
// It applies narrow overrides for box drawing characters if configured and handles Tabs.
|
||||
func calculateRunewidth(r rune, opts Options) int {
|
||||
if opts.ForceNarrowBorders && isBoxDrawingChar(r) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Explicitly handle Tabinal to ensure tables have enough space
|
||||
// when TrimTab is Off.
|
||||
if IsTab(r) {
|
||||
return TabWidth()
|
||||
}
|
||||
|
||||
dwOpts := displaywidth.Options{EastAsianWidth: opts.EastAsianWidth}
|
||||
return dwOpts.Rune(r)
|
||||
}
|
||||
|
||||
// isBoxDrawingChar checks if a rune is within the Unicode Box Drawing range.
|
||||
func isBoxDrawingChar(r rune) bool {
|
||||
return r >= 0x2500 && r <= 0x257F
|
||||
}
|
||||
|
||||
func strip(s string) string {
|
||||
if strings.IndexByte(s, '\x1b') == -1 {
|
||||
return s
|
||||
}
|
||||
return ansi.ReplaceAllLiteralString(s, "")
|
||||
}
|
||||
+608
@@ -0,0 +1,608 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// Blueprint implements a primary table rendering engine with customizable borders and alignments.
|
||||
type Blueprint struct {
|
||||
config tw.Rendition // Rendering configuration for table borders and symbols
|
||||
logger *ll.Logger // Logger for debug trace messages
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewBlueprint creates a new Blueprint instance with optional custom configurations.
|
||||
func NewBlueprint(configs ...tw.Rendition) *Blueprint {
|
||||
// Initialize with default configuration
|
||||
cfg := defaultBlueprint()
|
||||
if len(configs) > 0 {
|
||||
userCfg := configs[0]
|
||||
// Override default borders if provided
|
||||
if userCfg.Borders.Left != 0 {
|
||||
cfg.Borders.Left = userCfg.Borders.Left
|
||||
}
|
||||
if userCfg.Borders.Right != 0 {
|
||||
cfg.Borders.Right = userCfg.Borders.Right
|
||||
}
|
||||
if userCfg.Borders.Top != 0 {
|
||||
cfg.Borders.Top = userCfg.Borders.Top
|
||||
}
|
||||
if userCfg.Borders.Bottom != 0 {
|
||||
cfg.Borders.Bottom = userCfg.Borders.Bottom
|
||||
}
|
||||
// Override symbols if provided
|
||||
if userCfg.Symbols != nil {
|
||||
cfg.Symbols = userCfg.Symbols
|
||||
}
|
||||
|
||||
// Merge user settings with default settings
|
||||
cfg.Settings = mergeSettings(cfg.Settings, userCfg.Settings)
|
||||
}
|
||||
return &Blueprint{config: cfg, logger: ll.New("blueprint").Disable()}
|
||||
}
|
||||
|
||||
// Close performs cleanup (no-op in this implementation).
|
||||
func (f *Blueprint) Close() error {
|
||||
f.logger.Debug("Blueprint.Close() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config returns the renderer's current configuration.
|
||||
func (f *Blueprint) Config() tw.Rendition {
|
||||
return f.config
|
||||
}
|
||||
|
||||
// Footer renders the table footer section with configured formatting.
|
||||
func (f *Blueprint) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
f.logger.Debugf("Starting Footer render: IsSubRow=%v, Location=%v, Pos=%s", ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position)
|
||||
// Render the footer line
|
||||
f.renderLine(ctx)
|
||||
f.logger.Debug("Completed Footer render")
|
||||
}
|
||||
|
||||
// Header renders the table header section with configured formatting.
|
||||
func (f *Blueprint) Header(headers [][]string, ctx tw.Formatting) {
|
||||
f.logger.Debugf("Starting Header render: IsSubRow=%v, Location=%v, Pos=%s, lines=%d, widths=%v",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, len(ctx.Row.Current), ctx.Row.Widths)
|
||||
// Render the header line
|
||||
f.renderLine(ctx)
|
||||
f.logger.Debug("Completed Header render")
|
||||
}
|
||||
|
||||
// Line renders a full horizontal row line with junctions and segments.
|
||||
func (f *Blueprint) Line(ctx tw.Formatting) {
|
||||
// Initialize junction renderer
|
||||
jr := NewJunction(JunctionContext{
|
||||
Symbols: f.config.Symbols,
|
||||
Ctx: ctx,
|
||||
ColIdx: 0,
|
||||
Logger: f.logger,
|
||||
BorderTint: Tint{},
|
||||
SeparatorTint: Tint{},
|
||||
})
|
||||
|
||||
var line strings.Builder
|
||||
totalLineWidth := 0 // Track total display width
|
||||
// Get sorted column indices
|
||||
sortedKeys := ctx.Row.Widths.SortedKeys()
|
||||
numCols := 0
|
||||
if len(sortedKeys) > 0 {
|
||||
numCols = sortedKeys[len(sortedKeys)-1] + 1
|
||||
}
|
||||
|
||||
// Handle empty row case
|
||||
if numCols == 0 {
|
||||
prefix := tw.Empty
|
||||
suffix := tw.Empty
|
||||
if f.config.Borders.Left.Enabled() {
|
||||
prefix = jr.RenderLeft()
|
||||
}
|
||||
if f.config.Borders.Right.Enabled() {
|
||||
suffix = jr.RenderRight(-1)
|
||||
}
|
||||
if prefix != tw.Empty || suffix != tw.Empty {
|
||||
line.WriteString(prefix + suffix + tw.NewLine)
|
||||
totalLineWidth = twwidth.Width(prefix) + twwidth.Width(suffix)
|
||||
f.w.Write([]byte(line.String()))
|
||||
}
|
||||
f.logger.Debugf("Line: Handled empty row/widths case (total width %d)", totalLineWidth)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate target total width based on data rows
|
||||
targetTotalWidth := 0
|
||||
for _, colIdx := range sortedKeys {
|
||||
targetTotalWidth += ctx.Row.Widths.Get(colIdx)
|
||||
}
|
||||
if f.config.Borders.Left.Enabled() {
|
||||
targetTotalWidth += twwidth.Width(f.config.Symbols.Column())
|
||||
}
|
||||
if f.config.Borders.Right.Enabled() {
|
||||
targetTotalWidth += twwidth.Width(f.config.Symbols.Column())
|
||||
}
|
||||
if f.config.Settings.Separators.BetweenColumns.Enabled() && len(sortedKeys) > 1 {
|
||||
targetTotalWidth += twwidth.Width(f.config.Symbols.Column()) * (len(sortedKeys) - 1)
|
||||
}
|
||||
|
||||
// Add left border if enabled
|
||||
leftBorderWidth := 0
|
||||
if f.config.Borders.Left.Enabled() {
|
||||
leftBorder := jr.RenderLeft()
|
||||
line.WriteString(leftBorder)
|
||||
leftBorderWidth = twwidth.Width(leftBorder)
|
||||
totalLineWidth += leftBorderWidth
|
||||
f.logger.Debugf("Line: Left border='%s' (f.width %d)", leftBorder, leftBorderWidth)
|
||||
}
|
||||
|
||||
visibleColIndices := make([]int, 0)
|
||||
// Calculate visible columns
|
||||
for _, colIdx := range sortedKeys {
|
||||
colWidth := ctx.Row.Widths.Get(colIdx)
|
||||
if colWidth > 0 {
|
||||
visibleColIndices = append(visibleColIndices, colIdx)
|
||||
}
|
||||
}
|
||||
|
||||
f.logger.Debugf("Line: sortedKeys=%v, Widths=%v, visibleColIndices=%v, targetTotalWidth=%d", sortedKeys, ctx.Row.Widths, visibleColIndices, targetTotalWidth)
|
||||
// Render each column segment
|
||||
for keyIndex, currentColIdx := range visibleColIndices {
|
||||
jr.colIdx = currentColIdx
|
||||
segment := jr.GetSegment()
|
||||
colWidth := ctx.Row.Widths.Get(currentColIdx)
|
||||
// Adjust colWidth to account for wider borders
|
||||
adjustedColWidth := colWidth
|
||||
if f.config.Borders.Left.Enabled() && keyIndex == 0 {
|
||||
adjustedColWidth -= leftBorderWidth - twwidth.Width(f.config.Symbols.Column())
|
||||
}
|
||||
if f.config.Borders.Right.Enabled() && keyIndex == len(visibleColIndices)-1 {
|
||||
rightBorderWidth := twwidth.Width(jr.RenderRight(currentColIdx))
|
||||
adjustedColWidth -= rightBorderWidth - twwidth.Width(f.config.Symbols.Column())
|
||||
}
|
||||
if adjustedColWidth < 0 {
|
||||
adjustedColWidth = 0
|
||||
}
|
||||
f.logger.Debugf("Line: colIdx=%d, segment='%s', adjusted colWidth=%d", currentColIdx, segment, adjustedColWidth)
|
||||
if segment == tw.Empty {
|
||||
spaces := strings.Repeat(tw.Space, adjustedColWidth)
|
||||
line.WriteString(spaces)
|
||||
totalLineWidth += adjustedColWidth
|
||||
f.logger.Debugf("Line: Rendered spaces='%s' (f.width %d) for col %d", spaces, adjustedColWidth, currentColIdx)
|
||||
} else {
|
||||
segmentWidth := twwidth.Width(segment)
|
||||
if segmentWidth == 0 {
|
||||
segmentWidth = 1 // Avoid division by zero
|
||||
f.logger.Warnf("Line: Segment='%s' has zero width, using 1", segment)
|
||||
}
|
||||
// Calculate how many full segments fit
|
||||
repeat := adjustedColWidth / segmentWidth
|
||||
if repeat < 1 && adjustedColWidth > 0 {
|
||||
repeat = 1
|
||||
}
|
||||
repeatedSegment := strings.Repeat(segment, repeat)
|
||||
actualWidth := twwidth.Width(repeatedSegment)
|
||||
if actualWidth > adjustedColWidth {
|
||||
// Truncate if too long
|
||||
repeatedSegment = twwidth.Truncate(repeatedSegment, adjustedColWidth)
|
||||
actualWidth = twwidth.Width(repeatedSegment)
|
||||
f.logger.Debugf("Line: Truncated segment='%s' to width %d", repeatedSegment, actualWidth)
|
||||
} else if actualWidth < adjustedColWidth {
|
||||
// Pad with segment character to match adjustedColWidth
|
||||
remainingWidth := adjustedColWidth - actualWidth
|
||||
for i := 0; i < remainingWidth/segmentWidth; i++ {
|
||||
repeatedSegment += segment
|
||||
}
|
||||
actualWidth = twwidth.Width(repeatedSegment)
|
||||
if actualWidth < adjustedColWidth {
|
||||
repeatedSegment = tw.PadRight(repeatedSegment, tw.Space, adjustedColWidth)
|
||||
actualWidth = adjustedColWidth
|
||||
f.logger.Debugf("Line: Padded segment with spaces='%s' to width %d", repeatedSegment, actualWidth)
|
||||
}
|
||||
f.logger.Debugf("Line: Padded segment='%s' to width %d", repeatedSegment, actualWidth)
|
||||
}
|
||||
line.WriteString(repeatedSegment)
|
||||
totalLineWidth += actualWidth
|
||||
f.logger.Debugf("Line: Rendered segment='%s' (f.width %d) for col %d", repeatedSegment, actualWidth, currentColIdx)
|
||||
}
|
||||
|
||||
// Add junction between columns if not the last column
|
||||
isLast := keyIndex == len(visibleColIndices)-1
|
||||
if !isLast && f.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
nextColIdx := visibleColIndices[keyIndex+1]
|
||||
junction := jr.RenderJunction(currentColIdx, nextColIdx)
|
||||
// Use center symbol (❀) or column separator (|) to match data rows
|
||||
if twwidth.Width(junction) != twwidth.Width(f.config.Symbols.Column()) {
|
||||
junction = f.config.Symbols.Center()
|
||||
if twwidth.Width(junction) != twwidth.Width(f.config.Symbols.Column()) {
|
||||
junction = f.config.Symbols.Column()
|
||||
}
|
||||
}
|
||||
junctionWidth := twwidth.Width(junction)
|
||||
line.WriteString(junction)
|
||||
totalLineWidth += junctionWidth
|
||||
f.logger.Debugf("Line: Junction between %d and %d: '%s' (f.width %d)", currentColIdx, nextColIdx, junction, junctionWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// Add right border
|
||||
rightBorderWidth := 0
|
||||
if f.config.Borders.Right.Enabled() && len(visibleColIndices) > 0 {
|
||||
lastIdx := visibleColIndices[len(visibleColIndices)-1]
|
||||
rightBorder := jr.RenderRight(lastIdx)
|
||||
rightBorderWidth = twwidth.Width(rightBorder)
|
||||
line.WriteString(rightBorder)
|
||||
totalLineWidth += rightBorderWidth
|
||||
f.logger.Debugf("Line: Right border='%s' (f.width %d)", rightBorder, rightBorderWidth)
|
||||
}
|
||||
|
||||
// Write the final line
|
||||
line.WriteString(tw.NewLine)
|
||||
f.w.Write([]byte(line.String()))
|
||||
f.logger.Debugf("Line rendered: '%s' (total width %d, target %d)", strings.TrimSuffix(line.String(), tw.NewLine), totalLineWidth, targetTotalWidth)
|
||||
}
|
||||
|
||||
// Logger sets the logger for the Blueprint instance.
|
||||
func (f *Blueprint) Logger(logger *ll.Logger) {
|
||||
f.logger = logger.Namespace("blueprint")
|
||||
}
|
||||
|
||||
// Row renders a table data row with configured formatting.
|
||||
func (f *Blueprint) Row(row []string, ctx tw.Formatting) {
|
||||
f.logger.Debugf("Starting Row render: IsSubRow=%v, Location=%v, Pos=%s, hasFooter=%v",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, ctx.HasFooter)
|
||||
|
||||
// Render the row line
|
||||
f.renderLine(ctx)
|
||||
f.logger.Debug("Completed Row render")
|
||||
}
|
||||
|
||||
// Start initializes the rendering process (no-op in this implementation).
|
||||
func (f *Blueprint) Start(w io.Writer) error {
|
||||
f.w = w
|
||||
f.logger.Debug("Blueprint.Start() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatCell formats a cell's content with specified width, padding, and alignment, returning an empty string if width is non-positive.
|
||||
func (f *Blueprint) formatCell(content string, width int, padding tw.Padding, align tw.Align) string {
|
||||
if width <= 0 {
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
f.logger.Debugf("Formatting cell: content='%s', width=%d, align=%s, padding={L:'%s' R:'%s'}",
|
||||
content, width, align, padding.Left, padding.Right)
|
||||
|
||||
// Calculate display width of content
|
||||
runeWidth := twwidth.Width(content)
|
||||
|
||||
// Set default padding characters
|
||||
leftPadChar := padding.Left
|
||||
rightPadChar := padding.Right
|
||||
|
||||
// if f.config.Settings.Cushion.Enabled() || f.config.Settings.Cushion.Default() {
|
||||
// if leftPadChar == tw.Empty {
|
||||
// leftPadChar = tw.Space
|
||||
// }
|
||||
// if rightPadChar == tw.Empty {
|
||||
// rightPadChar = tw.Space
|
||||
// }
|
||||
//}
|
||||
|
||||
// Calculate padding widths
|
||||
padLeftWidth := twwidth.Width(leftPadChar)
|
||||
padRightWidth := twwidth.Width(rightPadChar)
|
||||
|
||||
// Calculate available width for content
|
||||
availableContentWidth := max(width-padLeftWidth-padRightWidth, 0)
|
||||
f.logger.Debugf("Available content width: %d", availableContentWidth)
|
||||
|
||||
// Truncate content if it exceeds available width
|
||||
if runeWidth > availableContentWidth {
|
||||
content = twwidth.Truncate(content, availableContentWidth)
|
||||
runeWidth = twwidth.Width(content)
|
||||
f.logger.Debugf("Truncated content to fit %d: '%s' (new width %d)", availableContentWidth, content, runeWidth)
|
||||
}
|
||||
|
||||
// Calculate total padding needed
|
||||
totalPaddingWidth := max(width-runeWidth, 0)
|
||||
f.logger.Debugf("Total padding width: %d", totalPaddingWidth)
|
||||
|
||||
var result strings.Builder
|
||||
var leftPaddingWidth, rightPaddingWidth int
|
||||
|
||||
// Apply alignment and padding
|
||||
switch align {
|
||||
case tw.AlignLeft:
|
||||
result.WriteString(leftPadChar)
|
||||
result.WriteString(content)
|
||||
rightPaddingWidth = totalPaddingWidth - padLeftWidth
|
||||
if rightPaddingWidth > 0 {
|
||||
padChar := rightPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadRight(tw.Empty, padChar, rightPaddingWidth))
|
||||
f.logger.Debugf("Applied right padding: '%s' for %d width", padChar, rightPaddingWidth)
|
||||
}
|
||||
case tw.AlignRight:
|
||||
leftPaddingWidth = totalPaddingWidth - padRightWidth
|
||||
if leftPaddingWidth > 0 {
|
||||
padChar := leftPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadLeft(tw.Empty, padChar, leftPaddingWidth))
|
||||
f.logger.Debugf("Applied left padding: '%s' for %d width", padChar, leftPaddingWidth)
|
||||
}
|
||||
result.WriteString(content)
|
||||
result.WriteString(rightPadChar)
|
||||
case tw.AlignCenter:
|
||||
leftPaddingWidth = (totalPaddingWidth-padLeftWidth-padRightWidth)/2 + padLeftWidth
|
||||
rightPaddingWidth = totalPaddingWidth - leftPaddingWidth
|
||||
if leftPaddingWidth > padLeftWidth {
|
||||
padChar := leftPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadLeft(tw.Empty, padChar, leftPaddingWidth-padLeftWidth))
|
||||
f.logger.Debugf("Applied left centering padding: '%s' for %d width", padChar, leftPaddingWidth-padLeftWidth)
|
||||
}
|
||||
result.WriteString(leftPadChar)
|
||||
result.WriteString(content)
|
||||
result.WriteString(rightPadChar)
|
||||
if rightPaddingWidth > padRightWidth {
|
||||
padChar := rightPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadRight(tw.Empty, padChar, rightPaddingWidth-padRightWidth))
|
||||
f.logger.Debugf("Applied right centering padding: '%s' for %d width", padChar, rightPaddingWidth-padRightWidth)
|
||||
}
|
||||
default:
|
||||
// Default to left alignment
|
||||
result.WriteString(leftPadChar)
|
||||
result.WriteString(content)
|
||||
rightPaddingWidth = totalPaddingWidth - padLeftWidth
|
||||
if rightPaddingWidth > 0 {
|
||||
padChar := rightPadChar
|
||||
if padChar == tw.Empty {
|
||||
padChar = tw.Space
|
||||
}
|
||||
result.WriteString(tw.PadRight(tw.Empty, padChar, rightPaddingWidth))
|
||||
f.logger.Debugf("Applied right padding: '%s' for %d width", padChar, rightPaddingWidth)
|
||||
}
|
||||
}
|
||||
|
||||
output := result.String()
|
||||
finalWidth := twwidth.Width(output)
|
||||
// Adjust output to match target width
|
||||
if finalWidth > width {
|
||||
output = twwidth.Truncate(output, width)
|
||||
f.logger.Debugf("formatCell: Truncated output to width %d", width)
|
||||
} else if finalWidth < width {
|
||||
output = tw.PadRight(output, tw.Space, width)
|
||||
f.logger.Debugf("formatCell: Padded output to meet width %d", width)
|
||||
}
|
||||
|
||||
// Log warning if final width doesn't match target
|
||||
if f.logger.Enabled() && twwidth.Width(output) != width {
|
||||
f.logger.Debugf("formatCell Warning: Final width %d does not match target %d for result '%s'",
|
||||
twwidth.Width(output), width, output)
|
||||
}
|
||||
|
||||
f.logger.Debugf("Formatted cell final result: '%s' (target width %d)", output, width)
|
||||
return output
|
||||
}
|
||||
|
||||
// renderLine renders a single line (header, row, or footer) with borders, separators, and merge handling.
|
||||
func (f *Blueprint) renderLine(ctx tw.Formatting) {
|
||||
// Get sorted column indices
|
||||
sortedKeys := ctx.Row.Widths.SortedKeys()
|
||||
numCols := 0
|
||||
if len(sortedKeys) > 0 {
|
||||
numCols = sortedKeys[len(sortedKeys)-1] + 1
|
||||
}
|
||||
|
||||
// Set column separator and borders
|
||||
columnSeparator := f.config.Symbols.Column()
|
||||
prefix := tw.Empty
|
||||
if f.config.Borders.Left.Enabled() {
|
||||
prefix = columnSeparator
|
||||
}
|
||||
suffix := tw.Empty
|
||||
if f.config.Borders.Right.Enabled() {
|
||||
suffix = columnSeparator
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
totalLineWidth := 0 // Track total display width
|
||||
if prefix != tw.Empty {
|
||||
output.WriteString(prefix)
|
||||
totalLineWidth += twwidth.Width(prefix)
|
||||
f.logger.Debugf("renderLine: Prefix='%s' (f.width %d)", prefix, twwidth.Width(prefix))
|
||||
}
|
||||
|
||||
colIndex := 0
|
||||
separatorDisplayWidth := 0
|
||||
if f.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
separatorDisplayWidth = twwidth.Width(columnSeparator)
|
||||
}
|
||||
|
||||
// Process each column
|
||||
for colIndex < numCols {
|
||||
visualWidth := ctx.Row.Widths.Get(colIndex)
|
||||
cellCtx, ok := ctx.Row.Current[colIndex]
|
||||
isHMergeStart := ok && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start
|
||||
if visualWidth == 0 && !isHMergeStart {
|
||||
f.logger.Debugf("renderLine: Skipping col %d (zero width, not HMerge start)", colIndex)
|
||||
colIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine if a separator is needed
|
||||
shouldAddSeparator := false
|
||||
if colIndex > 0 && f.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
prevWidth := ctx.Row.Widths.Get(colIndex - 1)
|
||||
prevCellCtx, prevOk := ctx.Row.Current[colIndex-1]
|
||||
prevIsHMergeEnd := prevOk && prevCellCtx.Merge.Horizontal.Present && prevCellCtx.Merge.Horizontal.End
|
||||
if (prevWidth > 0 || prevIsHMergeEnd) && (!ok || (!cellCtx.Merge.Horizontal.Present || cellCtx.Merge.Horizontal.Start)) {
|
||||
shouldAddSeparator = true
|
||||
}
|
||||
}
|
||||
if shouldAddSeparator {
|
||||
output.WriteString(columnSeparator)
|
||||
totalLineWidth += separatorDisplayWidth
|
||||
f.logger.Debugf("renderLine: Added separator '%s' before col %d (f.width %d)", columnSeparator, colIndex, separatorDisplayWidth)
|
||||
} else if colIndex > 0 {
|
||||
f.logger.Debugf("renderLine: Skipped separator before col %d due to zero-width prev col or HMerge continuation", colIndex)
|
||||
}
|
||||
|
||||
// Handle merged cells
|
||||
span := 1
|
||||
if isHMergeStart {
|
||||
span = cellCtx.Merge.Horizontal.Span
|
||||
if ctx.Row.Position == tw.Row {
|
||||
dynamicTotalWidth := 0
|
||||
for k := 0; k < span && colIndex+k < numCols; k++ {
|
||||
normWidth := max(ctx.NormalizedWidths.Get(colIndex+k), 0)
|
||||
dynamicTotalWidth += normWidth
|
||||
if k > 0 && separatorDisplayWidth > 0 && ctx.NormalizedWidths.Get(colIndex+k) > 0 {
|
||||
dynamicTotalWidth += separatorDisplayWidth
|
||||
}
|
||||
}
|
||||
visualWidth = dynamicTotalWidth
|
||||
f.logger.Debugf("renderLine: Row HMerge col %d, span %d, dynamic visualWidth %d", colIndex, span, visualWidth)
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(colIndex)
|
||||
f.logger.Debugf("renderLine: H/F HMerge col %d, span %d, pre-adjusted visualWidth %d", colIndex, span, visualWidth)
|
||||
}
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(colIndex)
|
||||
f.logger.Debugf("renderLine: Regular col %d, visualWidth %d", colIndex, visualWidth)
|
||||
}
|
||||
if visualWidth < 0 {
|
||||
visualWidth = 0
|
||||
}
|
||||
|
||||
// Skip processing for non-start merged cells
|
||||
if ok && cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start {
|
||||
f.logger.Debugf("renderLine: Skipping col %d processing (part of HMerge)", colIndex)
|
||||
colIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle empty cell context
|
||||
if !ok {
|
||||
if visualWidth > 0 {
|
||||
spaces := strings.Repeat(tw.Space, visualWidth)
|
||||
output.WriteString(spaces)
|
||||
totalLineWidth += visualWidth
|
||||
f.logger.Debugf("renderLine: No cell context for col %d, writing %d spaces (f.width %d)", colIndex, visualWidth, visualWidth)
|
||||
} else {
|
||||
f.logger.Debugf("renderLine: No cell context for col %d, visualWidth is 0, writing nothing", colIndex)
|
||||
}
|
||||
colIndex += span
|
||||
continue
|
||||
}
|
||||
|
||||
// Set cell padding and alignment
|
||||
padding := cellCtx.Padding
|
||||
align := cellCtx.Align
|
||||
switch align {
|
||||
case tw.AlignNone:
|
||||
switch ctx.Row.Position {
|
||||
case tw.Header:
|
||||
align = tw.AlignCenter
|
||||
case tw.Footer:
|
||||
align = tw.AlignRight
|
||||
default:
|
||||
align = tw.AlignLeft
|
||||
}
|
||||
f.logger.Debugf("renderLine: col %d (data: '%s') using renderer default align '%s' for position %s.", colIndex, cellCtx.Data, align, ctx.Row.Position)
|
||||
case tw.Skip:
|
||||
switch ctx.Row.Position {
|
||||
case tw.Header:
|
||||
align = tw.AlignCenter
|
||||
case tw.Footer:
|
||||
align = tw.AlignRight
|
||||
default:
|
||||
align = tw.AlignLeft
|
||||
}
|
||||
f.logger.Debugf("renderLine: col %d (data: '%s') cellCtx.Align was Skip/empty, falling back to basic default '%s'.", colIndex, cellCtx.Data, align)
|
||||
}
|
||||
|
||||
isTotalPattern := false
|
||||
|
||||
// Case-insensitive check for "total"
|
||||
if isHMergeStart && colIndex > 0 {
|
||||
if prevCellCtx, ok := ctx.Row.Current[colIndex-1]; ok {
|
||||
if strings.Contains(strings.ToLower(prevCellCtx.Data), "total") {
|
||||
isTotalPattern = true
|
||||
f.logger.Debugf("renderLine: total pattern in row in %d", colIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the alignment from the configuration
|
||||
align = cellCtx.Align
|
||||
|
||||
// Override alignment for footer merged cells
|
||||
if (ctx.Row.Position == tw.Footer && isHMergeStart) || isTotalPattern {
|
||||
if align == tw.AlignNone {
|
||||
f.logger.Debugf("renderLine: Applying AlignRight HMerge/TOTAL override for Footer col %d. Original/default align was: %s", colIndex, align)
|
||||
align = tw.AlignRight
|
||||
}
|
||||
}
|
||||
|
||||
// Handle vertical/hierarchical merges
|
||||
cellData := cellCtx.Data
|
||||
if (cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) ||
|
||||
(cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start) {
|
||||
cellData = tw.Empty
|
||||
f.logger.Debugf("renderLine: Blanked data for col %d (non-start V/Hierarchical)", colIndex)
|
||||
}
|
||||
|
||||
// Format and render the cell
|
||||
formattedCell := f.formatCell(cellData, visualWidth, padding, align)
|
||||
if len(formattedCell) > 0 {
|
||||
output.WriteString(formattedCell)
|
||||
cellWidth := twwidth.Width(formattedCell)
|
||||
totalLineWidth += cellWidth
|
||||
f.logger.Debugf("renderLine: Rendered col %d, formattedCell='%s' (f.width %d), totalLineWidth=%d", colIndex, formattedCell, cellWidth, totalLineWidth)
|
||||
}
|
||||
|
||||
// Log rendering details
|
||||
if isHMergeStart {
|
||||
f.logger.Debugf("renderLine: Rendered HMerge START col %d (span %d, visualWidth %d, align %v): '%s'",
|
||||
colIndex, span, visualWidth, align, formattedCell)
|
||||
} else {
|
||||
f.logger.Debugf("renderLine: Rendered regular col %d (visualWidth %d, align %v): '%s'",
|
||||
colIndex, visualWidth, align, formattedCell)
|
||||
}
|
||||
colIndex += span
|
||||
}
|
||||
|
||||
// Add suffix and adjust total width
|
||||
if output.Len() > len(prefix) || f.config.Borders.Right.Enabled() {
|
||||
output.WriteString(suffix)
|
||||
totalLineWidth += twwidth.Width(suffix)
|
||||
f.logger.Debugf("renderLine: Suffix='%s' (f.width %d)", suffix, twwidth.Width(suffix))
|
||||
}
|
||||
output.WriteString(tw.NewLine)
|
||||
f.w.Write([]byte(output.String()))
|
||||
f.logger.Debugf("renderLine: Final rendered line: '%s' (total width %d)", strings.TrimSuffix(output.String(), tw.NewLine), totalLineWidth)
|
||||
}
|
||||
|
||||
// Rendition updates the Blueprint's configuration.
|
||||
func (f *Blueprint) Rendition(config tw.Rendition) {
|
||||
f.config = mergeRendition(f.config, config)
|
||||
f.logger.Debugf("Blueprint.Rendition updated. New config: %+v", f.config)
|
||||
}
|
||||
|
||||
// Ensure Blueprint implements tw.Renditioning
|
||||
var _ tw.Renditioning = (*Blueprint)(nil)
|
||||
+702
@@ -0,0 +1,702 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/ll/lh"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// ColorizedConfig holds configuration for the Colorized table renderer.
|
||||
type ColorizedConfig struct {
|
||||
Borders tw.Border // Border visibility settings
|
||||
Settings tw.Settings // Rendering behavior settings (e.g., separators, whitespace)
|
||||
Header Tint // Colors for header cells
|
||||
Column Tint // Colors for row cells
|
||||
Footer Tint // Colors for footer cells
|
||||
Border Tint // Colors for borders and lines
|
||||
Separator Tint // Colors for column separators
|
||||
Symbols tw.Symbols // Symbols for table drawing (e.g., corners, lines)
|
||||
}
|
||||
|
||||
// Colorized renders colored ASCII tables with customizable borders, colors, and alignments.
|
||||
type Colorized struct {
|
||||
config ColorizedConfig // Renderer configuration
|
||||
trace []string // Debug trace messages
|
||||
newLine string // Newline character
|
||||
defaultAlign map[tw.Position]tw.Align // Default alignments for header, row, and footer
|
||||
logger *ll.Logger // Logger for debug messages
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewColorized creates a Colorized renderer with the specified configuration, falling back to defaults if none provided.
|
||||
// Only the first config is used if multiple are passed.
|
||||
func NewColorized(configs ...ColorizedConfig) *Colorized {
|
||||
// Initialize with default configuration
|
||||
baseCfg := defaultColorized()
|
||||
|
||||
if len(configs) > 0 {
|
||||
userCfg := configs[0]
|
||||
|
||||
// Override border settings if provided
|
||||
if userCfg.Borders.Left != 0 {
|
||||
baseCfg.Borders.Left = userCfg.Borders.Left
|
||||
}
|
||||
if userCfg.Borders.Right != 0 {
|
||||
baseCfg.Borders.Right = userCfg.Borders.Right
|
||||
}
|
||||
if userCfg.Borders.Top != 0 {
|
||||
baseCfg.Borders.Top = userCfg.Borders.Top
|
||||
}
|
||||
if userCfg.Borders.Bottom != 0 {
|
||||
baseCfg.Borders.Bottom = userCfg.Borders.Bottom
|
||||
}
|
||||
|
||||
// Merge separator and line settings
|
||||
baseCfg.Settings.Separators = mergeSeparators(baseCfg.Settings.Separators, userCfg.Settings.Separators)
|
||||
baseCfg.Settings.Lines = mergeLines(baseCfg.Settings.Lines, userCfg.Settings.Lines)
|
||||
|
||||
// Override compact mode if specified
|
||||
if userCfg.Settings.CompactMode != 0 {
|
||||
baseCfg.Settings.CompactMode = userCfg.Settings.CompactMode
|
||||
}
|
||||
|
||||
// Override color settings for various table elements
|
||||
if len(userCfg.Header.FG) > 0 || len(userCfg.Header.BG) > 0 || userCfg.Header.Columns != nil {
|
||||
baseCfg.Header = userCfg.Header
|
||||
}
|
||||
if len(userCfg.Column.FG) > 0 || len(userCfg.Column.BG) > 0 || userCfg.Column.Columns != nil {
|
||||
baseCfg.Column = userCfg.Column
|
||||
}
|
||||
if len(userCfg.Footer.FG) > 0 || len(userCfg.Footer.BG) > 0 || userCfg.Footer.Columns != nil {
|
||||
baseCfg.Footer = userCfg.Footer
|
||||
}
|
||||
if len(userCfg.Border.FG) > 0 || len(userCfg.Border.BG) > 0 || userCfg.Border.Columns != nil {
|
||||
baseCfg.Border = userCfg.Border
|
||||
}
|
||||
if len(userCfg.Separator.FG) > 0 || len(userCfg.Separator.BG) > 0 || userCfg.Separator.Columns != nil {
|
||||
baseCfg.Separator = userCfg.Separator
|
||||
}
|
||||
|
||||
// Override symbols if provided
|
||||
if userCfg.Symbols != nil {
|
||||
baseCfg.Symbols = userCfg.Symbols
|
||||
}
|
||||
}
|
||||
|
||||
cfg := baseCfg
|
||||
// Ensure symbols are initialized
|
||||
if cfg.Symbols == nil {
|
||||
cfg.Symbols = tw.NewSymbols(tw.StyleLight)
|
||||
}
|
||||
|
||||
// Initialize the Colorized renderer
|
||||
f := &Colorized{
|
||||
config: cfg,
|
||||
newLine: tw.NewLine,
|
||||
defaultAlign: map[tw.Position]tw.Align{
|
||||
tw.Header: tw.AlignCenter,
|
||||
tw.Row: tw.AlignLeft,
|
||||
tw.Footer: tw.AlignRight,
|
||||
},
|
||||
logger: ll.New("colorized", ll.WithHandler(lh.NewMemoryHandler())).Disable(),
|
||||
}
|
||||
// Log initialization details
|
||||
f.logger.Debugf("Initialized Colorized renderer with symbols: Center=%q, Row=%q, Column=%q", f.config.Symbols.Center(), f.config.Symbols.Row(), f.config.Symbols.Column())
|
||||
f.logger.Debugf("Final ColorizedConfig.Settings.Lines: %+v", f.config.Settings.Lines)
|
||||
f.logger.Debugf("Final ColorizedConfig.Borders: %+v", f.config.Borders)
|
||||
return f
|
||||
}
|
||||
|
||||
// Close performs cleanup (no-op in this implementation).
|
||||
func (c *Colorized) Close() error {
|
||||
c.logger.Debug("Colorized.Close() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config returns the renderer's configuration as a Rendition.
|
||||
func (c *Colorized) Config() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: c.config.Borders,
|
||||
Settings: c.config.Settings,
|
||||
Symbols: c.config.Symbols,
|
||||
Streaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Debug returns the accumulated debug trace messages.
|
||||
func (c *Colorized) Debug() []string {
|
||||
return c.trace
|
||||
}
|
||||
|
||||
// Footer renders the table footer with configured colors and formatting.
|
||||
func (c *Colorized) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
c.logger.Debugf("Starting Footer render: IsSubRow=%v, Location=%v, Pos=%s",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position)
|
||||
|
||||
// Check if there are footers to render
|
||||
if len(footers) == 0 || len(footers[0]) == 0 {
|
||||
c.logger.Debug("Footer: No footers to render")
|
||||
return
|
||||
}
|
||||
|
||||
// Render the footer line
|
||||
c.renderLine(ctx, footers[0], c.config.Footer)
|
||||
c.logger.Debug("Completed Footer render")
|
||||
}
|
||||
|
||||
// Header renders the table header with configured colors and formatting.
|
||||
func (c *Colorized) Header(headers [][]string, ctx tw.Formatting) {
|
||||
c.logger.Debugf("Starting Header render: IsSubRow=%v, Location=%v, Pos=%s, lines=%d, widths=%v",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, len(headers), ctx.Row.Widths)
|
||||
|
||||
// Check if there are headers to render
|
||||
if len(headers) == 0 || len(headers[0]) == 0 {
|
||||
c.logger.Debug("Header: No headers to render")
|
||||
return
|
||||
}
|
||||
|
||||
// Render the header line
|
||||
c.renderLine(ctx, headers[0], c.config.Header)
|
||||
c.logger.Debug("Completed Header render")
|
||||
}
|
||||
|
||||
// Line renders a horizontal row line with colored junctions and segments, skipping zero-width columns.
|
||||
func (c *Colorized) Line(ctx tw.Formatting) {
|
||||
c.logger.Debugf("Line: Starting with Level=%v, Location=%v, IsSubRow=%v, Widths=%v", ctx.Level, ctx.Row.Location, ctx.IsSubRow, ctx.Row.Widths)
|
||||
|
||||
// Initialize junction renderer
|
||||
jr := NewJunction(JunctionContext{
|
||||
Symbols: c.config.Symbols,
|
||||
Ctx: ctx,
|
||||
ColIdx: 0,
|
||||
BorderTint: c.config.Border,
|
||||
SeparatorTint: c.config.Separator,
|
||||
Logger: c.logger,
|
||||
})
|
||||
|
||||
var line strings.Builder
|
||||
|
||||
// Get sorted column indices and filter out zero-width columns
|
||||
allSortedKeys := ctx.Row.Widths.SortedKeys()
|
||||
effectiveKeys := []int{}
|
||||
keyWidthMap := make(map[int]int)
|
||||
|
||||
for _, k := range allSortedKeys {
|
||||
width := ctx.Row.Widths.Get(k)
|
||||
keyWidthMap[k] = width
|
||||
if width > 0 {
|
||||
effectiveKeys = append(effectiveKeys, k)
|
||||
}
|
||||
}
|
||||
c.logger.Debugf("Line: All keys=%v, Effective keys (width>0)=%v", allSortedKeys, effectiveKeys)
|
||||
|
||||
// Handle case with no effective columns
|
||||
if len(effectiveKeys) == 0 {
|
||||
prefix := tw.Empty
|
||||
suffix := tw.Empty
|
||||
if c.config.Borders.Left.Enabled() {
|
||||
prefix = jr.RenderLeft()
|
||||
}
|
||||
if c.config.Borders.Right.Enabled() {
|
||||
originalLastColIdx := -1
|
||||
if len(allSortedKeys) > 0 {
|
||||
originalLastColIdx = allSortedKeys[len(allSortedKeys)-1]
|
||||
}
|
||||
suffix = jr.RenderRight(originalLastColIdx)
|
||||
}
|
||||
if prefix != tw.Empty || suffix != tw.Empty {
|
||||
line.WriteString(prefix + suffix + tw.NewLine)
|
||||
c.w.Write([]byte(line.String()))
|
||||
}
|
||||
c.logger.Debug("Line: Handled empty row/widths case (no effective keys)")
|
||||
return
|
||||
}
|
||||
|
||||
// Add left border if enabled
|
||||
if c.config.Borders.Left.Enabled() {
|
||||
line.WriteString(jr.RenderLeft())
|
||||
}
|
||||
|
||||
// Render segments for each effective column
|
||||
for keyIndex, currentColIdx := range effectiveKeys {
|
||||
jr.colIdx = currentColIdx
|
||||
segment := jr.GetSegment()
|
||||
colWidth := keyWidthMap[currentColIdx]
|
||||
c.logger.Debugf("Line: Drawing segment for Effective colIdx=%d, segment='%s', width=%d", currentColIdx, segment, colWidth)
|
||||
|
||||
if segment == tw.Empty {
|
||||
line.WriteString(strings.Repeat(tw.Space, colWidth))
|
||||
} else {
|
||||
// Calculate how many times to repeat the segment
|
||||
segmentWidth := twwidth.Width(segment)
|
||||
if segmentWidth <= 0 {
|
||||
segmentWidth = 1
|
||||
}
|
||||
repeat := 0
|
||||
if colWidth > 0 && segmentWidth > 0 {
|
||||
repeat = colWidth / segmentWidth
|
||||
}
|
||||
drawnSegment := strings.Repeat(segment, repeat)
|
||||
line.WriteString(drawnSegment)
|
||||
|
||||
// Adjust for width discrepancies
|
||||
actualDrawnWidth := twwidth.Width(drawnSegment)
|
||||
if actualDrawnWidth < colWidth {
|
||||
missingWidth := colWidth - actualDrawnWidth
|
||||
spaces := strings.Repeat(tw.Space, missingWidth)
|
||||
if len(c.config.Border.BG) > 0 {
|
||||
line.WriteString(Tint{BG: c.config.Border.BG}.Apply(spaces))
|
||||
} else {
|
||||
line.WriteString(spaces)
|
||||
}
|
||||
c.logger.Debugf("Line: colIdx=%d corrected segment width, added %d spaces", currentColIdx, missingWidth)
|
||||
} else if actualDrawnWidth > colWidth {
|
||||
c.logger.Debugf("Line: WARNING colIdx=%d segment draw width %d > target %d", currentColIdx, actualDrawnWidth, colWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// Add junction between columns if not the last visible column
|
||||
isLastVisible := keyIndex == len(effectiveKeys)-1
|
||||
if !isLastVisible && c.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
nextVisibleColIdx := effectiveKeys[keyIndex+1]
|
||||
originalPrecedingCol := -1
|
||||
foundCurrent := false
|
||||
for _, k := range allSortedKeys {
|
||||
if k == currentColIdx {
|
||||
foundCurrent = true
|
||||
}
|
||||
if foundCurrent && k < nextVisibleColIdx {
|
||||
originalPrecedingCol = k
|
||||
}
|
||||
if k >= nextVisibleColIdx {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if originalPrecedingCol != -1 {
|
||||
jr.colIdx = originalPrecedingCol
|
||||
junction := jr.RenderJunction(originalPrecedingCol, nextVisibleColIdx)
|
||||
c.logger.Debugf("Line: Junction between visible %d (orig preceding %d) and next visible %d: '%s'", currentColIdx, originalPrecedingCol, nextVisibleColIdx, junction)
|
||||
line.WriteString(junction)
|
||||
} else {
|
||||
c.logger.Debugf("Line: Could not determine original preceding column for junction before visible %d", nextVisibleColIdx)
|
||||
line.WriteString(c.config.Separator.Apply(jr.sym.Center()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add right border if enabled
|
||||
if c.config.Borders.Right.Enabled() {
|
||||
originalLastColIdx := -1
|
||||
if len(allSortedKeys) > 0 {
|
||||
originalLastColIdx = allSortedKeys[len(allSortedKeys)-1]
|
||||
}
|
||||
jr.colIdx = originalLastColIdx
|
||||
line.WriteString(jr.RenderRight(originalLastColIdx))
|
||||
}
|
||||
|
||||
// Write the final line
|
||||
line.WriteString(c.newLine)
|
||||
c.w.Write([]byte(line.String()))
|
||||
c.logger.Debugf("Line rendered: %s", strings.TrimSuffix(line.String(), c.newLine))
|
||||
}
|
||||
|
||||
// Logger sets the logger for the Colorized instance.
|
||||
func (c *Colorized) Logger(logger *ll.Logger) {
|
||||
c.logger = logger.Namespace("colorized")
|
||||
}
|
||||
|
||||
// Reset clears the renderer's internal state, including debug traces.
|
||||
func (c *Colorized) Reset() {
|
||||
c.trace = nil
|
||||
c.logger.Debugf("Reset: Cleared debug trace")
|
||||
}
|
||||
|
||||
// Row renders a table data row with configured colors and formatting.
|
||||
func (c *Colorized) Row(row []string, ctx tw.Formatting) {
|
||||
c.logger.Debugf("Starting Row render: IsSubRow=%v, Location=%v, Pos=%s, hasFooter=%v",
|
||||
ctx.IsSubRow, ctx.Row.Location, ctx.Row.Position, ctx.HasFooter)
|
||||
|
||||
// Check if there is data to render
|
||||
if len(row) == 0 {
|
||||
c.logger.Debugf("Row: No data to render")
|
||||
return
|
||||
}
|
||||
|
||||
// Render the row line
|
||||
c.renderLine(ctx, row, c.config.Column)
|
||||
c.logger.Debugf("Completed Row render")
|
||||
}
|
||||
|
||||
// Start initializes the rendering process (no-op in this implementation).
|
||||
func (c *Colorized) Start(w io.Writer) error {
|
||||
c.w = w
|
||||
c.logger.Debugf("Colorized.Start() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatCell formats a cell's content with color, width, padding, and alignment, handling whitespace trimming and truncation.
|
||||
func (c *Colorized) formatCell(content string, width int, padding tw.Padding, align tw.Align, tint Tint) string {
|
||||
c.logger.Debugf("Formatting cell: content='%s', width=%d, align=%s, paddingL='%s', paddingR='%s', tintFG=%v, tintBG=%v",
|
||||
content, width, align, padding.Left, padding.Right, tint.FG, tint.BG)
|
||||
|
||||
// Return empty string if width is non-positive
|
||||
if width <= 0 {
|
||||
c.logger.Debugf("formatCell: width %d <= 0, returning empty string", width)
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
// Calculate visual width of content
|
||||
contentVisualWidth := twwidth.Width(content)
|
||||
|
||||
// Set padding characters
|
||||
padLeftCharStr := padding.Left
|
||||
padRightCharStr := padding.Right
|
||||
|
||||
// Determine the character to use for alignment filling.
|
||||
// We default to the padding character defined for that side.
|
||||
// If the padding character is empty (e.g. Overwrite: true), we MUST fallback to Space
|
||||
// for the alignment calculation to prevent the content from shifting incorrectly.
|
||||
alignFillLeft := padLeftCharStr
|
||||
if alignFillLeft == tw.Empty {
|
||||
alignFillLeft = tw.Space
|
||||
}
|
||||
alignFillRight := padRightCharStr
|
||||
if alignFillRight == tw.Empty {
|
||||
alignFillRight = tw.Space
|
||||
}
|
||||
|
||||
// Calculate padding widths
|
||||
definedPadLeftWidth := twwidth.Width(padLeftCharStr)
|
||||
definedPadRightWidth := twwidth.Width(padRightCharStr)
|
||||
|
||||
// Calculate available width for content and alignment
|
||||
availableForContentAndAlign := max(width-definedPadLeftWidth-definedPadRightWidth, 0)
|
||||
|
||||
// Truncate content if it exceeds available width
|
||||
if contentVisualWidth > availableForContentAndAlign {
|
||||
content = twwidth.Truncate(content, availableForContentAndAlign)
|
||||
contentVisualWidth = twwidth.Width(content)
|
||||
c.logger.Debugf("Truncated content to fit %d: '%s' (new width %d)", availableForContentAndAlign, content, contentVisualWidth)
|
||||
}
|
||||
|
||||
// Calculate remaining space for alignment
|
||||
remainingSpaceForAlignment := max(availableForContentAndAlign-contentVisualWidth, 0)
|
||||
|
||||
// Apply alignment padding
|
||||
// Note: We use tw.Pad* helpers here instead of strings.Repeat to handle multi-byte fill chars correctly.
|
||||
leftAlignmentPadSpaces := tw.Empty
|
||||
rightAlignmentPadSpaces := tw.Empty
|
||||
|
||||
switch align {
|
||||
case tw.AlignLeft:
|
||||
rightAlignmentPadSpaces = tw.PadRight(tw.Empty, alignFillRight, remainingSpaceForAlignment)
|
||||
case tw.AlignRight:
|
||||
leftAlignmentPadSpaces = tw.PadLeft(tw.Empty, alignFillLeft, remainingSpaceForAlignment)
|
||||
case tw.AlignCenter:
|
||||
leftSpacesCount := remainingSpaceForAlignment / 2
|
||||
rightSpacesCount := remainingSpaceForAlignment - leftSpacesCount
|
||||
if leftSpacesCount > 0 {
|
||||
leftAlignmentPadSpaces = tw.PadLeft(tw.Empty, alignFillLeft, leftSpacesCount)
|
||||
}
|
||||
if rightSpacesCount > 0 {
|
||||
rightAlignmentPadSpaces = tw.PadRight(tw.Empty, alignFillRight, rightSpacesCount)
|
||||
}
|
||||
default:
|
||||
// Default to left alignment
|
||||
rightAlignmentPadSpaces = tw.PadRight(tw.Empty, alignFillRight, remainingSpaceForAlignment)
|
||||
}
|
||||
|
||||
// Apply colors to content and padding
|
||||
coloredContent := tint.Apply(content)
|
||||
coloredPadLeft := padLeftCharStr
|
||||
coloredPadRight := padRightCharStr
|
||||
coloredAlignPadLeft := leftAlignmentPadSpaces
|
||||
coloredAlignPadRight := rightAlignmentPadSpaces
|
||||
|
||||
if len(tint.BG) > 0 {
|
||||
bgTint := Tint{BG: tint.BG}
|
||||
// Apply foreground color to non-space padding if foreground is defined
|
||||
if len(tint.FG) > 0 && padLeftCharStr != tw.Space {
|
||||
coloredPadLeft = tint.Apply(padLeftCharStr)
|
||||
} else {
|
||||
coloredPadLeft = bgTint.Apply(padLeftCharStr)
|
||||
}
|
||||
if len(tint.FG) > 0 && padRightCharStr != tw.Space {
|
||||
coloredPadRight = tint.Apply(padRightCharStr)
|
||||
} else {
|
||||
coloredPadRight = bgTint.Apply(padRightCharStr)
|
||||
}
|
||||
// Apply background color to alignment padding
|
||||
if leftAlignmentPadSpaces != tw.Empty {
|
||||
coloredAlignPadLeft = bgTint.Apply(leftAlignmentPadSpaces)
|
||||
}
|
||||
if rightAlignmentPadSpaces != tw.Empty {
|
||||
coloredAlignPadRight = bgTint.Apply(rightAlignmentPadSpaces)
|
||||
}
|
||||
} else if len(tint.FG) > 0 {
|
||||
// Apply foreground color to non-space padding
|
||||
if padLeftCharStr != tw.Space {
|
||||
coloredPadLeft = tint.Apply(padLeftCharStr)
|
||||
}
|
||||
if padRightCharStr != tw.Space {
|
||||
coloredPadRight = tint.Apply(padRightCharStr)
|
||||
}
|
||||
}
|
||||
|
||||
// Build final cell string
|
||||
var sb strings.Builder
|
||||
sb.WriteString(coloredPadLeft)
|
||||
sb.WriteString(coloredAlignPadLeft)
|
||||
sb.WriteString(coloredContent)
|
||||
sb.WriteString(coloredAlignPadRight)
|
||||
sb.WriteString(coloredPadRight)
|
||||
output := sb.String()
|
||||
|
||||
// Adjust output width if necessary (safety check)
|
||||
currentVisualWidth := twwidth.Width(output)
|
||||
if currentVisualWidth != width {
|
||||
c.logger.Debugf("formatCell MISMATCH: content='%s', target_w=%d. Calculated parts width = %d. String: '%s'",
|
||||
content, width, currentVisualWidth, output)
|
||||
if currentVisualWidth > width {
|
||||
output = twwidth.Truncate(output, width)
|
||||
} else {
|
||||
paddingSpacesStr := strings.Repeat(tw.Space, width-currentVisualWidth)
|
||||
if len(tint.BG) > 0 {
|
||||
output += Tint{BG: tint.BG}.Apply(paddingSpacesStr)
|
||||
} else {
|
||||
output += paddingSpacesStr
|
||||
}
|
||||
}
|
||||
c.logger.Debugf("formatCell Post-Correction: Target %d, New Visual width %d. Output: '%s'", width, twwidth.Width(output), output)
|
||||
}
|
||||
|
||||
c.logger.Debugf("Formatted cell final result: '%s' (target width %d, display width %d)", output, width, twwidth.Width(output))
|
||||
return output
|
||||
}
|
||||
|
||||
// renderLine renders a single line (header, row, or footer) with colors, handling merges and separators.
|
||||
func (c *Colorized) renderLine(ctx tw.Formatting, line []string, tint Tint) {
|
||||
// Determine number of columns
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Widths {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
|
||||
// Add left border if enabled
|
||||
prefix := tw.Empty
|
||||
if c.config.Borders.Left.Enabled() {
|
||||
prefix = c.config.Border.Apply(c.config.Symbols.Column())
|
||||
}
|
||||
output.WriteString(prefix)
|
||||
|
||||
// Set up separator
|
||||
separatorDisplayWidth := 0
|
||||
separatorString := tw.Empty
|
||||
if c.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
separatorString = c.config.Separator.Apply(c.config.Symbols.Column())
|
||||
separatorDisplayWidth = twwidth.Width(c.config.Symbols.Column())
|
||||
}
|
||||
|
||||
// Process each column
|
||||
for i := 0; i < numCols; {
|
||||
// Determine if a separator is needed
|
||||
shouldAddSeparator := false
|
||||
if i > 0 && c.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
cellCtx, ok := ctx.Row.Current[i]
|
||||
if !ok || (!cellCtx.Merge.Horizontal.Present || cellCtx.Merge.Horizontal.Start) {
|
||||
shouldAddSeparator = true
|
||||
}
|
||||
}
|
||||
if shouldAddSeparator {
|
||||
output.WriteString(separatorString)
|
||||
c.logger.Debugf("renderLine: Added separator '%s' before col %d", separatorString, i)
|
||||
} else if i > 0 {
|
||||
c.logger.Debugf("renderLine: Skipped separator before col %d due to HMerge continuation", i)
|
||||
}
|
||||
|
||||
// Get cell context, use default if not present
|
||||
cellCtx, ok := ctx.Row.Current[i]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{
|
||||
Data: tw.Empty,
|
||||
Align: c.defaultAlign[ctx.Row.Position],
|
||||
Padding: tw.Padding{Left: tw.Space, Right: tw.Space},
|
||||
Width: ctx.Row.Widths.Get(i),
|
||||
Merge: tw.MergeState{},
|
||||
}
|
||||
}
|
||||
|
||||
// Handle merged cells
|
||||
visualWidth := 0
|
||||
span := 1
|
||||
isHMergeStart := ok && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start
|
||||
|
||||
if isHMergeStart {
|
||||
span = cellCtx.Merge.Horizontal.Span
|
||||
if ctx.Row.Position == tw.Row {
|
||||
// Calculate dynamic width for row merges
|
||||
dynamicTotalWidth := 0
|
||||
for k := 0; k < span && i+k < numCols; k++ {
|
||||
colToSum := i + k
|
||||
normWidth := max(ctx.NormalizedWidths.Get(colToSum), 0)
|
||||
dynamicTotalWidth += normWidth
|
||||
if k > 0 && separatorDisplayWidth > 0 {
|
||||
dynamicTotalWidth += separatorDisplayWidth
|
||||
}
|
||||
}
|
||||
visualWidth = dynamicTotalWidth
|
||||
c.logger.Debugf("renderLine: Row HMerge col %d, span %d, dynamic visualWidth %d", i, span, visualWidth)
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(i)
|
||||
c.logger.Debugf("renderLine: H/F HMerge col %d, span %d, pre-adjusted visualWidth %d", i, span, visualWidth)
|
||||
}
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(i)
|
||||
c.logger.Debugf("renderLine: Regular col %d, visualWidth %d", i, visualWidth)
|
||||
}
|
||||
if visualWidth < 0 {
|
||||
visualWidth = 0
|
||||
}
|
||||
|
||||
// Skip processing for non-start merged cells
|
||||
if ok && cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start {
|
||||
c.logger.Debugf("renderLine: Skipping col %d processing (part of HMerge)", i)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle empty cell context with non-zero width
|
||||
if !ok && visualWidth > 0 {
|
||||
spaces := strings.Repeat(tw.Space, visualWidth)
|
||||
if len(tint.BG) > 0 {
|
||||
output.WriteString(Tint{BG: tint.BG}.Apply(spaces))
|
||||
} else {
|
||||
output.WriteString(spaces)
|
||||
}
|
||||
c.logger.Debugf("renderLine: No cell context for col %d, writing %d spaces", i, visualWidth)
|
||||
i += span
|
||||
continue
|
||||
}
|
||||
|
||||
// Set cell alignment
|
||||
padding := cellCtx.Padding
|
||||
align := cellCtx.Align
|
||||
if align == tw.AlignNone {
|
||||
align = c.defaultAlign[ctx.Row.Position]
|
||||
c.logger.Debugf("renderLine: col %d using default renderer align '%s' for position %s because cellCtx.Align was AlignNone", i, align, ctx.Row.Position)
|
||||
}
|
||||
|
||||
// Detect and handle TOTAL pattern
|
||||
isTotalPattern := false
|
||||
if i == 0 && isHMergeStart && cellCtx.Merge.Horizontal.Span >= 3 && strings.TrimSpace(cellCtx.Data) == "TOTAL" {
|
||||
isTotalPattern = true
|
||||
c.logger.Debugf("renderLine: Detected 'TOTAL' HMerge pattern at col 0")
|
||||
}
|
||||
// Override alignment for footer merges or TOTAL pattern
|
||||
if (ctx.Row.Position == tw.Footer && isHMergeStart) || isTotalPattern {
|
||||
if align == tw.AlignNone {
|
||||
c.logger.Debugf("renderLine: Applying AlignRight override for Footer HMerge/TOTAL pattern at col %d. Original/default align was: %s", i, align)
|
||||
align = tw.AlignRight
|
||||
}
|
||||
}
|
||||
|
||||
// Handle vertical/hierarchical merges
|
||||
content := cellCtx.Data
|
||||
if (cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) ||
|
||||
(cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start) {
|
||||
content = tw.Empty
|
||||
c.logger.Debugf("renderLine: Blanked data for col %d (non-start V/Hierarchical)", i)
|
||||
}
|
||||
|
||||
// Apply per-column tint if available
|
||||
cellTint := tint
|
||||
if i < len(tint.Columns) {
|
||||
columnTint := tint.Columns[i]
|
||||
if len(columnTint.FG) > 0 || len(columnTint.BG) > 0 {
|
||||
cellTint = columnTint
|
||||
}
|
||||
}
|
||||
|
||||
// Format and render the cell
|
||||
formattedCell := c.formatCell(content, visualWidth, padding, align, cellTint)
|
||||
if len(formattedCell) > 0 {
|
||||
output.WriteString(formattedCell)
|
||||
} else if visualWidth == 0 && isHMergeStart {
|
||||
c.logger.Debugf("renderLine: Rendered HMerge START col %d resulted in 0 visual width, wrote nothing.", i)
|
||||
} else if visualWidth == 0 {
|
||||
c.logger.Debugf("renderLine: Rendered regular col %d resulted in 0 visual width, wrote nothing.", i)
|
||||
}
|
||||
|
||||
// Log rendering details
|
||||
if isHMergeStart {
|
||||
c.logger.Debugf("renderLine: Rendered HMerge START col %d (span %d, visualWidth %d, align %s): '%s'",
|
||||
i, span, visualWidth, align, formattedCell)
|
||||
} else {
|
||||
c.logger.Debugf("renderLine: Rendered regular col %d (visualWidth %d, align %s): '%s'",
|
||||
i, visualWidth, align, formattedCell)
|
||||
}
|
||||
|
||||
i += span
|
||||
}
|
||||
|
||||
// Add right border if enabled
|
||||
suffix := tw.Empty
|
||||
if c.config.Borders.Right.Enabled() {
|
||||
suffix = c.config.Border.Apply(c.config.Symbols.Column())
|
||||
}
|
||||
output.WriteString(suffix)
|
||||
|
||||
// Write the final line
|
||||
output.WriteString(c.newLine)
|
||||
c.w.Write([]byte(output.String()))
|
||||
c.logger.Debugf("renderLine: Final rendered line: %s", strings.TrimSuffix(output.String(), c.newLine))
|
||||
}
|
||||
|
||||
// Rendition updates the parts of ColorizedConfig that correspond to tw.Rendition
|
||||
// by merging the provided newRendition. Color-specific Tints are not modified.
|
||||
func (c *Colorized) Rendition(newRendition tw.Rendition) { // Method name matches interface
|
||||
c.logger.Debug("Colorized.Rendition called. Current B/Sym/Set: B:%+v, Sym:%T, S:%+v. Override: %+v", c.config.Borders, c.config.Symbols, c.config.Settings, newRendition)
|
||||
|
||||
currentRenditionPart := tw.Rendition{
|
||||
Borders: c.config.Borders,
|
||||
Symbols: c.config.Symbols,
|
||||
Settings: c.config.Settings,
|
||||
}
|
||||
|
||||
mergedRenditionPart := mergeRendition(currentRenditionPart, newRendition)
|
||||
|
||||
c.config.Borders = mergedRenditionPart.Borders
|
||||
c.config.Symbols = mergedRenditionPart.Symbols
|
||||
if c.config.Symbols == nil {
|
||||
c.config.Symbols = tw.NewSymbols(tw.StyleLight)
|
||||
}
|
||||
c.config.Settings = mergedRenditionPart.Settings
|
||||
|
||||
c.logger.Debugf("Colorized.Rendition updated. New B/Sym/Set: B:%+v, Sym:%T, S:%+v",
|
||||
c.config.Borders, c.config.Symbols, c.config.Settings)
|
||||
}
|
||||
|
||||
// Ensure Colorized implements tw.Renditioning
|
||||
var _ tw.Renditioning = (*Colorized)(nil)
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// defaultBlueprint returns a default Rendition for ASCII table rendering with borders and light symbols.
|
||||
func defaultBlueprint() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: tw.Border{
|
||||
Left: tw.On,
|
||||
Right: tw.On,
|
||||
Top: tw.On,
|
||||
Bottom: tw.On,
|
||||
},
|
||||
Settings: tw.Settings{
|
||||
Separators: tw.Separators{
|
||||
ShowHeader: tw.On,
|
||||
ShowFooter: tw.On,
|
||||
BetweenRows: tw.Off,
|
||||
BetweenColumns: tw.On,
|
||||
},
|
||||
Lines: tw.Lines{
|
||||
ShowTop: tw.On,
|
||||
ShowBottom: tw.On,
|
||||
ShowHeaderLine: tw.On,
|
||||
ShowFooterLine: tw.On,
|
||||
},
|
||||
CompactMode: tw.Off,
|
||||
// Cushion: tw.On,
|
||||
},
|
||||
Symbols: tw.NewSymbols(tw.StyleLight),
|
||||
Streaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
// defaultColorized returns a default ColorizedConfig optimized for dark terminal backgrounds with colored headers, rows, and borders.
|
||||
func defaultColorized() ColorizedConfig {
|
||||
return ColorizedConfig{
|
||||
Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On},
|
||||
Settings: tw.Settings{
|
||||
Separators: tw.Separators{
|
||||
ShowHeader: tw.On,
|
||||
ShowFooter: tw.On,
|
||||
BetweenRows: tw.Off,
|
||||
BetweenColumns: tw.On,
|
||||
},
|
||||
Lines: tw.Lines{
|
||||
ShowTop: tw.On,
|
||||
ShowBottom: tw.On,
|
||||
ShowHeaderLine: tw.On,
|
||||
ShowFooterLine: tw.On,
|
||||
},
|
||||
|
||||
CompactMode: tw.Off,
|
||||
},
|
||||
Header: Tint{
|
||||
FG: Colors{color.FgWhite, color.Bold},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Column: Tint{
|
||||
FG: Colors{color.FgCyan},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Footer: Tint{
|
||||
FG: Colors{color.FgYellow},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Border: Tint{
|
||||
FG: Colors{color.FgWhite},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Separator: Tint{
|
||||
FG: Colors{color.FgWhite},
|
||||
BG: Colors{color.BgBlack},
|
||||
},
|
||||
Symbols: tw.NewSymbols(tw.StyleLight),
|
||||
}
|
||||
}
|
||||
|
||||
// defaultOceanRendererConfig returns a base tw.Rendition for the Ocean renderer.
|
||||
func defaultOceanRendererConfig() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: tw.Border{
|
||||
Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On,
|
||||
},
|
||||
Settings: tw.Settings{
|
||||
Separators: tw.Separators{
|
||||
ShowHeader: tw.On,
|
||||
ShowFooter: tw.Off,
|
||||
BetweenRows: tw.Off,
|
||||
BetweenColumns: tw.On,
|
||||
},
|
||||
Lines: tw.Lines{
|
||||
ShowTop: tw.On,
|
||||
ShowBottom: tw.On,
|
||||
ShowHeaderLine: tw.On,
|
||||
ShowFooterLine: tw.Off,
|
||||
},
|
||||
|
||||
CompactMode: tw.Off,
|
||||
},
|
||||
Symbols: tw.NewSymbols(tw.StyleDefault),
|
||||
Streaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
// getHTMLStyle remains the same
|
||||
func getHTMLStyle(align tw.Align) string {
|
||||
styleContent := tw.Empty
|
||||
switch align {
|
||||
case tw.AlignRight:
|
||||
styleContent = "text-align: right;"
|
||||
case tw.AlignCenter:
|
||||
styleContent = "text-align: center;"
|
||||
case tw.AlignLeft:
|
||||
styleContent = "text-align: left;"
|
||||
}
|
||||
if styleContent != tw.Empty {
|
||||
return fmt.Sprintf(` style="%s"`, styleContent)
|
||||
}
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
// mergeLines combines default and override line settings, preserving defaults for unset (zero) overrides.
|
||||
func mergeLines(defaults, overrides tw.Lines) tw.Lines {
|
||||
if overrides.ShowTop != 0 {
|
||||
defaults.ShowTop = overrides.ShowTop
|
||||
}
|
||||
if overrides.ShowBottom != 0 {
|
||||
defaults.ShowBottom = overrides.ShowBottom
|
||||
}
|
||||
if overrides.ShowHeaderLine != 0 {
|
||||
defaults.ShowHeaderLine = overrides.ShowHeaderLine
|
||||
}
|
||||
if overrides.ShowFooterLine != 0 {
|
||||
defaults.ShowFooterLine = overrides.ShowFooterLine
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
// mergeSeparators combines default and override separator settings, preserving defaults for unset (zero) overrides.
|
||||
func mergeSeparators(defaults, overrides tw.Separators) tw.Separators {
|
||||
if overrides.ShowHeader != 0 {
|
||||
defaults.ShowHeader = overrides.ShowHeader
|
||||
}
|
||||
if overrides.ShowFooter != 0 {
|
||||
defaults.ShowFooter = overrides.ShowFooter
|
||||
}
|
||||
if overrides.BetweenRows != 0 {
|
||||
defaults.BetweenRows = overrides.BetweenRows
|
||||
}
|
||||
if overrides.BetweenColumns != 0 {
|
||||
defaults.BetweenColumns = overrides.BetweenColumns
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
// mergeSettings combines default and override settings, preserving defaults for unset (zero) overrides.
|
||||
func mergeSettings(defaults, overrides tw.Settings) tw.Settings {
|
||||
if overrides.Separators.ShowHeader != tw.Unknown {
|
||||
defaults.Separators.ShowHeader = overrides.Separators.ShowHeader
|
||||
}
|
||||
if overrides.Separators.ShowFooter != tw.Unknown {
|
||||
defaults.Separators.ShowFooter = overrides.Separators.ShowFooter
|
||||
}
|
||||
if overrides.Separators.BetweenRows != tw.Unknown {
|
||||
defaults.Separators.BetweenRows = overrides.Separators.BetweenRows
|
||||
}
|
||||
if overrides.Separators.BetweenColumns != tw.Unknown {
|
||||
defaults.Separators.BetweenColumns = overrides.Separators.BetweenColumns
|
||||
}
|
||||
if overrides.Lines.ShowTop != tw.Unknown {
|
||||
defaults.Lines.ShowTop = overrides.Lines.ShowTop
|
||||
}
|
||||
if overrides.Lines.ShowBottom != tw.Unknown {
|
||||
defaults.Lines.ShowBottom = overrides.Lines.ShowBottom
|
||||
}
|
||||
if overrides.Lines.ShowHeaderLine != tw.Unknown {
|
||||
defaults.Lines.ShowHeaderLine = overrides.Lines.ShowHeaderLine
|
||||
}
|
||||
if overrides.Lines.ShowFooterLine != tw.Unknown {
|
||||
defaults.Lines.ShowFooterLine = overrides.Lines.ShowFooterLine
|
||||
}
|
||||
|
||||
if overrides.CompactMode != tw.Unknown {
|
||||
defaults.CompactMode = overrides.CompactMode
|
||||
}
|
||||
|
||||
// if overrides.Cushion != tw.Unknown {
|
||||
// defaults.Cushion = overrides.Cushion
|
||||
//}
|
||||
|
||||
return defaults
|
||||
}
|
||||
|
||||
// MergeRendition merges the 'override' rendition into the 'current' rendition.
|
||||
// It only updates fields in 'current' if they are explicitly set (non-zero/non-nil) in 'override'.
|
||||
// This allows for partial updates to a renderer's configuration.
|
||||
func mergeRendition(current, override tw.Rendition) tw.Rendition {
|
||||
// Merge Borders: Only update if override border states are explicitly set (not 0).
|
||||
// A tw.State's zero value is 0, which is distinct from tw.On (1) or tw.Off (-1).
|
||||
// So, if override.Borders.Left is 0, it means "not specified", so we keep current.
|
||||
if override.Borders.Left != 0 {
|
||||
current.Borders.Left = override.Borders.Left
|
||||
}
|
||||
if override.Borders.Right != 0 {
|
||||
current.Borders.Right = override.Borders.Right
|
||||
}
|
||||
if override.Borders.Top != 0 {
|
||||
current.Borders.Top = override.Borders.Top
|
||||
}
|
||||
if override.Borders.Bottom != 0 {
|
||||
current.Borders.Bottom = override.Borders.Bottom
|
||||
}
|
||||
|
||||
// Merge Symbols: Only update if override.Symbols is not nil.
|
||||
if override.Symbols != nil {
|
||||
current.Symbols = override.Symbols
|
||||
}
|
||||
|
||||
// Merge Settings: Use the existing mergeSettings for granular control.
|
||||
// mergeSettings already handles preserving defaults for unset (zero) overrides.
|
||||
current.Settings = mergeSettings(current.Settings, override.Settings)
|
||||
|
||||
// Streaming flag: typically set at renderer creation, but can be overridden if needed.
|
||||
// For now, let's assume it's not commonly changed post-creation by a generic rendition merge.
|
||||
// If override provides a different streaming capability, it might indicate a fundamental
|
||||
// change that a simple merge shouldn't handle without more context.
|
||||
// current.Streaming = override.Streaming // Or keep current.Streaming
|
||||
|
||||
return current
|
||||
}
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// HTMLConfig defines settings for the HTML table renderer.
|
||||
type HTMLConfig struct {
|
||||
EscapeContent bool // Whether to escape cell content
|
||||
AddLinesTag bool // Whether to wrap multiline content in <lines> tags
|
||||
TableClass string // CSS class for <table>
|
||||
HeaderClass string // CSS class for <thead>
|
||||
BodyClass string // CSS class for <tbody>
|
||||
FooterClass string // CSS class for <tfoot>
|
||||
RowClass string // CSS class for <tr> in body
|
||||
HeaderRowClass string // CSS class for <tr> in header
|
||||
FooterRowClass string // CSS class for <tr> in footer
|
||||
}
|
||||
|
||||
// HTML renders tables in HTML format with customizable classes and content handling.
|
||||
type HTML struct {
|
||||
config HTMLConfig // Renderer configuration
|
||||
w io.Writer // Output w
|
||||
trace []string // Debug trace messages
|
||||
debug bool // Enables debug logging
|
||||
tableStarted bool // Tracks if <table> tag is open
|
||||
tbodyStarted bool // Tracks if <tbody> tag is open
|
||||
tfootStarted bool // Tracks if <tfoot> tag is open
|
||||
vMergeTrack map[int]int // Tracks vertical merge spans by column index
|
||||
logger *ll.Logger
|
||||
}
|
||||
|
||||
// NewHTML initializes an HTML renderer with the given w, debug setting, and optional configuration.
|
||||
// It panics if the w is nil and applies defaults for unset config fields.
|
||||
// Update: see https://github.com/olekukonko/tablewriter/issues/258
|
||||
func NewHTML(configs ...HTMLConfig) *HTML {
|
||||
cfg := HTMLConfig{
|
||||
EscapeContent: true,
|
||||
AddLinesTag: false,
|
||||
}
|
||||
if len(configs) > 0 {
|
||||
userCfg := configs[0]
|
||||
cfg.EscapeContent = userCfg.EscapeContent
|
||||
cfg.AddLinesTag = userCfg.AddLinesTag
|
||||
cfg.TableClass = userCfg.TableClass
|
||||
cfg.HeaderClass = userCfg.HeaderClass
|
||||
cfg.BodyClass = userCfg.BodyClass
|
||||
cfg.FooterClass = userCfg.FooterClass
|
||||
cfg.RowClass = userCfg.RowClass
|
||||
cfg.HeaderRowClass = userCfg.HeaderRowClass
|
||||
cfg.FooterRowClass = userCfg.FooterRowClass
|
||||
}
|
||||
return &HTML{
|
||||
config: cfg,
|
||||
vMergeTrack: make(map[int]int),
|
||||
tableStarted: false,
|
||||
tbodyStarted: false,
|
||||
tfootStarted: false,
|
||||
logger: ll.New("html").Disable(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HTML) Logger(logger *ll.Logger) {
|
||||
h.logger = logger
|
||||
}
|
||||
|
||||
// Config returns a Rendition representation of the current configuration.
|
||||
func (h *HTML) Config() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: tw.BorderNone,
|
||||
Symbols: tw.NewSymbols(tw.StyleNone),
|
||||
Settings: tw.Settings{},
|
||||
Streaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
// debugLog appends a formatted message to the debug trace if debugging is enabled.
|
||||
// func (h *HTML) debugLog(format string, a ...interface{}) {
|
||||
// if h.debug {
|
||||
// msg := fmt.Sprintf(format, a...)
|
||||
// h.trace = append(h.trace, fmt.Sprintf("[HTML] %s", msg))
|
||||
// }
|
||||
//}
|
||||
|
||||
// Debug returns the accumulated debug trace messages.
|
||||
func (h *HTML) Debug() []string {
|
||||
return h.trace
|
||||
}
|
||||
|
||||
// Start begins the HTML table rendering by opening the <table> tag.
|
||||
func (h *HTML) Start(w io.Writer) error {
|
||||
h.w = w
|
||||
h.Reset()
|
||||
h.logger.Debug("HTML.Start() called.")
|
||||
|
||||
classAttr := tw.Empty
|
||||
if h.config.TableClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.TableClass)
|
||||
}
|
||||
h.logger.Debugf("Writing opening <table%s> tag", classAttr)
|
||||
_, err := fmt.Fprintf(h.w, "<table%s>\n", classAttr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.tableStarted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// closePreviousSection closes any open <tbody> or <tfoot> sections.
|
||||
func (h *HTML) closePreviousSection() {
|
||||
if h.tbodyStarted {
|
||||
h.logger.Debug("Closing <tbody> tag")
|
||||
fmt.Fprintln(h.w, "</tbody>")
|
||||
h.tbodyStarted = false
|
||||
}
|
||||
if h.tfootStarted {
|
||||
h.logger.Debug("Closing <tfoot> tag")
|
||||
fmt.Fprintln(h.w, "</tfoot>")
|
||||
h.tfootStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
// Header renders the <thead> section with header rows, supporting horizontal merges.
|
||||
func (h *HTML) Header(headers [][]string, ctx tw.Formatting) {
|
||||
if !h.tableStarted {
|
||||
h.logger.Debug("WARN: Header called before Start")
|
||||
return
|
||||
}
|
||||
if len(headers) == 0 || len(headers[0]) == 0 {
|
||||
h.logger.Debug("Header: No headers")
|
||||
return
|
||||
}
|
||||
|
||||
h.closePreviousSection()
|
||||
classAttr := tw.Empty
|
||||
if h.config.HeaderClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "<thead%s>\n", classAttr)
|
||||
|
||||
headerRow := headers[0]
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(headerRow) > 0 {
|
||||
numCols = len(headerRow)
|
||||
}
|
||||
|
||||
indent := " "
|
||||
rowClassAttr := tw.Empty
|
||||
if h.config.HeaderRowClass != tw.Empty {
|
||||
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.HeaderRowClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
|
||||
|
||||
renderedCols := 0
|
||||
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
|
||||
// Skip columns consumed by vertical merges
|
||||
if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 {
|
||||
h.logger.Debugf("Header: Skipping col %d due to vmerge", colIdx)
|
||||
h.vMergeTrack[colIdx]--
|
||||
if h.vMergeTrack[colIdx] <= 1 {
|
||||
delete(h.vMergeTrack, colIdx)
|
||||
}
|
||||
colIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
// Render cell
|
||||
cellCtx, ok := ctx.Row.Current[colIdx]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{Align: tw.AlignCenter}
|
||||
}
|
||||
originalContent := tw.Empty
|
||||
if colIdx < len(headerRow) {
|
||||
originalContent = headerRow[colIdx]
|
||||
}
|
||||
|
||||
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, true, colIdx)
|
||||
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
|
||||
renderedCols++
|
||||
|
||||
// Handle horizontal merges
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
renderedCols += (hSpan - 1)
|
||||
}
|
||||
colIdx += hSpan
|
||||
}
|
||||
fmt.Fprintf(h.w, "</tr>\n")
|
||||
fmt.Fprintln(h.w, "</thead>")
|
||||
}
|
||||
|
||||
// Row renders a <tr> element within <tbody>, supporting horizontal and vertical merges.
|
||||
func (h *HTML) Row(row []string, ctx tw.Formatting) {
|
||||
if !h.tableStarted {
|
||||
h.logger.Debug("WARN: Row called before Start")
|
||||
return
|
||||
}
|
||||
|
||||
if !h.tbodyStarted {
|
||||
h.closePreviousSection()
|
||||
classAttr := tw.Empty
|
||||
if h.config.BodyClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.BodyClass)
|
||||
}
|
||||
h.logger.Debugf("Writing opening <tbody%s> tag", classAttr)
|
||||
fmt.Fprintf(h.w, "<tbody%s>\n", classAttr)
|
||||
h.tbodyStarted = true
|
||||
}
|
||||
|
||||
h.logger.Debugf("Rendering row data: %v", row)
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(row) > 0 {
|
||||
numCols = len(row)
|
||||
}
|
||||
|
||||
indent := " "
|
||||
rowClassAttr := tw.Empty
|
||||
if h.config.RowClass != tw.Empty {
|
||||
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.RowClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
|
||||
|
||||
renderedCols := 0
|
||||
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
|
||||
// Skip columns consumed by vertical merges
|
||||
if remainingSpan, merging := h.vMergeTrack[colIdx]; merging && remainingSpan > 1 {
|
||||
h.logger.Debugf("Row: Skipping render for col %d due to vertical merge (remaining %d)", colIdx, remainingSpan-1)
|
||||
h.vMergeTrack[colIdx]--
|
||||
if h.vMergeTrack[colIdx] <= 1 {
|
||||
delete(h.vMergeTrack, colIdx)
|
||||
}
|
||||
colIdx++
|
||||
continue
|
||||
}
|
||||
|
||||
// Render cell
|
||||
cellCtx, ok := ctx.Row.Current[colIdx]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{Align: tw.AlignLeft}
|
||||
}
|
||||
originalContent := tw.Empty
|
||||
if colIdx < len(row) {
|
||||
originalContent = row[colIdx]
|
||||
}
|
||||
|
||||
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx)
|
||||
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
|
||||
renderedCols++
|
||||
|
||||
// Handle horizontal merges
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
renderedCols += (hSpan - 1)
|
||||
}
|
||||
colIdx += hSpan
|
||||
}
|
||||
fmt.Fprintf(h.w, "</tr>\n")
|
||||
}
|
||||
|
||||
// Footer renders the <tfoot> section with footer rows, supporting horizontal merges.
|
||||
func (h *HTML) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
if !h.tableStarted {
|
||||
h.logger.Debug("WARN: Footer called before Start")
|
||||
return
|
||||
}
|
||||
if len(footers) == 0 || len(footers[0]) == 0 {
|
||||
h.logger.Debug("Footer: No footers")
|
||||
return
|
||||
}
|
||||
|
||||
h.closePreviousSection()
|
||||
classAttr := tw.Empty
|
||||
if h.config.FooterClass != tw.Empty {
|
||||
classAttr = fmt.Sprintf(` class="%s"`, h.config.FooterClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "<tfoot%s>\n", classAttr)
|
||||
h.tfootStarted = true
|
||||
|
||||
footerRow := footers[0]
|
||||
numCols := 0
|
||||
if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(footerRow) > 0 {
|
||||
numCols = len(footerRow)
|
||||
}
|
||||
|
||||
indent := " "
|
||||
rowClassAttr := tw.Empty
|
||||
if h.config.FooterRowClass != tw.Empty {
|
||||
rowClassAttr = fmt.Sprintf(` class="%s"`, h.config.FooterRowClass)
|
||||
}
|
||||
fmt.Fprintf(h.w, "%s<tr%s>", indent, rowClassAttr)
|
||||
|
||||
renderedCols := 0
|
||||
for colIdx := 0; renderedCols < numCols && colIdx < numCols; {
|
||||
cellCtx, ok := ctx.Row.Current[colIdx]
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{Align: tw.AlignRight}
|
||||
}
|
||||
originalContent := tw.Empty
|
||||
if colIdx < len(footerRow) {
|
||||
originalContent = footerRow[colIdx]
|
||||
}
|
||||
|
||||
tag, attributes, processedContent := h.renderRowCell(originalContent, cellCtx, false, colIdx)
|
||||
fmt.Fprintf(h.w, "<%s%s>%s</%s>", tag, attributes, processedContent, tag)
|
||||
renderedCols++
|
||||
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
renderedCols += (hSpan - 1)
|
||||
}
|
||||
colIdx += hSpan
|
||||
}
|
||||
fmt.Fprintf(h.w, "</tr>\n")
|
||||
fmt.Fprintln(h.w, "</tfoot>")
|
||||
h.tfootStarted = false
|
||||
}
|
||||
|
||||
// renderRowCell generates HTML for a single cell, handling content escaping, merges, and alignment.
|
||||
func (h *HTML) renderRowCell(originalContent string, cellCtx tw.CellContext, isHeader bool, colIdx int) (tag, attributes, processedContent string) {
|
||||
tag = "td"
|
||||
if isHeader {
|
||||
tag = "th"
|
||||
}
|
||||
|
||||
// Process content
|
||||
processedContent = originalContent
|
||||
containsNewline := strings.Contains(originalContent, "\n")
|
||||
|
||||
if h.config.EscapeContent {
|
||||
if containsNewline {
|
||||
const newlinePlaceholder = "[[--HTML_RENDERER_BR_PLACEHOLDER--]]"
|
||||
tempContent := strings.ReplaceAll(originalContent, "\n", newlinePlaceholder)
|
||||
escapedContent := html.EscapeString(tempContent)
|
||||
processedContent = strings.ReplaceAll(escapedContent, newlinePlaceholder, "<br>")
|
||||
} else {
|
||||
processedContent = html.EscapeString(originalContent)
|
||||
}
|
||||
} else if containsNewline {
|
||||
processedContent = strings.ReplaceAll(originalContent, "\n", "<br>")
|
||||
}
|
||||
|
||||
if containsNewline && h.config.AddLinesTag {
|
||||
processedContent = "<lines>" + processedContent + "</lines>"
|
||||
}
|
||||
|
||||
// Build attributes
|
||||
var attrBuilder strings.Builder
|
||||
merge := cellCtx.Merge
|
||||
|
||||
if merge.Horizontal.Present && merge.Horizontal.Start && merge.Horizontal.Span > 1 {
|
||||
fmt.Fprintf(&attrBuilder, ` colspan="%d"`, merge.Horizontal.Span)
|
||||
}
|
||||
|
||||
vSpan := 0
|
||||
if !isHeader {
|
||||
if merge.Vertical.Present && merge.Vertical.Start {
|
||||
vSpan = merge.Vertical.Span
|
||||
} else if merge.Hierarchical.Present && merge.Hierarchical.Start {
|
||||
vSpan = merge.Hierarchical.Span
|
||||
}
|
||||
if vSpan > 1 {
|
||||
fmt.Fprintf(&attrBuilder, ` rowspan="%d"`, vSpan)
|
||||
h.vMergeTrack[colIdx] = vSpan
|
||||
h.logger.Debugf("renderRowCell: Tracking rowspan=%d for col %d", vSpan, colIdx)
|
||||
}
|
||||
}
|
||||
|
||||
if style := getHTMLStyle(cellCtx.Align); style != tw.Empty {
|
||||
attrBuilder.WriteString(style)
|
||||
}
|
||||
attributes = attrBuilder.String()
|
||||
return
|
||||
}
|
||||
|
||||
// Line is a no-op for HTML rendering, as structural lines are handled by tags.
|
||||
func (h *HTML) Line(ctx tw.Formatting) {}
|
||||
|
||||
// Reset clears the renderer's internal state, including debug traces and merge tracking.
|
||||
func (h *HTML) Reset() {
|
||||
h.logger.Debug("HTML.Reset() called.")
|
||||
h.tableStarted = false
|
||||
h.tbodyStarted = false
|
||||
h.tfootStarted = false
|
||||
h.vMergeTrack = make(map[int]int)
|
||||
h.trace = nil
|
||||
}
|
||||
|
||||
// Close ensures all open HTML tags (<table>, <tbody>, <tfoot>) are properly closed.
|
||||
func (h *HTML) Close() error {
|
||||
if h.w == nil {
|
||||
return errors.New("HTML Renderer Close called on nil internal w")
|
||||
}
|
||||
|
||||
if h.tableStarted {
|
||||
h.logger.Debug("HTML.Close() called.")
|
||||
h.closePreviousSection()
|
||||
h.logger.Debug("Closing <table> tag.")
|
||||
_, err := fmt.Fprintln(h.w, "</table>")
|
||||
h.tableStarted = false
|
||||
h.tbodyStarted = false
|
||||
h.tfootStarted = false
|
||||
h.vMergeTrack = make(map[int]int)
|
||||
return err
|
||||
}
|
||||
h.logger.Debug("HTML.Close() called, but table was not started (no-op).")
|
||||
return nil
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// Junction handles rendering of table junction points (corners, intersections) with color support.
|
||||
type Junction struct {
|
||||
sym tw.Symbols // Symbols used for rendering junctions and lines
|
||||
ctx tw.Formatting // Current table formatting context
|
||||
colIdx int // Index of the column being processed
|
||||
debugging bool // Enables debug logging
|
||||
borderTint Tint // Colors for border symbols
|
||||
separatorTint Tint // Colors for separator symbols
|
||||
logger *ll.Logger
|
||||
}
|
||||
|
||||
type JunctionContext struct {
|
||||
Symbols tw.Symbols
|
||||
Ctx tw.Formatting
|
||||
ColIdx int
|
||||
Logger *ll.Logger
|
||||
BorderTint Tint
|
||||
SeparatorTint Tint
|
||||
}
|
||||
|
||||
// NewJunction initializes a Junction with the given symbols, context, and tints.
|
||||
// If debug is nil, a no-op debug function is used.
|
||||
func NewJunction(ctx JunctionContext) *Junction {
|
||||
return &Junction{
|
||||
sym: ctx.Symbols,
|
||||
ctx: ctx.Ctx,
|
||||
colIdx: ctx.ColIdx,
|
||||
logger: ctx.Logger.Namespace("junction"),
|
||||
borderTint: ctx.BorderTint,
|
||||
separatorTint: ctx.SeparatorTint,
|
||||
}
|
||||
}
|
||||
|
||||
// getMergeState retrieves the merge state for a specific column in a row, returning an empty state if not found.
|
||||
func (jr *Junction) getMergeState(row map[int]tw.CellContext, colIdx int) tw.MergeState {
|
||||
if row == nil || colIdx < 0 {
|
||||
return tw.MergeState{}
|
||||
}
|
||||
return row[colIdx].Merge
|
||||
}
|
||||
|
||||
// GetSegment determines whether to render a colored horizontal line or an empty space based on merge states.
|
||||
func (jr *Junction) GetSegment() string {
|
||||
currentMerge := jr.getMergeState(jr.ctx.Row.Current, jr.colIdx)
|
||||
nextMerge := jr.getMergeState(jr.ctx.Row.Next, jr.colIdx)
|
||||
|
||||
vPassThruStrict := (currentMerge.Vertical.Present && nextMerge.Vertical.Present && !currentMerge.Vertical.End && !nextMerge.Vertical.Start) ||
|
||||
(currentMerge.Hierarchical.Present && nextMerge.Hierarchical.Present && !currentMerge.Hierarchical.End && !nextMerge.Hierarchical.Start)
|
||||
|
||||
if vPassThruStrict {
|
||||
jr.logger.Debugf("GetSegment col %d: VPassThruStrict=%v -> Empty segment", jr.colIdx, vPassThruStrict)
|
||||
return tw.Empty
|
||||
}
|
||||
symbol := jr.sym.Row()
|
||||
coloredSymbol := jr.borderTint.Apply(symbol)
|
||||
jr.logger.Debugf("GetSegment col %d: VPassThruStrict=%v -> Colored row symbol '%s'", jr.colIdx, vPassThruStrict, coloredSymbol)
|
||||
return coloredSymbol
|
||||
}
|
||||
|
||||
// RenderLeft selects and colors the leftmost junction symbol for the current row line based on position and merges.
|
||||
func (jr *Junction) RenderLeft() string {
|
||||
mergeAbove := jr.getMergeState(jr.ctx.Row.Current, 0)
|
||||
mergeBelow := jr.getMergeState(jr.ctx.Row.Next, 0)
|
||||
|
||||
jr.logger.Debugf("RenderLeft: Level=%v, Location=%v, Previous=%v", jr.ctx.Level, jr.ctx.Row.Location, jr.ctx.Row.Previous)
|
||||
|
||||
isTopBorder := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) ||
|
||||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && jr.ctx.Row.Previous == nil)
|
||||
if isTopBorder {
|
||||
symbol := jr.sym.TopLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
isBottom := jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter
|
||||
isFooter := jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd
|
||||
if isBottom || isFooter {
|
||||
symbol := jr.sym.BottomLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
isVPassThruStrict := (mergeAbove.Vertical.Present && mergeBelow.Vertical.Present && !mergeAbove.Vertical.End && !mergeBelow.Vertical.Start) ||
|
||||
(mergeAbove.Hierarchical.Present && mergeBelow.Hierarchical.Present && !mergeAbove.Hierarchical.End && !mergeBelow.Hierarchical.Start)
|
||||
if isVPassThruStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
|
||||
symbol := jr.sym.MidLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
// RenderRight selects and colors the rightmost junction symbol for the row line based on position, merges, and last column index.
|
||||
func (jr *Junction) RenderRight(lastColIdx int) string {
|
||||
jr.logger.Debugf("RenderRight: lastColIdx=%d, Level=%v, Location=%v, Previous=%v", lastColIdx, jr.ctx.Level, jr.ctx.Row.Location, jr.ctx.Row.Previous)
|
||||
|
||||
if lastColIdx < 0 {
|
||||
switch jr.ctx.Level {
|
||||
case tw.LevelHeader:
|
||||
symbol := jr.sym.TopRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
case tw.LevelFooter:
|
||||
symbol := jr.sym.BottomRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
default:
|
||||
if jr.ctx.Row.Location == tw.LocationFirst {
|
||||
symbol := jr.sym.TopRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if jr.ctx.Row.Location == tw.LocationEnd {
|
||||
symbol := jr.sym.BottomRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
}
|
||||
|
||||
mergeAbove := jr.getMergeState(jr.ctx.Row.Current, lastColIdx)
|
||||
mergeBelow := jr.getMergeState(jr.ctx.Row.Next, lastColIdx)
|
||||
|
||||
isTopBorder := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) ||
|
||||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && jr.ctx.Row.Previous == nil)
|
||||
if isTopBorder {
|
||||
symbol := jr.sym.TopRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
isBottom := jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter
|
||||
isFooter := jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd
|
||||
if isBottom || isFooter {
|
||||
symbol := jr.sym.BottomRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
isVPassThruStrict := (mergeAbove.Vertical.Present && mergeBelow.Vertical.Present && !mergeAbove.Vertical.End && !mergeBelow.Vertical.Start) ||
|
||||
(mergeAbove.Hierarchical.Present && mergeBelow.Hierarchical.Present && !mergeAbove.Hierarchical.End && !mergeBelow.Hierarchical.Start)
|
||||
if isVPassThruStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
// RenderJunction selects and colors the junction symbol between two adjacent columns based on merge states and table position.
|
||||
func (jr *Junction) RenderJunction(leftColIdx, rightColIdx int) string {
|
||||
mergeCurrentL := jr.getMergeState(jr.ctx.Row.Current, leftColIdx)
|
||||
mergeCurrentR := jr.getMergeState(jr.ctx.Row.Current, rightColIdx)
|
||||
mergeNextL := jr.getMergeState(jr.ctx.Row.Next, leftColIdx)
|
||||
mergeNextR := jr.getMergeState(jr.ctx.Row.Next, rightColIdx)
|
||||
|
||||
isSpannedCurrent := mergeCurrentL.Horizontal.Present && !mergeCurrentL.Horizontal.End
|
||||
isSpannedNext := mergeNextL.Horizontal.Present && !mergeNextL.Horizontal.End
|
||||
|
||||
vPassThruLStrict := (mergeCurrentL.Vertical.Present && mergeNextL.Vertical.Present && !mergeCurrentL.Vertical.End && !mergeNextL.Vertical.Start) ||
|
||||
(mergeCurrentL.Hierarchical.Present && mergeNextL.Hierarchical.Present && !mergeCurrentL.Hierarchical.End && !mergeNextL.Hierarchical.Start)
|
||||
vPassThruRStrict := (mergeCurrentR.Vertical.Present && mergeNextR.Vertical.Present && !mergeCurrentR.Vertical.End && !mergeNextR.Vertical.Start) ||
|
||||
(mergeCurrentR.Hierarchical.Present && mergeNextR.Hierarchical.Present && !mergeCurrentR.Hierarchical.End && !mergeNextR.Hierarchical.Start)
|
||||
|
||||
isTop := (jr.ctx.Level == tw.LevelHeader && jr.ctx.Row.Location == tw.LocationFirst) ||
|
||||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationFirst && len(jr.ctx.Row.Previous) == 0)
|
||||
isBottom := (jr.ctx.Level == tw.LevelFooter && jr.ctx.Row.Location == tw.LocationEnd) ||
|
||||
(jr.ctx.Level == tw.LevelBody && jr.ctx.Row.Location == tw.LocationEnd && !jr.ctx.HasFooter)
|
||||
isPreFooter := jr.ctx.Level == tw.LevelFooter && (jr.ctx.Row.Position == tw.Row || jr.ctx.Row.Position == tw.Header)
|
||||
|
||||
if isTop {
|
||||
if isSpannedNext {
|
||||
symbol := jr.sym.Row()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
symbol := jr.sym.TopMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
if isBottom {
|
||||
if vPassThruLStrict && vPassThruRStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruLStrict {
|
||||
symbol := jr.sym.MidLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruRStrict {
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedCurrent {
|
||||
symbol := jr.sym.Row()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
if isPreFooter {
|
||||
if vPassThruLStrict && vPassThruRStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruLStrict {
|
||||
symbol := jr.sym.MidLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruRStrict {
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if mergeCurrentL.Horizontal.Present {
|
||||
if !mergeCurrentL.Horizontal.End && mergeCurrentR.Horizontal.Present && !mergeCurrentR.Horizontal.End {
|
||||
jr.logger.Debugf("Footer separator: H-merge continues from col %d to %d (mid-span), using BottomMid", leftColIdx, rightColIdx)
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if !mergeCurrentL.Horizontal.End && mergeCurrentR.Horizontal.Present && mergeCurrentR.Horizontal.End {
|
||||
jr.logger.Debugf("Footer separator: H-merge ends at col %d, using BottomMid", rightColIdx)
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if mergeCurrentL.Horizontal.End && !mergeCurrentR.Horizontal.Present {
|
||||
jr.logger.Debugf("Footer separator: H-merge ends at col %d, next col %d not merged, using Center", leftColIdx, rightColIdx)
|
||||
symbol := jr.sym.Center()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
}
|
||||
if isSpannedNext {
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedCurrent {
|
||||
symbol := jr.sym.TopMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
symbol := jr.sym.Center()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
if vPassThruLStrict && vPassThruRStrict {
|
||||
symbol := jr.sym.Column()
|
||||
return jr.separatorTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruLStrict {
|
||||
symbol := jr.sym.MidLeft()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if vPassThruRStrict {
|
||||
symbol := jr.sym.MidRight()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedCurrent && isSpannedNext {
|
||||
symbol := jr.sym.Row()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedCurrent {
|
||||
symbol := jr.sym.TopMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
if isSpannedNext {
|
||||
symbol := jr.sym.BottomMid()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
|
||||
symbol := jr.sym.Center()
|
||||
return jr.borderTint.Apply(symbol)
|
||||
}
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// Markdown renders tables in Markdown format with customizable settings.
|
||||
type Markdown struct {
|
||||
config tw.Rendition // Rendering configuration
|
||||
logger *ll.Logger // Debug trace messages
|
||||
alignment tw.Alignment // alias of []tw.Align
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
// NewMarkdown initializes a Markdown renderer with defaults tailored for Markdown (e.g., pipes, header separator).
|
||||
// Only the first config is used if multiple are provided.
|
||||
func NewMarkdown(configs ...tw.Rendition) *Markdown {
|
||||
cfg := defaultBlueprint()
|
||||
// Configure Markdown-specific defaults
|
||||
cfg.Symbols = tw.NewSymbols(tw.StyleMarkdown)
|
||||
cfg.Borders = tw.Border{Left: tw.On, Right: tw.On, Top: tw.Off, Bottom: tw.Off}
|
||||
cfg.Settings.Separators.BetweenColumns = tw.On
|
||||
cfg.Settings.Separators.BetweenRows = tw.Off
|
||||
cfg.Settings.Lines.ShowHeaderLine = tw.On
|
||||
cfg.Settings.Lines.ShowTop = tw.Off
|
||||
cfg.Settings.Lines.ShowBottom = tw.Off
|
||||
cfg.Settings.Lines.ShowFooterLine = tw.Off
|
||||
// cfg.Settings.TrimWhitespace = tw.On
|
||||
|
||||
// Apply user overrides
|
||||
if len(configs) > 0 {
|
||||
cfg = mergeMarkdownConfig(cfg, configs[0])
|
||||
}
|
||||
return &Markdown{config: cfg, logger: ll.New("markdown").Disable()}
|
||||
}
|
||||
|
||||
// mergeMarkdownConfig combines user-provided config with Markdown defaults, enforcing Markdown-specific settings.
|
||||
func mergeMarkdownConfig(defaults, overrides tw.Rendition) tw.Rendition {
|
||||
if overrides.Borders.Left != 0 {
|
||||
defaults.Borders.Left = overrides.Borders.Left
|
||||
}
|
||||
if overrides.Borders.Right != 0 {
|
||||
defaults.Borders.Right = overrides.Borders.Right
|
||||
}
|
||||
if overrides.Symbols != nil {
|
||||
defaults.Symbols = overrides.Symbols
|
||||
}
|
||||
defaults.Settings = mergeSettings(defaults.Settings, overrides.Settings)
|
||||
// Enforce Markdown requirements
|
||||
defaults.Settings.Lines.ShowHeaderLine = tw.On
|
||||
defaults.Settings.Separators.BetweenColumns = tw.On
|
||||
// defaults.Settings.TrimWhitespace = tw.On
|
||||
return defaults
|
||||
}
|
||||
|
||||
func (m *Markdown) Logger(logger *ll.Logger) {
|
||||
m.logger = logger.Namespace("markdown")
|
||||
}
|
||||
|
||||
// Config returns the renderer's current configuration.
|
||||
func (m *Markdown) Config() tw.Rendition {
|
||||
return m.config
|
||||
}
|
||||
|
||||
// Header renders the Markdown table header and its separator line.
|
||||
func (m *Markdown) Header(headers [][]string, ctx tw.Formatting) {
|
||||
m.resolveAlignment(ctx)
|
||||
if len(headers) == 0 || len(headers[0]) == 0 {
|
||||
m.logger.Debug("Header: No headers to render")
|
||||
return
|
||||
}
|
||||
m.logger.Debugf("Rendering header with %d lines, widths=%v, current=%v, next=%v", len(headers), ctx.Row.Widths, ctx.Row.Current, ctx.Row.Next)
|
||||
|
||||
// Render header content
|
||||
m.renderMarkdownLine(headers[0], ctx, false)
|
||||
|
||||
// Render separator if enabled
|
||||
if m.config.Settings.Lines.ShowHeaderLine.Enabled() {
|
||||
sepCtx := ctx
|
||||
sepCtx.Row.Widths = ctx.Row.Widths
|
||||
sepCtx.Row.Current = ctx.Row.Current
|
||||
sepCtx.Row.Previous = ctx.Row.Current
|
||||
sepCtx.IsSubRow = true
|
||||
m.renderMarkdownLine(nil, sepCtx, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Row renders a Markdown table data row.
|
||||
func (m *Markdown) Row(row []string, ctx tw.Formatting) {
|
||||
m.resolveAlignment(ctx)
|
||||
m.logger.Debugf("Rendering row with data=%v, widths=%v, previous=%v, current=%v, next=%v", row, ctx.Row.Widths, ctx.Row.Previous, ctx.Row.Current, ctx.Row.Next)
|
||||
m.renderMarkdownLine(row, ctx, false)
|
||||
}
|
||||
|
||||
// Footer renders the Markdown table footer.
|
||||
func (m *Markdown) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
m.resolveAlignment(ctx)
|
||||
if len(footers) == 0 || len(footers[0]) == 0 {
|
||||
m.logger.Debug("Footer: No footers to render")
|
||||
return
|
||||
}
|
||||
m.logger.Debugf("Rendering footer with %d lines, widths=%v, previous=%v, current=%v, next=%v",
|
||||
len(footers), ctx.Row.Widths, ctx.Row.Previous, ctx.Row.Current, ctx.Row.Next)
|
||||
m.renderMarkdownLine(footers[0], ctx, false)
|
||||
}
|
||||
|
||||
// Line is a no-op for Markdown, as only the header separator is rendered (handled by Header).
|
||||
func (m *Markdown) Line(ctx tw.Formatting) {
|
||||
m.logger.Debugf("Line: Generic Line call received (pos: %s, loc: %s). Markdown ignores these.", ctx.Row.Position, ctx.Row.Location)
|
||||
}
|
||||
|
||||
// Reset clears the renderer's internal state, including debug traces.
|
||||
func (m *Markdown) Reset() {
|
||||
m.logger.Info("Reset: Cleared debug trace")
|
||||
}
|
||||
|
||||
func (m *Markdown) Start(w io.Writer) error {
|
||||
m.w = w
|
||||
m.logger.Warn("Markdown.Start() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Markdown) Close() error {
|
||||
m.logger.Warn("Markdown.Close() called (no-op).")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Markdown) resolveAlignment(ctx tw.Formatting) tw.Alignment {
|
||||
if len(m.alignment) != 0 {
|
||||
return m.alignment
|
||||
}
|
||||
|
||||
// get total columns
|
||||
total := len(ctx.Row.Current)
|
||||
|
||||
// build default alignment
|
||||
for i := 0; i < total; i++ {
|
||||
m.alignment = append(m.alignment, tw.AlignNone) // Default to AlignNone
|
||||
}
|
||||
|
||||
// add per column alignment if it exists
|
||||
for i := 0; i < total; i++ {
|
||||
m.alignment[i] = ctx.Row.Current[i].Align
|
||||
}
|
||||
|
||||
m.logger.Debugf(" → Align Resolved %s", m.alignment)
|
||||
return m.alignment
|
||||
}
|
||||
|
||||
// formatCell formats a Markdown cell's content with padding and alignment, ensuring at least 3 characters wide.
|
||||
func (m *Markdown) formatCell(content string, width int, align tw.Align, padding tw.Padding) string {
|
||||
// if m.config.Settings.TrimWhitespace.Enabled() {
|
||||
// content = strings.TrimSpace(content)
|
||||
//}
|
||||
contentVisualWidth := twwidth.Width(content)
|
||||
|
||||
// Use specified padding characters or default to spaces
|
||||
padLeftChar := padding.Left
|
||||
if padLeftChar == tw.Empty {
|
||||
padLeftChar = tw.Space
|
||||
}
|
||||
padRightChar := padding.Right
|
||||
if padRightChar == tw.Empty {
|
||||
padRightChar = tw.Space
|
||||
}
|
||||
|
||||
// Calculate padding widths
|
||||
padLeftCharWidth := twwidth.Width(padLeftChar)
|
||||
padRightCharWidth := twwidth.Width(padRightChar)
|
||||
minWidth := tw.Max(3, contentVisualWidth+padLeftCharWidth+padRightCharWidth)
|
||||
targetWidth := tw.Max(width, minWidth)
|
||||
|
||||
// Calculate padding
|
||||
totalPaddingNeeded := max(targetWidth-contentVisualWidth, 0)
|
||||
|
||||
var leftPadStr, rightPadStr string
|
||||
switch align {
|
||||
case tw.AlignRight:
|
||||
leftPadCount := tw.Max(0, totalPaddingNeeded-padRightCharWidth)
|
||||
rightPadCount := totalPaddingNeeded - leftPadCount
|
||||
leftPadStr = strings.Repeat(padLeftChar, leftPadCount)
|
||||
rightPadStr = strings.Repeat(padRightChar, rightPadCount)
|
||||
case tw.AlignCenter:
|
||||
leftPadCount := totalPaddingNeeded / 2
|
||||
rightPadCount := totalPaddingNeeded - leftPadCount
|
||||
if leftPadCount < padLeftCharWidth && totalPaddingNeeded >= padLeftCharWidth+padRightCharWidth {
|
||||
leftPadCount = padLeftCharWidth
|
||||
rightPadCount = totalPaddingNeeded - leftPadCount
|
||||
}
|
||||
if rightPadCount < padRightCharWidth && totalPaddingNeeded >= padLeftCharWidth+padRightCharWidth {
|
||||
rightPadCount = padRightCharWidth
|
||||
leftPadCount = totalPaddingNeeded - rightPadCount
|
||||
}
|
||||
leftPadStr = strings.Repeat(padLeftChar, leftPadCount)
|
||||
rightPadStr = strings.Repeat(padRightChar, rightPadCount)
|
||||
default: // AlignLeft
|
||||
rightPadCount := tw.Max(0, totalPaddingNeeded-padLeftCharWidth)
|
||||
leftPadCount := totalPaddingNeeded - rightPadCount
|
||||
leftPadStr = strings.Repeat(padLeftChar, leftPadCount)
|
||||
rightPadStr = strings.Repeat(padRightChar, rightPadCount)
|
||||
}
|
||||
|
||||
// Build result
|
||||
result := leftPadStr + content + rightPadStr
|
||||
|
||||
// Adjust width if needed
|
||||
finalWidth := twwidth.Width(result)
|
||||
if finalWidth != targetWidth {
|
||||
m.logger.Debugf("Markdown formatCell MISMATCH: content='%s', target_w=%d, paddingL='%s', paddingR='%s', align=%s -> result='%s', result_w=%d",
|
||||
content, targetWidth, padding.Left, padding.Right, align, result, finalWidth)
|
||||
adjNeeded := targetWidth - finalWidth
|
||||
if adjNeeded > 0 {
|
||||
adjStr := strings.Repeat(tw.Space, adjNeeded)
|
||||
switch align {
|
||||
case tw.AlignRight:
|
||||
result = adjStr + result
|
||||
case tw.AlignCenter:
|
||||
leftAdj := adjNeeded / 2
|
||||
rightAdj := adjNeeded - leftAdj
|
||||
result = strings.Repeat(tw.Space, leftAdj) + result + strings.Repeat(tw.Space, rightAdj)
|
||||
default:
|
||||
result += adjStr
|
||||
}
|
||||
} else {
|
||||
result = twwidth.Truncate(result, targetWidth)
|
||||
}
|
||||
m.logger.Debugf("Markdown formatCell Corrected: target_w=%d, result='%s', new_w=%d", targetWidth, result, twwidth.Width(result))
|
||||
}
|
||||
|
||||
m.logger.Debugf("Markdown formatCell: content='%s', width=%d, align=%s, paddingL='%s', paddingR='%s' -> '%s' (target %d)",
|
||||
content, width, align, padding.Left, padding.Right, result, targetWidth)
|
||||
return result
|
||||
}
|
||||
|
||||
// formatSeparator generates a Markdown separator (e.g., `---`, `:--`, `:-:`) with alignment indicators.
|
||||
func (m *Markdown) formatSeparator(width int, align tw.Align) string {
|
||||
targetWidth := tw.Max(3, width)
|
||||
var sb strings.Builder
|
||||
|
||||
switch align {
|
||||
case tw.AlignLeft:
|
||||
sb.WriteRune(':')
|
||||
sb.WriteString(strings.Repeat("-", targetWidth-1))
|
||||
case tw.AlignRight:
|
||||
sb.WriteString(strings.Repeat("-", targetWidth-1))
|
||||
sb.WriteRune(':')
|
||||
case tw.AlignCenter:
|
||||
sb.WriteRune(':')
|
||||
sb.WriteString(strings.Repeat("-", targetWidth-2))
|
||||
sb.WriteRune(':')
|
||||
case tw.AlignNone:
|
||||
sb.WriteString(strings.Repeat("-", targetWidth))
|
||||
default:
|
||||
sb.WriteString(strings.Repeat("-", targetWidth)) // Fallback
|
||||
}
|
||||
|
||||
result := sb.String()
|
||||
currentLen := twwidth.Width(result)
|
||||
if currentLen < targetWidth {
|
||||
result += strings.Repeat("-", targetWidth-currentLen)
|
||||
} else if currentLen > targetWidth {
|
||||
result = twwidth.Truncate(result, targetWidth)
|
||||
}
|
||||
|
||||
m.logger.Debugf("Markdown formatSeparator: width=%d, align=%s -> '%s'", width, align, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// renderMarkdownLine renders a single Markdown line (header, row, footer, or separator) with pipes and alignment.
|
||||
func (m *Markdown) renderMarkdownLine(line []string, ctx tw.Formatting, isHeaderSep bool) {
|
||||
numCols := 0
|
||||
if len(ctx.Row.Widths) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Widths {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(ctx.Row.Current) > 0 {
|
||||
maxKey := -1
|
||||
for k := range ctx.Row.Current {
|
||||
if k > maxKey {
|
||||
maxKey = k
|
||||
}
|
||||
}
|
||||
numCols = maxKey + 1
|
||||
} else if len(line) > 0 && !isHeaderSep {
|
||||
numCols = len(line)
|
||||
}
|
||||
|
||||
if numCols == 0 && !isHeaderSep {
|
||||
m.logger.Debug("renderMarkdownLine: Skipping line with zero columns.")
|
||||
return
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
prefix := m.config.Symbols.Column()
|
||||
if m.config.Borders.Left == tw.Off {
|
||||
prefix = tw.Empty
|
||||
}
|
||||
suffix := m.config.Symbols.Column()
|
||||
if m.config.Borders.Right == tw.Off {
|
||||
suffix = tw.Empty
|
||||
}
|
||||
separator := m.config.Symbols.Column()
|
||||
output.WriteString(prefix)
|
||||
|
||||
colIndex := 0
|
||||
separatorWidth := twwidth.Width(separator)
|
||||
|
||||
for colIndex < numCols {
|
||||
cellCtx, ok := ctx.Row.Current[colIndex]
|
||||
align := m.alignment[colIndex]
|
||||
|
||||
defaultPadding := tw.Padding{Left: tw.Space, Right: tw.Space}
|
||||
if !ok {
|
||||
cellCtx = tw.CellContext{
|
||||
Data: tw.Empty, Align: align, Padding: defaultPadding,
|
||||
Width: ctx.Row.Widths.Get(colIndex), Merge: tw.MergeState{},
|
||||
}
|
||||
} else if !cellCtx.Padding.Paddable() {
|
||||
cellCtx.Padding = defaultPadding
|
||||
}
|
||||
|
||||
// Add separator
|
||||
isContinuation := ok && cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start
|
||||
if colIndex > 0 && !isContinuation {
|
||||
output.WriteString(separator)
|
||||
m.logger.Debugf("renderMarkdownLine: Added separator '%s' before col %d", separator, colIndex)
|
||||
}
|
||||
|
||||
// Calculate width and span
|
||||
span := 1
|
||||
visualWidth := 0
|
||||
isHMergeStart := ok && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start
|
||||
if isHMergeStart {
|
||||
span = cellCtx.Merge.Horizontal.Span
|
||||
totalWidth := 0
|
||||
for k := 0; k < span && colIndex+k < numCols; k++ {
|
||||
colWidth := max(ctx.NormalizedWidths.Get(colIndex+k), 0)
|
||||
totalWidth += colWidth
|
||||
if k > 0 && separatorWidth > 0 {
|
||||
totalWidth += separatorWidth
|
||||
}
|
||||
}
|
||||
visualWidth = totalWidth
|
||||
m.logger.Debugf("renderMarkdownLine: HMerge col %d, span %d, visualWidth %d", colIndex, span, visualWidth)
|
||||
} else {
|
||||
visualWidth = ctx.Row.Widths.Get(colIndex)
|
||||
m.logger.Debugf("renderMarkdownLine: Regular col %d, visualWidth %d", colIndex, visualWidth)
|
||||
}
|
||||
if visualWidth < 0 {
|
||||
visualWidth = 0
|
||||
}
|
||||
|
||||
// Render segment
|
||||
if isContinuation {
|
||||
m.logger.Debugf("renderMarkdownLine: Skipping col %d (HMerge continuation)", colIndex)
|
||||
} else {
|
||||
var formattedSegment string
|
||||
if isHeaderSep {
|
||||
// Use header's alignment from ctx.Row.Previous
|
||||
headerAlign := align
|
||||
if headerCellCtx, headerOK := ctx.Row.Previous[colIndex]; headerOK {
|
||||
headerAlign = headerCellCtx.Align
|
||||
// Preserve tw.AlignNone for separator
|
||||
if headerAlign != tw.AlignNone && (headerAlign == tw.Empty || headerAlign == tw.Skip) {
|
||||
headerAlign = tw.AlignCenter
|
||||
}
|
||||
}
|
||||
formattedSegment = m.formatSeparator(visualWidth, headerAlign)
|
||||
} else {
|
||||
content := tw.Empty
|
||||
if colIndex < len(line) {
|
||||
content = line[colIndex]
|
||||
}
|
||||
// For rows, use the header's alignment if specified
|
||||
rowAlign := align
|
||||
if headerCellCtx, headerOK := ctx.Row.Previous[colIndex]; headerOK && !isHeaderSep {
|
||||
if headerCellCtx.Align != tw.AlignNone && headerCellCtx.Align != tw.Empty {
|
||||
rowAlign = headerCellCtx.Align
|
||||
}
|
||||
}
|
||||
if rowAlign == tw.AlignNone || rowAlign == tw.Empty {
|
||||
switch ctx.Row.Position {
|
||||
case tw.Header:
|
||||
rowAlign = tw.AlignCenter
|
||||
case tw.Footer:
|
||||
rowAlign = tw.AlignRight
|
||||
default:
|
||||
rowAlign = tw.AlignLeft
|
||||
}
|
||||
m.logger.Debugf("renderMarkdownLine: Col %d using default align '%s'", colIndex, rowAlign)
|
||||
}
|
||||
formattedSegment = m.formatCell(content, visualWidth, rowAlign, cellCtx.Padding)
|
||||
}
|
||||
output.WriteString(formattedSegment)
|
||||
m.logger.Debugf("renderMarkdownLine: Wrote col %d (span %d, width %d): '%s'", colIndex, span, visualWidth, formattedSegment)
|
||||
}
|
||||
|
||||
colIndex += span
|
||||
}
|
||||
|
||||
output.WriteString(suffix)
|
||||
output.WriteString(tw.NewLine)
|
||||
m.w.Write([]byte(output.String()))
|
||||
m.logger.Debugf("renderMarkdownLine: Final line: %s", strings.TrimSuffix(output.String(), tw.NewLine))
|
||||
}
|
||||
+462
@@ -0,0 +1,462 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// OceanConfig defines configuration specific to the Ocean renderer.
|
||||
type OceanConfig struct{}
|
||||
|
||||
// Ocean is a streaming table renderer that writes ASCII tables.
|
||||
type Ocean struct {
|
||||
config tw.Rendition
|
||||
oceanConfig OceanConfig
|
||||
fixedWidths tw.Mapper[int, int]
|
||||
widthsFinalized bool
|
||||
tableOutputStarted bool
|
||||
headerContentRendered bool // True if actual header *content* has been rendered by Ocean.Header
|
||||
logger *ll.Logger
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
func NewOcean(oceanConfig ...OceanConfig) *Ocean {
|
||||
cfg := defaultOceanRendererConfig()
|
||||
oCfg := OceanConfig{}
|
||||
if len(oceanConfig) > 0 {
|
||||
// Apply user-provided OceanConfig if necessary
|
||||
}
|
||||
r := &Ocean{
|
||||
config: cfg,
|
||||
oceanConfig: oCfg,
|
||||
fixedWidths: tw.NewMapper[int, int](),
|
||||
logger: ll.New("ocean").Disable(),
|
||||
}
|
||||
r.resetState()
|
||||
return r
|
||||
}
|
||||
|
||||
func (o *Ocean) resetState() {
|
||||
o.fixedWidths = tw.NewMapper[int, int]()
|
||||
o.widthsFinalized = false
|
||||
o.tableOutputStarted = false
|
||||
o.headerContentRendered = false
|
||||
o.logger.Debug("State reset.")
|
||||
}
|
||||
|
||||
func (o *Ocean) Logger(logger *ll.Logger) {
|
||||
o.logger = logger.Namespace("ocean")
|
||||
}
|
||||
|
||||
func (o *Ocean) Config() tw.Rendition {
|
||||
return o.config
|
||||
}
|
||||
|
||||
func (o *Ocean) tryFinalizeWidths(ctx tw.Formatting) {
|
||||
if o.widthsFinalized {
|
||||
return
|
||||
}
|
||||
if ctx.Row.Widths != nil && ctx.Row.Widths.Len() > 0 {
|
||||
o.fixedWidths = ctx.Row.Widths.Clone()
|
||||
o.widthsFinalized = true
|
||||
o.logger.Debugf("Widths finalized from context: %v", o.fixedWidths)
|
||||
} else {
|
||||
o.logger.Warn("Attempted to finalize widths, but no width data in context.")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Ocean) Start(w io.Writer) error {
|
||||
o.w = w
|
||||
o.logger.Debug("Start() called.")
|
||||
o.resetState()
|
||||
// Top border is drawn by the first component (Header or Row) that has widths
|
||||
// OR by an explicit Line() call from table.go's batch renderer.
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderTopBorderIfNeeded is called by Header or Row if it's the first to render
|
||||
// and tableOutputStarted is false.
|
||||
func (o *Ocean) renderTopBorderIfNeeded(currentPosition tw.Position, ctx tw.Formatting) {
|
||||
if !o.tableOutputStarted && o.widthsFinalized {
|
||||
// This renderer's config for Top border
|
||||
if o.config.Borders.Top.Enabled() && o.config.Settings.Lines.ShowTop.Enabled() {
|
||||
o.logger.Debugf("Ocean itself rendering top border (triggered by %s)", currentPosition)
|
||||
lineCtx := tw.Formatting{ // Construct specific context for this line
|
||||
Row: tw.RowContext{
|
||||
Widths: o.fixedWidths,
|
||||
Location: tw.LocationFirst,
|
||||
Position: currentPosition,
|
||||
Next: ctx.Row.Current, // The actual first content is "Next" to the top border
|
||||
},
|
||||
Level: tw.LevelHeader,
|
||||
}
|
||||
o.Line(lineCtx)
|
||||
o.tableOutputStarted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Ocean) Header(headers [][]string, ctx tw.Formatting) {
|
||||
o.logger.Debugf("Ocean.Header called: IsSubRow=%v, Location=%v, NumLines=%d", ctx.IsSubRow, ctx.Row.Location, len(headers))
|
||||
|
||||
if !o.widthsFinalized {
|
||||
o.tryFinalizeWidths(ctx)
|
||||
}
|
||||
|
||||
if !o.widthsFinalized {
|
||||
o.logger.Error("Ocean.Header: Cannot render content, widths are not finalized.")
|
||||
return
|
||||
}
|
||||
|
||||
if len(headers) > 0 && len(headers[0]) > 0 {
|
||||
for i, headerLineData := range headers {
|
||||
currentLineCtx := ctx
|
||||
currentLineCtx.Row.Widths = o.fixedWidths
|
||||
if i > 0 {
|
||||
currentLineCtx.IsSubRow = true
|
||||
}
|
||||
o.renderContentLine(currentLineCtx, headerLineData)
|
||||
o.tableOutputStarted = true // Content was written
|
||||
}
|
||||
o.headerContentRendered = true
|
||||
} else {
|
||||
o.logger.Debug("Ocean.Header: No actual header content lines to render.")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Ocean) Row(row []string, ctx tw.Formatting) {
|
||||
o.logger.Debugf("Ocean.Row called: IsSubRow=%v, Location=%v, DataItems=%d", ctx.IsSubRow, ctx.Row.Location, len(row))
|
||||
|
||||
if !o.widthsFinalized {
|
||||
o.tryFinalizeWidths(ctx)
|
||||
}
|
||||
if !o.widthsFinalized {
|
||||
o.logger.Error("Ocean.Row: Cannot render content, widths are not finalized.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Row.Widths = o.fixedWidths
|
||||
o.renderContentLine(ctx, row)
|
||||
o.tableOutputStarted = true
|
||||
}
|
||||
|
||||
func (o *Ocean) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
o.logger.Debugf("Ocean.Footer called: IsSubRow=%v, Location=%v, NumLines=%d", ctx.IsSubRow, ctx.Row.Location, len(footers))
|
||||
|
||||
if !o.widthsFinalized {
|
||||
o.tryFinalizeWidths(ctx)
|
||||
o.logger.Warn("Ocean.Footer: Widths finalized at Footer stage (unusual).")
|
||||
}
|
||||
if !o.widthsFinalized {
|
||||
o.logger.Error("Ocean.Footer: Cannot render content, widths are not finalized.")
|
||||
return
|
||||
}
|
||||
|
||||
if len(footers) > 0 && len(footers[0]) > 0 {
|
||||
for i, footerLineData := range footers {
|
||||
currentLineCtx := ctx
|
||||
currentLineCtx.Row.Widths = o.fixedWidths
|
||||
if i > 0 {
|
||||
currentLineCtx.IsSubRow = true
|
||||
}
|
||||
o.renderContentLine(currentLineCtx, footerLineData)
|
||||
o.tableOutputStarted = true
|
||||
}
|
||||
} else {
|
||||
o.logger.Debug("Ocean.Footer: No actual footer content lines to render.")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Ocean) Line(ctx tw.Formatting) {
|
||||
if !o.widthsFinalized {
|
||||
o.tryFinalizeWidths(ctx)
|
||||
if !o.widthsFinalized {
|
||||
o.logger.Error("Ocean.Line: Called but widths could not be finalized. Skipping line rendering.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Line uses the consistent fixedWidths for drawing
|
||||
ctx.Row.Widths = o.fixedWidths
|
||||
o.logger.Debugf("Ocean.Line DRAWING: Level=%v, Loc=%s, Pos=%s, IsSubRow=%t, WidthsLen=%d", ctx.Level, ctx.Row.Location, ctx.Row.Position, ctx.IsSubRow, ctx.Row.Widths.Len())
|
||||
|
||||
jr := NewJunction(JunctionContext{
|
||||
Symbols: o.config.Symbols,
|
||||
Ctx: ctx,
|
||||
ColIdx: 0,
|
||||
Logger: o.logger,
|
||||
BorderTint: Tint{},
|
||||
SeparatorTint: Tint{},
|
||||
})
|
||||
|
||||
var line strings.Builder
|
||||
sortedColIndices := o.fixedWidths.SortedKeys()
|
||||
|
||||
if len(sortedColIndices) == 0 {
|
||||
drewEmptyBorders := false
|
||||
if o.config.Borders.Left.Enabled() {
|
||||
line.WriteString(jr.RenderLeft())
|
||||
drewEmptyBorders = true
|
||||
}
|
||||
if o.config.Borders.Right.Enabled() {
|
||||
line.WriteString(jr.RenderRight(-1))
|
||||
drewEmptyBorders = true
|
||||
}
|
||||
if drewEmptyBorders {
|
||||
line.WriteString(tw.NewLine)
|
||||
o.w.Write([]byte(line.String()))
|
||||
o.logger.Debug("Line: Drew empty table borders based on Line call.")
|
||||
} else {
|
||||
o.logger.Debug("Line: Handled empty table case (no columns, no borders).")
|
||||
}
|
||||
o.tableOutputStarted = drewEmptyBorders // A line counts as output
|
||||
return
|
||||
}
|
||||
|
||||
if o.config.Borders.Left.Enabled() {
|
||||
line.WriteString(jr.RenderLeft())
|
||||
}
|
||||
|
||||
for i, colIdx := range sortedColIndices {
|
||||
jr.colIdx = colIdx
|
||||
segmentChar := jr.GetSegment()
|
||||
colVisualWidth := o.fixedWidths.Get(colIdx)
|
||||
|
||||
if colVisualWidth <= 0 {
|
||||
// Still need to consider separators after zero-width columns
|
||||
} else {
|
||||
if segmentChar == tw.Empty {
|
||||
segmentChar = o.config.Symbols.Row()
|
||||
}
|
||||
segmentDisplayWidth := twwidth.Width(segmentChar)
|
||||
if segmentDisplayWidth <= 0 {
|
||||
segmentDisplayWidth = 1
|
||||
}
|
||||
|
||||
repeatCount := 0
|
||||
if colVisualWidth > 0 {
|
||||
repeatCount = colVisualWidth / segmentDisplayWidth
|
||||
if repeatCount == 0 {
|
||||
repeatCount = 1
|
||||
}
|
||||
}
|
||||
line.WriteString(strings.Repeat(segmentChar, repeatCount))
|
||||
}
|
||||
|
||||
if i < len(sortedColIndices)-1 && o.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
nextColIdx := sortedColIndices[i+1]
|
||||
line.WriteString(jr.RenderJunction(colIdx, nextColIdx))
|
||||
}
|
||||
}
|
||||
|
||||
if o.config.Borders.Right.Enabled() {
|
||||
lastColIdx := sortedColIndices[len(sortedColIndices)-1]
|
||||
line.WriteString(jr.RenderRight(lastColIdx))
|
||||
}
|
||||
|
||||
line.WriteString(tw.NewLine)
|
||||
o.w.Write([]byte(line.String()))
|
||||
o.tableOutputStarted = true
|
||||
o.logger.Debugf("Line rendered by explicit call: %s", strings.TrimSuffix(line.String(), tw.NewLine))
|
||||
}
|
||||
|
||||
func (o *Ocean) Close() error {
|
||||
o.logger.Debug("Ocean.Close() called.")
|
||||
o.resetState()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Ocean) renderContentLine(ctx tw.Formatting, lineData []string) {
|
||||
if !o.widthsFinalized || o.fixedWidths.Len() == 0 {
|
||||
o.logger.Error("renderContentLine: Cannot render, fixedWidths not set or empty.")
|
||||
return
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if o.config.Borders.Left.Enabled() {
|
||||
output.WriteString(o.config.Symbols.Column())
|
||||
}
|
||||
|
||||
sortedColIndices := o.fixedWidths.SortedKeys()
|
||||
|
||||
for i, colIdx := range sortedColIndices {
|
||||
cellVisualWidth := o.fixedWidths.Get(colIdx)
|
||||
cellContent := tw.Empty
|
||||
align := tw.AlignDefault
|
||||
padding := tw.Padding{Left: tw.Space, Right: tw.Space}
|
||||
|
||||
switch ctx.Row.Position {
|
||||
case tw.Header:
|
||||
align = tw.AlignCenter
|
||||
case tw.Footer:
|
||||
align = tw.AlignRight
|
||||
default:
|
||||
align = tw.AlignLeft
|
||||
}
|
||||
|
||||
cellCtx, hasCellCtx := ctx.Row.Current[colIdx]
|
||||
if hasCellCtx {
|
||||
cellContent = cellCtx.Data
|
||||
if cellCtx.Align.Validate() == nil && cellCtx.Align != tw.AlignNone {
|
||||
align = cellCtx.Align
|
||||
}
|
||||
if cellCtx.Padding.Paddable() {
|
||||
padding = cellCtx.Padding
|
||||
}
|
||||
} else if colIdx < len(lineData) {
|
||||
cellContent = lineData[colIdx]
|
||||
}
|
||||
|
||||
actualCellWidthToRender := cellVisualWidth
|
||||
isHMergeContinuation := false
|
||||
|
||||
if hasCellCtx && cellCtx.Merge.Horizontal.Present {
|
||||
if cellCtx.Merge.Horizontal.Start {
|
||||
hSpan := cellCtx.Merge.Horizontal.Span
|
||||
if hSpan <= 0 {
|
||||
hSpan = 1
|
||||
}
|
||||
|
||||
currentMergeTotalRenderWidth := 0
|
||||
for k := 0; k < hSpan; k++ {
|
||||
idxInMergeSpan := colIdx + k
|
||||
// Check if idxInMergeSpan is a defined column in fixedWidths
|
||||
foundInFixedWidths := false
|
||||
if slices.Contains(sortedColIndices, idxInMergeSpan) {
|
||||
currentMergeTotalRenderWidth += o.fixedWidths.Get(idxInMergeSpan)
|
||||
foundInFixedWidths = true
|
||||
}
|
||||
if !foundInFixedWidths && idxInMergeSpan <= sortedColIndices[len(sortedColIndices)-1] {
|
||||
o.logger.Debugf("Col %d in HMerge span not found in fixedWidths, assuming 0-width contribution.", idxInMergeSpan)
|
||||
}
|
||||
|
||||
if k < hSpan-1 && o.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
currentMergeTotalRenderWidth += twwidth.Width(o.config.Symbols.Column())
|
||||
}
|
||||
}
|
||||
actualCellWidthToRender = currentMergeTotalRenderWidth
|
||||
} else {
|
||||
isHMergeContinuation = true
|
||||
}
|
||||
}
|
||||
|
||||
if isHMergeContinuation {
|
||||
o.logger.Debugf("renderContentLine: Col %d is HMerge continuation, skipping content render.", colIdx)
|
||||
// The separator logic below needs to handle this correctly.
|
||||
// If the *previous* column was the start of a merge that spans *this* column,
|
||||
// then the separator after the previous column should have been suppressed.
|
||||
} else if actualCellWidthToRender > 0 {
|
||||
formattedCell := o.formatCellContent(cellContent, actualCellWidthToRender, padding, align)
|
||||
output.WriteString(formattedCell)
|
||||
} else {
|
||||
o.logger.Debugf("renderContentLine: col %d has 0 render width, writing no content.", colIdx)
|
||||
}
|
||||
|
||||
// Add column separator if:
|
||||
// 1. It's not the last column in sortedColIndices
|
||||
// 2. Separators are enabled
|
||||
// 3. This cell is NOT a horizontal merge start that spans over the next column.
|
||||
if i < len(sortedColIndices)-1 && o.config.Settings.Separators.BetweenColumns.Enabled() {
|
||||
shouldAddSeparator := true
|
||||
if hasCellCtx && cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
// If this merge start spans beyond the current colIdx into the next sortedColIndex
|
||||
if colIdx+cellCtx.Merge.Horizontal.Span > sortedColIndices[i+1] {
|
||||
shouldAddSeparator = false // Separator is part of the merged cell's width
|
||||
o.logger.Debugf("renderContentLine: Suppressed separator after HMerge col %d.", colIdx)
|
||||
}
|
||||
}
|
||||
if shouldAddSeparator {
|
||||
output.WriteString(o.config.Symbols.Column())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if o.config.Borders.Right.Enabled() {
|
||||
output.WriteString(o.config.Symbols.Column())
|
||||
}
|
||||
|
||||
output.WriteString(tw.NewLine)
|
||||
o.w.Write([]byte(output.String()))
|
||||
o.logger.Debugf("Content line rendered: %s", strings.TrimSuffix(output.String(), tw.NewLine))
|
||||
}
|
||||
|
||||
func (o *Ocean) formatCellContent(content string, cellVisualWidth int, padding tw.Padding, align tw.Align) string {
|
||||
if cellVisualWidth <= 0 {
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
contentDisplayWidth := twwidth.Width(content)
|
||||
|
||||
padLeftChar := padding.Left
|
||||
if padLeftChar == tw.Empty {
|
||||
padLeftChar = tw.Space
|
||||
}
|
||||
padRightChar := padding.Right
|
||||
if padRightChar == tw.Empty {
|
||||
padRightChar = tw.Space
|
||||
}
|
||||
|
||||
padLeftDisplayWidth := twwidth.Width(padLeftChar)
|
||||
padRightDisplayWidth := twwidth.Width(padRightChar)
|
||||
|
||||
spaceForContentAndAlignment := max(cellVisualWidth-padLeftDisplayWidth-padRightDisplayWidth, 0)
|
||||
|
||||
if contentDisplayWidth > spaceForContentAndAlignment {
|
||||
content = twwidth.Truncate(content, spaceForContentAndAlignment)
|
||||
contentDisplayWidth = twwidth.Width(content)
|
||||
}
|
||||
|
||||
remainingSpace := max(spaceForContentAndAlignment-contentDisplayWidth, 0)
|
||||
|
||||
var PL, PR string
|
||||
switch align {
|
||||
case tw.AlignRight:
|
||||
PL = strings.Repeat(tw.Space, remainingSpace)
|
||||
case tw.AlignCenter:
|
||||
leftSpaces := remainingSpace / 2
|
||||
rightSpaces := remainingSpace - leftSpaces
|
||||
PL = strings.Repeat(tw.Space, leftSpaces)
|
||||
PR = strings.Repeat(tw.Space, rightSpaces)
|
||||
default:
|
||||
PR = strings.Repeat(tw.Space, remainingSpace)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(padLeftChar)
|
||||
sb.WriteString(PL)
|
||||
sb.WriteString(content)
|
||||
sb.WriteString(PR)
|
||||
sb.WriteString(padRightChar)
|
||||
|
||||
currentFormattedWidth := twwidth.Width(sb.String())
|
||||
if currentFormattedWidth < cellVisualWidth {
|
||||
if align == tw.AlignRight {
|
||||
prefixSpaces := strings.Repeat(tw.Space, cellVisualWidth-currentFormattedWidth)
|
||||
finalStr := prefixSpaces + sb.String()
|
||||
sb.Reset()
|
||||
sb.WriteString(finalStr)
|
||||
} else {
|
||||
sb.WriteString(strings.Repeat(tw.Space, cellVisualWidth-currentFormattedWidth))
|
||||
}
|
||||
} else if currentFormattedWidth > cellVisualWidth {
|
||||
tempStr := sb.String()
|
||||
sb.Reset()
|
||||
sb.WriteString(twwidth.Truncate(tempStr, cellVisualWidth))
|
||||
o.logger.Warnf("formatCellContent: Final string '%s' (width %d) exceeded target %d. Force truncated.", tempStr, currentFormattedWidth, cellVisualWidth)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (o *Ocean) Rendition(config tw.Rendition) {
|
||||
o.config = mergeRendition(o.config, config)
|
||||
o.logger.Debugf("Blueprint.Rendition updated. New internal config: %+v", o.config)
|
||||
}
|
||||
|
||||
// Ensure Blueprint implements tw.Renditioning
|
||||
var _ tw.Renditioning = (*Ocean)(nil)
|
||||
+702
@@ -0,0 +1,702 @@
|
||||
package renderer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
|
||||
"github.com/olekukonko/tablewriter/tw"
|
||||
)
|
||||
|
||||
// SVGConfig holds configuration for the SVG renderer.
|
||||
// Fields include font, colors, padding, and merge rendering options.
|
||||
// Used to customize SVG output appearance and behavior.
|
||||
type SVGConfig struct {
|
||||
FontFamily string // e.g., "Arial, sans-serif"
|
||||
FontSize float64 // Base font size in SVG units
|
||||
LineHeightFactor float64 // Factor for line height (e.g., 1.2)
|
||||
Padding float64 // Padding inside cells
|
||||
StrokeWidth float64 // Line width for borders
|
||||
StrokeColor string // Color for strokes (e.g., "black")
|
||||
HeaderBG string // Background color for header
|
||||
RowBG string // Background color for rows
|
||||
RowAltBG string // Alternating row background color
|
||||
FooterBG string // Background color for footer
|
||||
HeaderColor string // Text color for header
|
||||
RowColor string // Text color for rows
|
||||
FooterColor string // Text color for footer
|
||||
ApproxCharWidthFactor float64 // Char width relative to FontSize
|
||||
MinColWidth float64 // Minimum column width
|
||||
RenderTWConfigOverrides bool // Override SVG alignments with tablewriter
|
||||
Debug bool // Enable debug logging
|
||||
ScaleFactor float64 // Scaling factor for SVG
|
||||
}
|
||||
|
||||
// SVG implements tw.Renderer for SVG output.
|
||||
// Manages SVG element generation and merge tracking.
|
||||
type SVG struct {
|
||||
config SVGConfig
|
||||
trace []string
|
||||
|
||||
allVisualLineData [][][]string // [section][line][cell]
|
||||
allVisualLineCtx [][]tw.Formatting // [section][line]Formatting
|
||||
|
||||
maxCols int
|
||||
calculatedColWidths []float64
|
||||
svgElements strings.Builder
|
||||
currentY float64
|
||||
dataRowCounter int
|
||||
vMergeTrack map[int]int // Tracks vertical merge spans
|
||||
numVisualRowsDrawn int
|
||||
logger *ll.Logger
|
||||
w io.Writer
|
||||
}
|
||||
|
||||
const (
|
||||
sectionTypeHeader = 0
|
||||
sectionTypeRow = 1
|
||||
sectionTypeFooter = 2
|
||||
)
|
||||
|
||||
// NewSVG creates a new SVG renderer with configuration.
|
||||
// Parameter configs provides optional SVGConfig; defaults used if empty.
|
||||
// Returns a configured SVG instance.
|
||||
func NewSVG(configs ...SVGConfig) *SVG {
|
||||
cfg := SVGConfig{
|
||||
FontFamily: "sans-serif",
|
||||
FontSize: 12.0,
|
||||
LineHeightFactor: 1.4,
|
||||
Padding: 5.0,
|
||||
StrokeWidth: 1.0,
|
||||
StrokeColor: "black",
|
||||
HeaderBG: "#F0F0F0",
|
||||
RowBG: "white",
|
||||
RowAltBG: "#F9F9F9",
|
||||
FooterBG: "#F0F0F0",
|
||||
HeaderColor: "black",
|
||||
RowColor: "black",
|
||||
FooterColor: "black",
|
||||
ApproxCharWidthFactor: 0.6,
|
||||
MinColWidth: 30.0,
|
||||
ScaleFactor: 1.0,
|
||||
RenderTWConfigOverrides: true,
|
||||
Debug: false,
|
||||
}
|
||||
if len(configs) > 0 {
|
||||
userCfg := configs[0]
|
||||
if userCfg.FontFamily != tw.Empty {
|
||||
cfg.FontFamily = userCfg.FontFamily
|
||||
}
|
||||
if userCfg.FontSize > 0 {
|
||||
cfg.FontSize = userCfg.FontSize
|
||||
}
|
||||
if userCfg.LineHeightFactor > 0 {
|
||||
cfg.LineHeightFactor = userCfg.LineHeightFactor
|
||||
}
|
||||
if userCfg.Padding >= 0 {
|
||||
cfg.Padding = userCfg.Padding
|
||||
}
|
||||
if userCfg.StrokeWidth > 0 {
|
||||
cfg.StrokeWidth = userCfg.StrokeWidth
|
||||
}
|
||||
if userCfg.StrokeColor != tw.Empty {
|
||||
cfg.StrokeColor = userCfg.StrokeColor
|
||||
}
|
||||
if userCfg.HeaderBG != tw.Empty {
|
||||
cfg.HeaderBG = userCfg.HeaderBG
|
||||
}
|
||||
if userCfg.RowBG != tw.Empty {
|
||||
cfg.RowBG = userCfg.RowBG
|
||||
}
|
||||
cfg.RowAltBG = userCfg.RowAltBG
|
||||
if userCfg.FooterBG != tw.Empty {
|
||||
cfg.FooterBG = userCfg.FooterBG
|
||||
}
|
||||
if userCfg.HeaderColor != tw.Empty {
|
||||
cfg.HeaderColor = userCfg.HeaderColor
|
||||
}
|
||||
if userCfg.RowColor != tw.Empty {
|
||||
cfg.RowColor = userCfg.RowColor
|
||||
}
|
||||
if userCfg.FooterColor != tw.Empty {
|
||||
cfg.FooterColor = userCfg.FooterColor
|
||||
}
|
||||
if userCfg.ApproxCharWidthFactor > 0 {
|
||||
cfg.ApproxCharWidthFactor = userCfg.ApproxCharWidthFactor
|
||||
}
|
||||
if userCfg.MinColWidth >= 0 {
|
||||
cfg.MinColWidth = userCfg.MinColWidth
|
||||
}
|
||||
cfg.RenderTWConfigOverrides = userCfg.RenderTWConfigOverrides
|
||||
cfg.Debug = userCfg.Debug
|
||||
}
|
||||
r := &SVG{
|
||||
config: cfg,
|
||||
trace: make([]string, 0, 50),
|
||||
allVisualLineData: make([][][]string, 3),
|
||||
allVisualLineCtx: make([][]tw.Formatting, 3),
|
||||
vMergeTrack: make(map[int]int),
|
||||
logger: ll.New("svg").Disable(),
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
r.allVisualLineData[i] = make([][]string, 0)
|
||||
r.allVisualLineCtx[i] = make([]tw.Formatting, 0)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// calculateAllColumnWidths computes column widths based on content and merges.
|
||||
// Uses content length and merge spans; handles horizontal merges by distributing width.
|
||||
func (s *SVG) calculateAllColumnWidths() {
|
||||
s.debug("Calculating column widths")
|
||||
tempMaxCols := 0
|
||||
for sectionIdx := 0; sectionIdx < 3; sectionIdx++ {
|
||||
for lineIdx, lineCtx := range s.allVisualLineCtx[sectionIdx] {
|
||||
if lineCtx.Row.Current != nil {
|
||||
visualColCount := 0
|
||||
for colIdx := 0; colIdx < len(lineCtx.Row.Current); {
|
||||
cellCtx := lineCtx.Row.Current[colIdx]
|
||||
if cellCtx.Merge.Horizontal.Present && !cellCtx.Merge.Horizontal.Start {
|
||||
colIdx++ // Skip non-start merged cells
|
||||
continue
|
||||
}
|
||||
visualColCount++
|
||||
span := 1
|
||||
if cellCtx.Merge.Horizontal.Present && cellCtx.Merge.Horizontal.Start {
|
||||
span = cellCtx.Merge.Horizontal.Span
|
||||
if span <= 0 {
|
||||
span = 1
|
||||
}
|
||||
}
|
||||
colIdx += span
|
||||
}
|
||||
s.debug("Section %d, line %d: Visual columns = %d", sectionIdx, lineIdx, visualColCount)
|
||||
if visualColCount > tempMaxCols {
|
||||
tempMaxCols = visualColCount
|
||||
}
|
||||
} else if lineIdx < len(s.allVisualLineData[sectionIdx]) {
|
||||
if rawDataLen := len(s.allVisualLineData[sectionIdx][lineIdx]); rawDataLen > tempMaxCols {
|
||||
tempMaxCols = rawDataLen
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.maxCols = tempMaxCols
|
||||
s.debug("Max columns: %d", s.maxCols)
|
||||
if s.maxCols == 0 {
|
||||
s.calculatedColWidths = []float64{}
|
||||
return
|
||||
}
|
||||
s.calculatedColWidths = make([]float64, s.maxCols)
|
||||
for i := range s.calculatedColWidths {
|
||||
s.calculatedColWidths[i] = s.config.MinColWidth
|
||||
}
|
||||
|
||||
// Structure to track max width for each merge group
|
||||
type mergeKey struct {
|
||||
startCol int
|
||||
span int
|
||||
}
|
||||
maxMergeWidths := make(map[mergeKey]float64)
|
||||
|
||||
processSectionForWidth := func(sectionIdx int) {
|
||||
for lineIdx, visualLineData := range s.allVisualLineData[sectionIdx] {
|
||||
if lineIdx >= len(s.allVisualLineCtx[sectionIdx]) {
|
||||
s.debug("Warning: Missing context for section %d line %d", sectionIdx, lineIdx)
|
||||
continue
|
||||
}
|
||||
lineCtx := s.allVisualLineCtx[sectionIdx][lineIdx]
|
||||
currentTableCol := 0
|
||||
currentVisualCol := 0
|
||||
for currentVisualCol < len(visualLineData) && currentTableCol < s.maxCols {
|
||||
cellContent := visualLineData[currentVisualCol]
|
||||
cellCtx := tw.CellContext{}
|
||||
if lineCtx.Row.Current != nil {
|
||||
if c, ok := lineCtx.Row.Current[currentTableCol]; ok {
|
||||
cellCtx = c
|
||||
}
|
||||
}
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present {
|
||||
if cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
if hSpan <= 0 {
|
||||
hSpan = 1
|
||||
}
|
||||
} else {
|
||||
currentTableCol++
|
||||
continue
|
||||
}
|
||||
}
|
||||
textPixelWidth := s.estimateTextWidth(cellContent)
|
||||
contentAndPaddingWidth := textPixelWidth + (2 * s.config.Padding)
|
||||
if hSpan == 1 {
|
||||
if currentTableCol < len(s.calculatedColWidths) && contentAndPaddingWidth > s.calculatedColWidths[currentTableCol] {
|
||||
s.calculatedColWidths[currentTableCol] = contentAndPaddingWidth
|
||||
}
|
||||
} else {
|
||||
totalMergedWidth := contentAndPaddingWidth + (float64(hSpan-1) * s.config.Padding * 2)
|
||||
if totalMergedWidth < s.config.MinColWidth*float64(hSpan) {
|
||||
totalMergedWidth = s.config.MinColWidth * float64(hSpan)
|
||||
}
|
||||
if currentTableCol < len(s.calculatedColWidths) {
|
||||
key := mergeKey{currentTableCol, hSpan}
|
||||
if currentWidth, ok := maxMergeWidths[key]; ok {
|
||||
if totalMergedWidth > currentWidth {
|
||||
maxMergeWidths[key] = totalMergedWidth
|
||||
}
|
||||
} else {
|
||||
maxMergeWidths[key] = totalMergedWidth
|
||||
}
|
||||
s.debug("Horizontal merge at col %d, span %d: Total width %.2f", currentTableCol, hSpan, totalMergedWidth)
|
||||
}
|
||||
}
|
||||
currentTableCol += hSpan
|
||||
currentVisualCol++
|
||||
}
|
||||
}
|
||||
}
|
||||
processSectionForWidth(sectionTypeHeader)
|
||||
processSectionForWidth(sectionTypeRow)
|
||||
processSectionForWidth(sectionTypeFooter)
|
||||
|
||||
// Apply maximum widths for merged cells
|
||||
for key, width := range maxMergeWidths {
|
||||
s.calculatedColWidths[key.startCol] = width
|
||||
for i := 1; i < key.span && (key.startCol+i) < len(s.calculatedColWidths); i++ {
|
||||
s.calculatedColWidths[key.startCol+i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
for i := range s.calculatedColWidths {
|
||||
if s.calculatedColWidths[i] < s.config.MinColWidth && s.calculatedColWidths[i] != 0 {
|
||||
s.calculatedColWidths[i] = s.config.MinColWidth
|
||||
}
|
||||
}
|
||||
s.debug("Column widths: %v", s.calculatedColWidths)
|
||||
}
|
||||
|
||||
// Close finalizes SVG rendering and writes output.
|
||||
// Parameter w is the output w.
|
||||
// Returns an error if writing fails.
|
||||
func (s *SVG) Close() error {
|
||||
s.debug("Finalizing SVG output")
|
||||
s.calculateAllColumnWidths()
|
||||
s.renderBufferedData()
|
||||
if s.numVisualRowsDrawn == 0 && s.maxCols == 0 {
|
||||
fmt.Fprintf(s.w, `<svg xmlns="http://www.w3.org/2000/svg" width="%.2f" height="%.2f"></svg>`, s.config.StrokeWidth*2, s.config.StrokeWidth*2)
|
||||
return nil
|
||||
}
|
||||
totalWidth := s.config.StrokeWidth
|
||||
if len(s.calculatedColWidths) > 0 {
|
||||
for _, cw := range s.calculatedColWidths {
|
||||
colWidth := cw
|
||||
if colWidth <= 0 {
|
||||
colWidth = s.config.MinColWidth
|
||||
}
|
||||
totalWidth += colWidth + s.config.StrokeWidth
|
||||
}
|
||||
} else if s.maxCols > 0 {
|
||||
for i := 0; i < s.maxCols; i++ {
|
||||
totalWidth += s.config.MinColWidth + s.config.StrokeWidth
|
||||
}
|
||||
} else {
|
||||
totalWidth = s.config.StrokeWidth * 2
|
||||
}
|
||||
totalHeight := s.currentY
|
||||
singleVisualRowHeight := s.config.FontSize*s.config.LineHeightFactor + (2 * s.config.Padding)
|
||||
if s.numVisualRowsDrawn == 0 {
|
||||
if s.maxCols > 0 {
|
||||
totalHeight = s.config.StrokeWidth + singleVisualRowHeight + s.config.StrokeWidth
|
||||
} else {
|
||||
totalHeight = s.config.StrokeWidth * 2
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(s.w, `<svg xmlns="http://www.w3.org/2000/svg" width="%.2f" height="%.2f" font-family="%s" font-size="%.2f">`,
|
||||
totalWidth, totalHeight, html.EscapeString(s.config.FontFamily), s.config.FontSize)
|
||||
fmt.Fprintln(s.w)
|
||||
fmt.Fprintln(s.w, "<style>text { stroke: none; }</style>")
|
||||
if _, err := io.WriteString(s.w, s.svgElements.String()); err != nil {
|
||||
fmt.Fprintln(s.w, `</svg>`)
|
||||
return fmt.Errorf("failed to write SVG elements: %w", err)
|
||||
}
|
||||
if s.maxCols > 0 || s.numVisualRowsDrawn > 0 {
|
||||
fmt.Fprintf(s.w, ` <g class="table-borders" stroke="%s" stroke-width="%.2f" stroke-linecap="square">`,
|
||||
html.EscapeString(s.config.StrokeColor), s.config.StrokeWidth)
|
||||
fmt.Fprintln(s.w)
|
||||
yPos := s.config.StrokeWidth / 2.0
|
||||
borderRowsToDraw := s.numVisualRowsDrawn
|
||||
if borderRowsToDraw == 0 && s.maxCols > 0 {
|
||||
borderRowsToDraw = 1
|
||||
}
|
||||
lineStartX := s.config.StrokeWidth / 2.0
|
||||
lineEndX := s.config.StrokeWidth / 2.0
|
||||
for _, width := range s.calculatedColWidths {
|
||||
lineEndX += width + s.config.StrokeWidth
|
||||
}
|
||||
for i := 0; i <= borderRowsToDraw; i++ {
|
||||
fmt.Fprintf(s.w, ` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" />%s`,
|
||||
lineStartX, yPos, lineEndX, yPos, "\n")
|
||||
if i < borderRowsToDraw {
|
||||
yPos += singleVisualRowHeight + s.config.StrokeWidth
|
||||
}
|
||||
}
|
||||
xPos := s.config.StrokeWidth / 2.0
|
||||
borderLineStartY := s.config.StrokeWidth / 2.0
|
||||
borderLineEndY := totalHeight - (s.config.StrokeWidth / 2.0)
|
||||
for visualColIdx := 0; visualColIdx <= s.maxCols; visualColIdx++ {
|
||||
fmt.Fprintf(s.w, ` <line x1="%.2f" y1="%.2f" x2="%.2f" y2="%.2f" />%s`,
|
||||
xPos, borderLineStartY, xPos, borderLineEndY, "\n")
|
||||
if visualColIdx < s.maxCols {
|
||||
colWidth := s.config.MinColWidth
|
||||
if visualColIdx < len(s.calculatedColWidths) && s.calculatedColWidths[visualColIdx] > 0 {
|
||||
colWidth = s.calculatedColWidths[visualColIdx]
|
||||
}
|
||||
xPos += colWidth + s.config.StrokeWidth
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(s.w, " </g>")
|
||||
}
|
||||
fmt.Fprintln(s.w, `</svg>`)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config returns the renderer's configuration.
|
||||
// No parameters are required.
|
||||
// Returns a Rendition with border and debug settings.
|
||||
func (s *SVG) Config() tw.Rendition {
|
||||
return tw.Rendition{
|
||||
Borders: tw.Border{Left: tw.On, Right: tw.On, Top: tw.On, Bottom: tw.On},
|
||||
Settings: tw.Settings{},
|
||||
Streaming: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Debug returns the renderer's debug trace.
|
||||
// No parameters are required.
|
||||
// Returns a slice of debug messages.
|
||||
func (s *SVG) Debug() []string {
|
||||
return s.trace
|
||||
}
|
||||
|
||||
// estimateTextWidth estimates text width in SVG units.
|
||||
// Parameter text is the input string to measure.
|
||||
// Returns the estimated width based on font size and char factor.
|
||||
func (s *SVG) estimateTextWidth(text string) float64 {
|
||||
runeCount := float64(len([]rune(text)))
|
||||
return runeCount * s.config.FontSize * s.config.ApproxCharWidthFactor
|
||||
}
|
||||
|
||||
// Footer buffers footer lines for SVG rendering.
|
||||
// Parameters include w (w), footers (lines), and ctx (formatting).
|
||||
// No return value; stores data for later rendering.
|
||||
func (s *SVG) Footer(footers [][]string, ctx tw.Formatting) {
|
||||
s.debug("Buffering %d footer lines", len(footers))
|
||||
for i, line := range footers {
|
||||
currentCtx := ctx
|
||||
currentCtx.IsSubRow = (i > 0)
|
||||
s.storeVisualLine(sectionTypeFooter, line, currentCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// getSVGAnchorFromTW maps tablewriter alignment to SVG text-anchor.
|
||||
// Parameter align is the tablewriter alignment setting.
|
||||
// Returns the corresponding SVG text-anchor value or empty string.
|
||||
func (s *SVG) getSVGAnchorFromTW(align tw.Align) string {
|
||||
switch align {
|
||||
case tw.AlignLeft:
|
||||
return "start"
|
||||
case tw.AlignCenter:
|
||||
return "middle"
|
||||
case tw.AlignRight:
|
||||
return "end"
|
||||
case tw.AlignNone, tw.Skip:
|
||||
return tw.Empty
|
||||
}
|
||||
return tw.Empty
|
||||
}
|
||||
|
||||
// Header buffers header lines for SVG rendering.
|
||||
// Parameters include w (w), headers (lines), and ctx (formatting).
|
||||
// No return value; stores data for later rendering.
|
||||
func (s *SVG) Header(headers [][]string, ctx tw.Formatting) {
|
||||
s.debug("Buffering %d header lines", len(headers))
|
||||
for i, line := range headers {
|
||||
currentCtx := ctx
|
||||
currentCtx.IsSubRow = i > 0
|
||||
s.storeVisualLine(sectionTypeHeader, line, currentCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// Line handles border rendering (ignored in SVG renderer).
|
||||
// Parameters include w (w) and ctx (formatting).
|
||||
// No return value; SVG borders are drawn in Close.
|
||||
func (s *SVG) Line(ctx tw.Formatting) {
|
||||
s.debug("Line rendering ignored")
|
||||
}
|
||||
|
||||
// padLineSVG pads a line to the specified column count.
|
||||
// Parameters include line (input strings) and numCols (target length).
|
||||
// Returns the padded line with empty strings as needed.
|
||||
func padLineSVG(line []string, numCols int) []string {
|
||||
if numCols <= 0 {
|
||||
return []string{}
|
||||
}
|
||||
currentLen := len(line)
|
||||
if currentLen == numCols {
|
||||
return line
|
||||
}
|
||||
if currentLen > numCols {
|
||||
return line[:numCols]
|
||||
}
|
||||
padded := make([]string, numCols)
|
||||
copy(padded, line)
|
||||
return padded
|
||||
}
|
||||
|
||||
// renderBufferedData renders all buffered lines to SVG elements.
|
||||
// No parameters are required.
|
||||
// No return value; populates svgElements buffer.
|
||||
func (s *SVG) renderBufferedData() {
|
||||
s.debug("Rendering buffered data")
|
||||
s.currentY = s.config.StrokeWidth
|
||||
s.dataRowCounter = 0
|
||||
s.vMergeTrack = make(map[int]int)
|
||||
s.numVisualRowsDrawn = 0
|
||||
renderSection := func(sectionIdx int, position tw.Position) {
|
||||
for visualLineIdx, visualLineData := range s.allVisualLineData[sectionIdx] {
|
||||
if visualLineIdx >= len(s.allVisualLineCtx[sectionIdx]) {
|
||||
s.debug("Error: Missing context for section %d line %d", sectionIdx, visualLineIdx)
|
||||
continue
|
||||
}
|
||||
s.renderVisualLine(visualLineData, s.allVisualLineCtx[sectionIdx][visualLineIdx], position)
|
||||
}
|
||||
}
|
||||
renderSection(sectionTypeHeader, tw.Header)
|
||||
renderSection(sectionTypeRow, tw.Row)
|
||||
renderSection(sectionTypeFooter, tw.Footer)
|
||||
}
|
||||
|
||||
// renderVisualLine renders a single visual line as SVG elements.
|
||||
// Parameters include lineData (cell content), ctx (formatting), and position (section type).
|
||||
// No return value; handles horizontal and vertical merges.
|
||||
func (s *SVG) renderVisualLine(visualLineData []string, ctx tw.Formatting, position tw.Position) {
|
||||
if s.maxCols == 0 || len(s.calculatedColWidths) == 0 {
|
||||
s.debug("Skipping line rendering: maxCols=%d, widths=%d", s.maxCols, len(s.calculatedColWidths))
|
||||
return
|
||||
}
|
||||
s.numVisualRowsDrawn++
|
||||
s.debug("Rendering visual row %d", s.numVisualRowsDrawn)
|
||||
singleVisualRowHeight := s.config.FontSize*s.config.LineHeightFactor + (2 * s.config.Padding)
|
||||
bgColor := tw.Empty
|
||||
textColor := tw.Empty
|
||||
defaultTextAnchor := "start"
|
||||
switch position {
|
||||
case tw.Header:
|
||||
bgColor = s.config.HeaderBG
|
||||
textColor = s.config.HeaderColor
|
||||
defaultTextAnchor = "middle"
|
||||
case tw.Footer:
|
||||
bgColor = s.config.FooterBG
|
||||
textColor = s.config.FooterColor
|
||||
defaultTextAnchor = "end"
|
||||
default:
|
||||
textColor = s.config.RowColor
|
||||
if !ctx.IsSubRow {
|
||||
if s.config.RowAltBG != tw.Empty && s.dataRowCounter%2 != 0 {
|
||||
bgColor = s.config.RowAltBG
|
||||
} else {
|
||||
bgColor = s.config.RowBG
|
||||
}
|
||||
s.dataRowCounter++
|
||||
} else {
|
||||
parentDataRowStripeIndex := max(s.dataRowCounter-1, 0)
|
||||
if s.config.RowAltBG != tw.Empty && parentDataRowStripeIndex%2 != 0 {
|
||||
bgColor = s.config.RowAltBG
|
||||
} else {
|
||||
bgColor = s.config.RowBG
|
||||
}
|
||||
}
|
||||
}
|
||||
currentX := s.config.StrokeWidth
|
||||
currentVisualCellIdx := 0
|
||||
for tableColIdx := 0; tableColIdx < s.maxCols; {
|
||||
if tableColIdx >= len(s.calculatedColWidths) {
|
||||
s.debug("Table Col %d out of bounds for widths", tableColIdx)
|
||||
tableColIdx++
|
||||
continue
|
||||
}
|
||||
if remainingVSpan, isMerging := s.vMergeTrack[tableColIdx]; isMerging && remainingVSpan > 1 {
|
||||
s.vMergeTrack[tableColIdx]--
|
||||
if s.vMergeTrack[tableColIdx] <= 1 {
|
||||
delete(s.vMergeTrack, tableColIdx)
|
||||
}
|
||||
currentX += s.calculatedColWidths[tableColIdx] + s.config.StrokeWidth
|
||||
tableColIdx++
|
||||
continue
|
||||
}
|
||||
cellContentFromVisualLine := tw.Empty
|
||||
if currentVisualCellIdx < len(visualLineData) {
|
||||
cellContentFromVisualLine = visualLineData[currentVisualCellIdx]
|
||||
}
|
||||
cellCtx := tw.CellContext{}
|
||||
if ctx.Row.Current != nil {
|
||||
if c, ok := ctx.Row.Current[tableColIdx]; ok {
|
||||
cellCtx = c
|
||||
}
|
||||
}
|
||||
textToRender := cellContentFromVisualLine
|
||||
if cellCtx.Data != tw.Empty {
|
||||
if !((cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) || (cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start)) {
|
||||
textToRender = cellCtx.Data
|
||||
} else {
|
||||
textToRender = tw.Empty
|
||||
}
|
||||
} else if (cellCtx.Merge.Vertical.Present && !cellCtx.Merge.Vertical.Start) || (cellCtx.Merge.Hierarchical.Present && !cellCtx.Merge.Hierarchical.Start) {
|
||||
textToRender = tw.Empty
|
||||
}
|
||||
hSpan := 1
|
||||
if cellCtx.Merge.Horizontal.Present {
|
||||
if cellCtx.Merge.Horizontal.Start {
|
||||
hSpan = cellCtx.Merge.Horizontal.Span
|
||||
if hSpan <= 0 {
|
||||
hSpan = 1
|
||||
}
|
||||
} else {
|
||||
currentX += s.calculatedColWidths[tableColIdx] + s.config.StrokeWidth
|
||||
tableColIdx++
|
||||
continue
|
||||
}
|
||||
}
|
||||
vSpan := 1
|
||||
isVSpanStart := false
|
||||
if cellCtx.Merge.Vertical.Present && cellCtx.Merge.Vertical.Start {
|
||||
vSpan = cellCtx.Merge.Vertical.Span
|
||||
isVSpanStart = true
|
||||
} else if cellCtx.Merge.Hierarchical.Present && cellCtx.Merge.Hierarchical.Start {
|
||||
vSpan = cellCtx.Merge.Hierarchical.Span
|
||||
isVSpanStart = true
|
||||
}
|
||||
if vSpan <= 0 {
|
||||
vSpan = 1
|
||||
}
|
||||
rectWidth := 0.0
|
||||
for hs := 0; hs < hSpan && (tableColIdx+hs) < s.maxCols; hs++ {
|
||||
if (tableColIdx + hs) < len(s.calculatedColWidths) {
|
||||
rectWidth += s.calculatedColWidths[tableColIdx+hs]
|
||||
} else {
|
||||
rectWidth += s.config.MinColWidth
|
||||
}
|
||||
}
|
||||
if hSpan > 1 {
|
||||
rectWidth += float64(hSpan-1) * s.config.StrokeWidth
|
||||
}
|
||||
if rectWidth <= 0 {
|
||||
tableColIdx += hSpan
|
||||
if hSpan > 0 {
|
||||
currentVisualCellIdx++
|
||||
}
|
||||
continue
|
||||
}
|
||||
rectHeight := singleVisualRowHeight
|
||||
if isVSpanStart && vSpan > 1 {
|
||||
rectHeight = float64(vSpan)*singleVisualRowHeight + float64(vSpan-1)*s.config.StrokeWidth
|
||||
for hs := 0; hs < hSpan && (tableColIdx+hs) < s.maxCols; hs++ {
|
||||
s.vMergeTrack[tableColIdx+hs] = vSpan
|
||||
}
|
||||
s.debug("Vertical merge at col %d, span %d, height %.2f", tableColIdx, vSpan, rectHeight)
|
||||
} else if remainingVSpan, isMerging := s.vMergeTrack[tableColIdx]; isMerging && remainingVSpan > 1 {
|
||||
rectHeight = singleVisualRowHeight
|
||||
textToRender = tw.Empty
|
||||
}
|
||||
fmt.Fprintf(&s.svgElements, ` <rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" fill="%s"/>%s`,
|
||||
currentX, s.currentY, rectWidth, rectHeight, html.EscapeString(bgColor), "\n")
|
||||
cellTextAnchor := defaultTextAnchor
|
||||
if s.config.RenderTWConfigOverrides {
|
||||
if al := s.getSVGAnchorFromTW(cellCtx.Align); al != tw.Empty {
|
||||
cellTextAnchor = al
|
||||
}
|
||||
}
|
||||
textX := currentX + s.config.Padding
|
||||
switch cellTextAnchor {
|
||||
case "middle":
|
||||
textX = currentX + s.config.Padding + (rectWidth-2*s.config.Padding)/2.0
|
||||
case "end":
|
||||
textX = currentX + rectWidth - s.config.Padding
|
||||
}
|
||||
textY := s.currentY + rectHeight/2.0
|
||||
escapedCell := html.EscapeString(textToRender)
|
||||
fmt.Fprintf(&s.svgElements, ` <text x="%.2f" y="%.2f" fill="%s" text-anchor="%s" dominant-baseline="middle">%s</text>%s`,
|
||||
textX, textY, html.EscapeString(textColor), cellTextAnchor, escapedCell, "\n")
|
||||
currentX += rectWidth + s.config.StrokeWidth
|
||||
tableColIdx += hSpan
|
||||
currentVisualCellIdx++
|
||||
}
|
||||
s.currentY += singleVisualRowHeight + s.config.StrokeWidth
|
||||
}
|
||||
|
||||
// Reset clears the renderer's internal state.
|
||||
// No parameters are required.
|
||||
// No return value; prepares for new rendering.
|
||||
func (s *SVG) Reset() {
|
||||
s.debug("Resetting state")
|
||||
s.trace = make([]string, 0, 50)
|
||||
for i := 0; i < 3; i++ {
|
||||
s.allVisualLineData[i] = s.allVisualLineData[i][:0]
|
||||
s.allVisualLineCtx[i] = s.allVisualLineCtx[i][:0]
|
||||
}
|
||||
s.maxCols = 0
|
||||
s.calculatedColWidths = nil
|
||||
s.svgElements.Reset()
|
||||
s.currentY = 0
|
||||
s.dataRowCounter = 0
|
||||
s.vMergeTrack = make(map[int]int)
|
||||
s.numVisualRowsDrawn = 0
|
||||
}
|
||||
|
||||
// Row buffers a row line for SVG rendering.
|
||||
// Parameters include w (w), rowLine (cells), and ctx (formatting).
|
||||
// No return value; stores data for later rendering.
|
||||
func (s *SVG) Row(rowLine []string, ctx tw.Formatting) {
|
||||
s.debug("Buffering row line, IsSubRow: %v", ctx.IsSubRow)
|
||||
s.storeVisualLine(sectionTypeRow, rowLine, ctx)
|
||||
}
|
||||
|
||||
func (s *SVG) Logger(logger *ll.Logger) {
|
||||
s.logger = logger.Namespace("svg")
|
||||
}
|
||||
|
||||
// Start initializes SVG rendering.
|
||||
// Parameter w is the output w.
|
||||
// Returns nil; prepares internal state.
|
||||
func (s *SVG) Start(w io.Writer) error {
|
||||
s.w = w
|
||||
s.debug("Starting SVG rendering")
|
||||
s.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
// debug logs a message if debugging is enabled.
|
||||
// Parameters include format string and variadic arguments.
|
||||
// No return value; appends to trace.
|
||||
func (s *SVG) debug(format string, a ...interface{}) {
|
||||
if s.config.Debug {
|
||||
msg := fmt.Sprintf(format, a...)
|
||||
s.trace = append(s.trace, "[SVG] "+msg)
|
||||
}
|
||||
}
|
||||
|
||||
// storeVisualLine stores a visual line for rendering.
|
||||
// Parameters include sectionIdx, lineData (cells), and ctx (formatting).
|
||||
// No return value; buffers data and context.
|
||||
func (s *SVG) storeVisualLine(sectionIdx int, lineData []string, ctx tw.Formatting) {
|
||||
copiedLineData := make([]string, len(lineData))
|
||||
copy(copiedLineData, lineData)
|
||||
s.allVisualLineData[sectionIdx] = append(s.allVisualLineData[sectionIdx], copiedLineData)
|
||||
s.allVisualLineCtx[sectionIdx] = append(s.allVisualLineCtx[sectionIdx], ctx)
|
||||
hasCurrent := ctx.Row.Current != nil
|
||||
s.debug("Stored line in section %d, has context: %v", sectionIdx, hasCurrent)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package renderer
|
||||
|
||||
import "github.com/fatih/color"
|
||||
|
||||
// Colors is a slice of color attributes for use with fatih/color, such as color.FgWhite or color.Bold.
|
||||
type Colors []color.Attribute
|
||||
|
||||
// Tint defines foreground and background color settings for table elements, with optional per-column overrides.
|
||||
type Tint struct {
|
||||
FG Colors // Foreground color attributes
|
||||
BG Colors // Background color attributes
|
||||
Columns []Tint // Per-column color settings
|
||||
}
|
||||
|
||||
// Apply applies the Tint's foreground and background colors to the given text, returning the text unchanged if no colors are set.
|
||||
func (t Tint) Apply(text string) string {
|
||||
if len(t.FG) == 0 && len(t.BG) == 0 {
|
||||
return text
|
||||
}
|
||||
// Combine foreground and background colors
|
||||
combinedColors := append(t.FG, t.BG...)
|
||||
// Create a color function and apply it to the text
|
||||
c := color.New(combinedColors...).SprintFunc()
|
||||
return c(text)
|
||||
}
|
||||
+1164
File diff suppressed because it is too large
Load Diff
+2333
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
||||
package tw
|
||||
|
||||
// CellFormatting holds formatting options for table cells.
|
||||
type CellFormatting struct {
|
||||
AutoWrap int // Wrapping behavior (e.g., WrapTruncate, WrapNormal)
|
||||
AutoFormat State // Enables automatic formatting (e.g., title case for headers)
|
||||
|
||||
// Deprecated: Kept for backward compatibility. Use CellConfig.CellMerging.Mode instead.
|
||||
// This will be removed in a future version.
|
||||
MergeMode int
|
||||
|
||||
// Deprecated: Kept for backward compatibility. Use CellConfig.Alignment instead.
|
||||
// This will be removed in a future version.
|
||||
Alignment Align
|
||||
}
|
||||
|
||||
// CellMerging holds the configuration for how cells should be merged.
|
||||
// This new struct replaces the deprecated MergeMode.
|
||||
type CellMerging struct {
|
||||
// Mode is a bitmask specifying the type of merge (e.g., MergeHorizontal, MergeVertical).
|
||||
Mode int
|
||||
|
||||
// ByColumnIndex specifies which column indices should be considered for merging.
|
||||
// If the mapper is nil or empty, merging applies to all columns (if Mode is set).
|
||||
// Otherwise, only columns with an index present as a key will be merged.
|
||||
ByColumnIndex Mapper[int, bool]
|
||||
|
||||
// ByRowIndex is reserved for future features to specify merging on specific rows.
|
||||
ByRowIndex Mapper[int, bool]
|
||||
}
|
||||
|
||||
// CellPadding defines padding settings for table cells.
|
||||
type CellPadding struct {
|
||||
Global Padding // Default padding applied to all cells
|
||||
PerColumn []Padding // Column-specific padding overrides
|
||||
}
|
||||
|
||||
// CellFilter defines filtering functions for cell content.
|
||||
type CellFilter struct {
|
||||
Global func([]string) []string // Processes the entire row
|
||||
PerColumn []func(string) string // Processes individual cells by column
|
||||
}
|
||||
|
||||
// CellCallbacks holds callback functions for cell processing.
|
||||
// Note: These are currently placeholders and not fully implemented.
|
||||
type CellCallbacks struct {
|
||||
Global func() // Global callback applied to all cells
|
||||
PerColumn []func() // Column-specific callbacks
|
||||
}
|
||||
|
||||
// CellAlignment defines alignment settings for table cells.
|
||||
type CellAlignment struct {
|
||||
Global Align // Default alignment applied to all cells
|
||||
PerColumn []Align // Column-specific alignment overrides
|
||||
}
|
||||
|
||||
// CellConfig combines formatting, padding, and callback settings for a table section.
|
||||
type CellConfig struct {
|
||||
Formatting CellFormatting // Cell formatting options
|
||||
Padding CellPadding // Padding configuration
|
||||
Callbacks CellCallbacks // Callback functions (unused)
|
||||
Filter CellFilter // Function to filter cell content (renamed from Filter Filter)
|
||||
Alignment CellAlignment // Alignment configuration for cells
|
||||
ColMaxWidths CellWidth // Per-column maximum width overrides
|
||||
Merging CellMerging // Merging holds all configuration related to cell merging.
|
||||
|
||||
// Deprecated: use Alignment.PerColumn instead. Will be removed in a future version.
|
||||
// will be removed soon
|
||||
ColumnAligns []Align // Per-column alignment overrides
|
||||
}
|
||||
|
||||
type CellWidth struct {
|
||||
Global int
|
||||
PerColumn Mapper[int, int]
|
||||
}
|
||||
|
||||
func (c CellWidth) Constrained() bool {
|
||||
return c.Global > 0 || c.PerColumn.Len() > 0
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package tw
|
||||
|
||||
// Deprecated: SymbolASCII is deprecated; use Glyphs with StyleASCII instead.
|
||||
// this will be removed soon
|
||||
type SymbolASCII struct{}
|
||||
|
||||
// SymbolASCII symbol methods
|
||||
func (s *SymbolASCII) Name() string { return StyleNameASCII.String() }
|
||||
func (s *SymbolASCII) Center() string { return "+" }
|
||||
func (s *SymbolASCII) Row() string { return "-" }
|
||||
func (s *SymbolASCII) Column() string { return "|" }
|
||||
func (s *SymbolASCII) TopLeft() string { return "+" }
|
||||
func (s *SymbolASCII) TopMid() string { return "+" }
|
||||
func (s *SymbolASCII) TopRight() string { return "+" }
|
||||
func (s *SymbolASCII) MidLeft() string { return "+" }
|
||||
func (s *SymbolASCII) MidRight() string { return "+" }
|
||||
func (s *SymbolASCII) BottomLeft() string { return "+" }
|
||||
func (s *SymbolASCII) BottomMid() string { return "+" }
|
||||
func (s *SymbolASCII) BottomRight() string { return "+" }
|
||||
func (s *SymbolASCII) HeaderLeft() string { return "+" }
|
||||
func (s *SymbolASCII) HeaderMid() string { return "+" }
|
||||
func (s *SymbolASCII) HeaderRight() string { return "+" }
|
||||
|
||||
// Deprecated: SymbolUnicode is deprecated; use Glyphs with appropriate styles (e.g., StyleLight, StyleHeavy) instead.
|
||||
// this will be removed soon
|
||||
type SymbolUnicode struct {
|
||||
row string
|
||||
column string
|
||||
center string
|
||||
corners [9]string // [topLeft, topMid, topRight, midLeft, center, midRight, bottomLeft, bottomMid, bottomRight]
|
||||
}
|
||||
|
||||
// SymbolUnicode symbol methods
|
||||
func (s *SymbolUnicode) Name() string { return "unicode" }
|
||||
func (s *SymbolUnicode) Center() string { return s.center }
|
||||
func (s *SymbolUnicode) Row() string { return s.row }
|
||||
func (s *SymbolUnicode) Column() string { return s.column }
|
||||
func (s *SymbolUnicode) TopLeft() string { return s.corners[0] }
|
||||
func (s *SymbolUnicode) TopMid() string { return s.corners[1] }
|
||||
func (s *SymbolUnicode) TopRight() string { return s.corners[2] }
|
||||
func (s *SymbolUnicode) MidLeft() string { return s.corners[3] }
|
||||
func (s *SymbolUnicode) MidRight() string { return s.corners[5] }
|
||||
func (s *SymbolUnicode) BottomLeft() string { return s.corners[6] }
|
||||
func (s *SymbolUnicode) BottomMid() string { return s.corners[7] }
|
||||
func (s *SymbolUnicode) BottomRight() string { return s.corners[8] }
|
||||
func (s *SymbolUnicode) HeaderLeft() string { return s.MidLeft() }
|
||||
func (s *SymbolUnicode) HeaderMid() string { return s.Center() }
|
||||
func (s *SymbolUnicode) HeaderRight() string { return s.MidRight() }
|
||||
|
||||
// Deprecated: SymbolMarkdown is deprecated; use Glyphs with StyleMarkdown instead.
|
||||
// this will be removed soon
|
||||
type SymbolMarkdown struct{}
|
||||
|
||||
// SymbolMarkdown symbol methods
|
||||
func (s *SymbolMarkdown) Name() string { return StyleNameMarkdown.String() }
|
||||
func (s *SymbolMarkdown) Center() string { return "|" }
|
||||
func (s *SymbolMarkdown) Row() string { return "-" }
|
||||
func (s *SymbolMarkdown) Column() string { return "|" }
|
||||
func (s *SymbolMarkdown) TopLeft() string { return "" }
|
||||
func (s *SymbolMarkdown) TopMid() string { return "" }
|
||||
func (s *SymbolMarkdown) TopRight() string { return "" }
|
||||
func (s *SymbolMarkdown) MidLeft() string { return "|" }
|
||||
func (s *SymbolMarkdown) MidRight() string { return "|" }
|
||||
func (s *SymbolMarkdown) BottomLeft() string { return "" }
|
||||
func (s *SymbolMarkdown) BottomMid() string { return "" }
|
||||
func (s *SymbolMarkdown) BottomRight() string { return "" }
|
||||
func (s *SymbolMarkdown) HeaderLeft() string { return "|" }
|
||||
func (s *SymbolMarkdown) HeaderMid() string { return "|" }
|
||||
func (s *SymbolMarkdown) HeaderRight() string { return "|" }
|
||||
|
||||
// Deprecated: SymbolNothing is deprecated; use Glyphs with StyleNone instead.
|
||||
// this will be removed soon
|
||||
type SymbolNothing struct{}
|
||||
|
||||
// SymbolNothing symbol methods
|
||||
func (s *SymbolNothing) Name() string { return StyleNameNothing.String() }
|
||||
func (s *SymbolNothing) Center() string { return "" }
|
||||
func (s *SymbolNothing) Row() string { return "" }
|
||||
func (s *SymbolNothing) Column() string { return "" }
|
||||
func (s *SymbolNothing) TopLeft() string { return "" }
|
||||
func (s *SymbolNothing) TopMid() string { return "" }
|
||||
func (s *SymbolNothing) TopRight() string { return "" }
|
||||
func (s *SymbolNothing) MidLeft() string { return "" }
|
||||
func (s *SymbolNothing) MidRight() string { return "" }
|
||||
func (s *SymbolNothing) BottomLeft() string { return "" }
|
||||
func (s *SymbolNothing) BottomMid() string { return "" }
|
||||
func (s *SymbolNothing) BottomRight() string { return "" }
|
||||
func (s *SymbolNothing) HeaderLeft() string { return "" }
|
||||
func (s *SymbolNothing) HeaderMid() string { return "" }
|
||||
func (s *SymbolNothing) HeaderRight() string { return "" }
|
||||
|
||||
// Deprecated: SymbolGraphical is deprecated; use Glyphs with StyleGraphical instead.
|
||||
// this will be removed soon
|
||||
type SymbolGraphical struct{}
|
||||
|
||||
// SymbolGraphical symbol methods
|
||||
func (s *SymbolGraphical) Name() string { return StyleNameGraphical.String() }
|
||||
func (s *SymbolGraphical) Center() string { return "🟧" } // Orange square (matches mid junctions)
|
||||
func (s *SymbolGraphical) Row() string { return "🟥" } // Red square (matches corners)
|
||||
func (s *SymbolGraphical) Column() string { return "🟦" } // Blue square (vertical line)
|
||||
func (s *SymbolGraphical) TopLeft() string { return "🟥" } // Top-left corner
|
||||
func (s *SymbolGraphical) TopMid() string { return "🔳" } // Top junction
|
||||
func (s *SymbolGraphical) TopRight() string { return "🟥" } // Top-right corner
|
||||
func (s *SymbolGraphical) MidLeft() string { return "🟧" } // Left junction
|
||||
func (s *SymbolGraphical) MidRight() string { return "🟧" } // Right junction
|
||||
func (s *SymbolGraphical) BottomLeft() string { return "🟥" } // Bottom-left corner
|
||||
func (s *SymbolGraphical) BottomMid() string { return "🔳" } // Bottom junction
|
||||
func (s *SymbolGraphical) BottomRight() string { return "🟥" } // Bottom-right corner
|
||||
func (s *SymbolGraphical) HeaderLeft() string { return "🟧" } // Header left (matches mid junctions)
|
||||
func (s *SymbolGraphical) HeaderMid() string { return "🟧" } // Header middle (matches mid junctions)
|
||||
func (s *SymbolGraphical) HeaderRight() string { return "🟧" } // Header right (matches mid junctions)
|
||||
|
||||
// Deprecated: SymbolMerger is deprecated; use Glyphs with StyleMerger instead.
|
||||
// this will be removed soon
|
||||
type SymbolMerger struct {
|
||||
row string
|
||||
column string
|
||||
center string
|
||||
corners [9]string // [TL, TM, TR, ML, CenterIdx(unused), MR, BL, BM, BR]
|
||||
}
|
||||
|
||||
// SymbolMerger symbol methods
|
||||
func (s *SymbolMerger) Name() string { return StyleNameMerger.String() }
|
||||
func (s *SymbolMerger) Center() string { return s.center } // Main crossing symbol
|
||||
func (s *SymbolMerger) Row() string { return s.row }
|
||||
func (s *SymbolMerger) Column() string { return s.column }
|
||||
func (s *SymbolMerger) TopLeft() string { return s.corners[0] }
|
||||
func (s *SymbolMerger) TopMid() string { return s.corners[1] } // LevelHeader junction
|
||||
func (s *SymbolMerger) TopRight() string { return s.corners[2] }
|
||||
func (s *SymbolMerger) MidLeft() string { return s.corners[3] } // Left junction
|
||||
func (s *SymbolMerger) MidRight() string { return s.corners[5] } // Right junction
|
||||
func (s *SymbolMerger) BottomLeft() string { return s.corners[6] }
|
||||
func (s *SymbolMerger) BottomMid() string { return s.corners[7] } // LevelFooter junction
|
||||
func (s *SymbolMerger) BottomRight() string { return s.corners[8] }
|
||||
func (s *SymbolMerger) HeaderLeft() string { return s.MidLeft() }
|
||||
func (s *SymbolMerger) HeaderMid() string { return s.Center() }
|
||||
func (s *SymbolMerger) HeaderRight() string { return s.MidRight() }
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
// Package tw provides utility functions for text formatting, width calculation, and string manipulation
|
||||
// specifically tailored for table rendering, including handling ANSI escape codes and Unicode text.
|
||||
package tw
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/olekukonko/tablewriter/pkg/twwidth"
|
||||
// For mathematical operations like ceiling
|
||||
// For string-to-number conversions
|
||||
// For string manipulation utilities
|
||||
// For Unicode character classification
|
||||
// For UTF-8 rune handling
|
||||
)
|
||||
|
||||
// Title normalizes and uppercases a label string for use in headers.
|
||||
// It replaces underscores and certain dots with spaces and trims whitespace.
|
||||
func Title(name string) string {
|
||||
origLen := len(name)
|
||||
rs := []rune(name)
|
||||
for i, r := range rs {
|
||||
switch r {
|
||||
case '_':
|
||||
rs[i] = ' ' // Replace underscores with spaces
|
||||
case '.':
|
||||
// Replace dots with spaces unless they are between numeric or space characters
|
||||
if (i != 0 && !IsIsNumericOrSpace(rs[i-1])) || (i != len(rs)-1 && !IsIsNumericOrSpace(rs[i+1])) {
|
||||
rs[i] = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
name = string(rs)
|
||||
name = strings.TrimSpace(name)
|
||||
// If the input was non-empty but trimmed to empty, return a single space
|
||||
if len(name) == 0 && origLen > 0 {
|
||||
name = " "
|
||||
}
|
||||
// Convert to uppercase for header formatting
|
||||
return strings.ToUpper(name)
|
||||
}
|
||||
|
||||
// PadCenter centers a string within a specified width using a padding character.
|
||||
// Extra padding is split between left and right, with slight preference to left if uneven.
|
||||
func PadCenter(s, pad string, width int) string {
|
||||
gap := width - twwidth.Width(s)
|
||||
if gap > 0 {
|
||||
// Calculate left and right padding; ceil ensures left gets extra if gap is odd
|
||||
gapLeft := int(math.Ceil(float64(gap) / 2))
|
||||
gapRight := gap - gapLeft
|
||||
return strings.Repeat(pad, gapLeft) + s + strings.Repeat(pad, gapRight)
|
||||
}
|
||||
// If no padding needed or string is too wide, return as is
|
||||
return s
|
||||
}
|
||||
|
||||
// PadRight left-aligns a string within a specified width, filling remaining space on the right with padding.
|
||||
func PadRight(s, pad string, width int) string {
|
||||
gap := width - twwidth.Width(s)
|
||||
if gap > 0 {
|
||||
// Append padding to the right
|
||||
return s + strings.Repeat(pad, gap)
|
||||
}
|
||||
// If no padding needed or string is too wide, return as is
|
||||
return s
|
||||
}
|
||||
|
||||
// PadLeft right-aligns a string within a specified width, filling remaining space on the left with padding.
|
||||
func PadLeft(s, pad string, width int) string {
|
||||
gap := width - twwidth.Width(s)
|
||||
if gap > 0 {
|
||||
// Prepend padding to the left
|
||||
return strings.Repeat(pad, gap) + s
|
||||
}
|
||||
// If no padding needed or string is too wide, return as is
|
||||
return s
|
||||
}
|
||||
|
||||
// Pad aligns a string within a specified width using a padding character.
|
||||
// It truncates if the string is wider than the target width.
|
||||
func Pad(s, padChar string, totalWidth int, alignment Align) string {
|
||||
sDisplayWidth := twwidth.Width(s)
|
||||
if sDisplayWidth > totalWidth {
|
||||
return twwidth.Truncate(s, totalWidth) // Only truncate if necessary
|
||||
}
|
||||
switch alignment {
|
||||
case AlignLeft:
|
||||
return PadRight(s, padChar, totalWidth)
|
||||
case AlignRight:
|
||||
return PadLeft(s, padChar, totalWidth)
|
||||
case AlignCenter:
|
||||
return PadCenter(s, padChar, totalWidth)
|
||||
default:
|
||||
return PadRight(s, padChar, totalWidth)
|
||||
}
|
||||
}
|
||||
|
||||
// IsIsNumericOrSpace checks if a rune is a digit or space character.
|
||||
// Used in formatting logic to determine safe character replacements.
|
||||
func IsIsNumericOrSpace(r rune) bool {
|
||||
return ('0' <= r && r <= '9') || r == ' '
|
||||
}
|
||||
|
||||
// IsNumeric checks if a string represents a valid integer or floating-point number.
|
||||
func IsNumeric(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Try parsing as integer first
|
||||
if _, err := strconv.Atoi(s); err == nil {
|
||||
return true
|
||||
}
|
||||
// Then try parsing as float
|
||||
_, err := strconv.ParseFloat(s, 64)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// SplitCamelCase splits a camelCase or PascalCase or snake_case string into separate words.
|
||||
// It detects transitions between uppercase, lowercase, digits, and other characters.
|
||||
func SplitCamelCase(src string) (entries []string) {
|
||||
// Validate UTF-8 input; return as single entry if invalid
|
||||
if !utf8.ValidString(src) {
|
||||
return []string{src}
|
||||
}
|
||||
entries = []string{}
|
||||
var runes [][]rune
|
||||
lastClass := 0
|
||||
class := 0
|
||||
// Classify each rune into categories: lowercase (1), uppercase (2), digit (3), other (4)
|
||||
for _, r := range src {
|
||||
switch {
|
||||
case unicode.IsLower(r):
|
||||
class = 1
|
||||
case unicode.IsUpper(r):
|
||||
class = 2
|
||||
case unicode.IsDigit(r):
|
||||
class = 3
|
||||
default:
|
||||
class = 4
|
||||
}
|
||||
// Group consecutive runes of the same class together
|
||||
if class == lastClass {
|
||||
runes[len(runes)-1] = append(runes[len(runes)-1], r)
|
||||
} else {
|
||||
runes = append(runes, []rune{r})
|
||||
}
|
||||
lastClass = class
|
||||
}
|
||||
// Adjust for cases where an uppercase letter is followed by lowercase (e.g., CamelCase)
|
||||
for i := 0; i < len(runes)-1; i++ {
|
||||
if unicode.IsUpper(runes[i][0]) && unicode.IsLower(runes[i+1][0]) {
|
||||
// Move the last uppercase rune to the next group for proper word splitting
|
||||
runes[i+1] = append([]rune{runes[i][len(runes[i])-1]}, runes[i+1]...)
|
||||
runes[i] = runes[i][:len(runes[i])-1]
|
||||
}
|
||||
}
|
||||
// Convert rune groups to strings, excluding empty, underscore or whitespace-only groups
|
||||
for _, s := range runes {
|
||||
str := string(s)
|
||||
if len(s) > 0 && strings.TrimSpace(str) != "" && str != "_" {
|
||||
entries = append(entries, str)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Or provides a ternary-like operation for strings, returning 'valid' if cond is true, else 'inValid'.
|
||||
func Or(cond bool, valid, inValid string) string {
|
||||
if cond {
|
||||
return valid
|
||||
}
|
||||
return inValid
|
||||
}
|
||||
|
||||
// Max returns the greater of two integers.
|
||||
func Max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Min returns the smaller of two integers.
|
||||
func Min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// BreakPoint finds the rune index where the display width of a string first exceeds the specified limit.
|
||||
// It returns the number of runes if the entire string fits, or 0 if nothing fits.
|
||||
func BreakPoint(s string, limit int) int {
|
||||
// If limit is 0 or negative, nothing can fit
|
||||
if limit <= 0 {
|
||||
return 0
|
||||
}
|
||||
// Empty string has a breakpoint of 0
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
currentWidth := 0
|
||||
runeCount := 0
|
||||
// Iterate over runes, accumulating display width
|
||||
for _, r := range s {
|
||||
runeWidth := twwidth.Width(string(r)) // Calculate width of individual rune
|
||||
if currentWidth+runeWidth > limit {
|
||||
// Adding this rune would exceed the limit; breakpoint is before this rune
|
||||
if currentWidth == 0 {
|
||||
// First rune is too wide; allow breaking after it if limit > 0
|
||||
if runeWidth > limit && limit > 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return runeCount
|
||||
}
|
||||
currentWidth += runeWidth
|
||||
runeCount++
|
||||
}
|
||||
|
||||
// Entire string fits within the limit
|
||||
return runeCount
|
||||
}
|
||||
|
||||
func MakeAlign(l int, align Align) Alignment {
|
||||
aa := make(Alignment, l)
|
||||
for i := 0; i < l; i++ {
|
||||
aa[i] = align
|
||||
}
|
||||
return aa
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package tw
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// KeyValuePair represents a single key-value pair from a Mapper.
|
||||
type KeyValuePair[K comparable, V any] struct {
|
||||
Key K
|
||||
Value V
|
||||
}
|
||||
|
||||
// Mapper is a generic map type with comparable keys and any value type.
|
||||
// It provides type-safe operations on maps with additional convenience methods.
|
||||
type Mapper[K comparable, V any] map[K]V
|
||||
|
||||
// NewMapper creates and returns a new initialized Mapper.
|
||||
func NewMapper[K comparable, V any]() Mapper[K, V] {
|
||||
return make(Mapper[K, V])
|
||||
}
|
||||
|
||||
// Get returns the value associated with the key.
|
||||
// If the key doesn't exist or the map is nil, it returns the zero value for the value type.
|
||||
func (m Mapper[K, V]) Get(key K) V {
|
||||
if m == nil {
|
||||
var zero V
|
||||
return zero
|
||||
}
|
||||
return m[key]
|
||||
}
|
||||
|
||||
// OK returns the value associated with the key and a boolean indicating whether the key exists.
|
||||
func (m Mapper[K, V]) OK(key K) (V, bool) {
|
||||
if m == nil {
|
||||
var zero V
|
||||
return zero, false
|
||||
}
|
||||
val, ok := m[key]
|
||||
return val, ok
|
||||
}
|
||||
|
||||
// Set sets the value for the specified key.
|
||||
// Does nothing if the map is nil.
|
||||
func (m Mapper[K, V]) Set(key K, value V) Mapper[K, V] {
|
||||
if m != nil {
|
||||
m[key] = value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Delete removes the specified key from the map.
|
||||
// Does nothing if the key doesn't exist or the map is nil.
|
||||
func (m Mapper[K, V]) Delete(key K) Mapper[K, V] {
|
||||
if m != nil {
|
||||
delete(m, key)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Has returns true if the key exists in the map, false otherwise.
|
||||
func (m Mapper[K, V]) Has(key K) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
_, exists := m[key]
|
||||
return exists
|
||||
}
|
||||
|
||||
// Len returns the number of elements in the map.
|
||||
// Returns 0 if the map is nil.
|
||||
func (m Mapper[K, V]) Len() int {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
return len(m)
|
||||
}
|
||||
|
||||
// Keys returns a slice containing all keys in the map.
|
||||
// Returns nil if the map is nil or empty.
|
||||
func (m Mapper[K, V]) Keys() []K {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
keys := make([]K, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (m Mapper[K, V]) Clear() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
for k := range m {
|
||||
delete(m, k)
|
||||
}
|
||||
}
|
||||
|
||||
// Values returns a slice containing all values in the map.
|
||||
// Returns nil if the map is nil or empty.
|
||||
func (m Mapper[K, V]) Values() []V {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]V, 0, len(m))
|
||||
for _, v := range m {
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// Each iterates over each key-value pair in the map and calls the provided function.
|
||||
// Does nothing if the map is nil.
|
||||
func (m Mapper[K, V]) Each(fn func(K, V)) {
|
||||
for k, v := range m {
|
||||
fn(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter returns a new Mapper containing only the key-value pairs that satisfy the predicate.
|
||||
func (m Mapper[K, V]) Filter(fn func(K, V) bool) Mapper[K, V] {
|
||||
result := NewMapper[K, V]()
|
||||
for k, v := range m {
|
||||
if fn(k, v) {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MapValues returns a new Mapper with the same keys but values transformed by the provided function.
|
||||
func (m Mapper[K, V]) MapValues(fn func(V) V) Mapper[K, V] {
|
||||
result := NewMapper[K, V]()
|
||||
for k, v := range m {
|
||||
result[k] = fn(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Clone returns a shallow copy of the Mapper.
|
||||
func (m Mapper[K, V]) Clone() Mapper[K, V] {
|
||||
result := NewMapper[K, V]()
|
||||
for k, v := range m {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Slicer converts the Mapper to a Slicer of key-value pairs.
|
||||
func (m Mapper[K, V]) Slicer() Slicer[KeyValuePair[K, V]] {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(Slicer[KeyValuePair[K, V]], 0, len(m))
|
||||
for k, v := range m {
|
||||
result = append(result, KeyValuePair[K, V]{Key: k, Value: v})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m Mapper[K, V]) SortedKeys() []K {
|
||||
keys := make([]K, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
a, b := any(keys[i]), any(keys[j])
|
||||
|
||||
switch va := a.(type) {
|
||||
case int:
|
||||
if vb, ok := b.(int); ok {
|
||||
return va < vb
|
||||
}
|
||||
case int32:
|
||||
if vb, ok := b.(int32); ok {
|
||||
return va < vb
|
||||
}
|
||||
case int64:
|
||||
if vb, ok := b.(int64); ok {
|
||||
return va < vb
|
||||
}
|
||||
case uint:
|
||||
if vb, ok := b.(uint); ok {
|
||||
return va < vb
|
||||
}
|
||||
case uint64:
|
||||
if vb, ok := b.(uint64); ok {
|
||||
return va < vb
|
||||
}
|
||||
case float32:
|
||||
if vb, ok := b.(float32); ok {
|
||||
return va < vb
|
||||
}
|
||||
case float64:
|
||||
if vb, ok := b.(float64); ok {
|
||||
return va < vb
|
||||
}
|
||||
case string:
|
||||
if vb, ok := b.(string); ok {
|
||||
return va < vb
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to string comparison
|
||||
return fmt.Sprintf("%v", a) < fmt.Sprintf("%v", b)
|
||||
})
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
func NewBoolMapper[K comparable](keys ...K) Mapper[K, bool] {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
mapper := NewMapper[K, bool]()
|
||||
for _, key := range keys {
|
||||
mapper.Set(key, true)
|
||||
}
|
||||
return mapper
|
||||
}
|
||||
|
||||
func NewIntMapper[K comparable](keys ...K) Mapper[K, int] {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
mapper := NewMapper[K, int]()
|
||||
for _, key := range keys {
|
||||
mapper.Set(key, 0)
|
||||
}
|
||||
return mapper
|
||||
}
|
||||
|
||||
func NewIdentityMapper[K comparable](keys ...K) Mapper[K, K] {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
mapper := NewMapper[K, K]()
|
||||
for _, key := range keys {
|
||||
mapper.Set(key, key)
|
||||
}
|
||||
return mapper
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package tw
|
||||
|
||||
// BorderNone defines a border configuration with all sides disabled.
|
||||
var (
|
||||
// PaddingNone represents explicitly empty padding (no spacing on any side)
|
||||
// Equivalent to Padding{Overwrite: true}
|
||||
PaddingNone = Padding{Left: Empty, Right: Empty, Top: Empty, Bottom: Empty, Overwrite: true}
|
||||
BorderNone = Border{Left: Off, Right: Off, Top: Off, Bottom: Off}
|
||||
LinesNone = Lines{ShowTop: Off, ShowBottom: Off, ShowHeaderLine: Off, ShowFooterLine: Off}
|
||||
SeparatorsNone = Separators{ShowHeader: Off, ShowFooter: Off, BetweenRows: Off, BetweenColumns: Off}
|
||||
)
|
||||
|
||||
// PaddingDefault represents standard single-space padding on left/right
|
||||
// Equivalent to Padding{Left: " ", Right: " ", Overwrite: true}
|
||||
var PaddingDefault = Padding{Left: " ", Right: " ", Overwrite: true}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package tw
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/olekukonko/ll"
|
||||
)
|
||||
|
||||
// Renderer defines the interface for rendering tables to an io.Writer.
|
||||
// Implementations must handle headers, rows, footers, and separator lines.
|
||||
type Renderer interface {
|
||||
Start(w io.Writer) error
|
||||
Header(headers [][]string, ctx Formatting) // Renders table header
|
||||
Row(row []string, ctx Formatting) // Renders a single row
|
||||
Footer(footers [][]string, ctx Formatting) // Renders table footer
|
||||
Line(ctx Formatting) // Renders separator line
|
||||
Config() Rendition // Returns renderer config
|
||||
Close() error // Gets Rendition form Blueprint
|
||||
Logger(logger *ll.Logger) // send logger to renderers
|
||||
}
|
||||
|
||||
// Rendition holds the configuration for the default renderer.
|
||||
type Rendition struct {
|
||||
Borders Border // Border visibility settings
|
||||
Symbols Symbols // Symbols used for table drawing
|
||||
Settings Settings // Rendering behavior settings
|
||||
Streaming bool
|
||||
}
|
||||
|
||||
// Renditioning has a method to update its rendition.
|
||||
// Let's define an optional interface for this.
|
||||
type Renditioning interface {
|
||||
Rendition(r Rendition)
|
||||
}
|
||||
|
||||
// Formatting encapsulates the complete formatting context for a table row.
|
||||
// It provides all necessary information to render a row correctly within the table structure.
|
||||
type Formatting struct {
|
||||
Row RowContext // Detailed configuration for the row and its cells
|
||||
Level Level // Hierarchical level (Header, Body, Footer) affecting line drawing
|
||||
HasFooter bool // Indicates if the table includes a footer section
|
||||
IsSubRow bool // Marks this as a continuation or padding line in multi-line rows
|
||||
NormalizedWidths Mapper[int, int]
|
||||
}
|
||||
|
||||
// CellContext defines the properties and formatting state of an individual table cell.
|
||||
type CellContext struct {
|
||||
Data string // Content to be displayed in the cell, provided by the caller
|
||||
Align Align // Text alignment within the cell (Left, Right, Center, Skip)
|
||||
Padding Padding // Padding characters surrounding the cell content
|
||||
Width int // Suggested width (often overridden by Row.Widths)
|
||||
Merge MergeState // Details about cell spanning across rows or columns
|
||||
}
|
||||
|
||||
// MergeState captures how a cell merges across different directions.
|
||||
type MergeState struct {
|
||||
Vertical MergeStateOption // Properties for vertical merging (across rows)
|
||||
Horizontal MergeStateOption // Properties for horizontal merging (across columns)
|
||||
Hierarchical MergeStateOption // Properties for nested/hierarchical merging
|
||||
}
|
||||
|
||||
// MergeStateOption represents common attributes for merging in a specific direction.
|
||||
type MergeStateOption struct {
|
||||
Present bool // True if this merge direction is active
|
||||
Span int // Number of cells this merge spans
|
||||
Start bool // True if this cell is the starting point of the merge
|
||||
End bool // True if this cell is the ending point of the merge
|
||||
}
|
||||
|
||||
// RowContext manages layout properties and relationships for a row and its columns.
|
||||
// It maintains state about the current row and its neighbors for proper rendering.
|
||||
type RowContext struct {
|
||||
Position Position // Section of the table (Header, Row, Footer)
|
||||
Location Location // Boundary position (First, Middle, End)
|
||||
Current map[int]CellContext // Cells in this row, indexed by column
|
||||
Previous map[int]CellContext // Cells from the row above; nil if none
|
||||
Next map[int]CellContext // Cells from the row below; nil if none
|
||||
Widths Mapper[int, int] // Computed widths for each column
|
||||
ColMaxWidths CellWidth // Maximum allowed width per column
|
||||
}
|
||||
|
||||
func (r RowContext) GetCell(col int) CellContext {
|
||||
return r.Current[col]
|
||||
}
|
||||
|
||||
// Separators controls the visibility of separators in the table.
|
||||
type Separators struct {
|
||||
ShowHeader State // Controls header separator visibility
|
||||
ShowFooter State // Controls footer separator visibility
|
||||
BetweenRows State // Determines if lines appear between rows
|
||||
BetweenColumns State // Determines if separators appear between columns
|
||||
}
|
||||
|
||||
// Lines manages the visibility of table boundary lines.
|
||||
type Lines struct {
|
||||
ShowTop State // Top border visibility
|
||||
ShowBottom State // Bottom border visibility
|
||||
ShowHeaderLine State // Header separator line visibility
|
||||
ShowFooterLine State // Footer separator line visibility
|
||||
}
|
||||
|
||||
// Settings holds configuration preferences for rendering behavior.
|
||||
type Settings struct {
|
||||
Separators Separators // Separator visibility settings
|
||||
Lines Lines // Line visibility settings
|
||||
CompactMode State // Reserved for future compact rendering (unused)
|
||||
// Cushion State
|
||||
}
|
||||
|
||||
// Border defines the visibility states of table borders.
|
||||
type Border struct {
|
||||
Left State // Left border visibility
|
||||
Right State // Right border visibility
|
||||
Top State // Top border visibility
|
||||
Bottom State // Bottom border visibility
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
type StreamConfig struct {
|
||||
Enable bool
|
||||
|
||||
// StrictColumns, if true, causes Append() to return an error
|
||||
// in streaming mode if the number of cells in an appended row
|
||||
// does not match the established number of columns for the stream.
|
||||
// If false (default), rows with mismatched column counts will be
|
||||
// padded or truncated with a warning log.
|
||||
StrictColumns bool
|
||||
|
||||
// Deprecated: Use top-level Config.Widths for streaming width control.
|
||||
// This field will be removed in a future version. It will be respected if
|
||||
// Config.Widths is not set and this field is.
|
||||
Widths CellWidth
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package tw
|
||||
|
||||
import "slices"
|
||||
|
||||
// Slicer is a generic slice type that provides additional methods for slice manipulation.
|
||||
type Slicer[T any] []T
|
||||
|
||||
// NewSlicer creates and returns a new initialized Slicer.
|
||||
func NewSlicer[T any]() Slicer[T] {
|
||||
return make(Slicer[T], 0)
|
||||
}
|
||||
|
||||
// Get returns the element at the specified index.
|
||||
// Returns the zero value if the index is out of bounds or the slice is nil.
|
||||
func (s Slicer[T]) Get(index int) T {
|
||||
if s == nil || index < 0 || index >= len(s) {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
return s[index]
|
||||
}
|
||||
|
||||
// GetOK returns the element at the specified index and a boolean indicating whether the index was valid.
|
||||
func (s Slicer[T]) GetOK(index int) (T, bool) {
|
||||
if s == nil || index < 0 || index >= len(s) {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return s[index], true
|
||||
}
|
||||
|
||||
// Append appends elements to the slice and returns the new slice.
|
||||
func (s Slicer[T]) Append(elements ...T) Slicer[T] {
|
||||
return append(s, elements...)
|
||||
}
|
||||
|
||||
// Prepend adds elements to the beginning of the slice and returns the new slice.
|
||||
func (s Slicer[T]) Prepend(elements ...T) Slicer[T] {
|
||||
return append(elements, s...)
|
||||
}
|
||||
|
||||
// Len returns the number of elements in the slice.
|
||||
// Returns 0 if the slice is nil.
|
||||
func (s Slicer[T]) Len() int {
|
||||
if s == nil {
|
||||
return 0
|
||||
}
|
||||
return len(s)
|
||||
}
|
||||
|
||||
// IsEmpty returns true if the slice is nil or has zero elements.
|
||||
func (s Slicer[T]) IsEmpty() bool {
|
||||
return s.Len() == 0
|
||||
}
|
||||
|
||||
// Has returns true if the index exists in the slice.
|
||||
func (s Slicer[T]) Has(index int) bool {
|
||||
return index >= 0 && index < s.Len()
|
||||
}
|
||||
|
||||
// First returns the first element of the slice, or the zero value if empty.
|
||||
func (s Slicer[T]) First() T {
|
||||
return s.Get(0)
|
||||
}
|
||||
|
||||
// Last returns the last element of the slice, or the zero value if empty.
|
||||
func (s Slicer[T]) Last() T {
|
||||
return s.Get(s.Len() - 1)
|
||||
}
|
||||
|
||||
// Each iterates over each element in the slice and calls the provided function.
|
||||
// Does nothing if the slice is nil.
|
||||
func (s Slicer[T]) Each(fn func(T)) {
|
||||
for _, v := range s {
|
||||
fn(v)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter returns a new Slicer containing only elements that satisfy the predicate.
|
||||
func (s Slicer[T]) Filter(fn func(T) bool) Slicer[T] {
|
||||
result := NewSlicer[T]()
|
||||
for _, v := range s {
|
||||
if fn(v) {
|
||||
result = result.Append(v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Map returns a new Slicer with each element transformed by the provided function.
|
||||
func (s Slicer[T]) Map(fn func(T) T) Slicer[T] {
|
||||
result := NewSlicer[T]()
|
||||
for _, v := range s {
|
||||
result = result.Append(fn(v))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Contains returns true if the slice contains an element that satisfies the predicate.
|
||||
func (s Slicer[T]) Contains(fn func(T) bool) bool {
|
||||
return slices.ContainsFunc(s, fn)
|
||||
}
|
||||
|
||||
// Find returns the first element that satisfies the predicate, along with a boolean indicating if it was found.
|
||||
func (s Slicer[T]) Find(fn func(T) bool) (T, bool) {
|
||||
for _, v := range s {
|
||||
if fn(v) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
// Clone returns a shallow copy of the Slicer.
|
||||
func (s Slicer[T]) Clone() Slicer[T] {
|
||||
result := NewSlicer[T]()
|
||||
if s != nil {
|
||||
result = append(result, s...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// SlicerToMapper converts a Slicer of KeyValuePair to a Mapper.
|
||||
func SlicerToMapper[K comparable, V any](s Slicer[KeyValuePair[K, V]]) Mapper[K, V] {
|
||||
result := make(Mapper[K, V])
|
||||
if s == nil {
|
||||
return result
|
||||
}
|
||||
for _, pair := range s {
|
||||
result[pair.Key] = pair.Value
|
||||
}
|
||||
return result
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package tw
|
||||
|
||||
// State represents an on/off state
|
||||
type State int
|
||||
|
||||
// Public: Methods for State type
|
||||
|
||||
// Enabled checks if the state is on
|
||||
func (o State) Enabled() bool { return o == Success }
|
||||
|
||||
// Default checks if the state is unknown
|
||||
func (o State) Default() bool { return o == Unknown }
|
||||
|
||||
// Disabled checks if the state is off
|
||||
func (o State) Disabled() bool { return o == Fail }
|
||||
|
||||
// Toggle switches the state between on and off
|
||||
func (o State) Toggle() State {
|
||||
if o == Fail {
|
||||
return Success
|
||||
}
|
||||
return Fail
|
||||
}
|
||||
|
||||
// Cond executes a condition if the state is enabled
|
||||
func (o State) Cond(c func() bool) bool {
|
||||
if o.Enabled() {
|
||||
return c()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Or returns this state if enabled, else the provided state
|
||||
func (o State) Or(c State) State {
|
||||
if o.Enabled() {
|
||||
return o
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// String returns the string representation of the state
|
||||
func (o State) String() string {
|
||||
if o.Enabled() {
|
||||
return "on"
|
||||
}
|
||||
|
||||
if o.Disabled() {
|
||||
return "off"
|
||||
}
|
||||
return "undefined"
|
||||
}
|
||||
+1012
File diff suppressed because it is too large
Load Diff
+109
@@ -0,0 +1,109 @@
|
||||
package tw
|
||||
|
||||
// Operation Status Constants
|
||||
// Used to indicate the success or failure of operations
|
||||
const (
|
||||
Pending = 0 // Operation failed
|
||||
Fail = -1 // Operation failed
|
||||
Success = 1 // Operation succeeded
|
||||
|
||||
MinimumColumnWidth = 8
|
||||
|
||||
DefaultCacheStringCapacity = 10 * 1024 // 10 KB
|
||||
)
|
||||
|
||||
const (
|
||||
Empty = ""
|
||||
Skip = ""
|
||||
Space = " "
|
||||
NewLine = "\n"
|
||||
Column = ":"
|
||||
Dash = "-"
|
||||
)
|
||||
|
||||
// Feature State Constants
|
||||
// Represents enabled/disabled states for features
|
||||
const (
|
||||
Unknown State = Pending // Feature is enabled
|
||||
On State = Success // Feature is enabled
|
||||
Off State = Fail // Feature is disabled
|
||||
)
|
||||
|
||||
// Table Alignment Constants
|
||||
// Defines text alignment options for table content
|
||||
const (
|
||||
AlignNone Align = "none" // Center-aligned text
|
||||
AlignCenter Align = "center" // Center-aligned text
|
||||
AlignRight Align = "right" // Right-aligned text
|
||||
AlignLeft Align = "left" // Left-aligned text
|
||||
AlignDefault = AlignLeft // Left-aligned text
|
||||
)
|
||||
|
||||
const (
|
||||
Header Position = "header" // Table header section
|
||||
Row Position = "row" // Table row section
|
||||
Footer Position = "footer" // Table footer section
|
||||
)
|
||||
|
||||
const (
|
||||
LevelHeader Level = iota // Topmost line position
|
||||
LevelBody // LevelBody line position
|
||||
LevelFooter // LevelFooter line position
|
||||
)
|
||||
|
||||
const (
|
||||
LocationFirst Location = "first" // Topmost line position
|
||||
LocationMiddle Location = "middle" // LevelBody line position
|
||||
LocationEnd Location = "end" // LevelFooter line position
|
||||
)
|
||||
|
||||
const (
|
||||
SectionHeader = "header"
|
||||
SectionRow = "row"
|
||||
SectionFooter = "footer"
|
||||
)
|
||||
|
||||
// Text Wrapping Constants
|
||||
// Defines text wrapping behavior in table cells
|
||||
const (
|
||||
WrapNone = iota // No wrapping
|
||||
WrapNormal // Standard word wrapping
|
||||
WrapTruncate // Truncate text with ellipsis
|
||||
WrapBreak // Break words to fit
|
||||
)
|
||||
|
||||
// Cell Merge Constants
|
||||
// Specifies cell merging behavior in tables
|
||||
|
||||
const (
|
||||
MergeNone = iota // No merging
|
||||
MergeVertical // Merge cells vertically
|
||||
MergeHorizontal // Merge cells horizontally
|
||||
MergeBoth // Merge both vertically and horizontally
|
||||
MergeHierarchical // Hierarchical merging
|
||||
)
|
||||
|
||||
// Special Character Constants
|
||||
// Defines special characters used in formatting
|
||||
const (
|
||||
CharEllipsis = "…" // Ellipsis character for truncation
|
||||
CharBreak = "↩" // Break character for wrapping
|
||||
)
|
||||
|
||||
type Spot int
|
||||
|
||||
const (
|
||||
SpotNone Spot = iota
|
||||
SpotTopLeft
|
||||
SpotTopCenter
|
||||
SpotTopRight
|
||||
SpotBottomLeft
|
||||
SpotBottomCenter // Default for legacy SetCaption
|
||||
SpotBottomRight
|
||||
SpotLeftTop
|
||||
SpotLeftCenter
|
||||
SpotLeftBottom
|
||||
SpotRightTop
|
||||
SpotRightCenter
|
||||
SpotRIghtBottom
|
||||
)
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
// Package tw defines types and constants for table formatting and configuration,
|
||||
// including validation logic for various table properties.
|
||||
package tw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/olekukonko/errors"
|
||||
) // Custom error handling library
|
||||
|
||||
// Position defines where formatting applies in the table (e.g., header, footer, or rows).
|
||||
type Position string
|
||||
|
||||
// Validate checks if the Position is one of the allowed values: Header, Footer, or Row.
|
||||
func (pos Position) Validate() error {
|
||||
switch pos {
|
||||
case Header, Footer, Row:
|
||||
return nil // Valid position
|
||||
}
|
||||
// Return an error for any unrecognized position
|
||||
return errors.New("invalid position")
|
||||
}
|
||||
|
||||
// Filter defines a function type for processing cell content.
|
||||
// It takes a slice of strings (representing cell data) and returns a processed slice.
|
||||
type Filter func([]string) []string
|
||||
|
||||
// Formatter defines an interface for types that can format themselves into a string.
|
||||
// Used for custom formatting of table cell content.
|
||||
type Formatter interface {
|
||||
Format() string // Returns the formatted string representation
|
||||
}
|
||||
|
||||
// Counter defines an interface that combines io.Writer with a method to retrieve a total.
|
||||
// This is used by the WithCounter option to allow for counting lines, bytes, etc.
|
||||
type Counter interface {
|
||||
io.Writer // It must be a writer to be used in io.MultiWriter.
|
||||
Total() int
|
||||
}
|
||||
|
||||
// Align specifies the text alignment within a table cell.
|
||||
type Align string
|
||||
|
||||
// Validate checks if the Align is one of the allowed values: None, Center, Left, or Right.
|
||||
func (a Align) Validate() error {
|
||||
switch a {
|
||||
case AlignNone, AlignCenter, AlignLeft, AlignRight:
|
||||
return nil // Valid alignment
|
||||
}
|
||||
// Return an error for any unrecognized alignment
|
||||
return errors.New("invalid align")
|
||||
}
|
||||
|
||||
type Alignment []Align
|
||||
|
||||
func (a Alignment) String() string {
|
||||
var str strings.Builder
|
||||
for i, a := range a {
|
||||
if i > 0 {
|
||||
str.WriteString("; ")
|
||||
}
|
||||
str.WriteString(strconv.Itoa(i))
|
||||
str.WriteString("=")
|
||||
str.WriteString(string(a))
|
||||
}
|
||||
return str.String()
|
||||
}
|
||||
|
||||
func (a Alignment) Add(aligns ...Align) Alignment {
|
||||
aa := make(Alignment, len(aligns))
|
||||
copy(aa, aligns)
|
||||
return aa
|
||||
}
|
||||
|
||||
func (a Alignment) Set(col int, align Align) Alignment {
|
||||
if col >= 0 && col < len(a) {
|
||||
a[col] = align
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// Copy creates a new independent copy of the Alignment
|
||||
func (a Alignment) Copy() Alignment {
|
||||
aa := make(Alignment, len(a))
|
||||
copy(aa, a)
|
||||
return aa
|
||||
}
|
||||
|
||||
// Level indicates the vertical position of a line in the table (e.g., header, body, or footer).
|
||||
type Level int
|
||||
|
||||
// Validate checks if the Level is one of the allowed values: Header, Body, or Footer.
|
||||
func (l Level) Validate() error {
|
||||
switch l {
|
||||
case LevelHeader, LevelBody, LevelFooter:
|
||||
return nil // Valid level
|
||||
}
|
||||
// Return an error for any unrecognized level
|
||||
return errors.New("invalid level")
|
||||
}
|
||||
|
||||
// Location specifies the horizontal position of a cell or column within a table row.
|
||||
type Location string
|
||||
|
||||
// Validate checks if the Location is one of the allowed values: First, Middle, or End.
|
||||
func (l Location) Validate() error {
|
||||
switch l {
|
||||
case LocationFirst, LocationMiddle, LocationEnd:
|
||||
return nil // Valid location
|
||||
}
|
||||
// Return an error for any unrecognized location
|
||||
return errors.New("invalid location")
|
||||
}
|
||||
|
||||
type Caption struct {
|
||||
Text string
|
||||
Spot Spot
|
||||
Align Align
|
||||
Width int
|
||||
}
|
||||
|
||||
func (c Caption) WithText(text string) Caption {
|
||||
c.Text = text
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Caption) WithSpot(spot Spot) Caption {
|
||||
c.Spot = spot
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Caption) WithAlign(align Align) Caption {
|
||||
c.Align = align
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Caption) WithWidth(width int) Caption {
|
||||
c.Width = width
|
||||
return c
|
||||
}
|
||||
|
||||
type Control struct {
|
||||
Hide State
|
||||
}
|
||||
|
||||
// Compact configures compact width optimization for merged cells.
|
||||
type Compact struct {
|
||||
Merge State // Merge enables compact width calculation during cell merging, optimizing space allocation.
|
||||
}
|
||||
|
||||
// Struct holds settings for struct-based operations like AutoHeader.
|
||||
type Struct struct {
|
||||
// AutoHeader automatically extracts and sets headers from struct fields when Bulk is called with a slice of structs.
|
||||
// Uses JSON tags if present, falls back to field names (title-cased). Skips unexported or json:"-" fields.
|
||||
// Enabled by default for convenience.
|
||||
AutoHeader State
|
||||
|
||||
// Tags is a priority-ordered list of struct tag keys to check for header names.
|
||||
// The first tag found on a field will be used. Defaults to ["json", "db"].
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// Behavior defines settings that control table rendering behaviors, such as column visibility and content formatting.
|
||||
type Behavior struct {
|
||||
AutoHide State // AutoHide determines whether empty columns are hidden. Ignored in streaming mode.
|
||||
TrimSpace State // TrimSpace determines trimming of leading and trailing spaces from cell content.
|
||||
TrimLine State // TrimLine determines whether empty visual lines within a cell are collapsed.
|
||||
TrimTab State // TrimTab determines trimming of leading and trailing tabs from cell content.
|
||||
|
||||
Header Control // Header specifies control settings for the table header.
|
||||
Footer Control // Footer specifies control settings for the table footer.
|
||||
|
||||
// Compact enables optimized width calculation for merged cells, such as in horizontal merges,
|
||||
// by systematically determining the most efficient width instead of scaling by the number of columns.
|
||||
Compact Compact
|
||||
|
||||
// Structs contains settings for how struct data is processed.
|
||||
Structs Struct
|
||||
}
|
||||
|
||||
// Padding defines the spacing characters around cell content in all four directions.
|
||||
// A zero-value Padding struct will use the table's default padding unless Overwrite is true.
|
||||
type Padding struct {
|
||||
Left string
|
||||
Right string
|
||||
Top string
|
||||
Bottom string
|
||||
|
||||
// Overwrite forces tablewriter to use this padding configuration exactly as specified,
|
||||
// even when empty. When false (default), empty Padding fields will inherit defaults.
|
||||
//
|
||||
// For explicit no-padding, use the PaddingNone constant instead of setting Overwrite.
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
// Common padding configurations for convenience
|
||||
|
||||
// Equals reports whether two Padding configurations are identical in all fields.
|
||||
// This includes comparing the Overwrite flag as part of the equality check.
|
||||
func (p Padding) Equals(padding Padding) bool {
|
||||
return p.Left == padding.Left &&
|
||||
p.Right == padding.Right &&
|
||||
p.Top == padding.Top &&
|
||||
p.Bottom == padding.Bottom &&
|
||||
p.Overwrite == padding.Overwrite
|
||||
}
|
||||
|
||||
// Empty reports whether all padding strings are empty (all fields == "").
|
||||
// Note that an Empty padding may still take effect if Overwrite is true.
|
||||
func (p Padding) Empty() bool {
|
||||
return p.Left == "" && p.Right == "" && p.Top == "" && p.Bottom == ""
|
||||
}
|
||||
|
||||
// Paddable reports whether this Padding configuration should override existing padding.
|
||||
// Returns true if either:
|
||||
// - Any padding string is non-empty (!p.Empty())
|
||||
// - Overwrite flag is true (even with all strings empty)
|
||||
//
|
||||
// This is used internally during configuration merging to determine whether to
|
||||
// apply the padding settings.
|
||||
func (p Padding) Paddable() bool {
|
||||
return !p.Empty() || p.Overwrite
|
||||
}
|
||||
|
||||
// LineCounter is the default implementation of the Counter interface.
|
||||
// It counts the number of newline characters written to it.
|
||||
type LineCounter struct {
|
||||
count int
|
||||
}
|
||||
|
||||
// Write implements the io.Writer interface, counting newlines in the input.
|
||||
// It uses a pointer receiver to modify the internal count.
|
||||
func (lc *LineCounter) Write(p []byte) (n int, err error) {
|
||||
lc.count += bytes.Count(p, []byte{'\n'})
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Total implements the Counter interface, returning the final count.
|
||||
func (lc *LineCounter) Total() int {
|
||||
return lc.count
|
||||
}
|
||||
+1736
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user