├── .github └── workflows │ ├── build.yml │ ├── coverage.yml │ └── lint.yml ├── .gitignore ├── LICENSE ├── README.md ├── bindings.go ├── bindings_test.go ├── catwalk.go ├── catwalk_test.go ├── driver.go ├── example ├── go.mod ├── go.sum ├── model.go ├── model_test.go └── testdata │ ├── example │ └── viewport_tests ├── go.mod ├── go.sum ├── interface.go ├── options.go ├── options_test.go ├── styles.go ├── styles_test.go └── testdata ├── bindings ├── disable_auto_start ├── expansion ├── model_threading ├── observe ├── simple ├── styles └── window_size /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | strategy: 7 | matrix: 8 | go-version: [~1.16, ^1] 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v3 21 | 22 | - name: Download Go modules 23 | run: go mod download 24 | 25 | - name: Build 26 | run: go build -v ./... 27 | 28 | - name: Test 29 | run: go test ./... 30 | 31 | - name: Build examples 32 | run: go build -v ./... 33 | working-directory: ./example 34 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | coverage: 6 | strategy: 7 | matrix: 8 | go-version: [^1] 9 | os: [ubuntu-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v3 21 | 22 | - name: Coverage 23 | env: 24 | COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | run: | 26 | go test -race -covermode atomic -coverprofile=profile.cov ./... 27 | GO111MODULE=off go get github.com/mattn/goveralls 28 | $(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github 29 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | permissions: 7 | contents: read 8 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 9 | pull-requests: read 10 | 11 | jobs: 12 | golangci: 13 | name: lint 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Install Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: ^1 20 | 21 | - uses: actions/checkout@v3 22 | - name: golangci-lint 23 | uses: golangci/golangci-lint-action@v3 24 | with: 25 | # Optional: golangci-lint command line arguments. 26 | #args: 27 | # Optional: show only new issues if it's a pull request. The default value is `false`. 28 | only-new-issues: true 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /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 | # catwalk 2 | 3 | [![Latest Release](https://img.shields.io/github/release/knz/catwalk.svg)](https://github.com/knz/catwalk/releases) 4 | [![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/knz/catwalk) 5 | [![Build Status](https://github.com/knz/catwalk/workflows/build/badge.svg)](https://github.com/knz/catwalk/actions) 6 | [![Go ReportCard](https://goreportcard.com/badge/knz/catwalk)](https://goreportcard.com/report/knz/catwalk) 7 | [![Coverage Status](https://coveralls.io/repos/github/knz/catwalk/badge.svg)](https://coveralls.io/github/knz/catwalk) 8 | 9 | **catwalk** is a unit test library for [Bubbletea](https://github.com/charmbracelet/bubbletea) TUI models (a.k.a. “bubbles”). 10 | 11 | It enables implementers to verify the state of models and their `View` 12 | as they process `tea.Msg` objects through their `Update` method. 13 | 14 | It is implemented on top of 15 | [datadriven](https://github.com/cockroachdb/datadriven), an extension 16 | to Go's simple "table-driven testing" idiom. 17 | 18 | Datadriven tests use data files containing both the reference input 19 | and output, instead of a data structure. Each data file can contain 20 | multiple tests. When the implementation changes, the reference output 21 | can be quickly updated by re-running the tests with the `-rewrite` 22 | flag. 23 | 24 | **Why the name:** the framework forces the Tea models to "show 25 | themselves" on the runway of the test input files. 26 | 27 | ## Example 28 | 29 | Let's test the `viewport` [Bubble](https://github.com/charmbracelet/bubbles)! 30 | 31 | First, we define a top-level model around `viewport`: 32 | 33 | ```go 34 | type Model struct { 35 | viewport.Model 36 | } 37 | 38 | var _ tea.Model = (*Model)(nil) 39 | 40 | // New initializes a new model. 41 | func New(width, height int) *Model { 42 | return &Model{ 43 | Model: viewport.New(width, height), 44 | } 45 | } 46 | 47 | // Init adds some initial text inside the viewport. 48 | func (m *Model) Init() tea.Cmd { 49 | cmd := m.Model.Init() 50 | m.SetContent(`first line 51 | second line 52 | third line 53 | fourth line 54 | fifth line 55 | sixth line`) 56 | return cmd 57 | } 58 | ``` 59 | 60 | Then, we define a Go test which runs the above model: 61 | 62 | ```go 63 | func TestModel(t *testing.T) { 64 | // Initialize the model to test. 65 | m := New(40, 3) 66 | 67 | // Run all the tests in input file "testdata/viewport_tests" 68 | catwalk.RunModel(t, "testdata/viewport_tests", m) 69 | } 70 | ``` 71 | 72 | Then, we populate some test directives inside `testdata/viewport_tests`: 73 | 74 | ``` go 75 | run 76 | ---- 77 | 78 | # One line down 79 | run 80 | type j 81 | ---- 82 | 83 | # Two lines down 84 | run 85 | type jj 86 | ---- 87 | 88 | # One line up 89 | run 90 | key up 91 | ---- 92 | ``` 93 | 94 | Then, we run the test: `go test .`. 95 | 96 | What happens: the test fails! 97 | 98 | ``` 99 | --- FAIL: TestModel (0.00s) 100 | catwalk.go:64: 101 | testdata/viewport_tests:1: 102 | expected: 103 | 104 | found: 105 | -- view: 106 | first line␤ 107 | second line␤ 108 | third line🛇 109 | ``` 110 | 111 | This is because we haven't yet expressed 112 | what is the **expected output** for each step. 113 | 114 | Because it's tedious to do this manually, we can auto-generate 115 | the expected output from the actual output, using the `rewrite` flag: 116 | 117 | go test . -args -rewrite 118 | 119 | Observe what happened with the input file: 120 | 121 | ``` go 122 | run 123 | ---- 124 | -- view: 125 | first line␤ 126 | second line␤ 127 | third line🛇 128 | 129 | # One line down 130 | run 131 | type j 132 | ---- 133 | -- view: 134 | second line␤ 135 | third line␤ 136 | fourth line🛇 137 | 138 | # Two lines down 139 | run 140 | type jj 141 | ---- 142 | -- view: 143 | fourth line␤ 144 | fifth line␤ 145 | sixth line🛇 146 | 147 | # One line up 148 | run 149 | key up 150 | ---- 151 | -- view: 152 | third line␤ 153 | fourth line␤ 154 | fifth line🛇 155 | ``` 156 | 157 | Now each expected output reflects how the `viewport` reacts 158 | to the key presses. Now also `go test .` succeeds. 159 | 160 | ## Structure of a test file 161 | 162 | Test files contain zero or more tests, with the following structure: 163 | 164 | ``` go 165 | 166 | 167 | ---- 168 | 169 | ``` 170 | 171 | For example: 172 | 173 | ``` go 174 | run 175 | ---- 176 | -- view: 177 | My bubble rendered here. 178 | 179 | run 180 | type q 181 | ---- 182 | -- view: 183 | My bubble reacted to "q". 184 | ``` 185 | 186 | Catwalk supports the following directives: 187 | 188 | - `run`: apply state changes to the model via its `Update` method, then show the results. 189 | - `set`/`reset`: change configuration variables. 190 | 191 | Finally, directives can take arguments. For example: 192 | 193 | ``` 194 | run observe=(gostruct,view) 195 | ---- 196 | ``` 197 | 198 | This is explained further in the next sections. 199 | 200 | ## The `run` directive 201 | 202 | `run` defines one unit test. It applies some input commands to the 203 | model then compares the resulting state of the model with a reference 204 | expected output. 205 | 206 | Under `run`, the following input commands are supported: 207 | 208 | - `type `: produce a series of `tea.KeyMsg` with type 209 | `tea.KeyRunes`. Can contain spaces. 210 | 211 | For example: `type abc` produces 3 key presses for a, b, c. 212 | 213 | - `enter `: like `type`, but also add a key press for the 214 | `enter` key at the end. 215 | 216 | - `key `: produce one `tea.KeyMsg` for the given key. 217 | 218 | For example: `key ctrl+c` 219 | 220 | - `paste ""`: paste the text as a single key event. 221 | The text can contain Go escape sequences. 222 | 223 | For example: `paste "hello\nworld"` 224 | 225 | - `resize `: produce a `tea.WindowSizeMsg` with the specified size. 226 | 227 | You can also add support for your own input commands by passing an 228 | `Updater` function to `catwalk.RunModel` with the `WithUpdater()` 229 | option, and combine multiple updaters together using the 230 | `ChainUpdater()` function. 231 | 232 | The `run` directive accepts the following arguments: 233 | 234 | - `observe`: what to look at as expected output (`observe=xx` or `observe=(xx,yy)`). 235 | 236 | By default, `observe` is set to `view`: look at the model's `View()` method. 237 | Alternatively, you can use the following observers: 238 | 239 | - `gostruct`: show the contents of the model object as a go struct. 240 | - `debug`: call the model's `Debug() string` method, if defined. 241 | 242 | You can also add your own observers using the `WithObserver()` option. 243 | 244 | - `trace`: detail the intermediate steps of the test. 245 | 246 | Used for debugging tests. 247 | 248 | ## The `set` and `reset` directives 249 | 250 | These can be used to configure parameters in the test driver. 251 | 252 | For example: 253 | 254 | ``` go 255 | set cmd_timeout=100ms 256 | ---- 257 | cmd_timeout: 100ms 258 | 259 | reset cmd_timeout 260 | ---- 261 | ok 262 | ``` 263 | 264 | The following parameters are currently recognized: 265 | 266 | - `cmd_timeout`: how long to wait for a `tea.Cmd` to complete. 267 | This is set by default to 20ms, which is sufficient to 268 | ignore the commands of a blinking cursor. 269 | 270 | ## Advanced topic: testing style changes 271 | 272 | Many [bubbles](https://github.com/charmbracelet/bubbles) have a 273 | `Styles` struct with configurable styles (using [lipgloss](https://github.com/charmbracelet/lipgloss)). It's useful to verify 274 | that the bubbles react properly when the styles are reconfigured at 275 | run-time. 276 | 277 | For this, you can tell catwalk about your styles 278 | this will activate the following special `run` input commands: 279 | 280 | ``` 281 | restyle ` 282 | ``` 283 | 284 | 285 | For example: `restyle mymodel.ValueStyle foreground: #f00` changes the 286 | `ValueStyle` style to use the color red, as if `.ValueStyle.Foreground(lipgloss.Color("#f00"))` was called. 287 | 288 | To activate, use the option `catwalk.WithUpdater(catwalk.StylesUpdater(...))`. For example: 289 | 290 | ``` go 291 | func TestStyles(t *testing.T) { 292 | m := New(...) 293 | catwalk.RunModel(t, "testdata/styles", m, catwalk.WithUpdater( 294 | // The string "hello" is the prefix for identifying the styles container in tests. 295 | // Useful when there are multiple nested models. 296 | catwalk.StylesUpdater("hello", 297 | func(m tea.Model, fn func(interface{}) error) (tea.Model, error) { 298 | tm := m.(myModel) 299 | err := fn(&tm) 300 | return tm, err 301 | }), 302 | )) 303 | } 304 | ``` 305 | 306 | After this, the input command `restyle hello.X ...` will automatically 307 | affect the style `.X` in your model. 308 | 309 | Alternatively, if your model implements `tea.Model` by reference 310 | (i.e. the address of its styles does not change between `Update` 311 | calls), you can simplify as follows: 312 | 313 | ``` go 314 | func TestStyles(t *testing.T) { 315 | m := New(...) 316 | catwalk.RunModel(t, "testdata/bindings", &m, catwalk.WithUpdater( 317 | // The string "hello" is the prefix for identifying the styles container in tests. 318 | // Useful when there are multiple nested models. 319 | KeyMapUpdater("hello", catwalk.SimpleStylesApplier(&m)))) 320 | } 321 | ``` 322 | 323 | See the test `TestStyles` in `styles_test.go` and the input file 324 | `testdata/styles` for an example. 325 | 326 | ## Advanced topic: testing key bindings 327 | 328 | Many [bubbles](https://github.com/charmbracelet/bubbles) have a 329 | `KeyMap` struct with configurable key bindings. It's useful to verify 330 | that the bubbles react properly when the keymaps are reconfigured at 331 | run-time. 332 | 333 | For this, you can tell catwalk about your `KeyMap` struct and 334 | this will activate the following special `run` input commands: 335 | 336 | - `keybind ` 337 | 338 | For example: `keybind mykeys.CursorUp up j` rebinds the `CursorUp` 339 | binding in the KeyMap `mykeys` as if 340 | `key.NewBinding(key.WithKeys("up", "j"))` was called. 341 | 342 | - `keyhelp ` 343 | 344 | For example: `keybind mykeys.CursorUp up move the cursor up` rebinds 345 | the `CursorUp` binding in the KeyMap `mykeys` as if 346 | `key.NewBinding(key.WithHelp("up", "move the cursor up"))` was 347 | called. 348 | 349 | To declare a `KeyMap` in a test, use the option `catwalk.WithUpdater(catwalk.KeyMapUpdater(...))`. For example: 350 | 351 | ``` go 352 | func TestBindings(t *testing.T) { 353 | m := New(...) 354 | catwalk.RunModel(t, "testdata/bindings", m, catwalk.WithUpdater( 355 | // The string "hello" is the prefix for identifying the keymap in tests. 356 | // Useful when the model contains multiple keymaps. 357 | catwalk.KeyMapUpdater("hello", 358 | func(m tea.Model, fn func(interface{}) error) (tea.Model, error) { 359 | tm := m.(YourModel) 360 | err := fn(&tm.KeyMap) 361 | return tm, err 362 | }), 363 | )) 364 | } 365 | ``` 366 | 367 | After this, the input command `keybind hello.X ...` will automatically 368 | affect the binding `.KeyMap.X` in your model. 369 | 370 | Alternatively, if your model implements `tea.Model` by reference (i.e. the address of its KeyMap does not change between `Update` calls), you can simplify as follows: 371 | 372 | ``` go 373 | func TestBindings(t *testing.T) { 374 | m := New(...) 375 | catwalk.RunModel(t, "testdata/bindings", &m, catwalk.WithUpdater( 376 | // The string "hello" is the prefix for identifying the keymap in tests. 377 | // Useful when the model contains multiple keymaps. 378 | KeyMapUpdater("hello", catwalk.SimpleKeyMapApplier(&m.KeyMap)))) 379 | } 380 | ``` 381 | 382 | See the test `TestRebind` in `bindings_test.go` and the input file 383 | `testdata/bindings` for an example. 384 | 385 | ## Your turn! 386 | 387 | You can start using `catwalk` in your Bubbletea / Charm projects right 388 | away! 389 | 390 | If you have any questions or comments: 391 | 392 | - for bug fixes, feature requests, etc., [file an issue]() 393 | - for questions, suggestions, etc. you can come chat on the [Charm 394 | Discord](https://charm.sh/chat/). 395 | -------------------------------------------------------------------------------- /bindings.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/key" 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | // KeyMapUpdater defines an updater which supports the "keybind" and 13 | // "keyhelp" commands to change a KeyMap struct. You can add this to 14 | // a test using WithUpdater(). It is possible to add multiple keymap 15 | // updaters to the same test. 16 | // 17 | // A KeyMap struct is any go struct containing exported fields of type 18 | // key.Binding. For example, using: 19 | // 20 | // KeyMapUpdater("mymodel", 21 | // func(m tea.Model, changeKeyMap func(interface{})) (tea.Model, err) { 22 | // myModel := m.(mymodel) 23 | // if err := changeKeyMap(&myModel.KeyMap); err != nil { 24 | // return m, err 25 | // } 26 | // return myModel, nil 27 | // }) 28 | // 29 | // and mymodel.KeyMap containing a CursorUp binding, 30 | // it becomes possible to use "keybind mymodel.CursorUp ctrl+c" to 31 | // define a new keybinding during a test. 32 | // 33 | // If your model implements tea.Model by reference (i.e. its address 34 | // does not change through Update calls), you can simplify 35 | // the call as follows: 36 | // 37 | // KeyMapUpdater("...", SimpleKeyMapApplier(&yourmodel.KeyMap)). 38 | func KeyMapUpdater(prefix string, apply KeyMapApplier) Updater { 39 | return func(m tea.Model, inputCmd string, args ...string) (bool, tea.Model, tea.Cmd, error) { 40 | return handleKeyMapUpdate(prefix+".", apply, m, inputCmd, args...) 41 | } 42 | } 43 | 44 | // KeyMapApplier is the type of a function which applies the 45 | // changeKeyMap callback on a KeyMap struct inside the model, then 46 | // returns the resulting model. 47 | // 48 | // Example implementation: 49 | // func(m tea.Model, changeKeyMap func(interface{}) error) (tea.Model, err) { 50 | // myModel := m.(mymodel) 51 | // if err := changeKeyMap(&myModel.KeyMap); err != nil { 52 | // return m, err 53 | // } 54 | // return myModel, nil 55 | // } 56 | type KeyMapApplier func(m tea.Model, changeKeyMap func(interface{}) error) (tea.Model, error) 57 | 58 | // SimpleKeyMapApplier is a helper to simplify the definition of the 59 | // function argument to KeyMapUpdater, in the case the model is 60 | // implemented by reference -- i.e. the address of the KeyMap does not 61 | // change from one call to Update to the next. 62 | func SimpleKeyMapApplier(keymap interface{}) KeyMapApplier { 63 | return func(m tea.Model, changeKeyMap func(interface{}) error) (tea.Model, error) { 64 | return m, changeKeyMap(keymap) 65 | } 66 | } 67 | 68 | func handleKeyMapUpdate( 69 | prefix string, apply KeyMapApplier, m tea.Model, inputcmd string, args ...string, 70 | ) (bool, tea.Model, tea.Cmd, error) { 71 | switch inputcmd { 72 | case "keybind": 73 | if len(args) < 2 { 74 | return false, m, nil, fmt.Errorf("syntax: keybind ") 75 | } 76 | if !strings.HasPrefix(args[0], prefix) { 77 | // This keybind is meant for another updater. Not us. 78 | return false, m, nil, nil 79 | } 80 | bindingName := strings.TrimPrefix(args[0], prefix) 81 | newM, err := apply(m, func(km interface{}) error { 82 | return applyKeyRebind(km, bindingName, args[1:]...) 83 | }) 84 | return true, newM, nil, err 85 | 86 | case "keyhelp": 87 | if len(args) < 3 { 88 | return false, m, nil, fmt.Errorf("syntax: keyhelp ") 89 | } 90 | if !strings.HasPrefix(args[0], prefix) { 91 | // This keybind is meant for another updater. Not us. 92 | return false, m, nil, nil 93 | } 94 | bindingName := strings.TrimPrefix(args[0], prefix) 95 | newM, err := apply(m, func(km interface{}) error { 96 | return applyKeyNewHelp(km, bindingName, args[1], strings.Join(args[2:], " ")) 97 | }) 98 | return true, newM, nil, err 99 | 100 | default: 101 | // Command not supported. 102 | return false, m, nil, nil 103 | } 104 | } 105 | 106 | func applyKeyRebind(km interface{}, bindingName string, newKeys ...string) error { 107 | kb, err := getBinding(km, bindingName) 108 | if err != nil { 109 | return err 110 | } 111 | if len(newKeys) == 1 { 112 | switch newKeys[0] { 113 | case "enable": 114 | kb.SetEnabled(true) 115 | return nil 116 | case "disable": 117 | kb.SetEnabled(false) 118 | return nil 119 | case "unbind": 120 | kb.Unbind() 121 | return nil 122 | } 123 | } 124 | kb.SetKeys(newKeys...) 125 | return nil 126 | } 127 | 128 | func applyKeyNewHelp(km interface{}, bindingName, helpKey, helpText string) error { 129 | kb, err := getBinding(km, bindingName) 130 | if err != nil { 131 | return err 132 | } 133 | kb.SetHelp(helpKey, helpText) 134 | return nil 135 | } 136 | 137 | func getBinding(km interface{}, bindingName string) (*key.Binding, error) { 138 | v := reflect.ValueOf(km) 139 | if v.Type().Kind() != reflect.Ptr { 140 | return nil, fmt.Errorf("keymap type %T is not a pointer to struct", km) 141 | } 142 | v = v.Elem() 143 | if v.Type().Kind() != reflect.Struct { 144 | return nil, fmt.Errorf("keymap type %T is not a pointer to struct", km) 145 | } 146 | var zv reflect.Value 147 | fv := v.FieldByName(bindingName) 148 | if fv == zv { 149 | return nil, fmt.Errorf("keymap struct %T does not contain a field named %q", km, bindingName) 150 | } 151 | if fv.Type() != keyBindingType { 152 | return nil, fmt.Errorf("field %q of struct %T does not have type key.Binding", bindingName, km) 153 | } 154 | return fv.Addr().Interface().(*key.Binding), nil 155 | } 156 | 157 | var keyBindingType = reflect.TypeOf(key.Binding{}) 158 | -------------------------------------------------------------------------------- /bindings_test.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/key" 10 | tea "github.com/charmbracelet/bubbletea" 11 | ) 12 | 13 | type mybindings struct { 14 | NotBinding int 15 | MyBinding key.Binding 16 | Embedded 17 | } 18 | 19 | type Embedded struct { 20 | OtherBinding key.Binding 21 | } 22 | 23 | func TestGetBinding(t *testing.T) { 24 | var b mybindings 25 | var x int 26 | 27 | td := []struct { 28 | in interface{} 29 | name string 30 | out *key.Binding 31 | expErr string 32 | }{ 33 | {b, "hello", nil, `keymap type catwalk.mybindings is not a pointer to struct`}, 34 | {&x, "hello", nil, `keymap type *int is not a pointer to struct`}, 35 | {&b, "hello", nil, `keymap struct *catwalk.mybindings does not contain a field named "hello"`}, 36 | {&b, "hello", nil, `keymap struct *catwalk.mybindings does not contain a field named "hello"`}, 37 | {&b, "NotBinding", nil, `field "NotBinding" of struct *catwalk.mybindings does not have type key.Binding`}, 38 | {&b, "MyBinding", &b.MyBinding, ``}, 39 | {&b, "OtherBinding", &b.OtherBinding, ``}, 40 | } 41 | 42 | for i, tc := range td { 43 | t.Run(fmt.Sprint(i), func(t *testing.T) { 44 | out, err := getBinding(tc.in, tc.name) 45 | if err != nil { 46 | if err.Error() != tc.expErr { 47 | t.Fatalf("expected error %q, got: %v", tc.expErr, err) 48 | } 49 | return 50 | } 51 | if err == nil && tc.expErr != "" { 52 | t.Fatalf("expected error %q, got no error", tc.expErr) 53 | } 54 | if out != tc.out { 55 | t.Fatalf("not valid binding returned") 56 | } 57 | }) 58 | } 59 | } 60 | 61 | func TestChangeKeyHelp(t *testing.T) { 62 | var b mybindings 63 | 64 | err := applyKeyNewHelp(&b, "hello", "C-c", "break") 65 | if err == nil || err.Error() != `keymap struct *catwalk.mybindings does not contain a field named "hello"` { 66 | t.Fatalf("rebind did not fail: %v", err) 67 | } 68 | err = applyKeyNewHelp(&b, "MyBinding", "C-c", "break") 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | h := b.MyBinding.Help() 73 | if h.Key != "C-c" || h.Desc != "break" { 74 | t.Fatalf("help did not change: %+v", h) 75 | } 76 | } 77 | 78 | func TestChangeKeyBinding(t *testing.T) { 79 | var b mybindings 80 | 81 | err := applyKeyRebind(&b, "hello", "ctrl+c") 82 | if err == nil || err.Error() != `keymap struct *catwalk.mybindings does not contain a field named "hello"` { 83 | t.Fatalf("rebind did not fail: %v", err) 84 | } 85 | 86 | err = applyKeyRebind(&b, "MyBinding", "ctrl+c") 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | if !reflect.DeepEqual(b.MyBinding.Keys(), []string{"ctrl+c"}) { 91 | t.Fatalf("binding not set properly: %v", b.MyBinding.Keys()) 92 | } 93 | err = applyKeyRebind(&b, "MyBinding", "enable") 94 | if err != nil { 95 | t.Fatal(err) 96 | } 97 | if !b.MyBinding.Enabled() { 98 | t.Fatalf("binding not enabled") 99 | } 100 | err = applyKeyRebind(&b, "MyBinding", "disable") 101 | if err != nil { 102 | t.Fatal(err) 103 | } 104 | if b.MyBinding.Enabled() { 105 | t.Fatalf("binding not disabled") 106 | } 107 | err = applyKeyRebind(&b, "MyBinding", "unbind") 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | if len(b.MyBinding.Keys()) > 0 { 112 | t.Fatalf("binding still bound: %v", b.MyBinding) 113 | } 114 | } 115 | 116 | // TestRebind checks the key rebind commands. 117 | func TestRebind(t *testing.T) { 118 | t.Run("by-value", func(t *testing.T) { 119 | upd1 := KeyMapUpdater("hello", func(m tea.Model, fn func(interface{}) error) (tea.Model, error) { 120 | h := m.(helpModel) 121 | err := fn(&h.KeyMap) 122 | return h, err 123 | }) 124 | 125 | upd2 := KeyMapUpdater("world", func(m tea.Model, fn func(interface{}) error) (tea.Model, error) { 126 | h := m.(helpModel) 127 | err := fn(&h.OtherKeyMap) 128 | return h, err 129 | }) 130 | 131 | RunModel(t, "testdata/bindings", helpModel{}, WithUpdater(upd1), WithUpdater(upd2)) 132 | }) 133 | 134 | t.Run("by-reference", func(t *testing.T) { 135 | hm := &helpModelR{} 136 | upd1 := KeyMapUpdater("hello", SimpleKeyMapApplier(&hm.KeyMap)) 137 | upd2 := KeyMapUpdater("world", SimpleKeyMapApplier(&hm.OtherKeyMap)) 138 | 139 | RunModel(t, "testdata/bindings", hm, WithUpdater(upd1), WithUpdater(upd2)) 140 | }) 141 | } 142 | 143 | type helpModel struct { 144 | val int 145 | help help.Model 146 | KeyMap struct { 147 | MyKey key.Binding 148 | } 149 | OtherKeyMap struct { 150 | Other key.Binding 151 | } 152 | } 153 | 154 | func (h helpModel) Init() tea.Cmd { return nil } 155 | 156 | func (h helpModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 157 | h.val++ 158 | if kmsg, ok := msg.(tea.KeyMsg); ok { 159 | switch { 160 | case key.Matches(kmsg, h.KeyMap.MyKey): 161 | return h, tea.Println("MYKEY RECOGNIZED") 162 | case key.Matches(kmsg, h.OtherKeyMap.Other): 163 | return h, tea.Println("OTHERKEY RECOGNIZED") 164 | default: 165 | return h, tea.Println("UNKOWN KEY") 166 | } 167 | } 168 | return h, nil 169 | } 170 | 171 | func (h helpModel) View() string { 172 | return fmt.Sprintf("VALUE: %d\n%s", h.val, h.help.View(h)) 173 | } 174 | 175 | func (h helpModel) ShortHelp() []key.Binding { 176 | return []key.Binding{h.KeyMap.MyKey, h.OtherKeyMap.Other} 177 | } 178 | 179 | func (h helpModel) FullHelp() [][]key.Binding { 180 | return [][]key.Binding{{h.KeyMap.MyKey}, {h.OtherKeyMap.Other}} 181 | } 182 | 183 | type helpModelR helpModel 184 | 185 | func (h *helpModelR) Init() tea.Cmd { return nil } 186 | func (h *helpModelR) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 187 | newModel, newCmd := helpModel(*h).Update(msg) 188 | *h = helpModelR(newModel.(helpModel)) 189 | return h, newCmd 190 | } 191 | func (h *helpModelR) View() string { return helpModel(*h).View() } 192 | -------------------------------------------------------------------------------- /catwalk.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/cockroachdb/datadriven" 8 | ) 9 | 10 | // RunModel runs the tests contained in the file pointed to by 'path' 11 | // on the model m, using a fresh driver initialize via NewDriver and 12 | // the specified options. 13 | // 14 | // To apply RunModel on all the test files in a directory, 15 | // use datadriven.Walk. 16 | func RunModel(t *testing.T, path string, m tea.Model, opts ...Option) { 17 | t.Helper() 18 | d := NewDriver(m, opts...) 19 | defer d.Close(t) 20 | 21 | datadriven.RunTest(t, path, func(t *testing.T, td *datadriven.TestData) string { 22 | t.Helper() 23 | return d.RunOneTest(t, td) 24 | }) 25 | } 26 | 27 | // RunModelFromString is a version of RunModel which takes the input 28 | // test directives from a string directly. 29 | func RunModelFromString(t *testing.T, input string, m tea.Model, opts ...Option) { 30 | t.Helper() 31 | d := NewDriver(m, opts...) 32 | defer d.Close(t) 33 | 34 | datadriven.RunTestFromString(t, input, func(t *testing.T, td *datadriven.TestData) string { 35 | t.Helper() 36 | return d.RunOneTest(t, td) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /catwalk_test.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strconv" 7 | "testing" 8 | "time" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | ) 12 | 13 | // TestModel checks basic features. 14 | func TestModel(t *testing.T) { 15 | RunModel(t, "testdata/simple", emptyModel{}) 16 | } 17 | 18 | type emptyModel struct{} 19 | 20 | var _ tea.Model = emptyModel{} 21 | 22 | func (emptyModel) Init() tea.Cmd { 23 | return tea.Println("MODEL INIT") 24 | } 25 | func (emptyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 26 | if kmsg, ok := msg.(tea.KeyMsg); ok && kmsg.Type == tea.KeyRunes { 27 | switch string(kmsg.Runes) { 28 | case "q": 29 | return emptyModel{}, tea.Quit 30 | case "M": 31 | return emptyModel{}, tea.DisableMouse 32 | case "m": 33 | return emptyModel{}, tea.EnableMouseAllMotion 34 | case "c": 35 | return emptyModel{}, tea.EnableMouseCellMotion 36 | case "a": 37 | return emptyModel{}, tea.EnterAltScreen 38 | case "A": 39 | return emptyModel{}, tea.ExitAltScreen 40 | case "C": 41 | return emptyModel{}, tea.HideCursor 42 | case "x": 43 | return emptyModel{}, tea.ExecProcess(nil, nil) 44 | case "e": 45 | return emptyModel{}, func() tea.Msg { return nil } 46 | case "w": 47 | return emptyModel{}, func() tea.Msg { 48 | time.Sleep(100 * time.Millisecond) 49 | return tea.Println("DELAYED HELLO")() 50 | } 51 | } 52 | } 53 | return emptyModel{}, tea.Println("MODEL UPDATE") 54 | } 55 | func (emptyModel) View() string { return "MODEL VIEW" } 56 | 57 | // TestModelThreading checks that catwalk preserves the model returned 58 | // by the Update function. 59 | func TestModelThreading(t *testing.T) { 60 | RunModel(t, "testdata/model_threading", intModel(0), WithUpdater(updater)) 61 | } 62 | 63 | // TestFromString checks that a test can run from a string input directly. 64 | func TestFromString(t *testing.T) { 65 | const test = ` 66 | run 67 | ---- 68 | TEA PRINT: {MODEL INIT} 69 | -- view: 70 | MODEL VIEW🛇 71 | ` 72 | RunModelFromString(t, test, emptyModel{}) 73 | } 74 | 75 | // TestObserver checks that a test can use a custom observer. 76 | func TestObserver(t *testing.T) { 77 | const test = ` 78 | run observe=hello 79 | ---- 80 | TEA PRINT: {MODEL INIT} 81 | -- hello: 82 | world! 83 | ` 84 | o := func(buf io.Writer, _ tea.Model) error { fmt.Fprintln(buf, "world!"); return nil } 85 | RunModelFromString(t, test, emptyModel{}, WithObserver("hello", o)) 86 | } 87 | 88 | type intModel int 89 | 90 | var _ tea.Model = intModel(0) 91 | 92 | func (intModel) Init() tea.Cmd { 93 | return nil 94 | } 95 | func (m intModel) Update(tea.Msg) (tea.Model, tea.Cmd) { 96 | m++ 97 | return m, nil 98 | } 99 | func (m intModel) View() string { return "VALUE: " + strconv.Itoa(int(m)) } 100 | 101 | func updater(m tea.Model, cmd string, args ...string) (bool, tea.Model, tea.Cmd, error) { 102 | im := m.(intModel) 103 | switch cmd { 104 | case "double": 105 | im = im * 2 106 | case "noopcmd": 107 | default: 108 | return false, nil, nil, nil 109 | } 110 | return true, im, tea.Printf("TEST UPDATE CALLED WITH %s %v", cmd, args), nil 111 | } 112 | 113 | // TestCmdExpansion checks that tea.Batch and tea.Sequence are processed 114 | // properly. 115 | func TestCmdExpansion(t *testing.T) { 116 | RunModel(t, "testdata/expansion", cmdModel{}, WithUpdater(cmdUpdater)) 117 | } 118 | 119 | type cmdModel struct{} 120 | 121 | var _ tea.Model = cmdModel{} 122 | 123 | func (cmdModel) Init() tea.Cmd { 124 | return tea.Batch( 125 | tea.Println("init1"), func() tea.Msg { return nil }, 126 | tea.Sequence(tea.Println("init2"), tea.Println("init3"))) 127 | } 128 | func (cmdModel) Update(tea.Msg) (tea.Model, tea.Cmd) { 129 | return cmdModel{}, tea.Batch( 130 | tea.Println("upd1"), func() tea.Msg { return nil }, 131 | tea.Sequence(tea.Println("upd2"), tea.Println("upd3"))) 132 | } 133 | func (cmdModel) View() string { return "" } 134 | 135 | func cmdUpdater(m tea.Model, cmd string, args ...string) (bool, tea.Model, tea.Cmd, error) { 136 | return true, m, tea.Batch( 137 | tea.Println("tupd1"), 138 | tea.Sequence(tea.Println("tupd2"), tea.Println("tupd3"))), nil 139 | } 140 | 141 | // TestObserve tests the various accepted values for the "observe" 142 | // directive option. 143 | func TestObserve(t *testing.T) { 144 | RunModel(t, "testdata/observe", &structModel{}) 145 | } 146 | 147 | type structModel struct{ x int } 148 | 149 | var _ tea.Model = (*structModel)(nil) 150 | 151 | func (s *structModel) Init() tea.Cmd { 152 | s.x = 4242 153 | return nil 154 | } 155 | func (s *structModel) Update(tea.Msg) (tea.Model, tea.Cmd) { 156 | s.x++ 157 | return s, nil 158 | } 159 | func (s *structModel) View() string { return fmt.Sprintf("VALUE: %q", s.x) } 160 | 161 | func (s *structModel) Debug() string { return "DEBUG SAYS HI" } 162 | -------------------------------------------------------------------------------- /driver.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "reflect" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/cockroachdb/datadriven" 16 | "github.com/kr/pretty" 17 | ) 18 | 19 | // driver represents the test driver. 20 | type driver struct { 21 | ctx context.Context 22 | cancel func() 23 | 24 | m tea.Model 25 | 26 | result bytes.Buffer 27 | 28 | // Queued commands left for processing. 29 | cmds []tea.Cmd 30 | 31 | // cmdTimeout is how long to wait for a tea.Cmd 32 | // to return a tea.Msg. 33 | cmdTimeout time.Duration 34 | 35 | // Queued messages left for processing. 36 | msgs []tea.Msg 37 | 38 | // Test observers. 39 | observers map[string]Observer 40 | 41 | // Test model updater (optional). 42 | upd Updater 43 | 44 | startDone bool 45 | 46 | // Don't call m.Init() on start. 47 | disableAutoInit bool 48 | 49 | // Send a WindowSizeMsg on start. 50 | autoSize bool 51 | width int 52 | height int 53 | 54 | // pos is the position in the input data file. 55 | // Used to produce error messages etc. 56 | pos string 57 | } 58 | 59 | const defaultCmdTimeout time.Duration = 20 * time.Millisecond 60 | 61 | // NewDriver creates a test driver for the given model. 62 | func NewDriver(m tea.Model, opts ...Option) Driver { 63 | ctx, cancel := context.WithCancel(context.Background()) 64 | d := &driver{ 65 | ctx: ctx, 66 | cancel: cancel, 67 | 68 | m: m, 69 | cmdTimeout: defaultCmdTimeout, 70 | observers: map[string]Observer{ 71 | "view": observeView, 72 | "debug": observeDebug, 73 | "gostruct": observeGoStruct, 74 | }, 75 | } 76 | 77 | for _, opt := range opts { 78 | opt(d) 79 | } 80 | 81 | return d 82 | } 83 | 84 | func (d *driver) trace(traceEnabled bool, format string, args ...interface{}) { 85 | if traceEnabled { 86 | fmt.Fprintf(&d.result, "-- trace: "+format+"\n", args...) 87 | } 88 | } 89 | 90 | func (d *driver) processTeaCmds(trace bool) { 91 | if len(d.cmds) > 0 { 92 | d.trace(trace, "processing %d cmds", len(d.cmds)) 93 | } 94 | // TODO(knz): handle timeouts. 95 | var inputs []tea.Cmd 96 | for { 97 | if len(d.cmds) >= 0 { 98 | inputs = append(make([]tea.Cmd, 0, len(d.cmds)+len(inputs)), inputs...) 99 | inputs = append(inputs, d.cmds...) 100 | d.cmds = nil 101 | } 102 | if len(inputs) == 0 { 103 | break 104 | } 105 | cmd := inputs[0] 106 | inputs = inputs[1:] 107 | msg := d.runTeaCmd(cmd, trace) 108 | 109 | if msg != nil { 110 | rmsg := reflect.ValueOf(msg) 111 | if rmsg.Type().ConvertibleTo(cmdsType) { 112 | rcmds := rmsg.Convert(cmdsType) 113 | cmds := rcmds.Interface().([]tea.Cmd) 114 | d.trace(trace, "expanded %d commands", len(cmds)) 115 | d.addCmds(cmds...) 116 | continue 117 | } 118 | } 119 | 120 | d.trace(trace, "translated cmd: %T", msg) 121 | d.addMsg(msg) 122 | } 123 | } 124 | 125 | func (d *driver) runTeaCmd(cmd tea.Cmd, trace bool) (res tea.Msg) { 126 | ctx, cancel := context.WithTimeout(d.ctx, d.cmdTimeout) 127 | defer cancel() 128 | 129 | msg := make(chan tea.Msg, 1) 130 | go func() { 131 | msg <- cmd() 132 | }() 133 | select { 134 | case <-ctx.Done(): 135 | d.trace(trace, "timeout waiting for command") 136 | case res = <-msg: 137 | } 138 | return res 139 | } 140 | 141 | var ( 142 | cmdsType = reflect.TypeOf([]tea.Cmd{}) 143 | printType = reflect.TypeOf(tea.Println("hello")()) 144 | quitType = reflect.TypeOf(tea.Quit()) 145 | execType = reflect.TypeOf(tea.ExecProcess(nil, nil)()) 146 | hideCursorType = reflect.TypeOf(tea.HideCursor()) 147 | enterAltType = reflect.TypeOf(tea.EnterAltScreen()) 148 | exitAltType = reflect.TypeOf(tea.ExitAltScreen()) 149 | mouseCellType = reflect.TypeOf(tea.EnableMouseCellMotion()) 150 | mouseAllType = reflect.TypeOf(tea.EnableMouseAllMotion()) 151 | mouseDisType = reflect.TypeOf(tea.DisableMouse()) 152 | szType = reflect.TypeOf(tea.WindowSizeMsg{}) 153 | ) 154 | 155 | func (d *driver) processTeaMsgs(trace bool) { 156 | if len(d.msgs) > 0 { 157 | d.trace(trace, "processing %d messages", len(d.msgs)) 158 | } 159 | for _, msg := range d.msgs { 160 | d.trace(trace, "msg %#v", msg) 161 | 162 | switch reflect.TypeOf(msg) { 163 | case printType: 164 | fmt.Fprintf(&d.result, "TEA PRINT: %v\n", msg) 165 | case szType: 166 | fmt.Fprintf(&d.result, "TEA WINDOW SIZE: %v\n", msg) 167 | // Window size is also visible to the model. 168 | newM, newCmd := d.m.Update(msg) 169 | d.m = newM 170 | d.addCmds(newCmd) 171 | case quitType: 172 | fmt.Fprintf(&d.result, "TEA QUIT\n") 173 | case execType: 174 | fmt.Fprintf(&d.result, "TEA EXEC\n") 175 | case hideCursorType: 176 | fmt.Fprintf(&d.result, "TEA HIDE CURSOR\n") 177 | case enterAltType: 178 | fmt.Fprintf(&d.result, "TEA ENTER ALT\n") 179 | case exitAltType: 180 | fmt.Fprintf(&d.result, "TEA EXIT ALT\n") 181 | case mouseCellType: 182 | fmt.Fprintf(&d.result, "TEA ENABLE MOUSE CELL MOTION\n") 183 | case mouseAllType: 184 | fmt.Fprintf(&d.result, "TEA ENABLE MOUSE MOTION ALL\n") 185 | case mouseDisType: 186 | fmt.Fprintf(&d.result, "TEA DISABLE MOUSE\n") 187 | default: 188 | newM, newCmd := d.m.Update(msg) 189 | d.m = newM 190 | d.addCmds(newCmd) 191 | } 192 | } 193 | d.msgs = d.msgs[:0] 194 | } 195 | 196 | func (d *driver) addCmds(cmds ...tea.Cmd) { 197 | for _, cmd := range cmds { 198 | if cmd == nil { 199 | continue 200 | } 201 | d.cmds = append(d.cmds, cmd) 202 | } 203 | } 204 | 205 | func (d *driver) addMsg(msg tea.Msg) { 206 | if msg == nil { 207 | return 208 | } 209 | d.msgs = append(d.msgs, msg) 210 | } 211 | 212 | func (d *driver) Close(t TB) { 213 | d.cancel() 214 | } 215 | 216 | func (d *driver) RunOneTest(t TB, td *datadriven.TestData) string { 217 | // Save the input position. 218 | d.pos = td.Pos 219 | 220 | switch td.Cmd { 221 | case "set", "reset": 222 | return d.handleSet(t, td) 223 | case "run": 224 | return d.handleRun(t, td) 225 | default: 226 | t.Fatalf("%s: unrecognized test directive: %s", td.Pos, td.Cmd) 227 | panic("unreachable") 228 | } 229 | } 230 | 231 | func (d *driver) handleSet(t TB, td *datadriven.TestData) string { 232 | reset := td.Cmd == "reset" 233 | if len(td.CmdArgs) != 1 || 234 | (!reset && len(td.CmdArgs[0].Vals) != 1) || 235 | (reset && len(td.CmdArgs[0].Vals) != 0) { 236 | t.Fatalf("%s: invalid syntax", d.pos) 237 | } 238 | key := td.CmdArgs[0].Key 239 | val := "" 240 | if !reset { 241 | val = td.CmdArgs[0].Vals[0] 242 | } 243 | 244 | switch key { 245 | case "cmd_timeout": 246 | if reset { 247 | val = defaultCmdTimeout.String() 248 | } 249 | tm, err := time.ParseDuration(val) 250 | if err != nil { 251 | t.Fatalf("%s: invalid timeout value: %v", d.pos, err) 252 | } 253 | d.cmdTimeout = tm 254 | val = d.cmdTimeout.String() 255 | default: 256 | t.Fatalf("%s: unknown option %q", d.pos, key) 257 | } 258 | if reset { 259 | return "ok" 260 | } 261 | return fmt.Sprintf("%s: %s", key, val) 262 | } 263 | 264 | func (d *driver) handleRun(t TB, td *datadriven.TestData) string { 265 | d.result.Reset() 266 | 267 | // Observations: check if there's an observe=() key 268 | // on the first test input line. If not, just observe the view. 269 | var observe []string 270 | seen := false 271 | for i := range td.CmdArgs { 272 | if td.CmdArgs[i].Key == "observe" { 273 | observe = td.CmdArgs[i].Vals 274 | seen = true 275 | break 276 | } 277 | } 278 | if !seen { 279 | observe = []string{"view"} 280 | } 281 | 282 | traceEnabled := td.HasArg("trace") 283 | trace := func(format string, args ...interface{}) { 284 | d.trace(traceEnabled, format, args...) 285 | } 286 | 287 | doObserve := func() { 288 | for _, obs := range observe { 289 | o := d.Observe(t, obs) 290 | d.result.WriteString(o) 291 | // Terminate items with a newline if there's none yet. 292 | if d.result.Len() > 0 { 293 | if d.result.Bytes()[d.result.Len()-1] != '\n' { 294 | d.result.WriteByte('\n') 295 | } 296 | } 297 | } 298 | } 299 | 300 | // Process the initialization, if not done yet. 301 | if !d.startDone { 302 | if !d.disableAutoInit { 303 | trace("calling Init") 304 | d.addCmds(d.m.Init()) 305 | d.processTeaCmds(traceEnabled) 306 | } 307 | 308 | if d.autoSize { 309 | msg := tea.WindowSizeMsg{Width: d.width, Height: d.height} 310 | d.addMsg(msg) 311 | } 312 | d.startDone = true 313 | } 314 | 315 | // Process the commands in the test's input. 316 | testInputCommands := strings.Split(td.Input, "\n") 317 | 318 | for _, testInputCmd := range testInputCommands { 319 | testInputCmd = strings.TrimSpace(testInputCmd) 320 | if testInputCmd == "" || strings.HasPrefix(testInputCmd, "#") { 321 | // Comment or emptyline. 322 | continue 323 | } 324 | 325 | trace("before %q", testInputCmd) 326 | 327 | // If the previous testInputCmd produced 328 | // some tea.Cmds, process them now. 329 | d.processTeaMsgs(traceEnabled) 330 | 331 | // Apply the new testInputCmd. 332 | args := strings.Split(testInputCmd, " ") 333 | testInputCmd = args[0] 334 | args = args[1:] 335 | cmd := d.ApplyTextCommand(t, testInputCmd, args...) 336 | d.addCmds(cmd) 337 | d.processTeaCmds(traceEnabled) 338 | 339 | if traceEnabled { 340 | trace("after %q", testInputCmd) 341 | doObserve() 342 | } 343 | } 344 | 345 | if traceEnabled { 346 | trace("before finish") 347 | doObserve() 348 | } 349 | // Last round of command execution. 350 | d.processTeaMsgs(traceEnabled) 351 | d.processTeaCmds(traceEnabled) 352 | d.processTeaMsgs(traceEnabled) 353 | 354 | trace("at end") 355 | doObserve() 356 | return d.result.String() 357 | } 358 | 359 | func (d *driver) Observe(t TB, what string) string { 360 | var buf strings.Builder 361 | fmt.Fprintf(&buf, "-- %s:\n", what) 362 | switch what { 363 | case "msgs": 364 | fmt.Fprintf(&buf, "msg queue sz: %d\n", len(d.msgs)) 365 | for i, msg := range d.msgs { 366 | t := reflect.TypeOf(msg) 367 | fmt.Fprintf(&buf, "%d:%s: %v\n", i, t, msg) 368 | } 369 | 370 | case "cmds": 371 | fmt.Fprintf(&buf, "command queue sz: %d\n", len(d.cmds)) 372 | 373 | default: 374 | obs, ok := d.observers[what] 375 | if !ok { 376 | t.Fatalf("%s: unsupported observer %q, did you call WithObserver()?", d.pos, what) 377 | } 378 | if err := obs(&buf, d.m); err != nil { 379 | t.Fatalf("%s: observing %q: %v", d.pos, what, err) 380 | } 381 | } 382 | return buf.String() 383 | } 384 | 385 | func observeView(buf io.Writer, m tea.Model) error { 386 | o := m.View() 387 | // Make newlines visible. 388 | o = strings.ReplaceAll(o, "\n", "␤\n") 389 | // Add a "no newline at end" marker if there was no newline at the end. 390 | if len(o) == 0 || o[len(o)-1] != '\n' { 391 | o += "🛇" 392 | } 393 | _, err := io.WriteString(buf, o) 394 | return err 395 | } 396 | 397 | func observeDebug(buf io.Writer, m tea.Model) error { 398 | type dbg interface{ Debug() string } 399 | md, ok := m.(dbg) 400 | if !ok { 401 | return errors.New("model does not support a Debug() string method") 402 | } 403 | _, err := io.WriteString(buf, md.Debug()) 404 | return err 405 | } 406 | 407 | func observeGoStruct(buf io.Writer, m tea.Model) error { 408 | _, err := io.WriteString(buf, pretty.Sprint(m)) 409 | return err 410 | } 411 | 412 | func (d *driver) assertArgc(t TB, args []string, expected int) { 413 | if len(args) != expected { 414 | t.Fatalf("%s: expected %d args, got %d", d.pos, expected, len(args)) 415 | } 416 | } 417 | 418 | func (d *driver) getInt(t TB, v string) int { 419 | i, err := strconv.Atoi(v) 420 | if err != nil { 421 | t.Fatalf("%s: %v", d.pos, err) 422 | } 423 | return i 424 | } 425 | 426 | func (d *driver) ApplyTextCommand(t TB, cmd string, args ...string) tea.Cmd { 427 | switch cmd { 428 | case "resize": 429 | d.assertArgc(t, args, 2) 430 | w := d.getInt(t, args[0]) 431 | h := d.getInt(t, args[1]) 432 | msg := tea.WindowSizeMsg{Width: w, Height: h} 433 | d.addMsg(msg) 434 | 435 | case "key": 436 | d.assertArgc(t, args, 1) 437 | keyName := args[0] 438 | alt := false 439 | if strings.HasPrefix(keyName, "alt+") { 440 | alt = true 441 | keyName = strings.TrimPrefix(keyName, "alt+") 442 | } 443 | k, ok := allKeys[keyName] 444 | if !ok && len(keyName) != 1 { 445 | t.Fatalf("%s: unknown key: %s", d.pos, keyName) 446 | } 447 | if ok { 448 | k.Alt = alt 449 | msg := tea.KeyMsg(k) 450 | d.addMsg(msg) 451 | break 452 | } 453 | // Not a special key: it's runes. 454 | args[0] = keyName 455 | d.typeIn(args, alt) 456 | 457 | case "type": 458 | d.typeIn(args, false) 459 | 460 | case "enter": 461 | d.typeIn(args, false) 462 | d.addMsg(tea.KeyMsg(tea.Key{Type: tea.KeyEnter})) 463 | 464 | case "paste": 465 | arg := strings.Join(args, " ") 466 | s, err := strconv.Unquote(arg) 467 | if err != nil { 468 | t.Fatalf("%s: paste argment error: %v", d.pos, err) 469 | } 470 | d.addMsg(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune(s)})) 471 | 472 | default: 473 | if d.upd != nil { 474 | t.Logf("%s: applying command %q via model updater", d.pos, cmd) 475 | supported, newModel, teaCmd, err := d.upd(d.m, cmd, args...) 476 | if err != nil { 477 | t.Fatalf("%s: updater error: %v", d.pos, err) 478 | } 479 | if !supported { 480 | t.Fatalf("%s: unknown command %q", d.pos, cmd) 481 | } 482 | d.m = newModel 483 | return teaCmd 484 | } else { 485 | t.Fatalf("%s: unknown command %q, and no Updater defined", d.pos, cmd) 486 | } 487 | } 488 | 489 | return nil 490 | } 491 | 492 | func (d *driver) typeIn(args []string, alt bool) { 493 | var buf strings.Builder 494 | for i, arg := range args { 495 | if i > 0 { 496 | buf.WriteByte(' ') 497 | } 498 | buf.WriteString(arg) 499 | } 500 | for _, r := range buf.String() { 501 | d.addMsg(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{r}, Alt: alt})) 502 | } 503 | } 504 | 505 | var allKeys = func() map[string]tea.Key { 506 | result := make(map[string]tea.Key) 507 | for i := 0; ; i++ { 508 | k := tea.Key{Type: tea.KeyType(i)} 509 | keyName := k.String() 510 | // fmt.Println("found key:", keyName) 511 | if keyName == "" { 512 | break 513 | } 514 | result[keyName] = k 515 | } 516 | for i := -2; ; i-- { 517 | k := tea.Key{Type: tea.KeyType(i)} 518 | keyName := k.String() 519 | // fmt.Println("found key:", keyName) 520 | if keyName == "" { 521 | break 522 | } 523 | result[keyName] = k 524 | } 525 | result["space"] = tea.Key{Type: tea.KeySpace, Runes: []rune(" ")} 526 | result["backspace"] = tea.Key{Type: tea.KeyBackspace} 527 | return result 528 | }() 529 | -------------------------------------------------------------------------------- /example/go.mod: -------------------------------------------------------------------------------- 1 | module example 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.13.0 7 | github.com/charmbracelet/bubbletea v0.22.2-0.20220830200705-989d49f3e69f 8 | github.com/charmbracelet/lipgloss v0.5.0 9 | github.com/knz/catwalk v0.0.0-20220831193209-b17ece3d9ab2 10 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 11 | ) 12 | 13 | replace github.com/knz/catwalk => ./.. 14 | -------------------------------------------------------------------------------- /example/go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 2 | github.com/charmbracelet/bubbles v0.13.0 h1:zP/ROH3wJEBqZWKIsD50ZKKlx3ydLInq3LdD/Nrlb8w= 3 | github.com/charmbracelet/bubbles v0.13.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= 4 | github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= 5 | github.com/charmbracelet/bubbletea v0.22.2-0.20220830200705-989d49f3e69f h1:CbvZpu9ZO/ta2PDFB1W7ee8DnA2ZZ8PkrlU3bveRFMU= 6 | github.com/charmbracelet/bubbletea v0.22.2-0.20220830200705-989d49f3e69f/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0= 7 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 8 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 9 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 10 | github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= 11 | github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= 12 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 13 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 15 | github.com/knz/lipgloss-convert v0.1.0 h1:qUPUt6r8mqvi9DIV3nBPu3kEmFyHrZtXzv0BlPBPLNQ= 16 | github.com/knz/lipgloss-convert v0.1.0/go.mod h1:S14GmtoiW/VAHqB7xEzuZOt0/G6GQ2dfjJN0fHpm30Q= 17 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 18 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 19 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 20 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 21 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 22 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 23 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 24 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 25 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 26 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 27 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 28 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 29 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 30 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 31 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 32 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 33 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 34 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 35 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 36 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 37 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 38 | github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 39 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 40 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 41 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 42 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 43 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 44 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 45 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= 46 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 50 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 51 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 52 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= 53 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 54 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 55 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 56 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 57 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 58 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 59 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 60 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= 61 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 63 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 64 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 65 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 66 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 67 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 69 | -------------------------------------------------------------------------------- /example/model.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/bubbles/key" 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | // Model is a viewport adapted to follow the tea.Model interface. 12 | type Model struct { 13 | viewport.Model 14 | } 15 | 16 | var _ tea.Model = (*Model)(nil) 17 | 18 | // New initializes a new model. 19 | func New(width, height int) *Model { 20 | return &Model{ 21 | Model: viewport.New(width, height), 22 | } 23 | } 24 | 25 | const loremIpsum = ` 26 | lorem ipsum dolor sit amet, consectetur adipiscing 27 | elit, sed do eiusmod tempor incididunt ut labore et dolore magna 28 | aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco 29 | laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor 30 | in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla 31 | pariatur. Excepteur sint occaecat cupidatat non proident, sunt in 32 | culpa qui officia deserunt mollit anim id est laborum. 33 | 34 | Sed ut perspiciatis unde omnis iste natus error sit voluptatem 35 | accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae 36 | ab illo inventore veritatis et quasi architecto beatae vitae dicta 37 | sunt explicabo.` 38 | 39 | func (m *Model) Init() tea.Cmd { 40 | cmd := m.Model.Init() 41 | m.SetContent(`first line 42 | second line 43 | third line 44 | fourth line 45 | fifth line 46 | sixth line`) 47 | return cmd 48 | } 49 | 50 | var quitBinding = key.NewBinding(key.WithKeys("q")) 51 | var loremBinding = key.NewBinding(key.WithKeys("l")) 52 | 53 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 54 | if kmsg, ok := msg.(tea.KeyMsg); ok { 55 | switch { 56 | case key.Matches(kmsg, quitBinding): 57 | return m, tea.Quit 58 | case key.Matches(kmsg, loremBinding): 59 | s := strings.TrimSpace(loremIpsum) 60 | m.SetContent(s) 61 | return m, nil 62 | } 63 | } 64 | 65 | newView, cmd := m.Model.Update(msg) 66 | m.Model = newView 67 | return m, cmd 68 | } 69 | -------------------------------------------------------------------------------- /example/model_test.go: -------------------------------------------------------------------------------- 1 | package example 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/knz/catwalk" 8 | "github.com/muesli/termenv" 9 | ) 10 | 11 | func TestModel(t *testing.T) { 12 | // Initialize the model to test. 13 | m := New(40, 3) 14 | 15 | lipgloss.SetColorProfile(termenv.Ascii) 16 | 17 | // Run all the tests in input file "testdata/viewport_tests" 18 | catwalk.RunModel(t, "testdata/viewport_tests", m) 19 | } 20 | 21 | func TestColors(t *testing.T) { 22 | // Initialize the model to test. 23 | m := New(40, 3) 24 | 25 | lipgloss.SetColorProfile(termenv.ANSI) 26 | m.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) 27 | 28 | catwalk.RunModel(t, "testdata/example", m, 29 | catwalk.WithWindowSize(40, 3), 30 | catwalk.WithUpdater( 31 | catwalk.StylesUpdater("view", 32 | catwalk.SimpleStylesApplier(m))), 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /example/testdata/example: -------------------------------------------------------------------------------- 1 | run 2 | ---- 3 | TEA WINDOW SIZE: {40 3} 4 | -- view: 5 | first line ␤ 6 | second line␤ 7 | third line 🛇 8 | 9 | # One line down 10 | run 11 | type j 12 | ---- 13 | -- view: 14 | second line␤ 15 | third line ␤ 16 | fourth line🛇 17 | 18 | # Two lines down 19 | run 20 | type jj 21 | ---- 22 | -- view: 23 | fourth line␤ 24 | fifth line ␤ 25 | sixth line 🛇 26 | 27 | # Change the color to blue. 28 | run 29 | restyle view.Style foreground: 12 30 | ---- 31 | -- view: 32 | fourth line␤ 33 | fifth line ␤ 34 | sixth line 🛇 35 | 36 | # One line up 37 | run 38 | key up 39 | ---- 40 | -- view: 41 | third line ␤ 42 | fourth line␤ 43 | fifth line 🛇 44 | 45 | # Check the exit key works. 46 | run 47 | type q 48 | ---- 49 | TEA QUIT 50 | -- view: 51 | third line ␤ 52 | fourth line␤ 53 | fifth line 🛇 54 | -------------------------------------------------------------------------------- /example/testdata/viewport_tests: -------------------------------------------------------------------------------- 1 | run 2 | ---- 3 | -- view: 4 | first line␤ 5 | second line␤ 6 | third line🛇 7 | 8 | # One line down 9 | run 10 | type j 11 | ---- 12 | -- view: 13 | second line␤ 14 | third line␤ 15 | fourth line🛇 16 | 17 | # Two lines down 18 | run 19 | type jj 20 | ---- 21 | -- view: 22 | fourth line␤ 23 | fifth line␤ 24 | sixth line🛇 25 | 26 | # One line up 27 | run 28 | key up 29 | ---- 30 | -- view: 31 | third line␤ 32 | fourth line␤ 33 | fifth line🛇 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/knz/catwalk 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.13.0 7 | github.com/charmbracelet/bubbletea v0.22.2-0.20220830200705-989d49f3e69f 8 | github.com/charmbracelet/lipgloss v0.5.0 9 | github.com/cockroachdb/datadriven v1.0.2 10 | github.com/knz/lipgloss-convert v0.1.0 11 | github.com/kr/pretty v0.3.0 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 2 | github.com/charmbracelet/bubbles v0.13.0 h1:zP/ROH3wJEBqZWKIsD50ZKKlx3ydLInq3LdD/Nrlb8w= 3 | github.com/charmbracelet/bubbles v0.13.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= 4 | github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= 5 | github.com/charmbracelet/bubbletea v0.22.2-0.20220830200705-989d49f3e69f h1:CbvZpu9ZO/ta2PDFB1W7ee8DnA2ZZ8PkrlU3bveRFMU= 6 | github.com/charmbracelet/bubbletea v0.22.2-0.20220830200705-989d49f3e69f/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0= 7 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 8 | github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= 9 | github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= 10 | github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= 11 | github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= 12 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 13 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 15 | github.com/knz/lipgloss-convert v0.0.0-20220903144657-35a78f5bf926 h1:cT37vTQbZo8bL/Kznlyv6Lmi5P53f0iYg4lYjUXoERE= 16 | github.com/knz/lipgloss-convert v0.0.0-20220903144657-35a78f5bf926/go.mod h1:S14GmtoiW/VAHqB7xEzuZOt0/G6GQ2dfjJN0fHpm30Q= 17 | github.com/knz/lipgloss-convert v0.1.0 h1:qUPUt6r8mqvi9DIV3nBPu3kEmFyHrZtXzv0BlPBPLNQ= 18 | github.com/knz/lipgloss-convert v0.1.0/go.mod h1:S14GmtoiW/VAHqB7xEzuZOt0/G6GQ2dfjJN0fHpm30Q= 19 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 20 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 21 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 22 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 23 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 24 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 25 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 26 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 27 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 28 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 29 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 30 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 31 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 32 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 33 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 34 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 35 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 36 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 37 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 38 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 39 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 40 | github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 41 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 42 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 43 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 44 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 45 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 46 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 47 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= 48 | github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 49 | github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= 50 | github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= 51 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 52 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 53 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 54 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 55 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 56 | github.com/rivo/uniseg v0.3.4 h1:3Z3Eu6FGHZWSfNKJTOUiPatWwfc7DzJRU04jFUqJODw= 57 | github.com/rivo/uniseg v0.3.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 58 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= 59 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 60 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 61 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 62 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= 67 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 h1:v6hYoSR9T5oet+pMXwUWkbiVqx/63mlHjefrHmxwfeY= 69 | golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 71 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 72 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 73 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 74 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 75 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 76 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 77 | -------------------------------------------------------------------------------- /interface.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import ( 4 | "io" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/cockroachdb/datadriven" 8 | ) 9 | 10 | // Updater is an optional function added to RunModel(), which can 11 | // apply state change commands as input to a test. 12 | // 13 | // It should return false in the first return value to indicate that 14 | // the command is not supported. 15 | // 16 | // It can return an error e.g. to indicate that the command is 17 | // supported but its arguments use invalid syntax, or that the model 18 | // is in an invalid state. 19 | type Updater func(m tea.Model, testCmd string, args ...string) (supported bool, newModel tea.Model, teaCmd tea.Cmd, err error) 20 | 21 | // Observer is an optional function added to RunModel(), which can 22 | // extract information from the model to serve as expected output in 23 | // tests. 24 | type Observer func(out io.Writer, m tea.Model) error 25 | 26 | // Option is the type of an option which can be specified 27 | // with RunModel or NewDriver. 28 | type Option func(*driver) 29 | 30 | // TB is a shim interface for testing.T / testing.B. 31 | type TB interface { 32 | Fatal(...interface{}) 33 | Fatalf(string, ...interface{}) 34 | Logf(string, ...interface{}) 35 | } 36 | 37 | // Driver is the externally-visible interface for a test driver. 38 | type Driver interface { 39 | // Close stops the model at the end. 40 | Close(t TB) 41 | 42 | // ApplyTextCommand applies the given textual command to the model. 43 | // It may return an extra tea.Cmd to process by the test. 44 | ApplyTextCommand(t TB, cmd string, args ...string) tea.Cmd 45 | 46 | // Observe observes the given component of the model. 47 | // Supported values: 48 | // - view: call View() 49 | // - gostruct: print with %#v 50 | // - debug: call Debug() 51 | Observe(t TB, what string) string 52 | 53 | // RunOneTest runs one step of a test file. 54 | // 55 | // The following directives are supported: 56 | // 57 | // - run: apply some state changes and view the result. 58 | // 59 | // Supported directive options: 60 | // - trace: produce a log of the intermediate test steps. 61 | // - observe: what to observe after the state changes. 62 | // 63 | // Supported values for observe: 64 | // - view: the result of calling View(). 65 | // - gostruct: the result of printing the model with %#v. 66 | // - debug: the result of calling the Debug() method (it needs to be defined) 67 | // - msgs/cmds: print the residual tea.Cmd / tea.Msg input. 68 | // 69 | // Supported input commands under "run": 70 | // - type: enter some runes as tea.Key 71 | // - key: enter a special key or combination as a tea.Key 72 | RunOneTest(t TB, d *datadriven.TestData) string 73 | } 74 | -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import tea "github.com/charmbracelet/bubbletea" 4 | 5 | // WithAutoInitDisabled tells the test driver to not automatically 6 | // initialize the model (via the Init method) upon first use. 7 | func WithAutoInitDisabled() Option { 8 | return func(d *driver) { 9 | d.disableAutoInit = true 10 | } 11 | } 12 | 13 | // WithWindowSize tells the test driver to issue a tea.WindowSizeMsg 14 | // as the first event after initialization. 15 | func WithWindowSize(width, height int) Option { 16 | return func(d *driver) { 17 | d.autoSize = true 18 | d.width = width 19 | d.height = height 20 | } 21 | } 22 | 23 | // WithObserver tells the test driver to support an additional 24 | // observer with the given function. 25 | // 26 | // For example, after WithObserver("hello", myObserver) 27 | // The function myObserver() will be called every time 28 | // a test specifies `observe=hello` in the run directive. 29 | func WithObserver(what string, obs Observer) Option { 30 | return func(d *driver) { 31 | d.observers[what] = obs 32 | } 33 | } 34 | 35 | // WithUpdater adds the specified model updater to the test. 36 | // It is possible to use multiple WithUpdater options, which will 37 | // chain them automatically (using ChainUpdaters). 38 | func WithUpdater(upd Updater) Option { 39 | return func(d *driver) { 40 | d.upd = ChainUpdaters(d.upd, upd) 41 | } 42 | } 43 | 44 | // ChainUpdaters chains the specified updaters into a resulting updater 45 | // that supports all the commands in the chain. Test input commands 46 | // are passed to each updater in turn until the first updater 47 | // that supports it. 48 | // 49 | // For example: 50 | // - upd1 supports command "print" 51 | // - upd2 supports command "get" 52 | // - ChainUpdaters(upd1, upd2) will support both commands "print" and "get. 53 | func ChainUpdaters(upds ...Updater) Updater { 54 | actual := make([]Updater, 0, len(upds)) 55 | for _, u := range upds { 56 | if u != nil { 57 | actual = append(actual, u) 58 | } 59 | } 60 | if len(actual) == 1 { 61 | return actual[0] 62 | } 63 | return func(m tea.Model, inputCmd string, args ...string) (supported bool, newModel tea.Model, teaCmd tea.Cmd, err error) { 64 | for _, upd := range actual { 65 | supported, newModel, teaCmd, err = upd(m, inputCmd, args...) 66 | if supported || err != nil { 67 | return supported, newModel, teaCmd, err 68 | } 69 | } 70 | // None of the updaters supported the command. 71 | return false, nil, nil, nil 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /options_test.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import ( 4 | "testing" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | // TestDisableAutoInit checks the WithAutoInitDisabled configuration option. 10 | func TestDisableAutoInit(t *testing.T) { 11 | RunModel(t, "testdata/disable_auto_start", emptyModel{}, WithAutoInitDisabled()) 12 | } 13 | 14 | // TestInitWindowSize checks that a WindowSizeMsg is sent at the first interaction. 15 | func TestInitWindowSize(t *testing.T) { 16 | RunModel(t, "testdata/window_size", emptyModel{}, WithWindowSize(80, 25)) 17 | } 18 | 19 | func TestChainUpdaters(t *testing.T) { 20 | upd1 := func(_ tea.Model, cmd string, _ ...string) (bool, tea.Model, tea.Cmd, error) { 21 | if cmd == "hello" { 22 | return true, nil, nil, nil 23 | } 24 | return false, nil, nil, nil 25 | } 26 | upd2 := func(_ tea.Model, cmd string, _ ...string) (bool, tea.Model, tea.Cmd, error) { 27 | if cmd == "world" { 28 | return true, nil, nil, nil 29 | } 30 | return false, nil, nil, nil 31 | } 32 | 33 | upd := ChainUpdaters(upd1, upd2) 34 | 35 | if s, _, _, _ := ChainUpdaters(upd1)(nil, "hello"); !s { 36 | t.Errorf("updater doesn't propagate single argument") 37 | } 38 | if s, _, _, _ := upd(nil, "hello"); !s { 39 | t.Errorf("first updater did not register") 40 | } 41 | if s, _, _, _ := upd(nil, "world"); !s { 42 | t.Errorf("2nd updater did not register") 43 | } 44 | if s, _, _, _ := upd(nil, "unknown"); s { 45 | t.Errorf("surprising updater result") 46 | } 47 | if s, _, _, _ := ChainUpdaters()(nil, "unknown"); s { 48 | t.Errorf("surprising updater result") 49 | } 50 | } 51 | 52 | func TestChainComplexUpdaters(t *testing.T) { 53 | hm := &helpModelR{} 54 | upd1 := KeyMapUpdater("hello", SimpleKeyMapApplier(&hm.KeyMap)) 55 | upd2 := StylesUpdater("help", SimpleStylesApplier(&hm.help.Styles)) 56 | upd3 := func(m tea.Model, cmd string, args ...string) (bool, tea.Model, tea.Cmd, error) { 57 | m.(*helpModelR).val = 123 58 | return true, m, nil, nil 59 | } 60 | 61 | const test = ` 62 | run 63 | keybind hello.MyKey ctrl+c 64 | keyhelp hello.MyKey C-c break 65 | restyle help.Ellipsis foreground: #f00 66 | othercmd 67 | ---- 68 | -- view: 69 | VALUE: 123␤ 70 | C-c break 🛇` 71 | 72 | RunModelFromString(t, test, hm, WithUpdater(upd1), WithUpdater(upd2), WithUpdater(upd3)) 73 | } 74 | -------------------------------------------------------------------------------- /styles.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | lipglossc "github.com/knz/lipgloss-convert" 11 | ) 12 | 13 | // StylesUpdater defines an updater which supports the "restyle" 14 | // command to change a struct containing lipgloss.Style fields. You 15 | // can add this to a test using WithUpdater(). It is possible to add 16 | // multiple styles updaters to the same test. 17 | // 18 | // For example, using: 19 | // 20 | // StylesUpdater("mymodel", 21 | // func(m tea.Model, changeStyles func(interface{})) (tea.Model, err) { 22 | // myModel := m.(mymodel) 23 | // if err := changeStyles(&myModel); err != nil { 24 | // return m, err 25 | // } 26 | // return myModel, nil 27 | // }) 28 | // 29 | // and mymodel containing a CursorStyle field, 30 | // it becomes possible to use "restyle mymodel.CursorStyle foreground: 11" to 31 | // define a new style during a test. 32 | // 33 | // If your model implements tea.Model by reference (i.e. its address 34 | // does not change through Update calls), you can simplify 35 | // the call as follows: 36 | // 37 | // StylesUpdater("...", SimpleStylesApplier(&yourmodel)). 38 | func StylesUpdater(prefix string, apply StylesApplier) Updater { 39 | return func(m tea.Model, inputCmd string, args ...string) (bool, tea.Model, tea.Cmd, error) { 40 | return handleStyleUpdate(prefix+".", apply, m, inputCmd, args...) 41 | } 42 | } 43 | 44 | // StylesApplier is the type of a function which applies the 45 | // changeStyles callback on a struct inside the model, then 46 | // returns the resulting model. 47 | // 48 | // Example implementation: 49 | // func(m tea.Model, changeStyles func(interface{}) error) (tea.Model, err) { 50 | // myModel := m.(mymodel) 51 | // if err := changeKeyMap(&myModel); err != nil { 52 | // return m, err 53 | // } 54 | // return myModel, nil 55 | // } 56 | type StylesApplier func(m tea.Model, changeStyles func(interface{}) error) (tea.Model, error) 57 | 58 | // SimpleSyylesApplier is a helper to simplify the definition of the 59 | // function argument to StylesUpdater, in the case the model is 60 | // implemented by reference -- i.e. the address of the styles does not 61 | // change from one call to Update to the next. 62 | func SimpleStylesApplier(styledStruct interface{}) StylesApplier { 63 | return func(m tea.Model, changeStyles func(interface{}) error) (tea.Model, error) { 64 | return m, changeStyles(styledStruct) 65 | } 66 | } 67 | 68 | func handleStyleUpdate( 69 | prefix string, apply StylesApplier, m tea.Model, inputcmd string, args ...string, 70 | ) (bool, tea.Model, tea.Cmd, error) { 71 | switch inputcmd { 72 | case "restyle": 73 | if len(args) < 2 { 74 | return false, m, nil, fmt.Errorf("syntax: restyle ") 75 | } 76 | if !strings.HasPrefix(args[0], prefix) { 77 | // This keybind is meant for another updater. Not us. 78 | return false, m, nil, nil 79 | } 80 | styleName := strings.TrimPrefix(args[0], prefix) 81 | newM, err := apply(m, func(km interface{}) error { 82 | return applyStyleUpdate(km, styleName, strings.Join(args[1:], " ")) 83 | }) 84 | return true, newM, nil, err 85 | 86 | default: 87 | // Command not supported. 88 | return false, m, nil, nil 89 | } 90 | } 91 | 92 | func applyStyleUpdate(km interface{}, styleName, newStyle string) error { 93 | s, err := getStyle(km, styleName) 94 | if err != nil { 95 | return err 96 | } 97 | sres, err := lipglossc.Import(*s, newStyle) 98 | if err != nil { 99 | return err 100 | } 101 | *s = sres 102 | return nil 103 | } 104 | 105 | func getStyle(km interface{}, styleName string) (*lipgloss.Style, error) { 106 | v := reflect.ValueOf(km) 107 | if v.Type().Kind() != reflect.Ptr { 108 | return nil, fmt.Errorf("type %T is not a pointer to struct", km) 109 | } 110 | v = v.Elem() 111 | if v.Type().Kind() != reflect.Struct { 112 | return nil, fmt.Errorf("type %T is not a pointer to struct", km) 113 | } 114 | var zv reflect.Value 115 | fv := v.FieldByName(styleName) 116 | if fv == zv { 117 | return nil, fmt.Errorf("struct %T does not contain a field named %q", km, styleName) 118 | } 119 | if fv.Type() != keyStyleType { 120 | return nil, fmt.Errorf("field %q of struct %T does not have type lipgloss.Style", styleName, km) 121 | } 122 | return fv.Addr().Interface().(*lipgloss.Style), nil 123 | } 124 | 125 | var keyStyleType = reflect.TypeOf(lipgloss.NewStyle()) 126 | -------------------------------------------------------------------------------- /styles_test.go: -------------------------------------------------------------------------------- 1 | package catwalk 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | lipglossc "github.com/knz/lipgloss-convert" 11 | ) 12 | 13 | type mystyles struct { 14 | NotStyle int 15 | MyStyle lipgloss.Style 16 | EmbeddedStyle 17 | } 18 | 19 | type EmbeddedStyle struct { 20 | OtherStyle lipgloss.Style 21 | } 22 | 23 | func TestGetStyle(t *testing.T) { 24 | var b mystyles 25 | var x int 26 | 27 | td := []struct { 28 | in interface{} 29 | name string 30 | out *lipgloss.Style 31 | expErr string 32 | }{ 33 | {b, "hello", nil, `type catwalk.mystyles is not a pointer to struct`}, 34 | {&x, "hello", nil, `type *int is not a pointer to struct`}, 35 | {&b, "hello", nil, `struct *catwalk.mystyles does not contain a field named "hello"`}, 36 | {&b, "hello", nil, `struct *catwalk.mystyles does not contain a field named "hello"`}, 37 | {&b, "NotStyle", nil, `field "NotStyle" of struct *catwalk.mystyles does not have type lipgloss.Style`}, 38 | {&b, "MyStyle", &b.MyStyle, ``}, 39 | {&b, "OtherStyle", &b.OtherStyle, ``}, 40 | } 41 | 42 | for i, tc := range td { 43 | t.Run(fmt.Sprint(i), func(t *testing.T) { 44 | out, err := getStyle(tc.in, tc.name) 45 | if err != nil { 46 | if err.Error() != tc.expErr { 47 | t.Fatalf("expected error %q, got: %v", tc.expErr, err) 48 | } 49 | return 50 | } 51 | if err == nil && tc.expErr != "" { 52 | t.Fatalf("expected error %q, got no error", tc.expErr) 53 | } 54 | if out != tc.out { 55 | t.Fatalf("not valid style returned") 56 | } 57 | }) 58 | } 59 | } 60 | 61 | func TestChangeStyle(t *testing.T) { 62 | var b mystyles 63 | 64 | err := applyStyleUpdate(&b, "hello", "foreground:11") 65 | if err == nil || err.Error() != `struct *catwalk.mystyles does not contain a field named "hello"` { 66 | t.Fatalf("restyle did not fail: %v", err) 67 | } 68 | err = applyStyleUpdate(&b, "MyStyle", "unsupported:11") 69 | if err == nil || err.Error() != `in "unsupported:11": property not supported: "unsupported"` { 70 | t.Fatalf("restyle did not fail: %v", err) 71 | } 72 | 73 | err = applyStyleUpdate(&b, "MyStyle", "foreground:11") 74 | if err != nil { 75 | t.Fatal(err) 76 | } 77 | s := lipglossc.Export(b.MyStyle) 78 | if s != "foreground: 11;" { 79 | t.Fatalf("style did not change properly: %s", s) 80 | } 81 | } 82 | 83 | // TestRestyle checks the restyle command. 84 | func TestRestyle(t *testing.T) { 85 | t.Run("by-value", func(t *testing.T) { 86 | upd1 := StylesUpdater("view", func(m tea.Model, fn func(interface{}) error) (tea.Model, error) { 87 | h := m.(viewModel) 88 | err := fn(&h.viewport) 89 | return h, err 90 | }) 91 | 92 | upd2 := StylesUpdater("model", func(m tea.Model, fn func(interface{}) error) (tea.Model, error) { 93 | h := m.(viewModel) 94 | err := fn(&h) 95 | return h, err 96 | }) 97 | 98 | RunModel(t, "testdata/styles", newView(), WithUpdater(upd1), WithUpdater(upd2)) 99 | }) 100 | 101 | t.Run("by-reference", func(t *testing.T) { 102 | bv := newView() 103 | v := viewModelR(bv) 104 | upd1 := StylesUpdater("view", SimpleStylesApplier(&v.viewport)) 105 | upd2 := StylesUpdater("model", SimpleStylesApplier(&v)) 106 | 107 | RunModel(t, "testdata/styles", &v, WithUpdater(upd1), WithUpdater(upd2)) 108 | }) 109 | } 110 | 111 | type viewModel struct { 112 | val int 113 | viewport viewport.Model 114 | ValueStyle lipgloss.Style 115 | } 116 | 117 | func newView() viewModel { 118 | return viewModel{ 119 | viewport: viewport.New(10, 3), 120 | } 121 | } 122 | 123 | func (h viewModel) Init() tea.Cmd { return nil } 124 | 125 | func (h viewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 126 | h.val++ 127 | return h, nil 128 | } 129 | 130 | func (h viewModel) View() string { 131 | value := h.ValueStyle.Render(fmt.Sprintf("VALUE: %d", h.val)) 132 | h.viewport.SetContent(value) 133 | return h.viewport.View() 134 | } 135 | 136 | type viewModelR viewModel 137 | 138 | func (h *viewModelR) Init() tea.Cmd { return nil } 139 | func (h *viewModelR) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 140 | newModel, newCmd := viewModel(*h).Update(msg) 141 | *h = viewModelR(newModel.(viewModel)) 142 | return h, newCmd 143 | } 144 | func (h *viewModelR) View() string { return viewModel(*h).View() } 145 | -------------------------------------------------------------------------------- /testdata/bindings: -------------------------------------------------------------------------------- 1 | # Initially, MyKey is not bound. 2 | run 3 | type c 4 | ---- 5 | TEA PRINT: {UNKOWN KEY} 6 | -- view: 7 | VALUE: 1␤ 8 | 🛇 9 | 10 | # Let's bind MyKey to "c". 11 | # After that, typing "c" will be recognized. 12 | run 13 | keybind hello.MyKey c 14 | type c 15 | ---- 16 | TEA PRINT: {MYKEY RECOGNIZED} 17 | -- view: 18 | VALUE: 2␤ 19 | 🛇 20 | 21 | # Let's change the help text and check 22 | # the help widget updates accordingly. 23 | run 24 | keyhelp hello.MyKey c says hi 25 | ---- 26 | -- view: 27 | VALUE: 2␤ 28 | c says hi 🛇 29 | 30 | # Let's disable the binding. 31 | # The help text becomes hidden. 32 | run 33 | keybind hello.MyKey disable 34 | type c 35 | ---- 36 | TEA PRINT: {UNKOWN KEY} 37 | -- view: 38 | VALUE: 3␤ 39 | 🛇 40 | 41 | # Let's re-enable the binding. 42 | # This shows the help text again. 43 | run 44 | keybind hello.MyKey enable 45 | type c 46 | ---- 47 | TEA PRINT: {MYKEY RECOGNIZED} 48 | -- view: 49 | VALUE: 4␤ 50 | c says hi 🛇 51 | 52 | # We can also operate on multiple sets of keybindings. 53 | run 54 | keybind hello.MyKey d 55 | keyhelp hello.MyKey d says hello 56 | keybind world.Other c 57 | keyhelp world.Other c says more 58 | type c 59 | type d 60 | ---- 61 | TEA PRINT: {OTHERKEY RECOGNIZED} 62 | TEA PRINT: {MYKEY RECOGNIZED} 63 | -- view: 64 | VALUE: 6␤ 65 | d says helloc says more🛇 66 | 67 | # Show what happens when we unbind entirely. 68 | run 69 | keybind hello.MyKey unbind 70 | type d 71 | ---- 72 | TEA PRINT: {UNKOWN KEY} 73 | -- view: 74 | VALUE: 7␤ 75 | c says more🛇 76 | -------------------------------------------------------------------------------- /testdata/disable_auto_start: -------------------------------------------------------------------------------- 1 | # Show that the Init method is NOT called at the beginning 2 | # because the WithAutoInitDisabled option was set. 3 | run 4 | ---- 5 | -- view: 6 | MODEL VIEW🛇 7 | -------------------------------------------------------------------------------- /testdata/expansion: -------------------------------------------------------------------------------- 1 | # Check that init commands get expanded/processed. 2 | run 3 | ---- 4 | TEA PRINT: {init1} 5 | TEA PRINT: {init2} 6 | TEA PRINT: {init3} 7 | -- view: 8 | 🛇 9 | 10 | # Check that update commands get expanded/processed. 11 | run 12 | type a 13 | ---- 14 | TEA PRINT: {upd1} 15 | TEA PRINT: {upd2} 16 | TEA PRINT: {upd3} 17 | -- view: 18 | 🛇 19 | 20 | # Check that the TestUpdater gets called and its commands are expanded/processed. 21 | run 22 | noopcmd 23 | ---- 24 | TEA PRINT: {tupd1} 25 | TEA PRINT: {tupd2} 26 | TEA PRINT: {tupd3} 27 | -- view: 28 | 🛇 29 | 30 | # Show that commands can be reordered. 31 | run trace=on 32 | type a 33 | noopcmd 34 | ---- 35 | -- trace: before "type a" 36 | -- trace: after "type" 37 | -- view: 38 | 🛇 39 | -- trace: before "noopcmd" 40 | -- trace: processing 1 messages 41 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{97}, Alt:false} 42 | -- trace: processing 2 cmds 43 | -- trace: expanded 3 commands 44 | -- trace: expanded 2 commands 45 | -- trace: translated cmd: tea.printLineMessage 46 | -- trace: translated cmd: 47 | -- trace: expanded 2 commands 48 | -- trace: translated cmd: tea.printLineMessage 49 | -- trace: expanded 2 commands 50 | -- trace: translated cmd: tea.printLineMessage 51 | -- trace: translated cmd: tea.printLineMessage 52 | -- trace: translated cmd: tea.printLineMessage 53 | -- trace: translated cmd: tea.printLineMessage 54 | -- trace: after "noopcmd" 55 | -- view: 56 | 🛇 57 | -- trace: before finish 58 | -- view: 59 | 🛇 60 | -- trace: processing 6 messages 61 | -- trace: msg tea.printLineMessage{messageBody:"upd1"} 62 | TEA PRINT: {upd1} 63 | -- trace: msg tea.printLineMessage{messageBody:"tupd1"} 64 | TEA PRINT: {tupd1} 65 | -- trace: msg tea.printLineMessage{messageBody:"upd2"} 66 | TEA PRINT: {upd2} 67 | -- trace: msg tea.printLineMessage{messageBody:"upd3"} 68 | TEA PRINT: {upd3} 69 | -- trace: msg tea.printLineMessage{messageBody:"tupd2"} 70 | TEA PRINT: {tupd2} 71 | -- trace: msg tea.printLineMessage{messageBody:"tupd3"} 72 | TEA PRINT: {tupd3} 73 | -- trace: at end 74 | -- view: 75 | 🛇 76 | -------------------------------------------------------------------------------- /testdata/model_threading: -------------------------------------------------------------------------------- 1 | # The tests below check that each time an Update or TestUpdate 2 | # method returns a new model, the test framework picks up the new 3 | # model and continues to work with it. 4 | 5 | run 6 | ---- 7 | -- view: 8 | VALUE: 0🛇 9 | 10 | run 11 | type a 12 | ---- 13 | -- view: 14 | VALUE: 1🛇 15 | 16 | run 17 | noopcmd 18 | ---- 19 | TEA PRINT: {TEST UPDATE CALLED WITH noopcmd []} 20 | -- view: 21 | VALUE: 1🛇 22 | 23 | run 24 | double 25 | ---- 26 | TEA PRINT: {TEST UPDATE CALLED WITH double []} 27 | -- view: 28 | VALUE: 2🛇 29 | -------------------------------------------------------------------------------- /testdata/observe: -------------------------------------------------------------------------------- 1 | # The tests below demonstrate the observe directive argument. 2 | 3 | run 4 | ---- 5 | -- view: 6 | VALUE: '႒'🛇 7 | 8 | run observe=gostruct 9 | ---- 10 | -- gostruct: 11 | &catwalk.structModel{x:4242} 12 | 13 | run observe=debug 14 | ---- 15 | -- debug: 16 | DEBUG SAYS HI 17 | 18 | run trace=on observe=(view,gostruct,msgs,cmds) 19 | type a 20 | ---- 21 | -- trace: before "type a" 22 | -- trace: after "type" 23 | -- view: 24 | VALUE: '႒'🛇 25 | -- gostruct: 26 | &catwalk.structModel{x:4242} 27 | -- msgs: 28 | msg queue sz: 1 29 | 0:tea.KeyMsg: a 30 | -- cmds: 31 | command queue sz: 0 32 | -- trace: before finish 33 | -- view: 34 | VALUE: '႒'🛇 35 | -- gostruct: 36 | &catwalk.structModel{x:4242} 37 | -- msgs: 38 | msg queue sz: 1 39 | 0:tea.KeyMsg: a 40 | -- cmds: 41 | command queue sz: 0 42 | -- trace: processing 1 messages 43 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{97}, Alt:false} 44 | -- trace: at end 45 | -- view: 46 | VALUE: '႓'🛇 47 | -- gostruct: 48 | &catwalk.structModel{x:4243} 49 | -- msgs: 50 | msg queue sz: 0 51 | -- cmds: 52 | command queue sz: 0 53 | -------------------------------------------------------------------------------- /testdata/simple: -------------------------------------------------------------------------------- 1 | # Show that the Init method is called at the beginning. 2 | run 3 | ---- 4 | TEA PRINT: {MODEL INIT} 5 | -- view: 6 | MODEL VIEW🛇 7 | 8 | subtest key_input 9 | 10 | run trace=on 11 | type ab cd 12 | ---- 13 | -- trace: before "type ab cd" 14 | -- trace: after "type" 15 | -- view: 16 | MODEL VIEW🛇 17 | -- trace: before finish 18 | -- view: 19 | MODEL VIEW🛇 20 | -- trace: processing 5 messages 21 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{97}, Alt:false} 22 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{98}, Alt:false} 23 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{32}, Alt:false} 24 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{99}, Alt:false} 25 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{100}, Alt:false} 26 | -- trace: processing 5 cmds 27 | -- trace: translated cmd: tea.enterAltScreenMsg 28 | -- trace: translated cmd: tea.printLineMessage 29 | -- trace: translated cmd: tea.printLineMessage 30 | -- trace: translated cmd: tea.enableMouseCellMotionMsg 31 | -- trace: translated cmd: tea.printLineMessage 32 | -- trace: processing 5 messages 33 | -- trace: msg tea.enterAltScreenMsg{} 34 | TEA ENTER ALT 35 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 36 | TEA PRINT: {MODEL UPDATE} 37 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 38 | TEA PRINT: {MODEL UPDATE} 39 | -- trace: msg tea.enableMouseCellMotionMsg{} 40 | TEA ENABLE MOUSE CELL MOTION 41 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 42 | TEA PRINT: {MODEL UPDATE} 43 | -- trace: at end 44 | -- view: 45 | MODEL VIEW🛇 46 | 47 | run trace=on 48 | enter ab 49 | ---- 50 | -- trace: before "enter ab" 51 | -- trace: after "enter" 52 | -- view: 53 | MODEL VIEW🛇 54 | -- trace: before finish 55 | -- view: 56 | MODEL VIEW🛇 57 | -- trace: processing 3 messages 58 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{97}, Alt:false} 59 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{98}, Alt:false} 60 | -- trace: msg tea.KeyMsg{Type:13, Runes:[]int32(nil), Alt:false} 61 | -- trace: processing 3 cmds 62 | -- trace: translated cmd: tea.enterAltScreenMsg 63 | -- trace: translated cmd: tea.printLineMessage 64 | -- trace: translated cmd: tea.printLineMessage 65 | -- trace: processing 3 messages 66 | -- trace: msg tea.enterAltScreenMsg{} 67 | TEA ENTER ALT 68 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 69 | TEA PRINT: {MODEL UPDATE} 70 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 71 | TEA PRINT: {MODEL UPDATE} 72 | -- trace: at end 73 | -- view: 74 | MODEL VIEW🛇 75 | 76 | run trace=on 77 | paste "a b\nc d" 78 | ---- 79 | -- trace: before "paste \"a b\\nc d\"" 80 | -- trace: after "paste" 81 | -- view: 82 | MODEL VIEW🛇 83 | -- trace: before finish 84 | -- view: 85 | MODEL VIEW🛇 86 | -- trace: processing 1 messages 87 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{97, 32, 98, 10, 99, 32, 100}, Alt:false} 88 | -- trace: processing 1 cmds 89 | -- trace: translated cmd: tea.printLineMessage 90 | -- trace: processing 1 messages 91 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 92 | TEA PRINT: {MODEL UPDATE} 93 | -- trace: at end 94 | -- view: 95 | MODEL VIEW🛇 96 | 97 | run trace=on 98 | type ab 99 | type cd 100 | ---- 101 | -- trace: before "type ab" 102 | -- trace: after "type" 103 | -- view: 104 | MODEL VIEW🛇 105 | -- trace: before "type cd" 106 | -- trace: processing 2 messages 107 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{97}, Alt:false} 108 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{98}, Alt:false} 109 | -- trace: processing 2 cmds 110 | -- trace: translated cmd: tea.enterAltScreenMsg 111 | -- trace: translated cmd: tea.printLineMessage 112 | -- trace: after "type" 113 | -- view: 114 | MODEL VIEW🛇 115 | -- trace: before finish 116 | -- view: 117 | MODEL VIEW🛇 118 | -- trace: processing 4 messages 119 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{99}, Alt:false} 120 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{100}, Alt:false} 121 | -- trace: msg tea.enterAltScreenMsg{} 122 | TEA ENTER ALT 123 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 124 | TEA PRINT: {MODEL UPDATE} 125 | -- trace: processing 2 cmds 126 | -- trace: translated cmd: tea.enableMouseCellMotionMsg 127 | -- trace: translated cmd: tea.printLineMessage 128 | -- trace: processing 2 messages 129 | -- trace: msg tea.enableMouseCellMotionMsg{} 130 | TEA ENABLE MOUSE CELL MOTION 131 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 132 | TEA PRINT: {MODEL UPDATE} 133 | -- trace: at end 134 | -- view: 135 | MODEL VIEW🛇 136 | 137 | run trace=on 138 | key space 139 | key backspace 140 | key ctrl+c 141 | key alt+c 142 | key alt+ctrl+down 143 | ---- 144 | -- trace: before "key space" 145 | -- trace: after "key" 146 | -- view: 147 | MODEL VIEW🛇 148 | -- trace: before "key backspace" 149 | -- trace: processing 1 messages 150 | -- trace: msg tea.KeyMsg{Type:-12, Runes:[]int32{32}, Alt:false} 151 | -- trace: processing 1 cmds 152 | -- trace: translated cmd: tea.printLineMessage 153 | -- trace: after "key" 154 | -- view: 155 | MODEL VIEW🛇 156 | -- trace: before "key ctrl+c" 157 | -- trace: processing 2 messages 158 | -- trace: msg tea.KeyMsg{Type:127, Runes:[]int32(nil), Alt:false} 159 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 160 | TEA PRINT: {MODEL UPDATE} 161 | -- trace: processing 1 cmds 162 | -- trace: translated cmd: tea.printLineMessage 163 | -- trace: after "key" 164 | -- view: 165 | MODEL VIEW🛇 166 | -- trace: before "key alt+c" 167 | -- trace: processing 2 messages 168 | -- trace: msg tea.KeyMsg{Type:3, Runes:[]int32(nil), Alt:false} 169 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 170 | TEA PRINT: {MODEL UPDATE} 171 | -- trace: processing 1 cmds 172 | -- trace: translated cmd: tea.printLineMessage 173 | -- trace: after "key" 174 | -- view: 175 | MODEL VIEW🛇 176 | -- trace: before "key alt+ctrl+down" 177 | -- trace: processing 2 messages 178 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{99}, Alt:true} 179 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 180 | TEA PRINT: {MODEL UPDATE} 181 | -- trace: processing 1 cmds 182 | -- trace: translated cmd: tea.enableMouseCellMotionMsg 183 | -- trace: after "key" 184 | -- view: 185 | MODEL VIEW🛇 186 | -- trace: before finish 187 | -- view: 188 | MODEL VIEW🛇 189 | -- trace: processing 2 messages 190 | -- trace: msg tea.KeyMsg{Type:-14, Runes:[]int32(nil), Alt:true} 191 | -- trace: msg tea.enableMouseCellMotionMsg{} 192 | TEA ENABLE MOUSE CELL MOTION 193 | -- trace: processing 1 cmds 194 | -- trace: translated cmd: tea.printLineMessage 195 | -- trace: processing 1 messages 196 | -- trace: msg tea.printLineMessage{messageBody:"MODEL UPDATE"} 197 | TEA PRINT: {MODEL UPDATE} 198 | -- trace: at end 199 | -- view: 200 | MODEL VIEW🛇 201 | 202 | subtest end 203 | 204 | subtest resize 205 | 206 | run 207 | resize 80 25 208 | ---- 209 | TEA WINDOW SIZE: {80 25} 210 | TEA PRINT: {MODEL UPDATE} 211 | -- view: 212 | MODEL VIEW🛇 213 | 214 | subtest end 215 | 216 | subtest special_messages 217 | 218 | run 219 | type MmcaACxq 220 | ---- 221 | TEA DISABLE MOUSE 222 | TEA ENABLE MOUSE MOTION ALL 223 | TEA ENABLE MOUSE CELL MOTION 224 | TEA ENTER ALT 225 | TEA EXIT ALT 226 | TEA HIDE CURSOR 227 | TEA EXEC 228 | TEA QUIT 229 | -- view: 230 | MODEL VIEW🛇 231 | 232 | subtest end 233 | 234 | subtest cmd_returns_empty_msg 235 | 236 | run trace=on 237 | type e 238 | ---- 239 | -- trace: before "type e" 240 | -- trace: after "type" 241 | -- view: 242 | MODEL VIEW🛇 243 | -- trace: before finish 244 | -- view: 245 | MODEL VIEW🛇 246 | -- trace: processing 1 messages 247 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{101}, Alt:false} 248 | -- trace: processing 1 cmds 249 | -- trace: translated cmd: 250 | -- trace: at end 251 | -- view: 252 | MODEL VIEW🛇 253 | 254 | subtest end 255 | 256 | subtest cmd_timeout 257 | 258 | run trace=on 259 | type w 260 | ---- 261 | -- trace: before "type w" 262 | -- trace: after "type" 263 | -- view: 264 | MODEL VIEW🛇 265 | -- trace: before finish 266 | -- view: 267 | MODEL VIEW🛇 268 | -- trace: processing 1 messages 269 | -- trace: msg tea.KeyMsg{Type:-1, Runes:[]int32{119}, Alt:false} 270 | -- trace: processing 1 cmds 271 | -- trace: timeout waiting for command 272 | -- trace: translated cmd: 273 | -- trace: at end 274 | -- view: 275 | MODEL VIEW🛇 276 | 277 | 278 | set cmd_timeout=500ms 279 | ---- 280 | cmd_timeout: 500ms 281 | 282 | run 283 | type w 284 | ---- 285 | TEA PRINT: {DELAYED HELLO} 286 | -- view: 287 | MODEL VIEW🛇 288 | 289 | reset cmd_timeout 290 | ---- 291 | ok 292 | 293 | 294 | subtest end 295 | -------------------------------------------------------------------------------- /testdata/styles: -------------------------------------------------------------------------------- 1 | run 2 | ---- 3 | -- view: 4 | VALUE: 0␤ 5 | ␤ 6 | 7 | run 8 | restyle view.Style border: normal 9 | ---- 10 | -- view: 11 | ┌────────┐␤ 12 | │VALUE: 0│␤ 13 | │ │␤ 14 | │ │␤ 15 | └────────┘🛇 16 | 17 | run 18 | restyle model.ValueStyle border: rounded 19 | ---- 20 | -- view: 21 | ┌──────────┐␤ 22 | │╭────────╮│␤ 23 | ││VALUE: 0││␤ 24 | │╰────────╯│␤ 25 | └──────────┘🛇 26 | -------------------------------------------------------------------------------- /testdata/window_size: -------------------------------------------------------------------------------- 1 | # Show that a WindowSizeMsg event is delivered at the beginning 2 | # when the option WithWindowSize is set. 3 | run 4 | ---- 5 | TEA PRINT: {MODEL INIT} 6 | TEA WINDOW SIZE: {80 25} 7 | TEA PRINT: {MODEL UPDATE} 8 | -- view: 9 | MODEL VIEW🛇 10 | --------------------------------------------------------------------------------