├── .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 | ![Demo](https://raw.githubusercontent.com/76creates/stickers/master/.github/images/flex-box-with-table.gif) 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 | ![FlexBox Simple Demo](https://raw.githubusercontent.com/76creates/stickers/master/.github/images/flex-box-simple.gif) 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 | ![Table Multi-Type Demo](https://raw.githubusercontent.com/76creates/stickers/master/.github/images/table-multi-type.gif) 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 | The Charm logo 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 | --------------------------------------------------------------------------------