├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── go.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── example ├── go.mod ├── go.sum └── main.go ├── go.mod ├── go.sum ├── table.go └── table_test.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ v* ] 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | 12 | go: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v3 19 | with: 20 | go-version: 1.18 21 | 22 | - name: Lint 23 | uses: golangci/golangci-lint-action@v3 24 | with: 25 | version: v1.46.2 26 | 27 | - name: Test 28 | run: go test -race ./... 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | go-bubble-table 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/go,macos,linux,windows,dotenv,visualstudiocode,goland,vim,git 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=go,macos,linux,windows,dotenv,visualstudiocode,goland,vim,git 5 | 6 | ### dotenv ### 7 | .env 8 | 9 | ### Git ### 10 | # Created by git for backups. To disable backups in Git: 11 | # $ git config --global mergetool.keepBackup false 12 | *.orig 13 | 14 | # Created by git when using merge tools for conflicts 15 | *.BACKUP.* 16 | *.BASE.* 17 | *.LOCAL.* 18 | *.REMOTE.* 19 | *_BACKUP_*.txt 20 | *_BASE_*.txt 21 | *_LOCAL_*.txt 22 | *_REMOTE_*.txt 23 | 24 | ### Go ### 25 | # If you prefer the allow list template instead of the deny list, see community template: 26 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 27 | # 28 | # Binaries for programs and plugins 29 | *.exe 30 | *.exe~ 31 | *.dll 32 | *.so 33 | *.dylib 34 | 35 | # Test binary, built with `go test -c` 36 | *.test 37 | 38 | # Output of the go coverage tool, specifically when used with LiteIDE 39 | *.out 40 | 41 | # Dependency directories (remove the comment below to include it) 42 | # vendor/ 43 | 44 | # Go workspace file 45 | # go.work 46 | 47 | ### Go Patch ### 48 | /vendor/ 49 | /Godeps/ 50 | 51 | ### Goland ### 52 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm, Rider and Goland 53 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 54 | 55 | # User-specific stuff 56 | .idea/**/workspace.xml 57 | .idea/**/tasks.xml 58 | .idea/**/usage.statistics.xml 59 | .idea/**/dictionaries 60 | .idea/**/shelf 61 | 62 | # AWS User-specific 63 | .idea/**/aws.xml 64 | 65 | # Generated files 66 | .idea/**/contentModel.xml 67 | 68 | # Sensitive or high-churn files 69 | .idea/**/dataSources/ 70 | .idea/**/dataSources.ids 71 | .idea/**/dataSources.local.xml 72 | .idea/**/sqlDataSources.xml 73 | .idea/**/dynamic.xml 74 | .idea/**/uiDesigner.xml 75 | .idea/**/dbnavigator.xml 76 | 77 | # Gradle 78 | .idea/**/gradle.xml 79 | .idea/**/libraries 80 | 81 | # Gradle and Maven with auto-import 82 | # When using Gradle or Maven with auto-import, you should exclude module files, 83 | # since they will be recreated, and may cause churn. Uncomment if using 84 | # auto-import. 85 | # .idea/artifacts 86 | # .idea/compiler.xml 87 | # .idea/jarRepositories.xml 88 | # .idea/modules.xml 89 | # .idea/*.iml 90 | # .idea/modules 91 | # *.iml 92 | # *.ipr 93 | 94 | # CMake 95 | cmake-build-*/ 96 | 97 | # Mongo Explorer plugin 98 | .idea/**/mongoSettings.xml 99 | 100 | # File-based project format 101 | *.iws 102 | 103 | # IntelliJ 104 | out/ 105 | 106 | # mpeltonen/sbt-idea plugin 107 | .idea_modules/ 108 | 109 | # JIRA plugin 110 | atlassian-ide-plugin.xml 111 | 112 | # Cursive Clojure plugin 113 | .idea/replstate.xml 114 | 115 | # Crashlytics plugin (for Android Studio and IntelliJ) 116 | com_crashlytics_export_strings.xml 117 | crashlytics.properties 118 | crashlytics-build.properties 119 | fabric.properties 120 | 121 | # Editor-based Rest Client 122 | .idea/httpRequests 123 | 124 | # Ignores the whole .idea folder and all .iml files 125 | .idea/ 126 | 127 | # Android studio 3.1+ serialized cache file 128 | .idea/caches/build_file_checksums.ser 129 | 130 | ### Linux ### 131 | *~ 132 | 133 | # temporary files which can be created if a process still has a handle open of a deleted file 134 | .fuse_hidden* 135 | 136 | # KDE directory preferences 137 | .directory 138 | 139 | # Linux trash folder which might appear on any partition or disk 140 | .Trash-* 141 | 142 | # .nfs files are created when an open file is removed but is still being accessed 143 | .nfs* 144 | 145 | ### macOS ### 146 | # General 147 | .DS_Store 148 | .AppleDouble 149 | .LSOverride 150 | 151 | # Icon must end with two \r 152 | Icon 153 | 154 | 155 | # Thumbnails 156 | ._* 157 | 158 | # Files that might appear in the root of a volume 159 | .DocumentRevisions-V100 160 | .fseventsd 161 | .Spotlight-V100 162 | .TemporaryItems 163 | .Trashes 164 | .VolumeIcon.icns 165 | .com.apple.timemachine.donotpresent 166 | 167 | # Directories potentially created on remote AFP share 168 | .AppleDB 169 | .AppleDesktop 170 | Network Trash Folder 171 | Temporary Items 172 | .apdisk 173 | 174 | ### Vim ### 175 | # Swap 176 | [._]*.s[a-v][a-z] 177 | !*.svg # comment out if you don't need vector files 178 | [._]*.sw[a-p] 179 | [._]s[a-rt-v][a-z] 180 | [._]ss[a-gi-z] 181 | [._]sw[a-p] 182 | 183 | # Session 184 | Session.vim 185 | Sessionx.vim 186 | 187 | # Temporary 188 | .netrwhist 189 | # Auto-generated tag files 190 | tags 191 | # Persistent undo 192 | [._]*.un~ 193 | 194 | ### VisualStudioCode ### 195 | .vscode/* 196 | !.vscode/settings.json 197 | !.vscode/tasks.json 198 | !.vscode/launch.json 199 | !.vscode/extensions.json 200 | !.vscode/*.code-snippets 201 | 202 | # Local History for Visual Studio Code 203 | .history/ 204 | 205 | # Built Visual Studio Code Extensions 206 | *.vsix 207 | 208 | ### VisualStudioCode Patch ### 209 | # Ignore all local history of files 210 | .history 211 | .ionide 212 | 213 | # Support for Project snippet scope 214 | 215 | ### Windows ### 216 | # Windows thumbnail cache files 217 | Thumbs.db 218 | Thumbs.db:encryptable 219 | ehthumbs.db 220 | ehthumbs_vista.db 221 | 222 | # Dump file 223 | *.stackdump 224 | 225 | # Folder config file 226 | [Dd]esktop.ini 227 | 228 | # Recycle Bin used on file shares 229 | $RECYCLE.BIN/ 230 | 231 | # Windows Installer files 232 | *.cab 233 | *.msi 234 | *.msix 235 | *.msm 236 | *.msp 237 | 238 | # Windows shortcuts 239 | *.lnk 240 | 241 | # End of https://www.toptal.com/developers/gitignore/api/go,macos,linux,windows,dotenv,visualstudiocode,goland,vim,git 242 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | hello@calyptia.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Table 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/calyptia/go-bubble-table.svg)](https://pkg.go.dev/github.com/calyptia/go-bubble-table) 4 | 5 | TUI table component for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications. 6 | 7 | This component is used in the [Calyptia Cloud CLI](https://github.com/calyptia/cloud-cli) under the `top` command. 8 | 9 | https://user-images.githubusercontent.com/7969166/155205138-e205b38b-3631-43b2-a369-1f57914da838.mp4 10 | 11 | ## Installation 12 | 13 | ```bash 14 | go get github.com/calyptia/go-bubble-table 15 | ``` 16 | 17 | ## Example 18 | 19 | For an example please take a look at the `/example` directory. 20 | -------------------------------------------------------------------------------- /example/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/calyptia/go-bubble-table/example 2 | 3 | go 1.18 4 | 5 | replace github.com/calyptia/go-bubble-table => ../ 6 | 7 | require ( 8 | github.com/brianvoe/gofakeit/v6 v6.16.0 9 | github.com/calyptia/go-bubble-table v0.0.0-00010101000000-000000000000 10 | github.com/charmbracelet/bubbletea v0.21.0 11 | github.com/charmbracelet/lipgloss v0.5.0 12 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 13 | ) 14 | 15 | require ( 16 | github.com/charmbracelet/bubbles v0.11.0 // indirect 17 | github.com/containerd/console v1.0.3 // indirect 18 | github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect 19 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 20 | github.com/lunixbochs/vtclean v1.0.0 // indirect 21 | github.com/mattn/go-isatty v0.0.14 // indirect 22 | github.com/mattn/go-runewidth v0.0.13 // indirect 23 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect 24 | github.com/muesli/cancelreader v0.2.0 // indirect 25 | github.com/muesli/reflow v0.3.0 // indirect 26 | github.com/muesli/termenv v0.12.0 // indirect 27 | github.com/rivo/uniseg v0.2.0 // indirect 28 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /example/go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 2 | github.com/brianvoe/gofakeit/v6 v6.16.0 h1:EelCqtfArd8ppJ0z+TpOxXH8sVWNPBadPNdCDSMMw7k= 3 | github.com/brianvoe/gofakeit/v6 v6.16.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= 4 | github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q= 5 | github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= 6 | github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI= 7 | github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= 8 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 9 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 10 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 11 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 12 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 13 | github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o= 14 | github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= 15 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 16 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 17 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 18 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 19 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 20 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 21 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 22 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 23 | github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= 24 | github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= 25 | github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 26 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 27 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 28 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 29 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 30 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 31 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 32 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 33 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 34 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= 35 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 36 | github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q= 37 | github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 38 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 39 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 40 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 41 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 42 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 43 | github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= 44 | github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= 45 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 46 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 47 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 48 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 49 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 50 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 51 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= 57 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 59 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= 60 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 61 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 62 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 63 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/brianvoe/gofakeit/v6" 8 | table "github.com/calyptia/go-bubble-table" 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "golang.org/x/term" 12 | ) 13 | 14 | func main() { 15 | err := tea.NewProgram(initialModel(), tea.WithAltScreen()).Start() 16 | if err != nil { 17 | fmt.Fprintln(os.Stderr, err) 18 | os.Exit(1) 19 | } 20 | } 21 | 22 | var ( 23 | styleDoc = lipgloss.NewStyle().Padding(1) 24 | ) 25 | 26 | func initialModel() model { 27 | w, h, err := term.GetSize(int(os.Stdout.Fd())) 28 | if err != nil { 29 | w = 80 30 | h = 24 31 | } 32 | top, right, bottom, left := styleDoc.GetPadding() 33 | w = w - left - right 34 | h = h - top - bottom 35 | tbl := table.New([]string{"ID", "NAME", "AGE", "CITY"}, w, h) 36 | rows := make([]table.Row, 100) 37 | for i := 0; i < 100; i++ { 38 | rows[i] = table.SimpleRow{ 39 | i, 40 | gofakeit.Name(), 41 | gofakeit.Number(0, 122), 42 | gofakeit.City(), 43 | } 44 | } 45 | tbl.SetRows(rows) 46 | return model{table: tbl} 47 | } 48 | 49 | type model struct { 50 | table table.Model 51 | } 52 | 53 | func (m model) Init() tea.Cmd { 54 | return nil 55 | } 56 | 57 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 58 | switch msg := msg.(type) { 59 | case tea.WindowSizeMsg: 60 | top, right, bottom, left := styleDoc.GetPadding() 61 | m.table.SetSize( 62 | msg.Width-left-right, 63 | msg.Height-top-bottom, 64 | ) 65 | case tea.KeyMsg: 66 | switch msg.String() { 67 | case "ctrl+c": 68 | return m, tea.Quit 69 | } 70 | } 71 | 72 | var cmd tea.Cmd 73 | m.table, cmd = m.table.Update(msg) 74 | return m, cmd 75 | } 76 | 77 | func (m model) View() string { 78 | return styleDoc.Render( 79 | m.table.View(), 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/calyptia/go-bubble-table 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.11.0 7 | github.com/charmbracelet/bubbletea v0.21.0 8 | github.com/charmbracelet/lipgloss v0.5.0 9 | github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc 10 | github.com/muesli/reflow v0.3.0 11 | ) 12 | 13 | require ( 14 | github.com/containerd/console v1.0.3 // indirect 15 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 16 | github.com/lunixbochs/vtclean v1.0.0 // indirect 17 | github.com/mattn/go-isatty v0.0.14 // indirect 18 | github.com/mattn/go-runewidth v0.0.13 // indirect 19 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect 20 | github.com/muesli/cancelreader v0.2.0 // indirect 21 | github.com/muesli/termenv v0.12.0 // indirect 22 | github.com/rivo/uniseg v0.2.0 // indirect 23 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect 24 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 2 | github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q= 3 | github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= 4 | github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI= 5 | github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= 6 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 7 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 8 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 9 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 10 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 11 | github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o= 12 | github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= 13 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 14 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 15 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 16 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 17 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 18 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 19 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 | github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= 22 | github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= 23 | github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= 24 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 25 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 26 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 27 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 28 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 29 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 30 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 31 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 32 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= 33 | github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 34 | github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q= 35 | github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 36 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 37 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 38 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 39 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 40 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 41 | github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= 42 | github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= 43 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 44 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 45 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 46 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 47 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 48 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 49 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 50 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= 55 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 56 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 57 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= 58 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 59 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 60 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 61 | -------------------------------------------------------------------------------- /table.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "regexp" 7 | "strings" 8 | "unicode" 9 | 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/viewport" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | "github.com/juju/ansiterm/tabwriter" 15 | "github.com/muesli/reflow/ansi" 16 | ) 17 | 18 | // Row renderer. 19 | type Row interface { 20 | // Render the row into the given tabwriter. 21 | // To render correctly, join each cell by a tab character '\t'. 22 | // Use `m.Cursor() == index` to determine if the row is selected. 23 | // Take a look at the `SimpleRow` implementation for an example. 24 | Render(w io.Writer, model Model, index int) 25 | } 26 | 27 | // SimpleRow is a set of cells that can be rendered into a table. 28 | // It supports row highlight if selected. 29 | type SimpleRow []any 30 | 31 | // Render a simple row. 32 | func (row SimpleRow) Render(w io.Writer, model Model, rowIndex int) { 33 | cells := make([]string, len(row)) 34 | for i, v := range row { 35 | cells[i] = model.Styles.Cell(model, rowIndex, i).Render(fmt.Sprintf("%v", v)) 36 | } 37 | s := strings.Join(cells, "\t"+model.Styles.ColumnSeparator) 38 | fmt.Fprintln(w, s) 39 | } 40 | 41 | // New model. 42 | func New(cols []string, width, height int) Model { 43 | vp := viewport.New(width, maxInt(height-1, 0)) 44 | tw := &tabwriter.Writer{} 45 | return Model{ 46 | KeyMap: DefaultKeyMap(), 47 | Styles: DefaultStyles(), 48 | cols: cols, 49 | header: strings.Join(cols, " "), // simple initial header view without tabwriter. 50 | viewPort: vp, 51 | tabWriter: tw, 52 | } 53 | } 54 | 55 | func maxInt(a, b int) int { 56 | if a > b { 57 | return a 58 | } 59 | return b 60 | } 61 | 62 | // Model of a table component. 63 | type Model struct { 64 | KeyMap KeyMap 65 | Styles Styles 66 | cols []string 67 | rows []Row 68 | header string 69 | viewPort viewport.Model 70 | tabWriter *tabwriter.Writer 71 | cursor int 72 | offset uint 73 | contentWidth int 74 | } 75 | 76 | // KeyMap holds the key bindings for the table. 77 | type KeyMap struct { 78 | End key.Binding 79 | Home key.Binding 80 | PageDown key.Binding 81 | PageUp key.Binding 82 | Down key.Binding 83 | Up key.Binding 84 | Right key.Binding 85 | Left key.Binding 86 | } 87 | 88 | // DefaultKeyMap used by the `New` constructor. 89 | func DefaultKeyMap() KeyMap { 90 | return KeyMap{ 91 | End: key.NewBinding( 92 | key.WithKeys("end"), 93 | key.WithHelp("end", "bottom"), 94 | ), 95 | Home: key.NewBinding( 96 | key.WithKeys("home"), 97 | key.WithHelp("home", "top"), 98 | ), 99 | PageDown: key.NewBinding( 100 | key.WithKeys("pgdown"), 101 | key.WithHelp("pgdown", "page down"), 102 | ), 103 | PageUp: key.NewBinding( 104 | key.WithKeys("pgup"), 105 | key.WithHelp("pgup", "page up"), 106 | ), 107 | Down: key.NewBinding( 108 | key.WithKeys("down"), 109 | key.WithHelp("↓", "down"), 110 | ), 111 | Up: key.NewBinding( 112 | key.WithKeys("up"), 113 | key.WithHelp("↑", "up"), 114 | ), 115 | Right: key.NewBinding( 116 | key.WithKeys("right"), 117 | key.WithHelp("→", "right"), 118 | ), 119 | Left: key.NewBinding( 120 | key.WithKeys("left"), 121 | key.WithHelp("←", "left"), 122 | ), 123 | } 124 | } 125 | 126 | // Styles holds the styling for the table. 127 | type Styles struct { 128 | Title lipgloss.Style 129 | Cell func(model Model, rowIndex int, colIndex int) lipgloss.Style 130 | ColumnSeparator string 131 | } 132 | 133 | var ( 134 | defaultSelectionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")).Bold(true) 135 | defaultRowStyle = lipgloss.NewStyle().Bold(false) 136 | defaultHighlightedColStyle = defaultRowStyle.Copy().Italic(true).Foreground(lipgloss.Color("#EDFB78")) 137 | defaultColSeparator = lipgloss.NormalBorder().Left + " " 138 | ) 139 | 140 | // DefaultStyles used by the `New` constructor. 141 | func DefaultStyles() Styles { 142 | return Styles{ 143 | Title: lipgloss.NewStyle().Bold(true), 144 | Cell: func(model Model, rowIndex int, colIndex int) lipgloss.Style { 145 | if model.Cursor() == rowIndex { 146 | return defaultSelectionStyle 147 | } 148 | if colIndex == 1 { 149 | return defaultHighlightedColStyle 150 | } 151 | return defaultRowStyle 152 | }, 153 | ColumnSeparator: defaultColSeparator, 154 | } 155 | } 156 | 157 | // SetSize of the table and makes sure to update the view 158 | // and the selected row does not go out of bounds. 159 | func (m *Model) SetSize(width, height int) { 160 | m.viewPort.Width = width 161 | m.viewPort.Height = height - 1 162 | 163 | if m.cursor > m.viewPort.YOffset+m.viewPort.Height-1 { 164 | m.cursor = m.viewPort.YOffset + m.viewPort.Height - 1 165 | m.updateView() 166 | } 167 | } 168 | 169 | // Cursor returns the index of the selected row. 170 | func (m Model) Cursor() int { 171 | return m.cursor 172 | } 173 | 174 | // SelectedRow returns the selected row. 175 | // You can cast it to your own implementation. 176 | func (m Model) SelectedRow() Row { 177 | return m.rows[m.cursor] 178 | } 179 | 180 | // SetRows of the table and makes sure to update the view 181 | // and the selected row does not go out of bounds. 182 | func (m *Model) SetRows(rows []Row) { 183 | m.rows = rows 184 | m.updateView() 185 | } 186 | 187 | func (m *Model) updateView() { 188 | var b strings.Builder 189 | m.tabWriter.Init(&b, 0, 4, 1, ' ', 0) 190 | 191 | // rendering the header. 192 | fmt.Fprintln(m.tabWriter, m.Styles.Title.Render(strings.Join(m.cols, "\t"+m.Styles.ColumnSeparator))) 193 | 194 | // rendering the rows. 195 | for i, row := range m.rows { 196 | row.Render(m.tabWriter, *m, i) 197 | } 198 | 199 | m.tabWriter.Flush() 200 | 201 | content := b.String() 202 | m.contentWidth = lipgloss.Width(content) 203 | 204 | if m.offset > 0 { 205 | content = trucateOffset(content, m.offset) 206 | } 207 | 208 | // split table at first line-break to take header and rows apart. 209 | parts := strings.SplitN(content, "\n", 2) 210 | if len(parts) != 0 { 211 | m.header = parts[0] 212 | if len(parts) == 2 { 213 | m.viewPort.SetContent(strings.TrimRightFunc(parts[1], unicode.IsSpace)) 214 | } 215 | } 216 | } 217 | 218 | // CursorIsAtTop of the table. 219 | func (m Model) CursorIsAtTop() bool { 220 | return m.cursor == 0 221 | } 222 | 223 | // CursorIsAtBottom of the table. 224 | func (m Model) CursorIsAtBottom() bool { 225 | return m.cursor == len(m.rows)-1 226 | } 227 | 228 | // CursorIsPastBottom of the table. 229 | func (m Model) CursorIsPastBottom() bool { 230 | return m.cursor > len(m.rows)-1 231 | } 232 | 233 | // GoUp moves the selection to the previous row. 234 | // It can not go above the first row. 235 | func (m *Model) GoUp() { 236 | if m.CursorIsAtTop() { 237 | return 238 | } 239 | 240 | m.cursor-- 241 | m.updateView() 242 | 243 | if m.cursor < m.viewPort.YOffset { 244 | m.viewPort.LineUp(1) 245 | } 246 | } 247 | 248 | // GoDown moves the selection to the next row. 249 | // It can not go below the last row. 250 | func (m *Model) GoDown() { 251 | if m.CursorIsAtBottom() { 252 | return 253 | } 254 | 255 | m.cursor++ 256 | m.updateView() 257 | 258 | if m.cursor > m.viewPort.YOffset+m.viewPort.Height-1 { 259 | m.viewPort.LineDown(1) 260 | } 261 | } 262 | 263 | // GoPageUp moves the selection one page up. 264 | // It can not go above the first row. 265 | func (m *Model) GoPageUp() { 266 | if m.CursorIsAtTop() { 267 | return 268 | } 269 | 270 | m.cursor -= m.viewPort.Height 271 | if m.cursor < 0 { 272 | m.cursor = 0 273 | } 274 | 275 | m.updateView() 276 | 277 | m.viewPort.ViewUp() 278 | } 279 | 280 | // GoPageDown moves the selection one page down. 281 | // It can not go below the last row. 282 | func (m *Model) GoPageDown() { 283 | if m.CursorIsAtBottom() { 284 | return 285 | } 286 | 287 | m.cursor += m.viewPort.Height 288 | if m.CursorIsPastBottom() { 289 | m.cursor = len(m.rows) - 1 290 | } 291 | 292 | m.updateView() 293 | 294 | m.viewPort.ViewDown() 295 | } 296 | 297 | // GoTop moves the selection to the first row. 298 | func (m *Model) GoTop() { 299 | if m.CursorIsAtTop() { 300 | return 301 | } 302 | 303 | m.cursor = 0 304 | m.updateView() 305 | m.viewPort.GotoTop() 306 | } 307 | 308 | // GoBottom moves the selection to the last row. 309 | func (m *Model) GoBottom() { 310 | if m.CursorIsAtBottom() { 311 | return 312 | } 313 | 314 | m.cursor = len(m.rows) - 1 315 | m.updateView() 316 | m.viewPort.GotoBottom() 317 | } 318 | 319 | func (m *Model) GoRight() { 320 | if uint(m.viewPort.Width)+m.offset >= uint(m.contentWidth) { 321 | return 322 | } 323 | 324 | m.offset++ 325 | m.updateView() 326 | } 327 | 328 | func (m *Model) GoLeft() { 329 | if m.offset == 0 { 330 | return 331 | } 332 | 333 | m.offset-- 334 | m.updateView() 335 | } 336 | 337 | // Update tea.Model implementor. 338 | // It handles the key events. 339 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 340 | switch msg := msg.(type) { 341 | case tea.KeyMsg: 342 | switch { 343 | case key.Matches(msg, m.KeyMap.Up): 344 | m.GoUp() 345 | case key.Matches(msg, m.KeyMap.Down): 346 | m.GoDown() 347 | case key.Matches(msg, m.KeyMap.PageUp): 348 | m.GoPageUp() 349 | case key.Matches(msg, m.KeyMap.PageDown): 350 | m.GoPageDown() 351 | case key.Matches(msg, m.KeyMap.Home): 352 | m.GoTop() 353 | case key.Matches(msg, m.KeyMap.End): 354 | m.GoBottom() 355 | case key.Matches(msg, m.KeyMap.Right): 356 | m.GoRight() 357 | case key.Matches(msg, m.KeyMap.Left): 358 | m.GoLeft() 359 | } 360 | } 361 | 362 | return m, nil 363 | } 364 | 365 | // View tea.Model implementors. 366 | // It renders the table inside a viewport. 367 | func (m Model) View() string { 368 | return lipgloss.NewStyle().MaxWidth(m.viewPort.Width).Render( 369 | lipgloss.JoinVertical(lipgloss.Left, 370 | m.header, 371 | m.viewPort.View(), 372 | ), 373 | ) 374 | } 375 | 376 | var reANSISeq = regexp.MustCompile("^[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))") 377 | 378 | // trucateOffset trucates the beginning of the given block of text. 379 | // It handles more than 1 cell wide charaters 380 | // and preserves ANSI escape sequences. 381 | // 382 | // TODO: find a better way to keep ANSI escape sequences 383 | // than having to use regexp to remove them, trim the line 384 | // and restore them at the end. 385 | func trucateOffset(s string, offset uint) string { 386 | if offset == 0 { 387 | return s 388 | } 389 | 390 | var buf strings.Builder 391 | lines := strings.Split(s, "\n") 392 | last := len(lines) - 1 393 | for i, s := range lines { 394 | ansiSeq := reANSISeq.FindString(s) 395 | if ansiSeq != "" { 396 | s = strings.Replace(s, ansiSeq, "", 1) 397 | } 398 | 399 | var cutset, spaces, chars uint 400 | for _, r := range s { 401 | w := ansi.PrintableRuneWidth(string(r)) 402 | if w < 0 { 403 | continue 404 | } 405 | 406 | cutset += uint(w) 407 | chars++ 408 | 409 | if cutset >= offset { 410 | spaces = cutset - offset 411 | break 412 | } 413 | } 414 | 415 | line := strings.Repeat(" ", int(spaces)) + string([]rune(s)[chars:]) 416 | if ansiSeq != "" { 417 | line = ansiSeq + line 418 | } 419 | buf.WriteString(line) 420 | if i != last { 421 | buf.WriteRune('\n') 422 | } 423 | } 424 | return buf.String() 425 | } 426 | -------------------------------------------------------------------------------- /table_test.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | // prefixedRow renders the selected row with a `> ` prefix and no styles. 15 | type prefixedRow []any 16 | 17 | func (row prefixedRow) Render(w io.Writer, model Model, index int) { 18 | cells := make([]string, len(row)) 19 | for i, v := range row { 20 | cells[i] = fmt.Sprintf("%v", v) 21 | } 22 | s := strings.Join(cells, "\t") 23 | if index == model.Cursor() { 24 | s = "> " + s 25 | } else { 26 | s = " " + s 27 | } 28 | fmt.Fprintln(w, s) 29 | } 30 | 31 | var uncoloredStyles = Styles{ 32 | Title: lipgloss.NewStyle(), 33 | SelectedRow: lipgloss.NewStyle(), 34 | } 35 | 36 | func TestModel_View(t *testing.T) { 37 | model := New([]string{" ID", "EMAIL", "USERNAME", "CREATED-AT"}, 0, 5) 38 | model.Styles = uncoloredStyles 39 | model.SetRows([]Row{ 40 | prefixedRow{"1", "john@example.org", "john", "2022-02-21T18:02:29.762Z"}, 41 | prefixedRow{"2", "bob@example.org", "bob", "2022-02-21T18:02:29.762Z"}, 42 | prefixedRow{"3", "alice@example.org", "alice", "2022-02-21T18:02:29.762Z"}, 43 | prefixedRow{"4", "thomas@example.org", "thomas", "2022-02-21T18:02:29.762Z"}, 44 | }) 45 | got := model.View() 46 | wantEq(t, ""+ 47 | " ID EMAIL USERNAME CREATED-AT \n"+ 48 | "> 1 john@example.org john 2022-02-21T18:02:29.762Z\n"+ 49 | " 2 bob@example.org bob 2022-02-21T18:02:29.762Z\n"+ 50 | " 3 alice@example.org alice 2022-02-21T18:02:29.762Z\n"+ 51 | " 4 thomas@example.org thomas 2022-02-21T18:02:29.762Z", got) 52 | } 53 | 54 | func TestModel_Movements(t *testing.T) { 55 | model := New([]string{" #"}, 0, 4) 56 | model.Styles = uncoloredStyles 57 | rows := make([]Row, 10) 58 | for i := 0; i < 10; i++ { 59 | rows[i] = prefixedRow{i} 60 | } 61 | model.SetRows(rows) 62 | 63 | initial := model.View() 64 | wantEq(t, ""+ 65 | " #\n"+ 66 | "> 0\n"+ 67 | " 1\n"+ 68 | " 2", initial) 69 | 70 | t.Run("up", func(t *testing.T) { 71 | model.GoTop() 72 | model.GoUp() 73 | 74 | upFromTop := model.View() 75 | wantEq(t, initial, upFromTop) 76 | 77 | model.GoBottom() 78 | model.GoUp() 79 | 80 | upFromBottom := model.View() 81 | wantEq(t, ""+ 82 | " #\n"+ 83 | " 7\n"+ 84 | "> 8\n"+ 85 | " 9", upFromBottom) 86 | 87 | model.GoTop() 88 | model.GoPageDown() 89 | model.GoUp() 90 | 91 | upFromPageDown := model.View() 92 | wantEq(t, ""+ 93 | " #\n"+ 94 | "> 2\n"+ 95 | " 3\n"+ 96 | " 4", upFromPageDown) 97 | }) 98 | 99 | t.Run("down", func(t *testing.T) { 100 | model.GoTop() 101 | model.GoDown() 102 | 103 | downFromTop := model.View() 104 | wantEq(t, ""+ 105 | " #\n"+ 106 | " 0\n"+ 107 | "> 1\n"+ 108 | " 2", downFromTop) 109 | 110 | model.GoBottom() 111 | model.GoDown() 112 | 113 | downFromBottom := model.View() 114 | wantEq(t, ""+ 115 | " #\n"+ 116 | " 7\n"+ 117 | " 8\n"+ 118 | "> 9", downFromBottom) 119 | 120 | model.GoBottom() 121 | model.GoPageUp() 122 | model.GoDown() 123 | 124 | downFromPageUp := model.View() 125 | wantEq(t, ""+ 126 | " #\n"+ 127 | " 5\n"+ 128 | " 6\n"+ 129 | "> 7", downFromPageUp) 130 | 131 | model.GoBottom() 132 | model.GoPageUp() 133 | model.GoDown() 134 | }) 135 | 136 | t.Run("pgdown", func(t *testing.T) { 137 | model.GoTop() 138 | model.GoPageDown() 139 | 140 | pageDownFromTop := model.View() 141 | wantEq(t, ""+ 142 | " #\n"+ 143 | "> 3\n"+ 144 | " 4\n"+ 145 | " 5", pageDownFromTop) 146 | 147 | model.GoBottom() 148 | model.GoPageDown() 149 | 150 | pageDownFromBottom := model.View() 151 | wantEq(t, ""+ 152 | " #\n"+ 153 | " 7\n"+ 154 | " 8\n"+ 155 | "> 9", pageDownFromBottom) 156 | 157 | model.GoUp() 158 | model.GoPageDown() 159 | 160 | pageDownFromUp := model.View() 161 | wantEq(t, pageDownFromBottom, pageDownFromUp) 162 | }) 163 | 164 | t.Run("pgup", func(t *testing.T) { 165 | model.GoTop() 166 | model.GoPageUp() 167 | 168 | pageUpFromTop := model.View() 169 | wantEq(t, ""+ 170 | " #\n"+ 171 | "> 0\n"+ 172 | " 1\n"+ 173 | " 2", pageUpFromTop) 174 | 175 | model.GoDown() 176 | model.GoPageUp() 177 | 178 | pageUpFromDown := model.View() 179 | wantEq(t, pageUpFromTop, pageUpFromDown) 180 | 181 | model.GoBottom() 182 | model.GoPageUp() 183 | 184 | pageUpFromBottom := model.View() 185 | wantEq(t, ""+ 186 | " #\n"+ 187 | " 4\n"+ 188 | " 5\n"+ 189 | "> 6", pageUpFromBottom) 190 | }) 191 | 192 | t.Run("home", func(t *testing.T) { 193 | model.GoTop() 194 | 195 | top := model.View() 196 | wantEq(t, ""+ 197 | " #\n"+ 198 | "> 0\n"+ 199 | " 1\n"+ 200 | " 2", top) 201 | 202 | model.GoTop() 203 | 204 | topFromTop := model.View() 205 | wantEq(t, top, topFromTop) 206 | }) 207 | 208 | t.Run("end", func(t *testing.T) { 209 | model.GoBottom() 210 | 211 | bottom := model.View() 212 | wantEq(t, ""+ 213 | " #\n"+ 214 | " 7\n"+ 215 | " 8\n"+ 216 | "> 9", bottom) 217 | 218 | model.GoBottom() 219 | 220 | bottomFromBottom := model.View() 221 | wantEq(t, bottom, bottomFromBottom) 222 | }) 223 | } 224 | 225 | func TestModel_SetSize(t *testing.T) { 226 | model := New([]string{" #"}, 0, 4) 227 | model.Styles = uncoloredStyles 228 | rows := make([]Row, 10) 229 | for i := 0; i < 10; i++ { 230 | rows[i] = prefixedRow{fmt.Sprintf("item %d", i)} 231 | } 232 | model.SetRows(rows) 233 | 234 | initial := model.View() 235 | wantEq(t, ""+ 236 | " # \n"+ 237 | "> item 0\n"+ 238 | " item 1\n"+ 239 | " item 2", initial) 240 | 241 | model.SetSize(4, 5) 242 | 243 | got := model.View() 244 | wantEq(t, ""+ 245 | " # \n"+ 246 | "> it\n"+ 247 | " it\n"+ 248 | " it\n"+ 249 | " it", got) 250 | 251 | model.GoBottom() 252 | model.SetSize(0, 3) 253 | 254 | got = model.View() 255 | 256 | // TODO: maybe change behavoir and keep scroll position on the selected item. 257 | // Instead of moving selection to the bound of the new size. 258 | wantEq(t, ""+ 259 | " # \n"+ 260 | " item 6\n"+ 261 | "> item 7", got) 262 | } 263 | 264 | func TestModel_SelectedRow(t *testing.T) { 265 | model := New([]string{" #"}, 0, 4) 266 | rows := make([]Row, 10) 267 | for i := 0; i < 10; i++ { 268 | rows[i] = SimpleRow{i} 269 | } 270 | model.SetRows(rows) 271 | 272 | got := model.SelectedRow() 273 | wantEq(t, rows[0], got) 274 | 275 | model.GoPageDown() 276 | got = model.SelectedRow() 277 | wantEq(t, rows[3], got) 278 | } 279 | 280 | func TestModel_Update(t *testing.T) { 281 | tt := []struct { 282 | msg tea.KeyMsg 283 | wantCursor int 284 | }{ 285 | { 286 | msg: tea.KeyMsg{Type: tea.KeyEnd}, 287 | wantCursor: 9, 288 | }, 289 | { 290 | msg: tea.KeyMsg{Type: tea.KeyHome}, 291 | wantCursor: 0, 292 | }, 293 | { 294 | msg: tea.KeyMsg{Type: tea.KeyPgDown}, 295 | wantCursor: 3, 296 | }, 297 | { 298 | msg: tea.KeyMsg{Type: tea.KeyPgUp}, 299 | wantCursor: 0, 300 | }, 301 | { 302 | msg: tea.KeyMsg{Type: tea.KeyDown}, 303 | wantCursor: 1, 304 | }, 305 | { 306 | msg: tea.KeyMsg{Type: tea.KeyUp}, 307 | wantCursor: 0, 308 | }, 309 | } 310 | for _, tc := range tt { 311 | t.Run(tc.msg.String(), func(t *testing.T) { 312 | model := New([]string{" #"}, 0, 4) 313 | rows := make([]Row, 10) 314 | for i := 0; i < 10; i++ { 315 | rows[i] = SimpleRow{i} 316 | } 317 | model.SetRows(rows) 318 | 319 | got, _ := model.Update(tc.msg) 320 | wantEq(t, tc.wantCursor, got.Cursor()) 321 | }) 322 | } 323 | } 324 | 325 | func wantEq[T any](t *testing.T, want, got T) { 326 | t.Helper() 327 | if !reflect.DeepEqual(want, got) { 328 | t.Fatalf("want %v, got %v", want, got) 329 | } 330 | } 331 | --------------------------------------------------------------------------------