Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,11 @@
# folders
.idea
.vscode
/tmp
/lab
dev.sh
*csv2table
_test/
*.test
+55
View File
@@ -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
View File
@@ -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.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+466
View File
@@ -0,0 +1,466 @@
ASCII Table Writer
=========
[![ci](https://github.com/olekukonko/tablewriter/workflows/ci/badge.svg?branch=master)](https://github.com/olekukonko/tablewriter/actions?query=workflow%3Aci)
[![Total views](https://img.shields.io/sourcegraph/rrc/github.com/olekukonko/tablewriter.svg)](https://sourcegraph.com/github.com/olekukonko/tablewriter)
[![Godoc](https://godoc.org/github.com/olekukonko/tablewriter?status.svg)](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
![Table with Color](https://cloud.githubusercontent.com/assets/6460392/21101956/bbc7b356-c0a1-11e6-9f36-dba694746efc.png)
#### 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
![Table cells with Color](https://user-images.githubusercontent.com/9064687/63969376-bcd88d80-ca6f-11e9-9466-c3d954700b25.png)
#### 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
View File
@@ -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
File diff suppressed because it is too large Load Diff
+94
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 (132).
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
File diff suppressed because it is too large Load Diff
+109
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff