├── .github
└── images
│ ├── flex-box-simple.gif
│ ├── flex-box-with-table.gif
│ └── table-multi-type.gif
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── README.md
├── example
├── flex-box-horizonal
│ └── main.go
├── flex-box-simple
│ └── main.go
├── flex-box-with-table
│ └── main.go
├── sample.csv
├── table-multi-type
│ └── main.go
└── table-simple-string
│ └── main.go
├── flexbox
├── cell.go
├── column.go
├── flexbox.go
├── horizontal_flexbox.go
├── row.go
└── utils.go
├── go.mod
├── go.sum
└── table
├── table.go
└── tableSingleType.go
/.github/images/flex-box-simple.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/76creates/stickers/a9371c058155e4cf21648fa1cb755082f843d96c/.github/images/flex-box-simple.gif
--------------------------------------------------------------------------------
/.github/images/flex-box-with-table.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/76creates/stickers/a9371c058155e4cf21648fa1cb755082f843d96c/.github/images/flex-box-with-table.gif
--------------------------------------------------------------------------------
/.github/images/table-multi-type.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/76creates/stickers/a9371c058155e4cf21648fa1cb755082f843d96c/.github/images/table-multi-type.gif
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | vendor/
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 | ## [v1.4.2](https://github.com/76creates/stickers/compare/v1.4.1...v1.4.2) (2024-11-26)
5 | ### Fixes
6 | - Fix typo in flexbox/Cell.SetMinHeight
7 | ### Deprecations
8 | - `Cell.SetMinHeigth` is now deprecated in favour of SetMinHeight.
9 |
10 | ## [v1.4.1](https://github.com/76creates/stickers/compare/v1.4.0...v1.4.1) (2024-10-21)
11 | ### Features
12 | - Added `OrderByAsc` and `OrderByDesc` methods to `Table` #12 @drmille2
13 | ### Deprecations
14 | - `OrderByColumn` is deprecated by `OrderByAsc` and `OrderByDesc` methods. #12 @drmille2
15 | ### Updates
16 | - Updated Go to version `1.23`
17 | ### Dependencies
18 | - Updated `github.com/charmbracelet/lipgloss` to `v0.13.0`
19 | - Updated `github.com/charmbracelet/bubbletea` to `v1.1.1`
20 | - Updated `github.com/gocarina/gocsv` to `78e41c74b4b1`
21 |
22 | ## [v1.4.0](https://github.com/76creates/stickers/compare/v1.3.0...v1.4.0) (2024-09-11)
23 | ### ⚠ BREAKING CHANGES
24 | - Moved `flexbox` and `table` into separate packages, `github.com/76creates/stickers/flexbox` and `github.com/76creates/stickers/table` respectively. #10 @jon4hz
25 | ### Fixes
26 | - Minor lexical fixes
27 | - Fixed repo tags to match go semver format.
28 | ### Dependencies
29 | - Updated `github.com/charmbracelet/lipgloss` to `v0.6.0'
30 | ### Features
31 | - Added `SetStylePassing` to _Table_ that will pass down the style all the way, from box to cell. No granularity for now.
32 | - Added `HorizontalFlexBox`. #10 @jon4hz
33 | ### Updates
34 | - Refactored `FlexBox.GetRow`, `FlexBox.Row`, `FlexBox.MustGetRow`, `FlexBoxRow.Cell`, `FlexBoxRow.GetCellWithID`, `FlexBoxRow.MustGetCellWithIndex`.
They are replaced with `FlexBoxRow.GetCell`, `FlexBoxRow.GetCellCopy`, `FlexBox.GetRow`, `FlexBox.GetRowCopy`,`FlexBox.GetRowCellCopy`.
Get* now returns pointer and triggers _recalculation_, while one can use Copy* function to get pointer to copied structs which can be used to lookup values without triggering _recalculation_.
35 | - `AddCells` now take cells as a variadic argument. #10 @jon4hz
36 |
37 | ## [v1.3.0](https://github.com/76creates/stickers/compare/v1.2.0...v1.3.0) (2022-12-28)
38 | ### Fixes
39 | * Fixed cursor not moving to the last visible row when filtering
40 | * Fixed margins and borders not being rendered correctly #8
41 | * Additionally, fixed margin and border issues on box
42 | * Allow style override on _Table_ @joejag
43 | ### Features
44 | * Converted default cell inheritance of the row style to function `StylePassing` which can be set on the _Box_ and _Row_, if both box and row have style passing enabled, the row will inherit the box style before it passes style to the cells.
45 |
46 | ## [v1.2.0](https://github.com/76creates/stickers/compare/v1.1.0...v1.2.0) (2022-02-27)
47 | ### Features
48 | * Filtering is now available for `Table` and `TableSingleType` using new methods:
49 | * `UnsetFilter` remove filtering
50 | * `SetFilter` sets the filter on a column by index
51 | * `GetFilter` gets index of filtered column and the value of the filter
52 | * Added `MustGetCellWithIndex`
53 | * Fixed visible table calculations when filtering
54 | * Added filter info to the status box
55 | * Header rendering of sorting and filtering symbols is improved
56 |
57 | ## [v1.1.0](https://github.com/76creates/stickers/compare/v1.0.0...v1.1.0) (2022-02-26)
58 | ### ⚠ BREAKING CHANGES
59 | * Refactored `Table` to support sorting, some methods have changed most notably revolving around adding rows since now its taking [][]any instead of [][]string, initial `Table` is now closer to `TableSingleType[string]`
60 | * Stickers now uses generics, so go1.18 is mandatory
61 |
62 | ### Fixes
63 | * Fixed recalculation triggering when *FlexBoxCell or *FlexBoxRow is fetched from the FlexBox
64 | * Small lexical changes and tidying up
65 |
66 | ### Features
67 | * Sorting is now available for `Table` and `TableSingleType`
68 | * `Table` has been reformatted and now supports **sorting by type**, when `Table` is initialized each colum type is set to `string`, you can now update that using `SetType` method, types supported are located in interface `Ordered`
69 | * Added `TableSingleType` type which locks row type to `string`, this makes it easier for user when adding rows as there is much fewer errors that can occur as when using `Table` where all depends on a type
70 | * Added method `OrderByColumn` which invokes sorting for column `n`, for now you cannot explicitly set sorting direction and it's switching between `asc` and `desc` when you sort same column
71 | * Added method `GetCursorLocation` which returns `x`,`y` of the current cursor location on the table
72 | * Added error types `TableBadTypeError`, `TableRowLenError`, `TableBadCellTypeError`
73 | * Minor performance enhancements
74 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Dusan Gligoric
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Stickers 👾
2 | Is a collection of TUI elements, FlexBox and Table at the moment, its build for [bubbletea](https://github.com/charmbracelet/bubbletea) using [lipgloss](https://github.com/charmbracelet/lipgloss).
3 |
4 | 
5 |
6 | ## Flex Box 📦
7 | Responsive grid box insipred by CSS flexbox.
8 | Easy to create responsive grids that scale using ratios between these elements.
9 | 
10 |
11 | ## Table 🍰
12 | Responsive, x/y scrollable, sortable table using FlexBox.
13 | Tabel viewer with ability to get the content of the cell over which the cursor is placed at and sort the data by column. Sorting supports basic number and string type so number sorting is possible 🎉
14 | 
15 | ##### TODO
16 | - filtering ✅
17 | - sorting ✅
18 |
19 | ## Known issues
20 | ~~Table selected cell is not rendering background color correctly, will be fixed with https://github.com/charmbracelet/lipgloss/pull/69~~
21 |
22 | ***
23 | Made with [Charm](https://charm.sh).
24 |
25 |
26 |
--------------------------------------------------------------------------------
/example/flex-box-horizonal/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/76creates/stickers/flexbox"
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/lipgloss"
10 | )
11 |
12 | var (
13 | style1 = lipgloss.NewStyle().Background(lipgloss.Color("#fc5c65"))
14 | style2 = lipgloss.NewStyle().Background(lipgloss.Color("#fd9644"))
15 | style3 = lipgloss.NewStyle().Background(lipgloss.Color("#fed330"))
16 | style4 = lipgloss.NewStyle().Background(lipgloss.Color("#26de81"))
17 | style5 = lipgloss.NewStyle().Background(lipgloss.Color("#2bcbba"))
18 | style6 = lipgloss.NewStyle().Background(lipgloss.Color("#eb3b5a"))
19 | style7 = lipgloss.NewStyle().Background(lipgloss.Color("#fa8231"))
20 | style8 = lipgloss.NewStyle().Background(lipgloss.Color("#f7b731"))
21 | style9 = lipgloss.NewStyle().Background(lipgloss.Color("#20bf6b"))
22 | style10 = lipgloss.NewStyle().Background(lipgloss.Color("#0fb9b1"))
23 | style11 = lipgloss.NewStyle().Background(lipgloss.Color("#45aaf2"))
24 | style12 = lipgloss.NewStyle().Background(lipgloss.Color("#4b7bec"))
25 | style13 = lipgloss.NewStyle().Background(lipgloss.Color("#a55eea"))
26 | style14 = lipgloss.NewStyle().Background(lipgloss.Color("#d1d8e0"))
27 | style15 = lipgloss.NewStyle().Background(lipgloss.Color("#778ca3"))
28 | style16 = lipgloss.NewStyle().Background(lipgloss.Color("#2d98da"))
29 | style17 = lipgloss.NewStyle().Background(lipgloss.Color("#3867d6"))
30 | style18 = lipgloss.NewStyle().Background(lipgloss.Color("#8854d0"))
31 | style19 = lipgloss.NewStyle().Background(lipgloss.Color("#a5b1c2"))
32 | style20 = lipgloss.NewStyle().Background(lipgloss.Color("#4b6584"))
33 | )
34 |
35 | type model struct {
36 | flexBox *flexbox.HorizontalFlexBox
37 | }
38 |
39 | func main() {
40 | m := model{
41 | flexBox: flexbox.NewHorizontal(0, 0),
42 | }
43 |
44 | columns := []*flexbox.Column{
45 | m.flexBox.NewColumn().AddCells(
46 | flexbox.NewCell(1, 1).SetStyle(style1),
47 | flexbox.NewCell(1, 1).SetStyle(style2),
48 | ),
49 | m.flexBox.NewColumn().AddCells(
50 | flexbox.NewCell(2, 1).SetStyle(style3),
51 | ),
52 | m.flexBox.NewColumn().AddCells(
53 | flexbox.NewCell(1, 1).SetStyle(style4),
54 | flexbox.NewCell(1, 2).SetStyle(style5),
55 | flexbox.NewCell(1, 1).SetStyle(style6),
56 | ),
57 | }
58 |
59 | m.flexBox.AddColumns(columns)
60 |
61 | p := tea.NewProgram(&m, tea.WithAltScreen())
62 | if err := p.Start(); err != nil {
63 | fmt.Printf("Alas, there's been an error: %v", err)
64 | os.Exit(1)
65 | }
66 | }
67 |
68 | func (m *model) Init() tea.Cmd { return nil }
69 |
70 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
71 | switch msg := msg.(type) {
72 | case tea.WindowSizeMsg:
73 | m.flexBox.SetWidth(msg.Width)
74 | m.flexBox.SetHeight(msg.Height)
75 | case tea.KeyMsg:
76 | switch msg.String() {
77 | case "ctrl+c", "q":
78 | return m, tea.Quit
79 | }
80 |
81 | }
82 | return m, nil
83 | }
84 | func (m *model) View() string {
85 | return m.flexBox.Render()
86 | }
87 |
--------------------------------------------------------------------------------
/example/flex-box-simple/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/76creates/stickers/flexbox"
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/charmbracelet/lipgloss"
10 | )
11 |
12 | var (
13 | style1 = lipgloss.NewStyle().Background(lipgloss.Color("#fc5c65"))
14 | style2 = lipgloss.NewStyle().Background(lipgloss.Color("#fd9644"))
15 | style3 = lipgloss.NewStyle().Background(lipgloss.Color("#fed330"))
16 | style4 = lipgloss.NewStyle().Background(lipgloss.Color("#26de81"))
17 | style5 = lipgloss.NewStyle().Background(lipgloss.Color("#2bcbba"))
18 | style6 = lipgloss.NewStyle().Background(lipgloss.Color("#eb3b5a"))
19 | style7 = lipgloss.NewStyle().Background(lipgloss.Color("#fa8231"))
20 | style8 = lipgloss.NewStyle().Background(lipgloss.Color("#f7b731"))
21 | style9 = lipgloss.NewStyle().Background(lipgloss.Color("#20bf6b"))
22 | style10 = lipgloss.NewStyle().Background(lipgloss.Color("#0fb9b1"))
23 | style11 = lipgloss.NewStyle().Background(lipgloss.Color("#45aaf2"))
24 | style12 = lipgloss.NewStyle().Background(lipgloss.Color("#4b7bec"))
25 | style13 = lipgloss.NewStyle().Background(lipgloss.Color("#a55eea"))
26 | style14 = lipgloss.NewStyle().Background(lipgloss.Color("#d1d8e0"))
27 | style15 = lipgloss.NewStyle().Background(lipgloss.Color("#778ca3"))
28 | style16 = lipgloss.NewStyle().Background(lipgloss.Color("#2d98da"))
29 | style17 = lipgloss.NewStyle().Background(lipgloss.Color("#3867d6"))
30 | style18 = lipgloss.NewStyle().Background(lipgloss.Color("#8854d0"))
31 | style19 = lipgloss.NewStyle().Background(lipgloss.Color("#a5b1c2"))
32 | style20 = lipgloss.NewStyle().Background(lipgloss.Color("#4b6584"))
33 | )
34 |
35 | type model struct {
36 | flexBox *flexbox.FlexBox
37 | }
38 |
39 | func main() {
40 | m := model{
41 | flexBox: flexbox.New(0, 0),
42 | }
43 |
44 | rows := []*flexbox.Row{
45 | m.flexBox.NewRow().AddCells(
46 | flexbox.NewCell(1, 6).SetStyle(style1),
47 | flexbox.NewCell(1, 6).SetStyle(style2),
48 | flexbox.NewCell(1, 6).SetStyle(style3),
49 | ),
50 | m.flexBox.NewRow().AddCells(
51 | flexbox.NewCell(2, 4).SetStyle(style4),
52 | flexbox.NewCell(2, 4).SetStyle(style5),
53 | flexbox.NewCell(3, 4).SetStyle(style6),
54 | flexbox.NewCell(3, 4).SetStyle(style7),
55 | flexbox.NewCell(3, 4).SetStyle(style8),
56 | flexbox.NewCell(4, 4).SetStyle(style9),
57 | flexbox.NewCell(4, 4).SetStyle(style10),
58 | ),
59 | m.flexBox.NewRow().AddCells(
60 | flexbox.NewCell(2, 5).SetStyle(style11),
61 | flexbox.NewCell(3, 5).SetStyle(style12),
62 | flexbox.NewCell(10, 5).SetStyle(style13),
63 | flexbox.NewCell(3, 5).SetStyle(style14),
64 | flexbox.NewCell(2, 5).SetStyle(style15),
65 | ),
66 | m.flexBox.NewRow().AddCells(
67 | flexbox.NewCell(1, 4).SetStyle(style16),
68 | flexbox.NewCell(1, 3).SetStyle(style17),
69 | flexbox.NewCell(1, 2).SetStyle(style18),
70 | flexbox.NewCell(1, 3).SetStyle(style19),
71 | flexbox.NewCell(1, 4).SetStyle(style20),
72 | ),
73 | }
74 |
75 | m.flexBox.AddRows(rows)
76 |
77 | p := tea.NewProgram(&m, tea.WithAltScreen())
78 | if err := p.Start(); err != nil {
79 | fmt.Printf("Alas, there's been an error: %v", err)
80 | os.Exit(1)
81 | }
82 | }
83 |
84 | func (m *model) Init() tea.Cmd { return nil }
85 |
86 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87 | switch msg := msg.(type) {
88 | case tea.WindowSizeMsg:
89 | m.flexBox.SetWidth(msg.Width)
90 | m.flexBox.SetHeight(msg.Height)
91 | case tea.KeyMsg:
92 | switch msg.String() {
93 | case "ctrl+c", "q":
94 | return m, tea.Quit
95 | }
96 |
97 | }
98 | return m, nil
99 | }
100 | func (m *model) View() string {
101 | return m.flexBox.Render()
102 | }
103 |
--------------------------------------------------------------------------------
/example/flex-box-with-table/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/csv"
5 | "fmt"
6 | "math"
7 | "math/rand"
8 | "os"
9 | "strings"
10 | "unicode"
11 |
12 | "github.com/76creates/stickers/flexbox"
13 | "github.com/76creates/stickers/table"
14 | tea "github.com/charmbracelet/bubbletea"
15 | "github.com/charmbracelet/lipgloss"
16 | )
17 |
18 | var (
19 | styleRow = lipgloss.NewStyle().Align(lipgloss.Center).Foreground(lipgloss.Color("#000000")).Bold(true)
20 | styleBlank = lipgloss.NewStyle()
21 | styleBackground = lipgloss.NewStyle().Align(lipgloss.Center).Background(lipgloss.Color("#ffffff"))
22 | style1 = lipgloss.NewStyle().Align(lipgloss.Center).Background(lipgloss.Color("#f368e0"))
23 | style2 = lipgloss.NewStyle().Align(lipgloss.Center).Background(lipgloss.Color("#ff9f43"))
24 | style3 = lipgloss.NewStyle().Align(lipgloss.Center).Background(lipgloss.Color("#ee5253"))
25 | style4 = lipgloss.NewStyle().Align(lipgloss.Center).Background(lipgloss.Color("#0abde3"))
26 | style5 = lipgloss.NewStyle().Align(lipgloss.Center).Background(lipgloss.Color("#10ac84"))
27 | style6 = lipgloss.NewStyle().Align(lipgloss.Center).Background(lipgloss.Color("#222f3e"))
28 |
29 | tableRowIndex = 1
30 | tableCellIndex = 1
31 | )
32 |
33 | type model struct {
34 | flexBox *flexbox.FlexBox
35 | table *table.TableSingleType[string]
36 | headers []string
37 | }
38 |
39 | func main() {
40 | // read in CSV data
41 | f, err := os.Open("../sample.csv")
42 | if err != nil {
43 | panic(err)
44 | }
45 | defer f.Close()
46 |
47 | csvReader := csv.NewReader(f)
48 | data, err := csvReader.ReadAll()
49 | if err != nil {
50 | panic(err)
51 | }
52 |
53 | headers := data[0]
54 | rows := data[1:]
55 | ratio := []int{1, 10, 10, 5, 10}
56 | minSize := []int{4, 5, 5, 2, 5}
57 |
58 | m := model{
59 | flexBox: flexbox.New(0, 0).SetStyle(styleBackground),
60 | table: table.NewTableSingleType[string](0, 0, headers),
61 | headers: headers,
62 | }
63 |
64 | m.table.SetRatio(ratio).SetMinWidth(minSize)
65 | m.table.AddRows(rows).SetStylePassing(true)
66 |
67 | r1 := m.flexBox.NewRow().AddCells(
68 | flexbox.NewCell(5, 5).SetStyle(style2),
69 | flexbox.NewCell(2, 5).SetStyle(style3),
70 | flexbox.NewCell(5, 5).SetStyle(style5),
71 | ).SetStyle(styleRow)
72 | r2 := m.flexBox.NewRow().AddCells(
73 | flexbox.NewCell(1, 5).SetStyle(style6),
74 | flexbox.NewCell(10, 5).SetStyle(styleBlank),
75 | flexbox.NewCell(1, 5).SetStyle(style6),
76 | ).SetStyle(styleRow)
77 | r3 := m.flexBox.NewRow().AddCells(
78 | flexbox.NewCell(1, 5).SetStyle(style5),
79 | flexbox.NewCell(1, 4).SetStyle(style4),
80 | flexbox.NewCell(1, 3).SetStyle(style3),
81 | flexbox.NewCell(1, 4).SetStyle(style2),
82 | flexbox.NewCell(1, 5).SetStyle(style1),
83 | ).SetStyle(styleRow)
84 |
85 | _rows := []*flexbox.Row{r1, r2, r3}
86 | m.flexBox.AddRows(_rows)
87 |
88 | p := tea.NewProgram(&m, tea.WithAltScreen())
89 | if err := p.Start(); err != nil {
90 | fmt.Printf("Alas, there's been an error: %v", err)
91 | os.Exit(1)
92 | }
93 | }
94 |
95 | func (m *model) Init() tea.Cmd { return nil }
96 |
97 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
98 | switch msg := msg.(type) {
99 | case tea.WindowSizeMsg:
100 | windowHeight := msg.Height
101 | windowWidth := msg.Width
102 | m.flexBox.SetWidth(windowWidth)
103 | m.flexBox.SetHeight(windowHeight)
104 | m.table.SetWidth(windowWidth)
105 | m.table.SetHeight(windowHeight)
106 | case tea.KeyMsg:
107 | switch msg.String() {
108 | case "ctrl+c":
109 | return m, tea.Quit
110 | case "down":
111 | m.table.CursorDown()
112 | case "up":
113 | m.table.CursorUp()
114 | case "left":
115 | m.table.CursorLeft()
116 | case "right":
117 | m.table.CursorRight()
118 | case "ctrl+s":
119 | x, _ := m.table.GetCursorLocation()
120 | m.table.OrderByColumn(x)
121 | case "enter", " ":
122 | cellString := m.table.GetCursorValue()
123 | // add content to random boxes on flex box
124 | for ir := 0; ir < m.flexBox.RowsLen(); ir++ {
125 | // don't' want it on the middle row
126 | if ir == 1 {
127 | continue
128 | }
129 | // not handling error for example script
130 | for ic := 0; ic < m.flexBox.GetRow(ir).CellsLen(); ic++ {
131 | // adding a bit of randomness for fun
132 | if rand.Int()%2 == 0 {
133 | h := int(math.Floor(float64(m.flexBox.GetRowCellCopy(ir, ic).GetHeight()) / 2.0))
134 | m.flexBox.GetRow(ir).GetCell(ic).SetContent(strings.Repeat("\n", h) + cellString)
135 | } else {
136 | m.flexBox.GetRow(ir).GetCell(ic).SetContent("")
137 | }
138 | }
139 | }
140 | case "backspace":
141 | m.filterWithStr(msg.String())
142 | default:
143 | if len(msg.String()) == 1 {
144 | r := msg.Runes[0]
145 | if unicode.IsLetter(r) || unicode.IsDigit(r) {
146 | m.filterWithStr(msg.String())
147 | }
148 | }
149 | }
150 |
151 | }
152 | return m, nil
153 | }
154 |
155 | func (m *model) filterWithStr(key string) {
156 | i, s := m.table.GetFilter()
157 | x, _ := m.table.GetCursorLocation()
158 | if x != i && key != "backspace" {
159 | m.table.SetFilter(x, key)
160 | return
161 | }
162 | if key == "backspace" {
163 | if len(s) == 1 {
164 | m.table.UnsetFilter()
165 | return
166 | } else if len(s) > 1 {
167 | s = s[0 : len(s)-1]
168 | } else {
169 | return
170 | }
171 | } else {
172 | s = s + key
173 | }
174 | m.table.SetFilter(i, s)
175 | }
176 |
177 | func (m *model) View() string {
178 | m.flexBox.ForceRecalculate()
179 | _r := m.flexBox.GetRow(tableRowIndex)
180 | if _r == nil {
181 | panic("could not find the table row")
182 | }
183 | _c := _r.GetCell(tableCellIndex)
184 | if _c == nil {
185 | panic("could not find the table cell")
186 | }
187 | m.table.SetWidth(_c.GetWidth())
188 | m.table.SetHeight(_c.GetHeight())
189 | _c.SetContent(m.table.Render())
190 |
191 | return m.flexBox.Render()
192 | }
193 |
--------------------------------------------------------------------------------
/example/sample.csv:
--------------------------------------------------------------------------------
1 | id,First Name,Last Name,Age,Occupation
2 | 1,Madaline,Watson,26,Physicist
3 | 2,Eddy,Montgomery,20,Hairdresser
4 | 3,April,Grant,22,Mechanic
5 | 4,Amy,Watson,23,Insurer
6 | 5,Valeria,Higgins,21,Economist
7 | 6,Eddy,Harrison,19,Photographer
8 | 7,Sofia,Barrett,30,Lecturer
9 | 8,Maria,Owens,22,Dancer
10 | 9,Stuart,Lloyd,25,Interpreter
11 | 10,Chloe,Perry,25,Scientist
12 | 11,Lilianna,Ross,19,Architect
13 | 12,Bruce,Thompson,25,Archeologist
14 | 13,Sophia,Johnston,21,Biochemist
15 | 14,Mary,Kelly,23,Carpenter
16 | 15,Sarah,Alexander,28,Dancer
17 | 16,Grace,Clark,19,Florist
18 | 17,Dale,Johnson,25,Medic
19 | 18,Jacob,Johnson,22,Scientist
20 | 19,Sophia,Murray,30,Engineer
21 | 20,Fenton,Edwards,27,Fine Artist
22 | 21,Haris,Gibson,27,Chemist
23 | 22,Madaline,Armstrong,18,Hairdresser
24 | 23,Alan,Carroll,26,Firefighter
25 | 24,Annabella,Carroll,25,Mechanic
26 | 25,Lilianna,Allen,19,Agronomist
27 | 26,Michael,Ross,21,Manager
28 | 27,Tiana,Riley,21,Hairdresser
29 | 28,Henry,Brooks,28,Veterinarian
30 | 29,Lilianna,Richardson,30,Electrician
31 | 30,Dominik,Cameron,24,Archeologist
32 | 31,Carlos,Parker,19,Lecturer
33 | 32,Mike,Clark,25,Agronomist
34 | 33,Michelle,Mitchell,26,Archeologist
35 | 34,Rafael,Ross,23,Fashion Designer
36 | 35,Charlotte,Mitchell,18,Historian
37 | 36,Roland,Kelley,30,Interior Designer
38 | 37,Patrick,Morgan,24,Police Officer
39 | 38,James,Miller,30,Composer
40 | 39,Jessica,Brown,21,Salesman
41 | 40,Camila,Wright,19,Florist
42 | 41,Sarah,Brown,25,Florist
43 | 42,Carl,Bailey,22,Programmer
44 | 43,Elise,Hunt,26,Botanist
45 | 44,Michael,Richards,28,Engineer
46 | 45,Kelvin,Walker,28,Meteorologist
47 | 46,Brad,Mitchell,24,Photographer
48 | 47,Alberta,Hunt,29,Photographer
49 | 48,Amber,Carter,22,Lecturer
50 | 49,Luke,Grant,28,Chemist
51 | 50,Ada,Baker,29,Scientist
52 | 51,Sarah,Morrison,19,Scientist
53 | 52,Valeria,Walker,25,Dancer
54 | 53,Hailey,Owens,29,Insurer
55 | 54,Vincent,Taylor,27,Historian
56 | 55,Valeria,Morgan,20,Lawer
57 | 56,Maddie,Douglas,18,Producer
58 | 57,Eleanor,Andrews,18,Jeweller
59 | 58,Edward,Richards,24,Producer
60 | 59,Sam,Ellis,28,Mathematician
61 | 60,Edith,Gray,30,Jeweller
62 | 61,Alen,Gibson,30,Jeweller
63 | 62,Richard,Craig,30,Programmer
64 | 63,Jack,Mason,25,Insurer
65 | 64,Alisa,Alexander,26,Insurer
66 | 65,Abraham,Grant,23,Mathematician
67 | 66,Carina,Brown,26,Photographer
68 | 67,Alissa,Bailey,25,Hairdresser
69 | 68,Julia,Howard,30,Graphic Designer
70 | 69,Aston,Moore,25,Mechanic
71 | 70,Sabrina,Ryan,28,Auditor
72 | 71,Alford,Owens,18,Interior Designer
73 | 72,Chester,Thomas,19,Journalist
74 | 73,Ryan,Ellis,20,Engineer
75 | 74,Tony,Payne,27,Electrician
76 | 75,Patrick,Armstrong,29,Interpreter
77 | 76,Amelia,Barrett,18,Lawer
78 | 77,Kellan,Anderson,20,Driver
79 | 78,Adrian,Ellis,28,Botanist
80 | 79,Cadie,Carroll,23,Producer
81 | 80,Alexander,Henderson,18,Geologist
82 | 81,Reid,Myers,26,Insurer
83 | 82,Alfred,Baker,24,Archeologist
84 | 83,Alen,Mason,26,Producer
85 | 84,Byron,Phillips,24,Medic
86 | 85,Alexander,Smith,21,Chef
87 | 86,Julian,Cole,29,Mathematician
88 | 87,Brianna,Davis,25,Veterinarian
89 | 88,Cadie,Owens,21,Firefighter
90 | 89,Rebecca,Cole,22,Producer
91 | 90,Tiana,Howard,22,Engineer
92 | 91,Valeria,Grant,21,Meteorologist
93 | 92,Kelvin,Dixon,22,Mechanic
94 | 93,Mike,Bailey,22,Archeologist
95 | 94,Florrie,Holmes,29,Mechanic
96 | 95,Rafael,Cooper,30,Hairdresser
97 | 96,Mary,Hawkins,28,Jeweller
98 | 97,Evelyn,Martin,24,Composer
99 | 98,Kelsey,Spencer,24,Programmer
100 | 99,Robert,Owens,27,Dancer
--------------------------------------------------------------------------------
/example/table-multi-type/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "unicode"
7 |
8 | "github.com/76creates/stickers/flexbox"
9 | "github.com/76creates/stickers/table"
10 | tea "github.com/charmbracelet/bubbletea"
11 | "github.com/charmbracelet/lipgloss"
12 | "github.com/gocarina/gocsv"
13 | )
14 |
15 | var selectedValue string = "\nselect something with spacebar or enter"
16 |
17 | type model struct {
18 | table *table.Table
19 | infoBox *flexbox.FlexBox
20 | headers []string
21 | }
22 |
23 | func main() {
24 | // read in CSV data
25 | f, err := os.Open("../sample.csv")
26 | if err != nil {
27 | panic(err)
28 | }
29 | defer f.Close()
30 |
31 | type SampleData struct {
32 | ID int `csv:"id"`
33 | FirstName string `csv:"First Name"`
34 | LastName string `csv:"Last Name"`
35 | Age int `csv:"Age"`
36 | Occupation string `csv:"Occupation"`
37 | }
38 | var sampleData []*SampleData
39 |
40 | if err := gocsv.UnmarshalFile(f, &sampleData); err != nil {
41 | panic(err)
42 | }
43 |
44 | headers := []string{"id", "First Name", "Last Name", "Age", "Occupation"}
45 | ratio := []int{1, 10, 10, 5, 10}
46 | minSize := []int{4, 5, 5, 2, 5}
47 |
48 | var s string
49 | var i int
50 | types := []any{i, s, s, i, s}
51 |
52 | m := model{
53 | table: table.NewTable(0, 0, headers),
54 | infoBox: flexbox.New(0, 0).SetHeight(7),
55 | headers: headers,
56 | }
57 | // set types
58 | _, err = m.table.SetTypes(types...)
59 | if err != nil {
60 | panic(err)
61 | }
62 | // setup dimensions
63 | m.table.SetRatio(ratio).SetMinWidth(minSize)
64 | // set style passing
65 | m.table.SetStylePassing(true)
66 | // add rows
67 | // with multi type table we have to convert our rows to []any first which is a bit of a pain
68 | var orderedRows [][]any
69 | for _, row := range sampleData {
70 | orderedRows = append(orderedRows, []any{
71 | row.ID, row.FirstName, row.LastName, row.Age, row.Occupation,
72 | })
73 | }
74 | m.table.MustAddRows(orderedRows)
75 |
76 | // setup info box
77 | infoText := `
78 | use the arrows to navigate
79 | ctrl+s: sort by current column
80 | alphanumerics: filter column
81 | enter, spacebar: get column value
82 | ctrl+c: quit
83 | `
84 | r1 := m.infoBox.NewRow()
85 | r1.AddCells(
86 | flexbox.NewCell(1, 1).
87 | SetID("info").
88 | SetContent(infoText),
89 | flexbox.NewCell(1, 1).
90 | SetID("info").
91 | SetContent(selectedValue).
92 | SetStyle(lipgloss.NewStyle().Bold(true)),
93 | )
94 | m.infoBox.AddRows([]*flexbox.Row{r1})
95 |
96 | p := tea.NewProgram(&m, tea.WithAltScreen())
97 | if err := p.Start(); err != nil {
98 | fmt.Printf("Alas, there's been an error: %v", err)
99 | os.Exit(1)
100 | }
101 | }
102 |
103 | func (m *model) Init() tea.Cmd { return nil }
104 |
105 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106 | switch msg := msg.(type) {
107 | case tea.WindowSizeMsg:
108 | m.table.SetWidth(msg.Width)
109 | m.table.SetHeight(msg.Height - m.infoBox.GetHeight())
110 | m.infoBox.SetWidth(msg.Width)
111 | case tea.KeyMsg:
112 | switch msg.String() {
113 | case "ctrl+c":
114 | return m, tea.Quit
115 | case "down":
116 | m.table.CursorDown()
117 | case "up":
118 | m.table.CursorUp()
119 | case "left":
120 | m.table.CursorLeft()
121 | case "right":
122 | m.table.CursorRight()
123 | case "ctrl+s":
124 | x, _ := m.table.GetCursorLocation()
125 | m.table.OrderByColumn(x)
126 | case "enter", " ":
127 | selectedValue = m.table.GetCursorValue()
128 | m.infoBox.GetRow(0).GetCell(1).SetContent("\nselected cell: " + selectedValue)
129 | case "backspace":
130 | m.filterWithStr(msg.String())
131 | default:
132 | if len(msg.String()) == 1 {
133 | r := msg.Runes[0]
134 | if unicode.IsLetter(r) || unicode.IsDigit(r) {
135 | m.filterWithStr(msg.String())
136 | }
137 | }
138 | }
139 |
140 | }
141 | return m, nil
142 | }
143 |
144 | func (m *model) filterWithStr(key string) {
145 | i, s := m.table.GetFilter()
146 | x, _ := m.table.GetCursorLocation()
147 | if x != i && key != "backspace" {
148 | m.table.SetFilter(x, key)
149 | return
150 | }
151 | if key == "backspace" {
152 | if len(s) == 1 {
153 | m.table.UnsetFilter()
154 | return
155 | } else if len(s) > 1 {
156 | s = s[0 : len(s)-1]
157 | } else {
158 | return
159 | }
160 | } else {
161 | s = s + key
162 | }
163 | m.table.SetFilter(i, s)
164 | }
165 |
166 | func (m *model) View() string {
167 | return lipgloss.JoinVertical(lipgloss.Left, m.table.Render(), m.infoBox.Render())
168 | }
169 |
--------------------------------------------------------------------------------
/example/table-simple-string/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/csv"
5 | "fmt"
6 | "log"
7 | "os"
8 | "unicode"
9 |
10 | "github.com/76creates/stickers/flexbox"
11 | "github.com/76creates/stickers/table"
12 | tea "github.com/charmbracelet/bubbletea"
13 | "github.com/charmbracelet/lipgloss"
14 | )
15 |
16 | var selectedValue string = "\nselect something with spacebar or enter"
17 |
18 | type model struct {
19 | table *table.TableSingleType[string]
20 | infoBox *flexbox.FlexBox
21 | headers []string
22 | }
23 |
24 | func main() {
25 | // read in CSV data
26 | f, err := os.Open("../sample.csv")
27 | if err != nil {
28 | panic(err)
29 | }
30 | defer f.Close()
31 |
32 | csvReader := csv.NewReader(f)
33 | data, err := csvReader.ReadAll()
34 | if err != nil {
35 | log.Fatal(err)
36 | }
37 |
38 | headers := data[0]
39 | rows := data[1:]
40 | ratio := []int{1, 10, 10, 5, 10}
41 | minSize := []int{4, 5, 5, 2, 5}
42 |
43 | m := model{
44 | table: table.NewTableSingleType[string](0, 0, headers),
45 | infoBox: flexbox.New(0, 0).SetHeight(7),
46 | headers: headers,
47 | }
48 | m.table.SetStylePassing(true)
49 | // setup
50 | m.table.SetRatio(ratio).SetMinWidth(minSize)
51 | // add rows
52 | m.table.AddRows(rows)
53 |
54 | // setup info box
55 | infoText := `
56 | use the arrows to navigate
57 | ctrl+s: sort by current column
58 | alphanumerics: filter column
59 | enter, spacebar: get column value
60 | ctrl+c: quit
61 | `
62 | r1 := m.infoBox.NewRow()
63 | r1.AddCells(
64 | flexbox.NewCell(1, 1).
65 | SetID("info").
66 | SetContent(infoText),
67 | flexbox.NewCell(1, 1).
68 | SetID("info").
69 | SetContent(selectedValue).
70 | SetStyle(lipgloss.NewStyle().Bold(true)),
71 | )
72 | m.infoBox.AddRows([]*flexbox.Row{r1})
73 |
74 | p := tea.NewProgram(&m, tea.WithAltScreen())
75 | if err := p.Start(); err != nil {
76 | fmt.Printf("Alas, there's been an error: %v", err)
77 | os.Exit(1)
78 | }
79 | }
80 |
81 | func (m *model) Init() tea.Cmd { return nil }
82 |
83 | func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84 | switch msg := msg.(type) {
85 | case tea.WindowSizeMsg:
86 | m.table.SetWidth(msg.Width)
87 | m.table.SetHeight(msg.Height - m.infoBox.GetHeight())
88 | m.infoBox.SetWidth(msg.Width)
89 | case tea.KeyMsg:
90 | switch msg.String() {
91 | case "ctrl+c", "q":
92 | return m, tea.Quit
93 | case "down":
94 | m.table.CursorDown()
95 | case "up":
96 | m.table.CursorUp()
97 | case "left":
98 | m.table.CursorLeft()
99 | case "right":
100 | m.table.CursorRight()
101 | case "ctrl+s":
102 | x, _ := m.table.GetCursorLocation()
103 | m.table.OrderByColumn(x)
104 | case "enter", " ":
105 | selectedValue = m.table.GetCursorValue()
106 | m.infoBox.GetRow(0).GetCell(1).SetContent("\nselected cell: " + selectedValue)
107 | case "backspace":
108 | m.filterWithStr(msg.String())
109 | default:
110 | if len(msg.String()) == 1 {
111 | r := msg.Runes[0]
112 | if unicode.IsLetter(r) || unicode.IsDigit(r) {
113 | m.filterWithStr(msg.String())
114 | }
115 | }
116 | }
117 |
118 | }
119 | return m, nil
120 | }
121 |
122 | func (m *model) filterWithStr(key string) {
123 | i, s := m.table.GetFilter()
124 | x, _ := m.table.GetCursorLocation()
125 | if x != i && key != "backspace" {
126 | m.table.SetFilter(x, key)
127 | return
128 | }
129 | if key == "backspace" {
130 | if len(s) == 1 {
131 | m.table.UnsetFilter()
132 | return
133 | } else if len(s) > 1 {
134 | s = s[0 : len(s)-1]
135 | } else {
136 | return
137 | }
138 | } else {
139 | s = s + key
140 | }
141 | m.table.SetFilter(i, s)
142 | }
143 |
144 | func (m *model) View() string {
145 | return lipgloss.JoinVertical(lipgloss.Left, m.table.Render(), m.infoBox.Render())
146 | }
147 |
--------------------------------------------------------------------------------
/flexbox/cell.go:
--------------------------------------------------------------------------------
1 | package flexbox
2 |
3 | import (
4 | "github.com/charmbracelet/lipgloss"
5 | )
6 |
7 | // Cell is a building block object of the FlexBox, it represents a single cell within a box
8 | // A FlexBox stacks cells horizontally.
9 | // A HorizontalFlexBox stacks cells vertically. (controverse, isn't it?)
10 | type Cell struct {
11 | // style of the cell, when rendering it will inherit the style of the parent row
12 | style lipgloss.Style
13 | // id of the cell, if not set it will default to the index in the row
14 | id string
15 |
16 | // TODO: all ratios and sizes should be uint
17 | // ratioX width ratio of the cell
18 | ratioX int
19 | // ratioY height ratio of the cell
20 | ratioY int
21 | // minWidth minimal width of the cell
22 | minWidth int
23 | // minHeight minimal height of the cell
24 | minHeight int
25 |
26 | width int
27 | height int
28 | content string
29 | }
30 |
31 | // NewCell initialize FlexBoxCell object with defaults
32 | func NewCell(ratioX, ratioY int) *Cell {
33 | return &Cell{
34 | style: lipgloss.NewStyle(),
35 | ratioX: ratioX,
36 | ratioY: ratioY,
37 | minWidth: 0,
38 | width: 0,
39 | height: 0,
40 | }
41 | }
42 |
43 | // SetID sets the cells ID
44 | func (r *Cell) SetID(id string) *Cell {
45 | r.id = id
46 | return r
47 | }
48 |
49 | // SetContent sets the cells content
50 | func (r *Cell) SetContent(content string) *Cell {
51 | r.content = content
52 | return r
53 | }
54 |
55 | // GetContent returns the cells raw content
56 | func (r *Cell) GetContent() string {
57 | return r.content
58 | }
59 |
60 | // SetMinWidth sets the cells minimum width, this will not disable responsivness.
61 | // This has only an effect to cells of a normal FlexBox, not a HorizontalFlexBox.
62 | func (r *Cell) SetMinWidth(value int) *Cell {
63 | r.minWidth = value
64 | return r
65 | }
66 |
67 |
68 | // Deprecated: use [*Cell.SetMinHeight]
69 | func (r *Cell) SetMinHeigth(value int) *Cell {
70 | return r.SetMinHeight(value)
71 | }
72 |
73 | // SetMinHeight sets the cells minimum height, this will not disable responsivness.
74 | // This has only an effect to cells of a HorizontalFlexBox.
75 | func (r *Cell) SetMinHeight(value int) *Cell {
76 | r.minHeight = value
77 | return r
78 | }
79 |
80 | // SetStyle replaces the style, it unsets width/height related keys
81 | func (r *Cell) SetStyle(style lipgloss.Style) *Cell {
82 | r.style = style.
83 | UnsetWidth().
84 | UnsetMaxWidth().
85 | UnsetHeight().
86 | UnsetMaxHeight()
87 | return r
88 | }
89 |
90 | // GetStyle returns the copy of the cells current style
91 | func (r *Cell) GetStyle() lipgloss.Style {
92 | return r.style
93 | }
94 |
95 | // GetWidth returns real width of the cell
96 | func (r *Cell) GetWidth() int {
97 | return r.getMaxWidth()
98 | }
99 |
100 | // GetHeight returns real height of the cell
101 | func (r *Cell) GetHeight() int {
102 | return r.getMaxHeight()
103 | }
104 |
105 | // render the cell into string
106 | func (r *Cell) render(inherited ...lipgloss.Style) string {
107 | for _, style := range inherited {
108 | r.style = r.style.Inherit(style)
109 | }
110 |
111 | s := r.GetStyle().
112 | Width(r.getContentWidth()).MaxWidth(r.getMaxWidth()).
113 | Height(r.getContentHeight()).MaxHeight(r.getMaxHeight())
114 | return s.Render(r.content)
115 | }
116 |
117 | func (r *Cell) getContentWidth() int {
118 | return r.getMaxWidth() - r.getExtraWidth()
119 | }
120 |
121 | func (r *Cell) getContentHeight() int {
122 | return r.getMaxHeight() - r.getExtraHeight()
123 | }
124 |
125 | func (r *Cell) getMaxWidth() int {
126 | return r.width
127 | }
128 |
129 | func (r *Cell) getMaxHeight() int {
130 | return r.height
131 | }
132 |
133 | func (r *Cell) getExtraWidth() int {
134 | return r.style.GetHorizontalMargins() + r.style.GetHorizontalBorderSize()
135 | }
136 |
137 | func (r *Cell) getExtraHeight() int {
138 | return r.style.GetVerticalMargins() + r.style.GetVerticalBorderSize()
139 | }
140 |
141 | func (r *Cell) copy() Cell {
142 | cellCopy := *r
143 | cellCopy.style = r.GetStyle()
144 | return cellCopy
145 | }
146 |
--------------------------------------------------------------------------------
/flexbox/column.go:
--------------------------------------------------------------------------------
1 | package flexbox
2 |
3 | import (
4 | "strconv"
5 |
6 | "github.com/charmbracelet/lipgloss"
7 | )
8 |
9 | // Column is the container for the cells, this object has the least to do with the ratio
10 | // of the construction as it takes all of the needed ratio information from the cell slice
11 | // columns are stacked horizontally.
12 | type Column struct {
13 | // style of the column
14 | style lipgloss.Style
15 | styleAncestor bool
16 |
17 | cells []*Cell
18 |
19 | height int
20 | width int
21 |
22 | // recalculateFlag indicates if next render should make calculations regarding
23 | // the cells objects height/width
24 | recalculateFlag bool
25 | }
26 |
27 | // AddCells appends the cells to the column
28 | // if the cell ID is not set it will default to the index of the cell
29 | func (r *Column) AddCells(cells ...*Cell) *Column {
30 | r.cells = append(r.cells, cells...)
31 | for i, cell := range r.cells {
32 | if cell.id == "" {
33 | cell.id = strconv.Itoa(i)
34 | }
35 | }
36 | r.setRecalculate()
37 | return r
38 | }
39 |
40 | // CellsLen returns the len of the cells slice
41 | func (r *Column) CellsLen() int {
42 | return len(r.cells)
43 | }
44 |
45 | // GetCell returns the Cell on the given index if it exists
46 | // note: forces the recalculation if found
47 | //
48 | // returns nil if not found
49 | func (r *Column) GetCell(index int) *Cell {
50 | if index >= 0 && index < len(r.cells) {
51 | r.setRecalculate()
52 | return r.cells[index]
53 | }
54 | return nil
55 | }
56 |
57 | // GetCellCopy returns a copy of the Cell on the given index, if cell
58 | // does not exist it will return nil. This is useful when you need to get
59 | // cells attribute without triggering a recalculation.
60 | func (r *Column) GetCellCopy(index int) *Cell {
61 | if index >= 0 && index < len(r.cells) {
62 | c := r.cells[index].copy()
63 | return &c
64 | }
65 | return nil
66 | }
67 |
68 | // GetCellWithID returns the cell with the given ID if existing
69 | // note: forces the recalculation if found
70 | //
71 | // returns nil if not found
72 | func (r *Column) GetCellWithID(id string) *Cell {
73 | for _, c := range r.cells {
74 | if c.id == id {
75 | r.setRecalculate()
76 | return c
77 | }
78 | }
79 | return nil
80 | }
81 |
82 | // UpdateCellWithIndex replaces the cell on the given index if it exists
83 | // if its not existing no changes will apply
84 | func (r *Column) UpdateCellWithIndex(index int, cell *Cell) {
85 | if index >= 0 && len(r.cells) > 0 && index < len(r.cells) {
86 | r.cells[index] = cell
87 | r.setRecalculate()
88 | }
89 | }
90 |
91 | // SetStyle replaces the style, it unsets width/height related keys
92 | func (r *Column) SetStyle(style lipgloss.Style) *Column {
93 | r.style = style.
94 | UnsetWidth().
95 | UnsetMaxWidth().
96 | UnsetHeight().
97 | UnsetMaxHeight()
98 |
99 | return r
100 | }
101 |
102 | // StylePassing set whether the style should be passed to the cells
103 | func (r *Column) StylePassing(value bool) *Column {
104 | r.styleAncestor = value
105 | return r
106 | }
107 |
108 | func (r *Column) setHeight(value int) {
109 | r.height = value
110 | r.setRecalculate()
111 | }
112 |
113 | func (r *Column) setWidth(value int) {
114 | r.width = value
115 | r.setRecalculate()
116 | }
117 |
118 | func (r *Column) render(inherited ...lipgloss.Style) string {
119 | var inheritedStyle []lipgloss.Style
120 |
121 | for _, style := range inherited {
122 | r.style = r.style.Inherit(style)
123 | }
124 |
125 | // intentionally applied after column inherits the box style
126 | if r.styleAncestor {
127 | inheritedStyle = append(inheritedStyle, r.style)
128 | }
129 |
130 | r.recalculate()
131 | var renderedCells []string
132 | for _, cell := range r.cells {
133 | renderedCells = append(renderedCells, cell.render(inheritedStyle...))
134 | }
135 | return r.style.
136 | Width(r.getContentWidth()).MaxWidth(r.getMaxWidth()).
137 | Height(r.getContentHeight()).MaxHeight(r.getMaxHeight()).
138 | Render(lipgloss.JoinVertical(lipgloss.Left, renderedCells...))
139 | }
140 |
141 | func (r *Column) setRecalculate() {
142 | r.recalculateFlag = true
143 | }
144 |
145 | func (r *Column) unsetRecalculate() {
146 | r.recalculateFlag = false
147 | }
148 |
149 | // recalculate fetches the cell's height/width distribution slices and sets it on the cells
150 | func (r *Column) recalculate() {
151 | if r.recalculateFlag {
152 | if len(r.cells) > 0 {
153 | r.distributeCellDimensions(r.calculateCellsDimensions())
154 | }
155 | r.unsetRecalculate()
156 | }
157 | }
158 |
159 | // distributeCellDimensions sets width of each column per distribution array
160 | func (r *Column) distributeCellDimensions(xMatrix, yMatrix []int) {
161 | for index, y := range yMatrix {
162 | r.cells[index].width = xMatrix[index]
163 | r.cells[index].height = y
164 | }
165 | }
166 |
167 | // calculateCellsDimensions calculates the height and width of the each cell
168 | func (r *Column) calculateCellsDimensions() (xMatrix, yMatrix []int) {
169 | // calculate the cell height, it uses fixed combined ratio since the height of each cell
170 | // is individual and does not stack, column width will be calculated using the ratio of the
171 | // widest cell in the slice
172 | cellXMatrix, cellXMatrixMax := r.getCellWidthMatrix()
173 |
174 | // reminder not needed here due to how combined ratio is passed
175 | xMatrix, _ = distributeToMatrix(r.getContentWidth(), cellXMatrixMax, cellXMatrix)
176 |
177 | // get the min height matrix of the cells if any
178 | withMinHeight := false
179 | var minHeightMatrix []int
180 | for _, c := range r.cells {
181 | minHeightMatrix = append(minHeightMatrix, c.minHeight)
182 | if c.minHeight > 0 {
183 | withMinHeight = true
184 | }
185 | }
186 |
187 | // calculate the cell height matrix
188 | if withMinHeight {
189 | yMatrix = calculateRatioWithMinimum(r.getContentHeight(), r.getCellHeightMatrix(), minHeightMatrix)
190 | } else {
191 | yMatrix = calculateRatio(r.getContentHeight(), r.getCellHeightMatrix())
192 | }
193 |
194 | return xMatrix, yMatrix
195 | }
196 |
197 | // getCellHeightMatrix return the matrix of the cell height in cells
198 | func (r *Column) getCellHeightMatrix() (cellHeightMatrix []int) {
199 | for _, cell := range r.cells {
200 | cellHeightMatrix = append(cellHeightMatrix, cell.ratioY)
201 | }
202 | return cellHeightMatrix
203 | }
204 |
205 | // getCellWidthMatrix return the matrix of the cell width in cells and the max value in it
206 | func (r *Column) getCellWidthMatrix() (cellWidthMatrix []int, max int) {
207 | max = 0
208 | for _, cell := range r.cells {
209 | if cell.ratioX > max {
210 | max = cell.ratioX
211 | }
212 | cellWidthMatrix = append(cellWidthMatrix, cell.ratioX)
213 | }
214 | return cellWidthMatrix, max
215 | }
216 |
217 | func (r *Column) getContentWidth() int {
218 | return r.getMaxWidth() - r.getExtraWidth()
219 | }
220 |
221 | func (r *Column) getContentHeight() int {
222 | return r.getMaxHeight() - r.getExtraHeight()
223 | }
224 |
225 | func (r *Column) getMaxWidth() int {
226 | return r.width
227 | }
228 |
229 | func (r *Column) getMaxHeight() int {
230 | return r.height
231 | }
232 |
233 | func (r *Column) getExtraWidth() int {
234 | return r.style.GetHorizontalMargins() + r.style.GetHorizontalBorderSize()
235 | }
236 |
237 | func (r *Column) getExtraHeight() int {
238 | return r.style.GetVerticalMargins() + r.style.GetVerticalBorderSize()
239 | }
240 |
241 | func (r *Column) copy() Column {
242 | var cells []*Cell
243 | for _, cell := range r.cells {
244 | cellCopy := cell.copy()
245 | cells = append(cells, &cellCopy)
246 | }
247 | columnCopy := *r
248 | columnCopy.cells = cells
249 | columnCopy.style = r.style
250 |
251 | return columnCopy
252 | }
253 |
--------------------------------------------------------------------------------
/flexbox/flexbox.go:
--------------------------------------------------------------------------------
1 | package flexbox
2 |
3 | import "github.com/charmbracelet/lipgloss"
4 |
5 | // FlexBox responsive box grid inspired by CSS flexbox
6 | type FlexBox struct {
7 | // style to apply to the gridbox itself
8 | style lipgloss.Style
9 | styleAncestor bool
10 |
11 | // width is fixed width of the box
12 | width int
13 | // height is fixed height of the box
14 | height int
15 | // fixedRowHeight will lock row height to a number, this disabless responsivness
16 | fixedRowHeight int
17 |
18 | rows []*Row
19 |
20 | // recalculateFlag indicates if next render should make calculations regarding
21 | // the rows objects height
22 | recalculateFlag bool
23 | }
24 |
25 | // New initialize FlexBox object with defaults
26 | func New(width, height int) *FlexBox {
27 | r := &FlexBox{
28 | width: width,
29 | height: height,
30 | fixedRowHeight: -1,
31 | style: lipgloss.NewStyle(),
32 | recalculateFlag: false,
33 | }
34 | return r
35 | }
36 |
37 | // SetStyle replaces the style, it unsets width/height related keys
38 | func (r *FlexBox) SetStyle(style lipgloss.Style) *FlexBox {
39 | r.style = style.
40 | UnsetWidth().
41 | UnsetMaxWidth().
42 | UnsetHeight().
43 | UnsetMaxHeight()
44 | return r
45 | }
46 |
47 | // StylePassing set whether the style should be passed to the rows
48 | func (r *FlexBox) StylePassing(value bool) *FlexBox {
49 | r.styleAncestor = value
50 | return r
51 | }
52 |
53 | // NewRow initialize a new Row with width inherited from the FlexBox
54 | func (r *FlexBox) NewRow() *Row {
55 | rw := &Row{
56 | cells: []*Cell{},
57 | width: r.width,
58 | style: lipgloss.NewStyle(),
59 | }
60 | return rw
61 | }
62 |
63 | // AddRows appends additional rows to the FlexBox
64 | func (r *FlexBox) AddRows(rows []*Row) *FlexBox {
65 | r.rows = append(r.rows, rows...)
66 | r.setRecalculate()
67 | return r
68 | }
69 |
70 | // SetRows replace rows on the FlexBox
71 | func (r *FlexBox) SetRows(rows []*Row) *FlexBox {
72 | r.rows = rows
73 | r.setRecalculate()
74 | return r
75 | }
76 |
77 | // RowsLen returns the len of the rows slice
78 | func (r *FlexBox) RowsLen() int {
79 | return len(r.rows)
80 | }
81 |
82 | // GetRow returns the Row on the given index if it exists
83 | // note: forces the recalculation if found
84 | //
85 | // returns nil if not found
86 | func (r *FlexBox) GetRow(index int) *Row {
87 | if index >= 0 && index < len(r.rows) {
88 | r.setRecalculate()
89 | return r.rows[index]
90 | }
91 | return nil
92 | }
93 |
94 | // GetRowCopy returns a copy of the Row on the given index, if row
95 | // does not exist it will return nil. Copied row also gets copies of the
96 | // cells. This is useful when you need to get rows attribute without
97 | // triggering a recalculation.
98 | func (r *FlexBox) GetRowCopy(index int) *Row {
99 | if index >= 0 && index < len(r.rows) {
100 | rowCopy := r.rows[index].copy()
101 | return &rowCopy
102 | }
103 | return nil
104 | }
105 |
106 | // GetRowCellCopy returns a copy of the FlexBoxCell on the given index x,
107 | // within the given row with index y, if row or cell do not exist it will
108 | // return nil. This is useful when you need to get rows attribute without
109 | // triggering a recalculation.
110 | func (r *FlexBox) GetRowCellCopy(rowIndex, cellIndex int) *Cell {
111 | if rowIndex >= 0 && rowIndex < len(r.rows) {
112 | if cellIndex >= 0 && cellIndex < len(r.rows[rowIndex].cells) {
113 | cellCopy := r.rows[rowIndex].cells[cellIndex].copy()
114 | return &cellCopy
115 | }
116 | }
117 | return nil
118 | }
119 |
120 | // UpdateRow replaces the Row on the given index
121 | func (r *FlexBox) UpdateRow(index int, row *Row) *FlexBox {
122 | r.rows[index] = row
123 | r.setRecalculate()
124 | return r
125 | }
126 |
127 | // LockRowHeight sets the fixed height value for all the rows
128 | // this will disable vertical scaling
129 | func (r *FlexBox) LockRowHeight(value int) *FlexBox {
130 | r.fixedRowHeight = value
131 | return r
132 | }
133 |
134 | // SetHeight sets the FlexBox height
135 | func (r *FlexBox) SetHeight(value int) *FlexBox {
136 | r.height = value
137 | r.setRecalculate()
138 | return r
139 | }
140 |
141 | // SetWidth sets the FlexBox width
142 | func (r *FlexBox) SetWidth(value int) *FlexBox {
143 | r.width = value
144 | for _, row := range r.rows {
145 | row.setWidth(value)
146 | }
147 | return r
148 | }
149 |
150 | // GetHeight yields current FlexBox height
151 | func (r *FlexBox) GetHeight() int {
152 | return r.getMaxHeight()
153 | }
154 |
155 | // GetWidth yields current FlexBox width
156 | func (r *FlexBox) GetWidth() int {
157 | return r.getMaxWidth()
158 | }
159 |
160 | // Render initiates the recalculation of the rows dimensions(height) if the recalculate flag is on,
161 | // and then it renders all the rows and combines them on the vertical axis
162 | func (r *FlexBox) Render() string {
163 | var inheritedStyle []lipgloss.Style
164 | if r.styleAncestor {
165 | inheritedStyle = append(inheritedStyle, r.style)
166 | }
167 |
168 | r.recalculate()
169 | var renderedRows []string
170 | for _, row := range r.rows {
171 | renderedRows = append(renderedRows, row.render(inheritedStyle...))
172 | }
173 | // TODO: allow setting join align value for rows of variable width
174 | return r.style.
175 | Width(r.getContentWidth()).MaxWidth(r.getMaxWidth()).
176 | Height(r.getContentHeight()).MaxHeight(r.getMaxHeight()).
177 | Render(lipgloss.JoinVertical(lipgloss.Left, renderedRows...))
178 | }
179 |
180 | // ForceRecalculate forces the recalculation for the box and all the rows
181 | func (r *FlexBox) ForceRecalculate() {
182 | r.recalculate()
183 | for _, rw := range r.rows {
184 | rw.recalculate()
185 | }
186 | }
187 |
188 | // recalculate fetches the row height distribution slice and sets it on the rows
189 | func (r *FlexBox) recalculate() {
190 | if r.recalculateFlag {
191 | if len(r.rows) > 0 {
192 | r.distributeRowsDimensions(r.calculateRowHeight())
193 | }
194 | r.unsetRecalculate()
195 | }
196 | }
197 |
198 | func (r *FlexBox) setRecalculate() {
199 | r.recalculateFlag = true
200 | }
201 |
202 | func (r *FlexBox) unsetRecalculate() {
203 | r.recalculateFlag = false
204 | }
205 |
206 | // calculateRowHeight calculates the height of each row and returns the distribution array
207 | func (r *FlexBox) calculateRowHeight() (distribution []int) {
208 | if r.fixedRowHeight > 0 {
209 | var fixedRows []int
210 | for range r.rows {
211 | fixedRows = append(fixedRows, r.fixedRowHeight)
212 | }
213 | return fixedRows
214 | }
215 | return calculateMatrixRatio(r.getContentHeight(), r.getRowMatrix())
216 | }
217 |
218 | // distributeRowsDimensions sets height and width of each row per distribution array
219 | func (r *FlexBox) distributeRowsDimensions(ratioDistribution []int) {
220 | for index, row := range r.rows {
221 | row.setHeight(ratioDistribution[index])
222 | row.setWidth(r.getContentWidth())
223 | }
224 | }
225 |
226 | // getRowMatrix return the matrix of the cell heights for all the rows
227 | func (r *FlexBox) getRowMatrix() (rowMatrix [][]int) {
228 | for _, row := range r.rows {
229 | var cellValues []int
230 | for _, cell := range row.cells {
231 | cellValues = append(cellValues, cell.ratioY)
232 | }
233 | rowMatrix = append(rowMatrix, cellValues)
234 | }
235 | return rowMatrix
236 | }
237 |
238 | func (r *FlexBox) getContentWidth() int {
239 | return r.getMaxWidth() - r.getExtraWidth()
240 | }
241 |
242 | func (r *FlexBox) getContentHeight() int {
243 | return r.getMaxHeight() - r.getExtraHeight()
244 | }
245 |
246 | func (r *FlexBox) getMaxWidth() int {
247 | return r.width
248 | }
249 |
250 | func (r *FlexBox) getMaxHeight() int {
251 | return r.height
252 | }
253 |
254 | func (r *FlexBox) getExtraWidth() int {
255 | return r.style.GetHorizontalMargins() + r.style.GetHorizontalBorderSize()
256 | }
257 |
258 | func (r *FlexBox) getExtraHeight() int {
259 | return r.style.GetVerticalMargins() + r.style.GetVerticalBorderSize()
260 | }
261 |
--------------------------------------------------------------------------------
/flexbox/horizontal_flexbox.go:
--------------------------------------------------------------------------------
1 | package flexbox
2 |
3 | import "github.com/charmbracelet/lipgloss"
4 |
5 | // HorizontalFlexBox responsive box grid inspired by CSS flexbox
6 | type HorizontalFlexBox struct {
7 | // style to apply to the gridbox itself
8 | style lipgloss.Style
9 | styleAncestor bool
10 |
11 | // width is fixed width of the box
12 | width int
13 | // height is fixed height of the box
14 | height int
15 |
16 | // fixedColumnWidth will lock column width to a number, this disabless responsivness
17 | fixedColumnWidth int
18 |
19 | columns []*Column
20 |
21 | // recalculateFlag indicates if next render should make calculations regarding
22 | // the columns objects height
23 | recalculateFlag bool
24 | }
25 |
26 | // NewHorizontal initialize a HorizontalFlexBox object with defaults
27 | func NewHorizontal(width, height int) *HorizontalFlexBox {
28 | r := &HorizontalFlexBox{
29 | width: width,
30 | height: height,
31 | fixedColumnWidth: -1,
32 | style: lipgloss.NewStyle(),
33 | recalculateFlag: false,
34 | }
35 | return r
36 | }
37 |
38 | // SetStyle replaces the style, it unsets width/height related keys
39 | func (r *HorizontalFlexBox) SetStyle(style lipgloss.Style) *HorizontalFlexBox {
40 | r.style = style.
41 | UnsetWidth().
42 | UnsetMaxWidth().
43 | UnsetHeight().
44 | UnsetMaxHeight()
45 | return r
46 | }
47 |
48 | // StylePassing set whether the style should be passed to the columns
49 | func (r *HorizontalFlexBox) StylePassing(value bool) *HorizontalFlexBox {
50 | r.styleAncestor = value
51 | return r
52 | }
53 |
54 | // NewColumn initialize a new FlexBoxColumn with width inherited from the FlexBox
55 | func (r *HorizontalFlexBox) NewColumn() *Column {
56 | rw := &Column{
57 | cells: []*Cell{},
58 | width: r.width,
59 | style: lipgloss.NewStyle(),
60 | }
61 | return rw
62 | }
63 |
64 | // AddColumns appends additional columns to the FlexBox
65 | func (r *HorizontalFlexBox) AddColumns(columns []*Column) *HorizontalFlexBox {
66 | r.columns = append(r.columns, columns...)
67 | r.setRecalculate()
68 | return r
69 | }
70 |
71 | // SetColumns replace columns on the FlexBox
72 | func (r *HorizontalFlexBox) SetColumns(columns []*Column) *HorizontalFlexBox {
73 | r.columns = columns
74 | r.setRecalculate()
75 | return r
76 | }
77 |
78 | // ColumnsLen returns the len of the columns slice
79 | func (r *HorizontalFlexBox) ColumnsLen() int {
80 | return len(r.columns)
81 | }
82 |
83 | // GetColumn returns the FlexBoxColumn on the given index if it exists
84 | // note: forces the recalculation if found
85 | //
86 | // returns nil if not found
87 | func (r *HorizontalFlexBox) GetColumn(index int) *Column {
88 | if index >= 0 && index < len(r.columns) {
89 | r.setRecalculate()
90 | return r.columns[index]
91 | }
92 | return nil
93 | }
94 |
95 | // GetColumnCopy returns a copy of the FlexBoxColumn on the given index, if column
96 | // does not exist it will return nil. Copied column also gets copies of the
97 | // cells. This is useful when you need to get columns attribute without
98 | // triggering a recalculation.
99 | func (r *HorizontalFlexBox) GetColumnCopy(index int) *Column {
100 | if index >= 0 && index < len(r.columns) {
101 | columnCopy := r.columns[index].copy()
102 | return &columnCopy
103 | }
104 | return nil
105 | }
106 |
107 | // GetColumnCellCopy returns a copy of the FlexBoxCell on the given index x,
108 | // within the given column with index y, if column or cell do not exist it will
109 | // return nil. This is useful when you need to get columns attribute without
110 | // triggering a recalculation.
111 | func (r *HorizontalFlexBox) GetColumnCellCopy(columnIndex, cellIndex int) *Cell {
112 | if columnIndex >= 0 && columnIndex < len(r.columns) {
113 | if cellIndex >= 0 && cellIndex < len(r.columns[columnIndex].cells) {
114 | cellCopy := r.columns[columnIndex].cells[cellIndex].copy()
115 | return &cellCopy
116 | }
117 | }
118 | return nil
119 | }
120 |
121 | // UpdateColumn replaces the FlexBoxColumn on the given index
122 | func (r *HorizontalFlexBox) UpdateColumn(index int, column *Column) *HorizontalFlexBox {
123 | r.columns[index] = column
124 | r.setRecalculate()
125 | return r
126 | }
127 |
128 | // LockColumnWidth sets the fixed width value for all the columns
129 | // this will disable horizontal scaling
130 | func (r *HorizontalFlexBox) LockColumnWidth(value int) *HorizontalFlexBox {
131 | r.fixedColumnWidth = value
132 | return r
133 | }
134 |
135 | // SetHeight sets the FlexBox height
136 | func (r *HorizontalFlexBox) SetHeight(value int) *HorizontalFlexBox {
137 | r.height = value
138 | for _, column := range r.columns {
139 | column.setHeight(value)
140 | }
141 | return r
142 | }
143 |
144 | // SetWidth sets the FlexBox width
145 | func (r *HorizontalFlexBox) SetWidth(value int) *HorizontalFlexBox {
146 | r.width = value
147 | r.setRecalculate()
148 | return r
149 | }
150 |
151 | // GetHeight yields current FlexBox height
152 | func (r *HorizontalFlexBox) GetHeight() int {
153 | return r.getMaxHeight()
154 | }
155 |
156 | // GetWidth yields current FlexBox width
157 | func (r *HorizontalFlexBox) GetWidth() int {
158 | return r.getMaxWidth()
159 | }
160 |
161 | // Render initiates the recalculation of the columns dimensions(width) if the recalculate flag is on,
162 | // and then it renders all the columns and combines them on the horizontal axis
163 | func (r *HorizontalFlexBox) Render() string {
164 | var inheritedStyle []lipgloss.Style
165 | if r.styleAncestor {
166 | inheritedStyle = append(inheritedStyle, r.style)
167 | }
168 |
169 | r.recalculate()
170 | var renderedColumns []string
171 | for _, column := range r.columns {
172 | renderedColumns = append(renderedColumns, column.render(inheritedStyle...))
173 | }
174 | // TODO: allow setting join align value for columns of variable width
175 | return r.style.
176 | Width(r.getContentWidth()).MaxWidth(r.getMaxWidth()).
177 | Height(r.getContentHeight()).MaxHeight(r.getMaxHeight()).
178 | Render(lipgloss.JoinHorizontal(lipgloss.Top, renderedColumns...))
179 | }
180 |
181 | // ForceRecalculate forces the recalculation for the box and all the columns
182 | func (r *HorizontalFlexBox) ForceRecalculate() {
183 | r.recalculate()
184 | for _, rw := range r.columns {
185 | rw.recalculate()
186 | }
187 | }
188 |
189 | // recalculate fetches the column height distribution slice and sets it on the columns
190 | func (r *HorizontalFlexBox) recalculate() {
191 | if r.recalculateFlag {
192 | if len(r.columns) > 0 {
193 | r.distributeColumnsDimensions(r.calculateColumnWidth())
194 | }
195 | r.unsetRecalculate()
196 | }
197 | }
198 |
199 | func (r *HorizontalFlexBox) setRecalculate() {
200 | r.recalculateFlag = true
201 | }
202 |
203 | func (r *HorizontalFlexBox) unsetRecalculate() {
204 | r.recalculateFlag = false
205 | }
206 |
207 | // calculateColumnWidth calculates the width of each column and returns the distribution array
208 | func (r *HorizontalFlexBox) calculateColumnWidth() (distribution []int) {
209 | if r.fixedColumnWidth > 0 {
210 | var fixedColumns []int
211 | for range r.columns {
212 | fixedColumns = append(fixedColumns, r.fixedColumnWidth)
213 | }
214 | return fixedColumns
215 | }
216 | return calculateMatrixRatio(r.getContentWidth(), r.getColumnMatrix())
217 | }
218 |
219 | // distributeColumnsDimensions sets height and width of each column per distribution array
220 | func (r *HorizontalFlexBox) distributeColumnsDimensions(ratioDistribution []int) {
221 | for index, column := range r.columns {
222 | column.setHeight(r.getContentHeight())
223 | column.setWidth(ratioDistribution[index])
224 | }
225 | }
226 |
227 | // getColumnMatrix return the matrix of the cell widths for all the columns
228 | func (r *HorizontalFlexBox) getColumnMatrix() (columnMatrix [][]int) {
229 | for _, column := range r.columns {
230 | var cellValues []int
231 | for _, cell := range column.cells {
232 | cellValues = append(cellValues, cell.ratioX)
233 | }
234 | columnMatrix = append(columnMatrix, cellValues)
235 | }
236 | return columnMatrix
237 | }
238 |
239 | func (r *HorizontalFlexBox) getContentWidth() int {
240 | return r.getMaxWidth() - r.getExtraWidth()
241 | }
242 |
243 | func (r *HorizontalFlexBox) getContentHeight() int {
244 | return r.getMaxHeight() - r.getExtraHeight()
245 | }
246 |
247 | func (r *HorizontalFlexBox) getMaxWidth() int {
248 | return r.width
249 | }
250 |
251 | func (r *HorizontalFlexBox) getMaxHeight() int {
252 | return r.height
253 | }
254 |
255 | func (r *HorizontalFlexBox) getExtraWidth() int {
256 | return r.style.GetHorizontalMargins() + r.style.GetHorizontalBorderSize()
257 | }
258 |
259 | func (r *HorizontalFlexBox) getExtraHeight() int {
260 | return r.style.GetVerticalMargins() + r.style.GetVerticalBorderSize()
261 | }
262 |
--------------------------------------------------------------------------------
/flexbox/row.go:
--------------------------------------------------------------------------------
1 | package flexbox
2 |
3 | import (
4 | "strconv"
5 |
6 | "github.com/charmbracelet/lipgloss"
7 | )
8 |
9 | // Row is the container for the cells, this object has the least to do with the ratio
10 | // of the construction as it takes all of the needed ratio information from the cell slice
11 | // rows are stacked vertically
12 | type Row struct {
13 | // style of the row
14 | style lipgloss.Style
15 | styleAncestor bool
16 |
17 | cells []*Cell
18 |
19 | height int
20 | width int
21 |
22 | // recalculateFlag indicates if next render should make calculations regarding
23 | // the cells objects height/width
24 | recalculateFlag bool
25 | }
26 |
27 | // AddCells appends the cells to the row
28 | // if the cell ID is not set it will default to the index of the cell
29 | func (r *Row) AddCells(cells ...*Cell) *Row {
30 | r.cells = append(r.cells, cells...)
31 | for i, cell := range r.cells {
32 | if cell.id == "" {
33 | cell.id = strconv.Itoa(i)
34 | }
35 | }
36 | r.setRecalculate()
37 | return r
38 | }
39 |
40 | // CellsLen returns the len of the cells slice
41 | func (r *Row) CellsLen() int {
42 | return len(r.cells)
43 | }
44 |
45 | // GetCell returns the FlexBoxCell on the given index if it exists
46 | // note: forces the recalculation if found
47 | //
48 | // returns nil if not found
49 | func (r *Row) GetCell(index int) *Cell {
50 | if index >= 0 && index < len(r.cells) {
51 | r.setRecalculate()
52 | return r.cells[index]
53 | }
54 | return nil
55 | }
56 |
57 | // GetCellCopy returns a copy of the FlexBoxCell on the given index, if cell
58 | // does not exist it will return nil. This is useful when you need to get
59 | // cells attribute without triggering a recalculation.
60 | func (r *Row) GetCellCopy(index int) *Cell {
61 | if index >= 0 && index < len(r.cells) {
62 | c := r.cells[index].copy()
63 | return &c
64 | }
65 | return nil
66 | }
67 |
68 | // GetCellWithID returns the cell with the given ID if existing
69 | // note: forces the recalculation if found
70 | //
71 | // returns nil if not found
72 | func (r *Row) GetCellWithID(id string) *Cell {
73 | for _, c := range r.cells {
74 | if c.id == id {
75 | r.setRecalculate()
76 | return c
77 | }
78 | }
79 | return nil
80 | }
81 |
82 | // UpdateCellWithIndex replaces the cell on the given index if it exists
83 | // if its not existing no changes will apply
84 | func (r *Row) UpdateCellWithIndex(index int, cell *Cell) {
85 | if index >= 0 && len(r.cells) > 0 && index < len(r.cells) {
86 | r.cells[index] = cell
87 | r.setRecalculate()
88 | }
89 | }
90 |
91 | // SetStyle replaces the style, it unsets width/height related keys
92 | func (r *Row) SetStyle(style lipgloss.Style) *Row {
93 | r.style = style.
94 | UnsetWidth().
95 | UnsetMaxWidth().
96 | UnsetHeight().
97 | UnsetMaxHeight()
98 |
99 | return r
100 | }
101 |
102 | // StylePassing set whether the style should be passed to the cells
103 | func (r *Row) StylePassing(value bool) *Row {
104 | r.styleAncestor = value
105 | return r
106 | }
107 |
108 | func (r *Row) setHeight(value int) {
109 | r.height = value
110 | r.setRecalculate()
111 | }
112 |
113 | func (r *Row) setWidth(value int) {
114 | r.width = value
115 | r.setRecalculate()
116 | }
117 |
118 | func (r *Row) render(inherited ...lipgloss.Style) string {
119 | var inheritedStyle []lipgloss.Style
120 |
121 | for _, style := range inherited {
122 | r.style = r.style.Inherit(style)
123 | }
124 |
125 | // intentionally applied after row inherits the box style
126 | if r.styleAncestor {
127 | inheritedStyle = append(inheritedStyle, r.style)
128 | }
129 |
130 | r.recalculate()
131 | var renderedCells []string
132 | for _, cell := range r.cells {
133 | renderedCells = append(renderedCells, cell.render(inheritedStyle...))
134 | }
135 | return r.style.
136 | Width(r.getContentWidth()).MaxWidth(r.getMaxWidth()).
137 | Height(r.getContentHeight()).MaxHeight(r.getMaxHeight()).
138 | Render(lipgloss.JoinHorizontal(lipgloss.Top, renderedCells...))
139 | }
140 |
141 | func (r *Row) setRecalculate() {
142 | r.recalculateFlag = true
143 | }
144 |
145 | func (r *Row) unsetRecalculate() {
146 | r.recalculateFlag = false
147 | }
148 |
149 | // recalculate fetches the cell's height/width distribution slices and sets it on the cells
150 | func (r *Row) recalculate() {
151 | if r.recalculateFlag {
152 | if len(r.cells) > 0 {
153 | r.distributeCellDimensions(r.calculateCellsDimensions())
154 | }
155 | r.unsetRecalculate()
156 | }
157 | }
158 |
159 | // distributeCellDimensions sets height of each row per distribution array
160 | func (r *Row) distributeCellDimensions(xMatrix, yMatrix []int) {
161 | for index, x := range xMatrix {
162 | r.cells[index].width = x
163 | r.cells[index].height = yMatrix[index]
164 | }
165 | }
166 |
167 | // calculateCellsDimensions calculates the height and width of the each cell
168 | func (r *Row) calculateCellsDimensions() (xMatrix, yMatrix []int) {
169 | // calculate the cell height, it uses fixed combined ratio since the height of each cell
170 | // is individual and does not stack, row height will be calculated using the ratio of the
171 | // highest cell in the slice
172 | cellYMatrix, cellYMatrixMax := r.getCellHeightMatrix()
173 | // reminder not needed here due to how combined ratio is passed
174 | yMatrix, _ = distributeToMatrix(r.getContentHeight(), cellYMatrixMax, cellYMatrix)
175 |
176 | // get the min width matrix of the cells if any
177 | withMinWidth := false
178 | var minWidthMatrix []int
179 | for _, c := range r.cells {
180 | minWidthMatrix = append(minWidthMatrix, c.minWidth)
181 | if c.minWidth > 0 {
182 | withMinWidth = true
183 | }
184 | }
185 |
186 | // calculate the cell width matrix
187 | if withMinWidth {
188 | xMatrix = calculateRatioWithMinimum(r.getContentWidth(), r.getCellWidthMatrix(), minWidthMatrix)
189 | } else {
190 | xMatrix = calculateRatio(r.getContentWidth(), r.getCellWidthMatrix())
191 | }
192 |
193 | return xMatrix, yMatrix
194 | }
195 |
196 | // getCellHeightMatrix return the matrix of the cell height in cells and the max value in it
197 | func (r *Row) getCellHeightMatrix() (cellHeightMatrix []int, max int) {
198 | max = 0
199 | for _, cell := range r.cells {
200 | if cell.ratioY > max {
201 | max = cell.ratioY
202 | }
203 | cellHeightMatrix = append(cellHeightMatrix, cell.ratioY)
204 | }
205 | return cellHeightMatrix, max
206 | }
207 |
208 | // getCellWidthMatrix return the matrix of the cell width in cells
209 | func (r *Row) getCellWidthMatrix() (cellWidthMatrix []int) {
210 | for _, cell := range r.cells {
211 | cellWidthMatrix = append(cellWidthMatrix, cell.ratioX)
212 | }
213 | return cellWidthMatrix
214 | }
215 |
216 | func (r *Row) getContentWidth() int {
217 | return r.getMaxWidth() - r.getExtraWidth()
218 | }
219 |
220 | func (r *Row) getContentHeight() int {
221 | return r.getMaxHeight() - r.getExtraHeight()
222 | }
223 |
224 | func (r *Row) getMaxWidth() int {
225 | return r.width
226 | }
227 |
228 | func (r *Row) getMaxHeight() int {
229 | return r.height
230 | }
231 |
232 | func (r *Row) getExtraWidth() int {
233 | return r.style.GetHorizontalMargins() + r.style.GetHorizontalBorderSize()
234 | }
235 |
236 | func (r *Row) getExtraHeight() int {
237 | return r.style.GetVerticalMargins() + r.style.GetVerticalBorderSize()
238 | }
239 |
240 | func (r *Row) copy() Row {
241 | var cells []*Cell
242 | for _, cell := range r.cells {
243 | cellCopy := cell.copy()
244 | cells = append(cells, &cellCopy)
245 | }
246 | rowCopy := *r
247 | rowCopy.cells = cells
248 | rowCopy.style = r.style
249 |
250 | return rowCopy
251 | }
252 |
--------------------------------------------------------------------------------
/flexbox/utils.go:
--------------------------------------------------------------------------------
1 | package flexbox
2 |
3 | import (
4 | "math"
5 | )
6 |
7 | // TODO: explain this mess using some comments
8 |
9 | func calculateRatioWithMinimum(distribute int, matrix []int, minimumMatrix []int) (ratioDistribution []int) {
10 | for range matrix {
11 | ratioDistribution = append(ratioDistribution, 0)
12 | }
13 |
14 | dist := calculateRatio(distribute, matrix)
15 | for i, d := range dist {
16 | if minimumMatrix[i] > d {
17 | ratioDistribution[i] = minimumMatrix[i]
18 |
19 | distribute -= minimumMatrix[i]
20 | matrix[i] = 0
21 | minimumMatrix[i] = 0
22 | _dist := calculateRatioWithMinimum(distribute, matrix, minimumMatrix)
23 | for ii, _d := range _dist {
24 | if ii != i {
25 | ratioDistribution[ii] = _d
26 | }
27 | }
28 | break
29 | } else {
30 | ratioDistribution[i] = d
31 | }
32 | }
33 | // TODO: calculate remainder and if negative shrink right most column
34 |
35 | return ratioDistribution
36 | }
37 |
38 | func calculateRatio(distribute int, matrix []int) (ratioDistribution []int) {
39 | if distribute == 0 {
40 | for range matrix {
41 | ratioDistribution = append(ratioDistribution, 0)
42 | }
43 | return ratioDistribution
44 | }
45 |
46 | var combinedRatios int
47 | for _, value := range matrix {
48 | combinedRatios += value
49 | }
50 |
51 | if combinedRatios > 0 {
52 | var remainder int
53 | ratioDistribution, remainder = distributeToMatrix(distribute, combinedRatios, matrix)
54 | if remainder > 0 {
55 | for index, remainderAdded := range distributeRemainder(remainder, matrix) {
56 | ratioDistribution[index] += remainderAdded
57 | remainder -= remainderAdded
58 | }
59 | }
60 | // TODO: rethink maybe, does this fn belong here
61 | if remainder < 0 {
62 | // happens when there is minimum value
63 | }
64 | }
65 |
66 | return ratioDistribution
67 | }
68 |
69 | func distributeToMatrix(distribute int, combinedRatio int, matrix []int) (distribution []int, remainder int) {
70 | remainder = distribute
71 | for _, max := range matrix {
72 | ratioDistributionValue := int(math.Floor((float64(max) / float64(combinedRatio)) * float64(distribute)))
73 | distribution = append(distribution, ratioDistributionValue)
74 | remainder -= ratioDistributionValue
75 |
76 | }
77 | return distribution, remainder
78 | }
79 |
80 | func calculateMatrixRatio(distribute int, matrix [][]int) (ratioDistribution []int) {
81 | // get matrix max ratio for each int in matrix slice
82 | var maxRatio []int
83 | for matrixIndex, ratios := range matrix {
84 | maxRatio = append(maxRatio, 0)
85 | for _, ratio := range ratios {
86 | if ratio > maxRatio[matrixIndex] {
87 | maxRatio[matrixIndex] = ratio
88 | }
89 | }
90 | }
91 |
92 | return calculateRatio(distribute, maxRatio)
93 | }
94 |
95 | // distributeRemainder is simple remainder distributor, it will distribute add 1 to next highest
96 | // matrix value till it runs out of remainder to distribute, this might be improved for some more
97 | // complex cases
98 | func distributeRemainder(remainder int, matrixMaxRatio []int) (remainderDistribution []int) {
99 | for range matrixMaxRatio {
100 | remainderDistribution = append(remainderDistribution, 0)
101 | }
102 |
103 | distributed := 0
104 | for remainder > 0 {
105 | maxIndex := 0
106 | maxRatio := 0
107 | for index, ratio := range matrixMaxRatio {
108 | // skip if already expanded
109 | if remainderDistribution[index] > 0 {
110 | continue
111 | }
112 | if ratio > maxRatio {
113 | maxRatio = ratio
114 | maxIndex = index
115 | }
116 | }
117 | remainderDistribution[maxIndex] += 1
118 | distributed += 1
119 | remainder -= 1
120 | }
121 |
122 | return remainderDistribution
123 | }
124 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/76creates/stickers
2 |
3 | go 1.23
4 |
5 | require (
6 | github.com/charmbracelet/bubbletea v1.1.1
7 | github.com/charmbracelet/lipgloss v0.13.0
8 | github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
9 | )
10 |
11 | require (
12 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
13 | github.com/charmbracelet/x/ansi v0.2.3 // indirect
14 | github.com/charmbracelet/x/term v0.2.0 // indirect
15 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
16 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
17 | github.com/mattn/go-isatty v0.0.20 // indirect
18 | github.com/mattn/go-localereader v0.0.1 // indirect
19 | github.com/mattn/go-runewidth v0.0.15 // indirect
20 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
21 | github.com/muesli/cancelreader v0.2.2 // indirect
22 | github.com/muesli/termenv v0.15.2 // indirect
23 | github.com/rivo/uniseg v0.4.7 // indirect
24 | golang.org/x/sync v0.8.0 // indirect
25 | golang.org/x/sys v0.24.0 // indirect
26 | golang.org/x/text v0.3.8 // indirect
27 | )
28 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3 | github.com/charmbracelet/bubbletea v1.1.1 h1:KJ2/DnmpfqFtDNVTvYZ6zpPFL9iRCRr0qqKOCvppbPY=
4 | github.com/charmbracelet/bubbletea v1.1.1/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
5 | github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
6 | github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
7 | github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
8 | github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
9 | github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
10 | github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
11 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
12 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
13 | github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1 h1:FWNFq4fM1wPfcK40yHE5UO3RUdSNPaBC+j3PokzA6OQ=
14 | github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
15 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
16 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
17 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
18 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
19 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
20 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
21 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
22 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
23 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
24 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
25 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
26 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
27 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
28 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
29 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
30 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
31 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
32 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
33 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
34 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
35 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
36 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
37 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
38 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
39 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
40 |
--------------------------------------------------------------------------------
/table/table.go:
--------------------------------------------------------------------------------
1 | package table
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "log"
7 | "math"
8 | "reflect"
9 | "strconv"
10 | "strings"
11 | "unicode/utf8"
12 |
13 | "github.com/76creates/stickers/flexbox"
14 | "github.com/charmbracelet/lipgloss"
15 | )
16 |
17 | var (
18 | tableDefaultHeaderStyle = lipgloss.NewStyle().
19 | Background(lipgloss.Color("#7158e2")).
20 | Foreground(lipgloss.Color("#ffffff"))
21 | tableDefaultFooterStyle = tableDefaultHeaderStyle.Align(lipgloss.Right).Height(1)
22 | tableDefaultRowsStyle = lipgloss.NewStyle().
23 | Background(lipgloss.Color("#4b4b4b")).
24 | Foreground(lipgloss.Color("#ffffff"))
25 | tableDefaultRowsSubsequentStyle = lipgloss.NewStyle().
26 | Background(lipgloss.Color("#3d3d3d")).
27 | Foreground(lipgloss.Color("#ffffff"))
28 | tableDefaultRowsCursorStyle = lipgloss.NewStyle().
29 | Background(lipgloss.Color("#f7b731")).
30 | Foreground(lipgloss.Color("#000000")).
31 | Bold(true)
32 | tableDefaultCellCursorStyle = lipgloss.NewStyle().
33 | Background(lipgloss.Color("#f6e58d")).
34 | Foreground(lipgloss.Color("#000000"))
35 |
36 | tableDefaultSortAscChar = "▲"
37 | tableDefaultSortDescChar = "▼"
38 | tableDefaultFilterChar = "⑂"
39 |
40 | tableDefaultStyles = map[TableStyleKey]lipgloss.Style{
41 | TableHeaderStyleKey: tableDefaultHeaderStyle,
42 | TableFooterStyleKey: tableDefaultFooterStyle,
43 | TableRowsStyleKey: tableDefaultRowsStyle,
44 | TableRowsSubsequentStyleKey: tableDefaultRowsSubsequentStyle,
45 | TableRowsCursorStyleKey: tableDefaultRowsCursorStyle,
46 | TableCellCursorStyleKey: tableDefaultCellCursorStyle,
47 | }
48 | )
49 |
50 | type TableStyleKey int
51 |
52 | const (
53 | TableHeaderStyleKey TableStyleKey = iota
54 | TableFooterStyleKey
55 | TableRowsStyleKey
56 | TableRowsSubsequentStyleKey
57 | TableRowsCursorStyleKey
58 | TableCellCursorStyleKey
59 | )
60 |
61 | type TableSortingOrderKey int
62 |
63 | const (
64 | TableSortingAscending = 0
65 | TableSortingDescending = 1
66 | )
67 |
68 | type Ordered interface {
69 | int | int8 | int32 | int16 | int64 | float32 | float64 | string
70 | }
71 |
72 | // TableBadTypeError type does not match Ordered interface types
73 | type TableBadTypeError struct {
74 | msg string
75 | }
76 |
77 | func (e TableBadTypeError) Error() string {
78 | return e.msg
79 | }
80 |
81 | // TableRowLenError row length is not matching headers len
82 | type TableRowLenError struct {
83 | msg string
84 | }
85 |
86 | func (e TableRowLenError) Error() string {
87 | return e.msg
88 | }
89 |
90 | // TableBadCellTypeError type of cell does not match type of column
91 | type TableBadCellTypeError struct {
92 | msg string
93 | }
94 |
95 | func (e TableBadCellTypeError) Error() string {
96 | return e.msg
97 | }
98 |
99 | // Table responsive, x/y scrollable table that uses magic of FlexBox
100 | type Table struct {
101 | // columnRatio ratio of the columns, is applied to rows as well
102 | columnRatio []int
103 | // columnMinWidth minimal width of the column
104 | columnMinWidth []int
105 | // columnHeaders column text headers
106 | // TODO: make this optional, as well as footer
107 | columnHeaders []string
108 | columnType []any
109 | rows [][]any
110 |
111 | filteredRows [][]any
112 | filteredColumn int
113 | filterString string
114 |
115 | // orderColumnIndex notes which column is used for sorting
116 | // -1 means that no column is sorted
117 | orderedColumnIndex int
118 | // orderedColumnPhase remarks if the sort is asc or desc, basically works like a toggle
119 | // 0 indicates desc sorting, 1 indicates
120 | orderedColumnPhase TableSortingOrderKey
121 |
122 | // rowsTopIndex top visible index
123 | rowsTopIndex int
124 | cursorIndexY int
125 | cursorIndexX int
126 |
127 | height int
128 | width int
129 |
130 | rowsBoxHeight int
131 | // rowHeight fixed row height value, maybe this should be optional?
132 | rowHeight int
133 |
134 | styles map[TableStyleKey]lipgloss.Style
135 | // stylePassing if true, styles are passed all the way down from box to cell
136 | stylePassing bool
137 |
138 | headerBox *flexbox.FlexBox
139 | rowsBox *flexbox.FlexBox
140 |
141 | // these flags indicate weather we should update rows and headers flex boxes
142 | updateRowsFlag bool
143 | updateHeadersFlag bool
144 | }
145 |
146 | // NewTable initialize Table object with defaults
147 | func NewTable(width, height int, columnHeaders []string) *Table {
148 | var columnRatio, columnMinWidth []int
149 | for range columnHeaders {
150 | columnRatio = append(columnRatio, 1)
151 | columnMinWidth = append(columnMinWidth, 0)
152 | }
153 |
154 | // by default all columns are of type string
155 | var defaultType string
156 | var defaultTypes []any
157 | for range columnHeaders {
158 | defaultTypes = append(defaultTypes, defaultType)
159 | }
160 |
161 | styles := tableDefaultStyles
162 |
163 | r := &Table{
164 | columnHeaders: columnHeaders,
165 | columnRatio: columnRatio,
166 | columnMinWidth: columnMinWidth,
167 | cursorIndexX: 0,
168 | cursorIndexY: 0,
169 |
170 | columnType: defaultTypes,
171 | orderedColumnIndex: -1,
172 | orderedColumnPhase: TableSortingDescending,
173 |
174 | filteredColumn: -1,
175 | filterString: "",
176 |
177 | height: height,
178 | width: width,
179 | // when optional header/footer is set rework this
180 | rowsBoxHeight: height - 2,
181 |
182 | rowsTopIndex: 0,
183 | rowHeight: 1,
184 |
185 | headerBox: flexbox.New(width, 1).SetStyle(tableDefaultHeaderStyle),
186 | rowsBox: flexbox.New(width, height-1),
187 |
188 | styles: styles,
189 | stylePassing: false,
190 | }
191 | r.setHeadersUpdate()
192 | return r
193 | }
194 |
195 | // SetRatio replaces the ratio slice, it has to be exactly the len of the headers/rows slices
196 | // also each value have to be greater than 0, if either fails we panic
197 | func (r *Table) SetRatio(values []int) *Table {
198 | if len(values) != len(r.columnHeaders) {
199 | log.Fatalf("ratio list[%d] not of proper length[%d]\n", len(values), len(r.columnHeaders))
200 | }
201 | for _, val := range values {
202 | if val < 1 {
203 | log.Fatalf("ratio value must be greater than 0")
204 | }
205 | }
206 | r.columnRatio = values
207 | r.setHeadersUpdate()
208 | r.setRowsUpdate()
209 | return r
210 | }
211 |
212 | // SetTypes sets the column type, setting this will remove all the rows so make sure you do it when instantiating
213 | // Table object or add new rows after this, types have to be one of Ordered interface types
214 | func (r *Table) SetTypes(columnTypes ...any) (*Table, error) {
215 | if len(columnTypes) != len(r.columnHeaders) {
216 | return r, errors.New("column types not the same len as headers")
217 | }
218 | for i, t := range columnTypes {
219 | if !isOrdered(t) {
220 | message := fmt.Sprintf(
221 | "column of type %s on index %d is not of type Ordered", reflect.TypeOf(t).String(), i,
222 | )
223 | return r, TableBadTypeError{msg: message}
224 | }
225 | }
226 | r.cursorIndexY, r.cursorIndexX = 0, 0
227 | r.rows = [][]any{}
228 | r.columnType = columnTypes
229 | r.setRowsUpdate()
230 | return r, nil
231 | }
232 |
233 | // SetMinWidth replaces the minimum width slice, it has to be exactly the len of the headers/rows slices
234 | // if it's not matching len it will trigger fatal error
235 | func (r *Table) SetMinWidth(values []int) *Table {
236 | if len(values) != len(r.columnHeaders) {
237 | log.Fatalf("min width list[%d] not of proper length[%d]\n", len(values), len(r.columnHeaders))
238 | }
239 | r.columnMinWidth = values
240 | r.setHeadersUpdate()
241 | r.setRowsUpdate()
242 | return r
243 | }
244 |
245 | // SetHeight sets the height of the table including the header and footer
246 | func (r *Table) SetHeight(value int) *Table {
247 | r.height = value
248 | // we deduct two to take header/footer into the account
249 | r.rowsBoxHeight = value - 2
250 | r.rowsBox.SetHeight(r.rowsBoxHeight)
251 | r.setRowsUpdate()
252 | r.setTopRow()
253 | return r
254 | }
255 |
256 | // SetWidth sets the width of the table
257 | func (r *Table) SetWidth(value int) *Table {
258 | r.width = value
259 | r.rowsBox.SetWidth(value)
260 | r.headerBox.SetWidth(value)
261 | return r
262 | }
263 |
264 | // SetStyles allows overrides of styling elements of the table
265 | // When only a partial set of overrides are provided, the default styling will be used
266 | func (r *Table) SetStyles(styles map[TableStyleKey]lipgloss.Style) *Table {
267 | mergedStyles := tableDefaultStyles
268 | for key, style := range styles {
269 | mergedStyles[key] = style
270 | }
271 | r.styles = mergedStyles
272 | r.setRowsUpdate()
273 | r.setHeadersUpdate()
274 | return r
275 | }
276 |
277 | // SetStylePassing sets the style passing flag, if true, styles are passed all the way down from box to cell
278 | func (r *Table) SetStylePassing(value bool) *Table {
279 | r.stylePassing = value
280 | r.headerBox.StylePassing(value)
281 | r.rowsBox.StylePassing(value)
282 | r.setRowsUpdate()
283 | r.setHeadersUpdate()
284 | return r
285 | }
286 |
287 | // UnsetFilter resets filtering
288 | func (r *Table) UnsetFilter() *Table {
289 | r.filterString = ""
290 | r.filteredColumn = -1
291 | r.setTopRow()
292 | r.setRowsUpdate()
293 | r.setHeadersUpdate()
294 | return r
295 | }
296 |
297 | // SetFilter sets filtering string on a column
298 | func (r *Table) SetFilter(columnIndex int, s string) *Table {
299 | if columnIndex < len(r.columnHeaders) {
300 | r.filterString = s
301 | r.filteredColumn = columnIndex
302 |
303 | r.setRowsUpdate()
304 | }
305 | return r
306 | }
307 |
308 | // GetFilter returns string used for filtering and the column index
309 | // TODO: enable multi column filtering
310 | func (r *Table) GetFilter() (columnIndex int, s string) {
311 | return r.filteredColumn, r.filterString
312 | }
313 |
314 | // CursorDown move table cursor down
315 | func (r *Table) CursorDown() *Table {
316 | if r.cursorIndexY+1 < len(r.filteredRows) {
317 | r.cursorIndexY++
318 | r.setTopRow()
319 | r.setRowsUpdate()
320 | }
321 | return r
322 | }
323 |
324 | // CursorUp move table cursor up
325 | func (r *Table) CursorUp() *Table {
326 | if r.cursorIndexY-1 > -1 {
327 | r.cursorIndexY--
328 | r.setTopRow()
329 | r.setRowsUpdate()
330 | }
331 | return r
332 | }
333 |
334 | // CursorLeft move table cursor left
335 | func (r *Table) CursorLeft() *Table {
336 | if r.cursorIndexX-1 > -1 {
337 | r.cursorIndexX--
338 | // TODO: update row only
339 | r.setRowsUpdate()
340 | }
341 | return r
342 | }
343 |
344 | // CursorRight move table cursor right
345 | func (r *Table) CursorRight() *Table {
346 | if r.cursorIndexX+1 < len(r.columnHeaders) {
347 | r.cursorIndexX++
348 | // TODO: update row only
349 | r.setRowsUpdate()
350 | }
351 | return r
352 | }
353 |
354 | // GetCursorLocation returns the current x,y position of the cursor
355 | func (r *Table) GetCursorLocation() (int, int) {
356 | return r.cursorIndexX, r.cursorIndexY
357 | }
358 |
359 | // GetCursorValue returns the string of the cell under the cursor
360 | func (r *Table) GetCursorValue() string {
361 | // handle 0 rows situation and when table is not active
362 | if len(r.filteredRows) == 0 || r.cursorIndexX < 0 || r.cursorIndexY < 0 {
363 | return ""
364 | }
365 | return getStringFromOrdered(r.filteredRows[r.cursorIndexY][r.cursorIndexX])
366 | }
367 |
368 | // AddRows add multiple rows, will return error on the first instance of a row that does not match the type set on table
369 | // will update rows only when there are no errors
370 | func (r *Table) AddRows(rows [][]any) (*Table, error) {
371 | // check for errors
372 | for _, row := range rows {
373 | if err := r.validateRow(row...); err != nil {
374 | return r, err
375 | }
376 | }
377 | // append rows
378 | r.rows = append(r.rows, rows...)
379 |
380 | r.applyFilter()
381 | r.setRowsUpdate()
382 | return r, nil
383 | }
384 |
385 | // MustAddRows executes AddRows and panics if there is an error
386 | func (r *Table) MustAddRows(rows [][]any) *Table {
387 | if _, err := r.AddRows(rows); err != nil {
388 | panic(err)
389 | }
390 | return r
391 | }
392 |
393 | // ClearRows removes all previously added rows, can be used as part of an update loop
394 | func (r *Table) ClearRows() *Table {
395 | r.rows = make([][]any, 0, 10)
396 | r.setRowsUpdate()
397 | return r
398 | }
399 |
400 | // OrderByColumn orders rows by a column with the index n, simple bubble sort, nothing too fancy
401 | // does not apply when there is less than 2 row in a table
402 | // Deprecated: this function will be removed in a future release
403 | // TODO: this messes up numbering that one might use, implement automatic indexing of rows
404 | // TODO: allow user to disable ordering
405 | func (r *Table) OrderByColumn(index int) *Table {
406 | // sanity check first, we won't return errors here, simply ignore if the user sends non existing index
407 | if index < len(r.columnHeaders) && len(r.filteredRows) > 1 {
408 | r.updateOrderedVars(index)
409 | r.sortRows(index)
410 | }
411 | return r
412 | }
413 |
414 | // GetOrder returns the current order column index and phase (0 for asc, 1 for desc)
415 | func (r *Table) GetOrder() (int, int) {
416 | return r.orderedColumnIndex, int(r.orderedColumnPhase)
417 | }
418 |
419 | // OrderByAsc orders rows by a column with index n, in ascending order
420 | func (r *Table) OrderByAsc(index int) *Table {
421 | // sanity check first, we won't return errors here, simply ignore if the user sends non existing index
422 | if index < len(r.columnHeaders) && len(r.filteredRows) > 1 {
423 | r.orderedColumnPhase = TableSortingAscending
424 | r.sortRows(index)
425 | r.orderedColumnIndex = index
426 | r.setHeadersUpdate()
427 | }
428 | return r
429 | }
430 |
431 | // OrderByDesc orders rows by a column with index n, in descending order
432 | func (r *Table) OrderByDesc(index int) *Table {
433 | // sanity check first, we won't return errors here, simply ignore if the user sends non existing index
434 | if index < len(r.columnHeaders) && len(r.filteredRows) > 1 {
435 | r.orderedColumnPhase = TableSortingDescending
436 | r.sortRows(index)
437 | r.orderedColumnIndex = index
438 | r.setHeadersUpdate()
439 | }
440 | return r
441 | }
442 |
443 | func (r *Table) sortRows(index int) {
444 | // sorted rows
445 | var sorted [][]any
446 | // list of column values used for ordering
447 | var orderingCol []any
448 | for _, rw := range r.rows {
449 | orderingCol = append(orderingCol, rw[index])
450 | }
451 | // get sorting index
452 | sortingIndex := sortIndexByOrderedColumn(orderingCol, r.orderedColumnPhase)
453 | // update rows
454 | for _, i := range sortingIndex {
455 | sorted = append(sorted, r.rows[i])
456 | }
457 | r.rows = sorted
458 | r.setRowsUpdate()
459 | }
460 |
461 | // Render renders the table into the string
462 | func (r *Table) Render() string {
463 | r.updateRows()
464 | r.updateHeader()
465 |
466 | statusMessage := fmt.Sprintf(
467 | "%d:%d / %d:%d ",
468 | r.cursorIndexX,
469 | r.cursorIndexY,
470 | r.rowsBox.GetWidth(),
471 | r.rowsBox.GetHeight(),
472 | )
473 | if r.cursorIndexX == r.filteredColumn {
474 | statusMessage = fmt.Sprintf("filtered by: %q / %s", r.filterString, statusMessage)
475 | }
476 |
477 | return lipgloss.JoinVertical(
478 | lipgloss.Left,
479 | r.headerBox.Render(),
480 | r.rowsBox.Render(),
481 | r.styles[TableFooterStyleKey].Width(r.width).Render(statusMessage),
482 | )
483 | }
484 |
485 | func (r *Table) setRowsUpdate() {
486 | r.updateRowsFlag = true
487 | }
488 |
489 | func (r *Table) unsetRowsUpdate() {
490 | r.updateRowsFlag = false
491 | }
492 |
493 | func (r *Table) setHeadersUpdate() {
494 | r.updateHeadersFlag = true
495 | }
496 |
497 | func (r *Table) unsetHeadersUpdate() {
498 | r.updateHeadersFlag = false
499 | }
500 |
501 | // validateRow checks the row for validity, number of cells must match table header length
502 | // and header types per cell as well
503 | func (r *Table) validateRow(cells ...any) error {
504 | var message string
505 | // check row len
506 | if len(cells) != len(r.columnType) {
507 | message = fmt.Sprintf(
508 | "len of row[%d] does not equal number of columns[%d]", len(cells), len(r.columnType),
509 | )
510 | return TableRowLenError{msg: message}
511 | }
512 | // check cell type
513 | for i, c := range cells {
514 | switch c.(type) {
515 | case string, int, int8, int16, int32, float32, float64:
516 | // check if the cell matches the type of the column
517 | if reflect.TypeOf(c) != reflect.TypeOf(r.columnType[i]) {
518 | message = fmt.Sprintf(
519 | "type of the cell[%v] on index %d not matching type of the column[%v]",
520 | reflect.TypeOf(c), i, reflect.TypeOf(r.columnType[i]),
521 | )
522 | return TableBadCellTypeError{msg: message}
523 | }
524 | default:
525 | message = fmt.Sprintf(
526 | "type[%v] on index %d not matching Ordered interface types", reflect.TypeOf(c), i,
527 | )
528 | return TableBadTypeError{msg: message}
529 | }
530 | }
531 | return nil
532 | }
533 |
534 | // updateHeader recomputes the header of the table
535 | func (r *Table) updateHeader() *Table {
536 | if !r.updateHeadersFlag {
537 | return r
538 | }
539 | var cells []*flexbox.Cell
540 | r.headerBox.SetStyle(r.styles[TableHeaderStyleKey])
541 | for i, title := range r.columnHeaders {
542 | // titleSuffix at the moment can be sort and filter characters
543 | // filtering symbol should be visible always, if possible of course, and as far right as possible
544 | // there should be a minimum of space bar between two symbols and symbol and row to the right
545 | var titleSuffix string
546 | _h := r.headerBox.GetRow(0)
547 | // skip the case when we initialize table
548 | if _h != nil {
549 | _c := _h.GetCellCopy(i)
550 | if _c == nil {
551 | panic("cell with index " + strconv.Itoa(i) + " is nil")
552 | }
553 | _w := _c.GetWidth()
554 |
555 | // add sorting symbol if the sorting is active on the column
556 | if r.orderedColumnIndex == i {
557 | if r.orderedColumnPhase == TableSortingDescending {
558 | titleSuffix = " " + tableDefaultSortDescChar
559 | } else if r.orderedColumnPhase == TableSortingAscending {
560 | titleSuffix = " " + tableDefaultSortAscChar
561 | }
562 | }
563 |
564 | // add filtering symbol if the filtering is active on the column
565 | if r.filteredColumn == i && r.filterString != "" {
566 | // add at least one space bar between char to the left, and one to the right
567 | titleSuffix = titleSuffix + strings.Repeat(
568 | " ", int(math.Max(
569 | 1,
570 | float64(
571 | _w-utf8.RuneCountInString(title+titleSuffix)-2,
572 | ),
573 | )),
574 | ) + tableDefaultFilterChar + " "
575 | }
576 |
577 | // if title and suffix exceed width trim the title
578 | if _w-utf8.RuneCountInString(title+titleSuffix) < 0 {
579 | // this will be the cae only when sort is on and filter is off
580 | // add one space bar between sort and column to the right
581 | if utf8.RuneCountInString(titleSuffix) == 2 {
582 | titleSuffix = titleSuffix + " "
583 | }
584 | // trim the title
585 | title = title[0:int(math.Max(0, float64(_w-utf8.RuneCountInString(titleSuffix))))]
586 | }
587 | }
588 | cells = append(
589 | cells,
590 | flexbox.NewCell(r.columnRatio[i], 1).SetMinWidth(r.columnMinWidth[i]).SetContent(title+titleSuffix),
591 | )
592 | }
593 | r.headerBox.SetRows(
594 | []*flexbox.Row{
595 | r.headerBox.NewRow().StylePassing(r.stylePassing).AddCells(cells...),
596 | },
597 | )
598 | r.unsetHeadersUpdate()
599 | return r
600 | }
601 |
602 | // updateRows recomputes the rows of the table
603 | // calculate the visible rows top/bottom indexes
604 | // create rows and their cells with styles depending on state
605 | func (r *Table) updateRows() {
606 | if !r.updateRowsFlag {
607 | return
608 | }
609 | if r.rowsBoxHeight < 0 {
610 | r.unsetRowsUpdate()
611 | return
612 | }
613 | r.applyFilter()
614 |
615 | // calculate the bottom most visible row index
616 | rowsBottomIndex := r.rowsTopIndex + r.rowsBoxHeight
617 | if rowsBottomIndex > len(r.filteredRows) {
618 | rowsBottomIndex = len(r.filteredRows)
619 | }
620 |
621 | var rows []*flexbox.Row
622 | for ir, columns := range r.filteredRows[r.rowsTopIndex:rowsBottomIndex] {
623 | // irCorrected is corrected row index since we iterate only visible rows
624 | irCorrected := ir + r.rowsTopIndex
625 |
626 | var cells []*flexbox.Cell
627 | for ic, column := range columns {
628 | // initialize column cell
629 | c := flexbox.NewCell(r.columnRatio[ic], r.rowHeight).
630 | SetMinWidth(r.columnMinWidth[ic]).
631 | SetContent(getStringFromOrdered(column))
632 | // update style if cursor is on the cell, otherwise it's inherited from the row
633 | if irCorrected == r.cursorIndexY && ic == r.cursorIndexX {
634 | c.SetStyle(r.styles[TableCellCursorStyleKey])
635 | }
636 | cells = append(cells, c)
637 | }
638 | // initialize new row from the rows box and add generated cells
639 | rw := r.rowsBox.NewRow().StylePassing(r.stylePassing).AddCells(cells...)
640 |
641 | // rows have three styles, normal, subsequent and selected
642 | // normal and subsequent rows should differ for readability
643 | // TODO: make this ^ optional
644 | if irCorrected == r.cursorIndexY {
645 | rw.SetStyle(r.styles[TableRowsCursorStyleKey])
646 | } else if irCorrected%2 == 0 || irCorrected == 0 {
647 | rw.SetStyle(r.styles[TableRowsSubsequentStyleKey])
648 | } else {
649 | rw.SetStyle(r.styles[TableRowsStyleKey])
650 | }
651 |
652 | rows = append(rows, rw)
653 | }
654 |
655 | // lock row height, this might get optional at some point
656 | r.rowsBox.LockRowHeight(r.rowHeight)
657 | r.rowsBox.SetRows(rows)
658 | r.unsetRowsUpdate()
659 | }
660 |
661 | // applyFilter filters column n by a value s
662 | func (r *Table) applyFilter() *Table {
663 | // sending empty string should reset the filtering
664 | if r.filterString == "" {
665 | r.filteredRows = r.rows
666 | return r
667 | }
668 | var filteredRows [][]any
669 | for _, row := range r.rows {
670 | cellValue := getStringFromOrdered(row[r.filteredColumn])
671 | // convert to lower, not sure if anybody needs case-sensitive filtering
672 | // if you are reading this and need it, open up an issue :zap:
673 | if strings.Contains(strings.ToLower(cellValue), strings.ToLower(r.filterString)) {
674 | filteredRows = append(filteredRows, row)
675 | }
676 | }
677 | r.filteredRows = filteredRows
678 | r.setTopRow()
679 | r.setHeadersUpdate()
680 | return r
681 | }
682 |
683 | // updateOrderedVars updates bits and pieces revolving around ordering
684 | // toggling between asc and desc
685 | // updating ordering vars on TableOrdered
686 | func (r *Table) updateOrderedVars(index int) {
687 | // toggle between ascending and descending and set default first sort to ascending
688 | if r.orderedColumnIndex == index {
689 | switch r.orderedColumnPhase {
690 | case TableSortingAscending:
691 | r.orderedColumnPhase = TableSortingDescending
692 |
693 | case TableSortingDescending:
694 | r.orderedColumnPhase = TableSortingAscending
695 | }
696 | } else {
697 | r.orderedColumnPhase = TableSortingDescending
698 | }
699 | r.orderedColumnIndex = index
700 |
701 | r.setHeadersUpdate()
702 | }
703 |
704 | // setTopRow calculates the row top index used when deciding what is visible
705 | func (r *Table) setTopRow() {
706 | // if rows are empty set y to 0, retain x pos
707 | // will be useful for filtering
708 | if len(r.filteredRows) == 0 {
709 | r.cursorIndexY = 0
710 | } else if r.cursorIndexY > len(r.filteredRows) {
711 | // when filtering if cursor is higher than row length
712 | // set it to the bottom of the list
713 | r.cursorIndexY = len(r.filteredRows) - 1
714 | }
715 |
716 | // case when cursor is in between top or bottom visible row
717 | if r.cursorIndexY >= r.rowsTopIndex && r.cursorIndexY < r.rowsTopIndex+r.rowsBoxHeight {
718 | // if cursor is on the last item in row, adjust the row top
719 | if r.cursorIndexY == len(r.filteredRows)-1 {
720 | // if all rows can fit on screen
721 | if len(r.filteredRows) <= r.rowsBoxHeight {
722 | r.rowsTopIndex = 0
723 | return
724 | }
725 | // fit max rows on the table
726 | r.rowsTopIndex = r.cursorIndexY - (r.rowsBoxHeight - 1)
727 | } else if r.cursorIndexY > len(r.filteredRows)-1 && len(r.filteredRows) != 0 {
728 | r.cursorIndexY = len(r.filteredRows) - 1
729 | }
730 | return
731 | }
732 |
733 | // if cursor is above the top
734 | if r.cursorIndexY < r.rowsTopIndex {
735 | if r.cursorIndexY == len(r.filteredRows)-1 {
736 | // if all rows can fit on screen
737 | if len(r.filteredRows) <= r.rowsBoxHeight {
738 | r.rowsTopIndex = 0
739 | return
740 | }
741 | // fit max rows on the table
742 | r.rowsTopIndex = r.cursorIndexY - (r.rowsBoxHeight - 1)
743 | return
744 | }
745 | r.rowsTopIndex = r.cursorIndexY
746 | return
747 | }
748 |
749 | if r.cursorIndexY > r.rowsTopIndex {
750 | r.rowsTopIndex = r.cursorIndexY - r.rowsBoxHeight + 1
751 | return
752 | }
753 | }
754 |
755 | // isOrdered check if type is one of valid Ordered types
756 | func isOrdered(e any) bool {
757 | switch e.(type) {
758 | case string, int, int8, int16, int32, float32, float64:
759 | return true
760 | default:
761 | return false
762 | }
763 | }
764 |
765 | // getStringFromOrdered returns string from interface that was produced with one of Ordered types
766 | func getStringFromOrdered(i any) string {
767 | switch i := i.(type) {
768 | case string:
769 | return i
770 | case int:
771 | return strconv.Itoa(i)
772 | case int8:
773 | return strconv.Itoa(int(i))
774 | case int16:
775 | return strconv.Itoa(int(i))
776 | case int32:
777 | return strconv.Itoa(int(i))
778 | case int64:
779 | return strconv.Itoa(int(i))
780 | case float32:
781 | // default precision of 24
782 | return strconv.FormatFloat(float64(i), 'G', 0, 32)
783 | case float64:
784 | // default precision of 24
785 | return strconv.FormatFloat(i, 'G', 0, 64)
786 | default:
787 | return ""
788 | }
789 | }
790 |
791 | // sortIndexByOrderedColumn casts to the one of Ordered type that is used on the column and sends to sorting
792 | // returns sorted index of elements rather than elements themselves
793 | func sortIndexByOrderedColumn(i []any, order TableSortingOrderKey) (sortedIndex []int) {
794 | // if len of slice is 0 return empty sort order
795 | if len(i) == 0 {
796 | return sortedIndex
797 | }
798 |
799 | switch i[0].(type) {
800 | case string:
801 | var s []string
802 | for _, el := range i {
803 | s = append(s, el.(string))
804 | }
805 | return sortIndex(s, order)
806 | case int:
807 | var s []int
808 | for _, el := range i {
809 | s = append(s, el.(int))
810 | }
811 | return sortIndex(s, order)
812 | case int8:
813 | var s []int8
814 | for _, el := range i {
815 | s = append(s, el.(int8))
816 | }
817 | return sortIndex(s, order)
818 | case int16:
819 | var s []int16
820 | for _, el := range i {
821 | s = append(s, el.(int16))
822 | }
823 | return sortIndex(s, order)
824 | case int32:
825 | var s []int32
826 | for _, el := range i {
827 | s = append(s, el.(int32))
828 | }
829 | return sortIndex(s, order)
830 | case int64:
831 | var s []int64
832 | for _, el := range i {
833 | s = append(s, el.(int64))
834 | }
835 | return sortIndex(s, order)
836 | case float32:
837 | var s []float32
838 | for _, el := range i {
839 | s = append(s, el.(float32))
840 | }
841 | return sortIndex(s, order)
842 | case float64:
843 | var s []float64
844 | for _, el := range i {
845 | s = append(s, el.(float64))
846 | }
847 | return sortIndex(s, order)
848 |
849 | default:
850 | panic(fmt.Sprintf("type %s not subtype of Ordered", reflect.TypeOf(i[0]).String()))
851 | }
852 | }
853 |
854 | // sortIndex is simple generic bubble sort, returns sorted index slice
855 | // bubble sort implemented for simplicity, if you need faster alg feel free to open a PR for it :zap:
856 | func sortIndex[T Ordered](slice []T, order TableSortingOrderKey) []int {
857 | // could do this in sortIndexByOrderedColumn where we cycle through the slice anyhow
858 | // tho I think this is cheap op and makes code a bit cleaner, worthy trade for now
859 | var index []int
860 | for i := 0; i < len(slice); i++ {
861 | index = append(index, i)
862 | }
863 |
864 | // bubble sort slice and update index in a process
865 | for i := len(slice); i > 0; i-- {
866 | for j := 1; j < i; j++ {
867 | if order == TableSortingDescending && slice[j] < slice[j-1] {
868 | slice[j], slice[j-1] = slice[j-1], slice[j]
869 | index[j], index[j-1] = index[j-1], index[j]
870 | } else if order == TableSortingAscending && slice[j] > slice[j-1] {
871 | slice[j], slice[j-1] = slice[j-1], slice[j]
872 | index[j], index[j-1] = index[j-1], index[j]
873 | }
874 | }
875 | }
876 | return index
877 | }
878 |
--------------------------------------------------------------------------------
/table/tableSingleType.go:
--------------------------------------------------------------------------------
1 | package table
2 |
3 | // TableSingleType is Table that is using only 1 type for rows allowing for easier AddRows with fewer errors
4 | type TableSingleType[T Ordered] struct {
5 | Table
6 | }
7 |
8 | // NewTableSingleType initialize TableSingleType object with defaults
9 | func NewTableSingleType[T Ordered](width, height int, columnHeaders []string) *TableSingleType[T] {
10 | var defaultTypes []any
11 | var usedType T
12 |
13 | // set type to selected type
14 | for range columnHeaders {
15 | defaultTypes = append(defaultTypes, usedType)
16 | }
17 |
18 | t := &TableSingleType[T]{
19 | Table: *NewTable(width, height, columnHeaders),
20 | }
21 |
22 | _, err := t.Table.SetTypes(defaultTypes...)
23 | if err != nil {
24 | panic(err)
25 | }
26 |
27 | return t
28 | }
29 |
30 | // SetTypes overridden for TableSimple
31 | func (r *TableSingleType[T]) SetTypes() {
32 | }
33 |
34 | func (r *TableSingleType[T]) AddRows(rows [][]T) *TableSingleType[T] {
35 | for _, row := range rows {
36 | var _row []any
37 | for _, cell := range row {
38 | _row = append(_row, cell)
39 | }
40 | r.rows = append(r.rows, _row)
41 | }
42 |
43 | r.applyFilter()
44 | r.setRowsUpdate()
45 | return r
46 | }
47 |
48 | func (r *TableSingleType[T]) MustAddRows(rows [][]T) *TableSingleType[T] {
49 | return r.AddRows(rows)
50 | }
51 |
--------------------------------------------------------------------------------