├── .gitignore ├── LICENSE ├── README.md ├── demo.gif ├── demos ├── hello_world │ ├── greeter │ │ └── greeter.go │ └── main.go ├── journal │ ├── app │ │ └── app.go │ ├── content_item │ │ └── item.go │ └── main.go ├── keypress_counter │ ├── keypress_counter │ │ └── keypress_counter.go │ └── main.go ├── reactive_menu │ ├── app │ │ └── app.go │ └── main.go ├── scoreboard │ ├── main.go │ └── scoreboard │ │ ├── scoreboard.go │ │ └── scoreboard_opts.go └── secret_agent_terminal │ ├── README.md │ ├── bio_card │ ├── bio_card.go │ └── bio_card_impl.go │ ├── colors │ └── reactive_form_colors.go │ ├── identification_form │ ├── identification_form.go │ ├── identification_form_impl.go │ └── identification_form_opts.go │ ├── main.go │ └── secret_agent_terminal │ ├── agent_terminal.go │ └── agent_terminal_impl.go ├── go.mod ├── go.sum └── teact ├── component_test ├── component_assertion.go ├── content_size_assertion.go ├── height_at_width_assertion.go └── rendered_content_assertion.go ├── components ├── component.go ├── dimensions_cache.go ├── flexbox │ ├── axis_alignment.go │ ├── axis_min_max_combiners.go │ ├── axis_size_calculators.go │ ├── axis_size_calculators_test.go │ ├── direction.go │ ├── flexbox.go │ ├── flexbox_impl.go │ ├── flexbox_opts.go │ └── flexbox_test.go ├── flexbox_item │ ├── flexbox_item.go │ ├── flexbox_item_dimension.go │ ├── flexbox_item_impl.go │ ├── flexbox_item_opts.go │ └── flexbox_item_test.go ├── highlightable_list │ ├── highlightable_component.go │ ├── highlightable_list.go │ └── highlightable_list_impl.go ├── interactive_component.go ├── list │ ├── list.go │ └── list_impl.go ├── stylebox │ ├── stylebox.go │ ├── stylebox_impl.go │ ├── stylebox_opts.go │ └── stylebox_test.go ├── text │ ├── text.go │ ├── text_impl.go │ ├── text_opts.go │ └── text_test.go └── text_input │ ├── text_input.go │ ├── text_input_impl.go │ └── text_input_opts.go ├── run.go ├── teact_model.go ├── teact_model_opts.go └── utilities ├── layout_utils.go ├── style.go └── update_utils.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignoring the file that gets generated with the repo's version 2 | /kurtosis_version/kurtosis_version.go 3 | 4 | # IntelliJ 5 | .idea 6 | *.iws 7 | *.iml 8 | *.ipr 9 | 10 | # VS Code 11 | *.vscode 12 | 13 | # Jenv 14 | .java-version 15 | 16 | # Pyenv 17 | .python-version 18 | 19 | # Mac spotlight index files 20 | .DS_Store 21 | 22 | *.un~ 23 | Session.vim 24 | .netrwhist 25 | *~ 26 | *.pyc 27 | *.pydevproject 28 | .project 29 | .metadata 30 | bin/** 31 | tmp/** 32 | tmp/**/* 33 | *.tmp 34 | *.bak 35 | *~.nib 36 | local.properties 37 | .classpath 38 | .settings/ 39 | .loadpath 40 | 41 | # Vim swapfiles 42 | *.swp 43 | 44 | # Binaries for programs and plugins 45 | *.exe 46 | *.exe~ 47 | *.dll 48 | *.so 49 | *.dylib 50 | 51 | # Archive files 52 | *.tgz 53 | *.zip 54 | 55 | # External tool builders 56 | .externalToolBuilders/ 57 | 58 | # Locally stored "Eclipse launch configurations" 59 | *.launch 60 | 61 | # CDT-specific 62 | .cproject 63 | 64 | # PDT-specific 65 | .buildpath 66 | 67 | # Ignore Vim helptags 68 | doc/tags 69 | 70 | # Output of the go coverage tool, specifically when used with LiteIDE 71 | *.out 72 | 73 | # Test binary, built with `go test -c` 74 | *.test 75 | 76 | 77 | # Output directories 78 | build/ 79 | dist/ 80 | cli/cli/scripts/completions/ 81 | 82 | # Java 83 | .gradle 84 | 85 | # Node 86 | node_modules 87 | yarn-error.log 88 | 89 | # Bazel 90 | /bazel-* 91 | 92 | # Typescript 93 | tsconfig.tsbuildinfo 94 | 95 | # Generated files 96 | .docusaurus 97 | .cache-loader 98 | 99 | # We had a bug where we were using both Yarn and NPM to maintain Docusaurus, which 100 | # meant separate and conflicting lockfiles 101 | # We don't want NPM lockfiles for Docusaurus - just Yarn 102 | docs/package-lock.json 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kevin Today 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Teact 🍵 2 | ======== 3 | Teact is a React-like abstraction built on top of Charm's [Bubbletea system](https://github.com/charmbracelet/bubbletea) that will make your TUIs easier to build, and responsive to terminal size. It's like HTML + CSS + your browser's layout engine, all-in-one for the terminal. 4 | 5 | 6 | 7 | Basic Teact 8 | ----------- 9 | ### Teact Apps 10 | Every Teact app starts with a call to `teact.Run` in its `main.go`, to the component that will be the root of your application. For example, this runs a Hello World application ([source code here](https://github.com/mieubrisse/teact/blob/main/demos/hello_world/main.go)): 11 | 12 | ```go 13 | func main() { 14 | myApp := greeter.New() 15 | if _, err := teact.Run(myApp, tea.WithAltScreen()); err != nil { 16 | fmt.Printf("An error occurred running the program:\n%v", err) 17 | os.Exit(1) 18 | } 19 | } 20 | ``` 21 | 22 | Teact apps can be quit by default with `ctrl-c` or `ctrl-d` (and this can be changed). 23 | 24 | ### Teact Components 25 | A Teact component is just an implementation of the `Component` interface, and is analogous to an HTML element. It provides size & display information to Teact's layout/rendering system. 26 | 27 | However, 98% of the time you won't need to deal with any sizing because your custom components can be formed from [the default Teact components](https://github.com/mieubrisse/teact/tree/main/teact/components). You can think of the default Teact components like inbuilt HTML tags - `

`, `

`, `
  • `, etc. Your components should mostly be compositions of other components (just like in React). 28 | 29 | For example, here's a `HelloWorldApp` component ([source code here](https://github.com/mieubrisse/teact/blob/main/demos/hello_world/greeter/greeter.go)) that's a composition of a flexbox containing styled text: 30 | 31 | ```go 32 | // A custom component 33 | type Greeter interface { 34 | components.Component 35 | } 36 | 37 | // Implementation of the custom component 38 | type greeterImpl struct { 39 | // So long as we assign a component to this then our component will call down to it (via Go struct embedding) 40 | components.Component 41 | } 42 | 43 | func New() Greeter { 44 | // This is a tree, just like HTML, with leaf nodes indented the most 45 | root := flexbox.NewWithOpts( 46 | []flexbox_item.FlexboxItem{ 47 | flexbox_item.New( 48 | stylebox.New( 49 | text.New("Hello, world!"), 50 | stylebox.WithStyle( 51 | style.WithForeground(lipgloss.Color("#B6DCFE")), 52 | ), 53 | ), 54 | ), 55 | }, 56 | flexbox.WithVerticalAlignment(flexbox.AlignCenter), 57 | flexbox.WithHorizontalAlignment(flexbox.AlignCenter), 58 | ) 59 | 60 | return &greeterImpl{ 61 | Component: root, 62 | } 63 | } 64 | ``` 65 | 66 | Because the component has a `Component` struct embedded inside of it, `HelloWorldApp` fulfills the `Component` interface and Teact will know to use the embedded struct (which in this case is the flexbox) for rendering the `HelloWorldApp` component. 67 | 68 | ### Interactivity 69 | Interactivity is accomplished by making a component implement the `InteractiveComponent` interface, which in turn uses the Bubbletea `Update` function. For example, this component keeps track of the number of keypresses it's seen and displays it ([source code here](https://github.com/mieubrisse/teact/blob/main/demos/keypress_counter/app/app.go): 70 | 71 | ```go 72 | type KeypressCounter interface { 73 | components.InteractiveComponent 74 | } 75 | 76 | type keypressCounterImpl struct { 77 | components.Component 78 | 79 | keysPressed int 80 | output text.Text 81 | } 82 | 83 | func New() KeypressCounter { 84 | output := text.New() 85 | result := &keypressCounterImpl{ 86 | Component: output, 87 | keysPressed: 0, 88 | output: output, 89 | } 90 | result.updateOutputText() 91 | return result 92 | } 93 | 94 | func (k *keypressCounterImpl) Update(msg tea.Msg) tea.Cmd { 95 | if utilities.GetMaybeKeyMsgStr(msg) != "" { 96 | k.keysPressed += 1 97 | k.updateOutputText() 98 | } 99 | return nil 100 | } 101 | 102 | func (k keypressCounterImpl) SetFocus(isFocused bool) tea.Cmd { 103 | return nil 104 | } 105 | 106 | func (k keypressCounterImpl) IsFocused() bool { 107 | return true 108 | } 109 | 110 | func (b *keypressCounterImpl) updateOutputText() { 111 | b.output.SetContents(fmt.Sprintf("You've pressed %v keys", b.keysPressed)) 112 | } 113 | ``` 114 | 115 | You can see that each time the component receives a message, it checks if it's a keyboard message (since there are non-keyboard messages) and counts it. 116 | 117 | ### Utilities 118 | Teact includes several utility functions ([under the `utilities` directory](https://github.com/mieubrisse/teact/tree/main/teact/utilities)) to make writing your components easier. Of note: 119 | 120 | 1. `NewStyle`, which allows building a `lipgloss.Style` object with the Go options pattern. For example: 121 | ```go 122 | NewStyle( 123 | WithBold(true), 124 | WithUnderline(true), 125 | WithBorder(lipgloss.Border()), 126 | ) 127 | ``` 128 | 1. `GetMaybeKeyMsgStr`, which is shorthand for testing if a `tea.Msg` is a `tea.KeyMsg`, and getting its string value if so. 129 | 130 | ### Testing 131 | Teact includes [rudimentary component testing tools](https://github.com/mieubrisse/teact/tree/main/teact/component_test). These come in the form of assertions that are applied at various times in the component render loop (see below for more information on how this works). These are especially good if you're writing a new component from scratch (i.e. not embedding a `Component` in your impl `struct`). 132 | 133 | Why not vanilla Bubbletea? 134 | -------------------------- 135 | Bubbletea is a great foundation to build on, but it has several shortcomings that I hit when trying to build with it: 136 | 137 | ### No size communication from child to parent 138 | In vanilla Bubbletea, parent components receive a simple `string` from child components via `tea.Model.View`. This means that the parent has no idea how to resize a given child's string - only the child knows how to render their `View` at the right size. 139 | 140 | The logical next step is a `Resize` method that cascades from child to parents, so that children are aware of the size they ought to be rendering at. However, this prevents a layout that responds to content: when a child grows of its own accord (say, it intercepted a keypress and added something to its width), a parent flexbox would need to resize the child's siblings. How does the child signify to the parent that it's wider and a recalculation needs to occur? 141 | 142 | The way to do it in vanilla Bubbletea would be to have the child return a wider string. However, the parent might have preferences on how wide the child should be (e.g. to avoid overflowing the parent), so the parent might want to compress (perhaps by word-wrapping) the child text. But we know from earlier that a parent doesn't know how to resize a child's text - only a child knows that - so truly responsive layouts are impossible with vanilla Bubbletea. 143 | 144 | Teact fixes this in the same way as your browser: doing a two-pass approach, where item preferred sizes are calculated first and then actual sizes are settled on using that information. 145 | 146 | ### By-value updating 147 | `tea.Model.Update` returns a `tea.Model`. This means that a child Bubbletea component can either: 148 | 149 | 1. Implement `tea.Model`, but then its `Update` will return a `tea.Model` (thereby requiring the parent to cast it before storing the `Update` result) 150 | 2. Not actually implement `tea.Model` (which is what most of the components in [the Bubbles repo](https://github.com/charmbracelet/bubbles) do) 151 | 152 | The by-value `Update` is also problematic when trying to create a generic component. For example, I was writing `FilterableList[T].Update`, with `T` being the element component that the list would contain. No matter how I tried, I couldn't get implementations of the `FilterableList[T]` interface to conform to the `Update(msg tea.Msg) T` function on the interface. 153 | 154 | The by-value state transitioning is nice in theory (very Redux-y), but in practice I found it to be cumbersome so Teact only supports by-reference components. 155 | 156 | ### No first-class focus controls 157 | The concept of "focusable component" is very useful and showed up in nearly all the example Bubbles, but it's not encoded in the BubbleTea framework in any way (all the example Bubbles recreate `Focus`, `Blur`, and `Focused` by hand). 158 | 159 | ### No flexbox 160 | A resize of my terminal window should have each parent resizing their children (because the parent knows what size the children should be), but there was no out-of-the-box way for components to do this. 161 | 162 | Best Practices 163 | -------------- 164 | - 98% of the time, you should simply be assembling the [default Teact components](https://github.com/mieubrisse/teact/tree/main/teact/components) into a new component rather than writing the `View`, `GetContentMinMax`, etc. methods. 165 | - Put each of your components in its own directory. This will help you stay organized. 166 | - Give each component a public interface that implements either `Component` or `InteractiveComponent`. This will make it clear which type your component implements. 167 | - Give each component a private implementation, built by a `New()` constructor. For example: 168 | 169 | In `my_component/my_component.go` 170 | ```go 171 | type Greeter interface { 172 | component.Component // We know this isn't an interactive component 173 | } 174 | ``` 175 | 176 | In `my_component/my_component_impl.go` 177 | ```go 178 | type greeterImpl struct { 179 | component.Component 180 | } 181 | 182 | func New() Greeter { 183 | root := text.New("Hello, world!") 184 | return &greeterImpl{ 185 | Component: root, 186 | } 187 | } 188 | ``` 189 | - Embed a `Component` inside each private component implementation. This will transparently cause the `struct` to implement the `Component` interface, so that the rendering system will render however the embedded `Component` instance wants. 190 | - To track subcomponents that your component needs to modify, store them as properties on your component (NOT replacing the embedded `Component`) and use them as needed. For example: 191 | ```go 192 | type greeterImpl struct { 193 | components.Component 194 | 195 | toUpdate text.Text 196 | } 197 | 198 | func New() Greeter { 199 | toUpdate := text.New("Hello, World!") 200 | root := flexbox.NewWithOpts( 201 | []flexbox_item.FlexboxItem{ 202 | flexbox_item.New( 203 | stylebox.New( 204 | toUpdate, 205 | stylebox.WithStyle( 206 | style.WithForeground(lipgloss.Color("#B6DCFE")), 207 | ), 208 | ), 209 | ), 210 | }, 211 | flexbox.WithVerticalAlignment(flexbox.AlignCenter), 212 | flexbox.WithHorizontalAlignment(flexbox.AlignCenter), 213 | ) 214 | 215 | return &greeterImpl{ 216 | Component: root, 217 | } 218 | } 219 | 220 | func (impl *greeterImpl) UpdateGreeting(greeting string) Greeter { 221 | impl.toUpdate.SetContents(greeting) 222 | } 223 | ``` 224 | - When your component is configurable, use the [Go options pattern](https://michalzalecki.com/golang-options-pattern/) with a constructor like `New(opts ...MyComponentOpt)`. This will make it much easier to do the initial instantiation of your component, as all configuration for a component can be aligned visually. For comparison: 225 | ```go 226 | // If Teact components didn't have the Go optional pattern 227 | root := flexbox.New().SetChildren([]flexbox_item.FlexboxItem{ 228 | flexbox_item.New().WithContent( 229 | stylebox.New( 230 | text.New().SetContent("Hello, world") 231 | ).SetStyle(someStyle) 232 | ).WithMaxWidth(flexbox_item.FixedSize(20)).WithVerticalGrowthFactor(1) 233 | }).SetHorizontalAlignment(flexbox.Center).SetVerticalAlignment(flexbox.Center) 234 | 235 | // With Go options pattern (notice how each component is an indentation level) 236 | root := flexbox.New( 237 | WithChildren(flexbox_item.FlexboxItem{ 238 | flexbox_item.New( 239 | WithContent( 240 | stylebox.New( 241 | text.New( 242 | WithContent("Hello, world") 243 | ) 244 | WithStyle(someStyle), 245 | ) 246 | ), 247 | WithMaxWidth(flexbox_item.FixedSize(20)), 248 | WithVerticalGrowthFactor(1), 249 | ), 250 | }), 251 | WithHorizontalAlignment(flexbox.Center), 252 | WithVerticalAlignment(flexbox.Center), 253 | ) 254 | ``` 255 | - Pass `tea.Msg` events solely from `InteractiveComponent` to `InteractiveComponent`. I.e., when an `InteractiveComponent` needs to pass a `tea.Msg` event downwards, have the parent's `Update` method pass the `tea.Msg` directly to the descendant that should receive it. Don't try to pass the event through a bunch of non-`InteractiveComponent`s (of which you will have many - `Flexbox`, `Stylebox`, `Text`, etc.). 256 | 257 | 263 | 264 | How Teact Rendering Works 265 | ------------------------- 266 | Teact rendering is a rudimentary version of what happens in your browser. Basically: 267 | 268 | 1. **X-Pass:** The minimum & desired widths & heights of each component in the graph is calculated (the equivalent of `min-content` and `max-content` in CSS), from bottom-to-top. 269 | - For those not familiar with CSS, components can have different sizes because word-wrapping can reduce the width of text (at a corresponding increase in height). The max width of a block of text is the length of its longest line without wrapping, and the min width is the length of the shortest word. 270 | 1. **Y-Pass:** Incorporating each component's desired width and the actual viewport width of your terminal, go top-to-bottom giving actual sizes to each component and calculating the components desired height given that width. 271 | 1. **Render:** Using all information, give an actual width to each component and render each component into a string to be displayed. 272 | 273 | These three phases correspond to the three functions on the `Component` interface: 274 | 275 | 1. `GetContentMinMax()` 276 | 1. `SetWidthAndGetDesiredHeight(actualWidth)` 277 | 1. `View(actualWidth, actualHeight)` 278 | 279 | Still TODO 280 | ---------- 281 | - Add Grid layout!! 282 | - Make every component styleable, so we don't need styleboxes everywhere??? 283 | - Add some sort of inline/span thing 284 | - Create a single "position" enum (so that we don't have different ones between flexbox and text, etc.) 285 | - Make flexbox alignments purely "MainAxis" and "CrossAxis", so that when flipping the box things will be nicer 286 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mieubrisse/teact/f0cf636a2239894bb390d0e6387798b165a46290/demo.gif -------------------------------------------------------------------------------- /demos/hello_world/greeter/greeter.go: -------------------------------------------------------------------------------- 1 | package greeter 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/components" 6 | "github.com/mieubrisse/teact/teact/components/flexbox" 7 | "github.com/mieubrisse/teact/teact/components/flexbox_item" 8 | "github.com/mieubrisse/teact/teact/components/stylebox" 9 | "github.com/mieubrisse/teact/teact/components/text" 10 | "github.com/mieubrisse/teact/teact/utilities" 11 | ) 12 | 13 | // A custom component 14 | type Greeter interface { 15 | components.Component 16 | } 17 | 18 | // Implementation of the custom component 19 | type greeterImpl struct { 20 | // So long as we assign a component to this then our component will call down to it (via Go struct embedding) 21 | components.Component 22 | } 23 | 24 | func New() Greeter { 25 | // This is a tree, just like HTML, with leaf nodes indented the most 26 | root := flexbox.NewWithOpts( 27 | []flexbox_item.FlexboxItem{ 28 | flexbox_item.New( 29 | stylebox.New( 30 | text.New("Hello, world!"), 31 | stylebox.WithStyle( 32 | utilities.WithForeground(lipgloss.Color("#B6DCFE")), 33 | ), 34 | ), 35 | ), 36 | }, 37 | flexbox.WithVerticalAlignment(flexbox.AlignCenter), 38 | flexbox.WithHorizontalAlignment(flexbox.AlignCenter), 39 | ) 40 | 41 | return &greeterImpl{ 42 | Component: root, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /demos/hello_world/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/mieubrisse/teact/demos/hello_world/greeter" 7 | "github.com/mieubrisse/teact/teact" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | myApp := greeter.New() 13 | if _, err := teact.Run(myApp, tea.WithAltScreen()); err != nil { 14 | fmt.Printf("An error occurred running the program:\n%v", err) 15 | os.Exit(1) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /demos/journal/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/mieubrisse/teact/demos/journal/content_item" 7 | components2 "github.com/mieubrisse/teact/teact/components" 8 | "github.com/mieubrisse/teact/teact/components/highlightable_list" 9 | "github.com/mieubrisse/teact/teact/components/stylebox" 10 | "time" 11 | ) 12 | 13 | type App interface { 14 | components2.InteractiveComponent 15 | } 16 | 17 | type appImpl struct { 18 | components2.Component 19 | 20 | itemsList highlightable_list.HighlightableList[content_item.ContentItem] 21 | 22 | isFocused bool 23 | } 24 | 25 | func New() App { 26 | items := []content_item.ContentItem{ 27 | content_item.New(time.Now(), "foo.md", []string{"general-reference"}), 28 | content_item.New(time.Now(), "bar-bang-baz.md", []string{"project-support/starlark"}), 29 | content_item.New(time.Now(), "something-else.md", []string{"general-reference/wealthdraft"}), 30 | } 31 | 32 | itemsList := highlightable_list.New[content_item.ContentItem]() 33 | itemsList.SetItems(items) 34 | itemsList.SetHighlightedIdx(0) 35 | itemsList.SetFocus(true) 36 | 37 | root := stylebox.New(itemsList).SetStyle(lipgloss.NewStyle().Padding(1, 2)) 38 | root = stylebox.New(root).SetStyle(lipgloss.NewStyle().Padding(1, 2)) 39 | return &appImpl{ 40 | Component: root, 41 | itemsList: itemsList, 42 | isFocused: false, 43 | } 44 | } 45 | 46 | func (a *appImpl) Update(msg tea.Msg) tea.Cmd { 47 | if !a.isFocused { 48 | return nil 49 | } 50 | 51 | return a.itemsList.Update(msg) 52 | } 53 | 54 | func (a *appImpl) SetFocus(isFocused bool) tea.Cmd { 55 | a.isFocused = true 56 | return nil 57 | } 58 | 59 | func (a appImpl) IsFocused() bool { 60 | return a.isFocused 61 | } 62 | -------------------------------------------------------------------------------- /demos/journal/content_item/item.go: -------------------------------------------------------------------------------- 1 | package content_item 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/components" 6 | "github.com/mieubrisse/teact/teact/components/flexbox" 7 | flexbox_item2 "github.com/mieubrisse/teact/teact/components/flexbox_item" 8 | "github.com/mieubrisse/teact/teact/components/highlightable_list" 9 | "github.com/mieubrisse/teact/teact/components/stylebox" 10 | "github.com/mieubrisse/teact/teact/components/text" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | var highlightedBackgroundColor = lipgloss.Color("#333333") 16 | 17 | var nameStyle = lipgloss.NewStyle(). 18 | Foreground(lipgloss.Color("#FFFFFF")). 19 | Padding(0, 1, 0, 0) 20 | 21 | var tagsStyle = lipgloss.NewStyle(). 22 | Foreground(lipgloss.Color("#FF5555")) 23 | 24 | type ContentItem interface { 25 | components.Component 26 | highlightable_list.HighlightableComponent 27 | 28 | GetTimestamp() time.Time 29 | 30 | GetName() string 31 | SetName(name string) ContentItem 32 | 33 | GetTags() []string 34 | SetTags(tags []string) ContentItem 35 | } 36 | 37 | type impl struct { 38 | // Root item 39 | components.Component 40 | 41 | timestamp time.Time 42 | name string 43 | tags []string 44 | 45 | nameText text.Text 46 | tagsText text.Text 47 | 48 | isHighlighted bool 49 | toChangeOnHighlightToggle []stylebox.Stylebox 50 | } 51 | 52 | func New(timestamp time.Time, name string, tags []string) ContentItem { 53 | nameText := text.New(name) 54 | tagsText := text.New(strings.Join(tags, " ")) 55 | 56 | styledName := stylebox.New(nameText).SetStyle(nameStyle) 57 | styledTags := stylebox.New(nameText).SetStyle(tagsStyle) 58 | 59 | itemsRow := flexbox.NewWithContents( 60 | flexbox_item2.New(styledName). 61 | SetMinWidth(flexbox_item2.FixedSize(20)). 62 | SetMaxWidth(flexbox_item2.FixedSize(30)), 63 | flexbox_item2.New(styledTags).SetHorizontalGrowthFactor(1), 64 | ) 65 | 66 | styledItemsRow := stylebox.New(itemsRow) 67 | 68 | toChangeOnHighlightToggle := []stylebox.Stylebox{ 69 | styledName, 70 | styledTags, 71 | styledItemsRow, 72 | } 73 | 74 | return &impl{ 75 | Component: styledItemsRow, 76 | timestamp: timestamp, 77 | name: name, 78 | tags: tags, 79 | nameText: nameText, 80 | tagsText: tagsText, 81 | isHighlighted: false, 82 | toChangeOnHighlightToggle: toChangeOnHighlightToggle, 83 | } 84 | } 85 | 86 | func (f *impl) GetTimestamp() time.Time { 87 | return f.timestamp 88 | } 89 | 90 | func (f *impl) GetName() string { 91 | return f.name 92 | } 93 | 94 | func (f *impl) SetName(name string) ContentItem { 95 | f.name = name 96 | f.nameText.SetContents(name) 97 | return f 98 | } 99 | 100 | func (f *impl) GetTags() []string { 101 | return f.tags 102 | } 103 | 104 | func (f *impl) SetTags(tags []string) ContentItem { 105 | f.tags = tags 106 | f.tagsText.SetContents(strings.Join(tags, " ")) 107 | return f 108 | } 109 | 110 | func (f *impl) IsHighlighted() bool { 111 | return f.isHighlighted 112 | } 113 | 114 | func (f *impl) SetHighlight(isHighlighted bool) highlightable_list.HighlightableComponent { 115 | f.isHighlighted = isHighlighted 116 | 117 | for _, box := range f.toChangeOnHighlightToggle { 118 | if isHighlighted { 119 | box.GetStyle().Bold(true).Background(highlightedBackgroundColor) 120 | } else { 121 | box.GetStyle().UnsetBold().UnsetBackground() 122 | } 123 | } 124 | 125 | return f 126 | } 127 | -------------------------------------------------------------------------------- /demos/journal/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/mieubrisse/teact/demos/journal/app" 7 | "github.com/mieubrisse/teact/teact" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | myApp := app.New() 13 | myApp.SetFocus(true) 14 | 15 | if _, err := teact.Run(myApp, tea.WithAltScreen()); err != nil { 16 | fmt.Printf("An error occurred running the program:\n%v", err) 17 | os.Exit(1) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /demos/keypress_counter/keypress_counter/keypress_counter.go: -------------------------------------------------------------------------------- 1 | package keypress_counter 2 | 3 | import ( 4 | "fmt" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/mieubrisse/teact/teact/components" 7 | "github.com/mieubrisse/teact/teact/components/text" 8 | "github.com/mieubrisse/teact/teact/utilities" 9 | ) 10 | 11 | type KeypressCounter interface { 12 | components.InteractiveComponent 13 | } 14 | 15 | type keypressCounterImpl struct { 16 | components.Component 17 | 18 | keysPressed int 19 | output text.Text 20 | } 21 | 22 | func New() KeypressCounter { 23 | output := text.New("") 24 | result := &keypressCounterImpl{ 25 | Component: output, 26 | keysPressed: 0, 27 | output: output, 28 | } 29 | result.updateOutputText() 30 | return result 31 | } 32 | 33 | func (k *keypressCounterImpl) Update(msg tea.Msg) tea.Cmd { 34 | if utilities.GetMaybeKeyMsgStr(msg) != "" { 35 | k.keysPressed += 1 36 | k.updateOutputText() 37 | } 38 | return nil 39 | } 40 | 41 | func (k keypressCounterImpl) SetFocus(isFocused bool) tea.Cmd { 42 | return nil 43 | } 44 | 45 | func (k keypressCounterImpl) IsFocused() bool { 46 | return true 47 | } 48 | 49 | func (b *keypressCounterImpl) updateOutputText() { 50 | b.output.SetContents(fmt.Sprintf("You've pressed %v keys", b.keysPressed)) 51 | } 52 | -------------------------------------------------------------------------------- /demos/keypress_counter/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/mieubrisse/teact/demos/keypress_counter/keypress_counter" 7 | "github.com/mieubrisse/teact/teact" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | myApp := keypress_counter.New() 13 | if _, err := teact.Run(myApp, tea.WithAltScreen()); err != nil { 14 | fmt.Printf("An error occurred running the program:\n%v", err) 15 | os.Exit(1) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /demos/reactive_menu/app/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/components" 6 | "github.com/mieubrisse/teact/teact/components/flexbox" 7 | "github.com/mieubrisse/teact/teact/components/flexbox_item" 8 | "github.com/mieubrisse/teact/teact/components/list" 9 | "github.com/mieubrisse/teact/teact/components/stylebox" 10 | "github.com/mieubrisse/teact/teact/components/text" 11 | "github.com/mieubrisse/teact/teact/utilities" 12 | ) 13 | 14 | // This is an app with a sidebar (like we'd see on websites), but which switches to a stacked orientation when 15 | // the viewport is too small (like you'd see on mobile) 16 | type ReactiveMenuApp interface { 17 | components.Component 18 | } 19 | 20 | type impl struct { 21 | // Root component 22 | components.Component 23 | 24 | box flexbox.Flexbox 25 | } 26 | 27 | func New() ReactiveMenuApp { 28 | menu := stylebox.New( 29 | list.NewWithContents[text.Text]( 30 | text.New("Home", text.WithAlign(text.AlignCenter)), 31 | text.New("Search", text.WithAlign(text.AlignCenter)), 32 | text.New("Docs", text.WithAlign(text.AlignCenter)), 33 | text.New("About", text.WithAlign(text.AlignCenter)), 34 | ).SetHorizontalAlignment(flexbox.AlignCenter), 35 | stylebox.WithExistingStyle(utilities.NewStyle( 36 | utilities.WithBorder(lipgloss.NormalBorder()), 37 | utilities.WithPadding(0, 1), 38 | )), 39 | ) 40 | 41 | content := stylebox.New( 42 | text.New( 43 | "Four score and seven years ago our fathers brought forth "+ 44 | "on this continent, a new nation, conceived in Liberty, and dedicated to the "+ 45 | "proposition that all men are created equal.", 46 | ), 47 | stylebox.WithExistingStyle(utilities.NewStyle( 48 | utilities.WithPadding(0, 1, 0, 1), 49 | utilities.WithBorder(lipgloss.NormalBorder()), 50 | )), 51 | ) 52 | 53 | box := flexbox.New( 54 | flexbox_item.New( 55 | menu, 56 | flexbox_item.WithMaxWidth(flexbox_item.FixedSize(20)), 57 | flexbox_item.WithHorizontalGrowthFactor(2), 58 | flexbox_item.WithVerticalGrowthFactor(1), 59 | ), 60 | flexbox_item.New( 61 | content, 62 | flexbox_item.WithHorizontalGrowthFactor(5), 63 | flexbox_item.WithVerticalGrowthFactor(1), 64 | ), 65 | ) 66 | 67 | return &impl{ 68 | Component: box, 69 | box: box, 70 | } 71 | } 72 | 73 | func (impl *impl) SetWidthAndGetDesiredHeight(actualWidth int) int { 74 | if actualWidth >= 60 { 75 | impl.box.SetDirection(flexbox.Row) 76 | } else { 77 | impl.box.SetDirection(flexbox.Column) 78 | } 79 | return impl.Component.SetWidthAndGetDesiredHeight(actualWidth) 80 | } 81 | -------------------------------------------------------------------------------- /demos/reactive_menu/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/mieubrisse/teact/demos/reactive_menu/app" 7 | "github.com/mieubrisse/teact/teact" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | myApp := app.New() 13 | 14 | if _, err := teact.Run(myApp, tea.WithAltScreen()); err != nil { 15 | fmt.Printf("An error occurred running the program:\n%v", err) 16 | os.Exit(1) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /demos/scoreboard/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/mieubrisse/teact/demos/scoreboard/scoreboard" 7 | "github.com/mieubrisse/teact/teact" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | // Can configure the scoreboard 13 | myApp := scoreboard.New( 14 | scoreboard.WithHomeScore(3), 15 | scoreboard.WithAwayScore(2), 16 | ) 17 | if _, err := teact.Run(myApp, tea.WithAltScreen()); err != nil { 18 | fmt.Printf("An error occurred running the program:\n%v", err) 19 | os.Exit(1) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /demos/scoreboard/scoreboard/scoreboard.go: -------------------------------------------------------------------------------- 1 | package scoreboard 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mieubrisse/teact/teact/components" 6 | "github.com/mieubrisse/teact/teact/components/text" 7 | ) 8 | 9 | type Scoreboard interface { 10 | components.Component 11 | 12 | GetHomeTeamScore() int 13 | SetHomeTeamScore(score int) Scoreboard 14 | 15 | GetAwayTeamScore() int 16 | SetAwayTeamScore(score int) Scoreboard 17 | } 18 | 19 | type scoreboardImpl struct { 20 | components.Component 21 | 22 | homeScore int 23 | awayScore int 24 | 25 | display text.Text 26 | } 27 | 28 | func New(opts ...ScoreboardOpts) Scoreboard { 29 | display := text.New("") 30 | 31 | result := &scoreboardImpl{ 32 | Component: display, 33 | display: display, 34 | homeScore: 0, 35 | awayScore: 0, 36 | } 37 | for _, opt := range opts { 38 | opt(result) 39 | } 40 | result.updateDisplay() 41 | return result 42 | } 43 | 44 | func (s scoreboardImpl) GetHomeTeamScore() int { 45 | return s.homeScore 46 | } 47 | 48 | func (s *scoreboardImpl) SetHomeTeamScore(score int) Scoreboard { 49 | s.homeScore = score 50 | return s 51 | } 52 | 53 | func (s scoreboardImpl) GetAwayTeamScore() int { 54 | return s.awayScore 55 | } 56 | 57 | func (s *scoreboardImpl) SetAwayTeamScore(score int) Scoreboard { 58 | s.awayScore = score 59 | return s 60 | } 61 | 62 | func (s *scoreboardImpl) updateDisplay() { 63 | s.display.SetContents(fmt.Sprintf("Home: %v, Away: %v", s.homeScore, s.awayScore)) 64 | } 65 | -------------------------------------------------------------------------------- /demos/scoreboard/scoreboard/scoreboard_opts.go: -------------------------------------------------------------------------------- 1 | package scoreboard 2 | 3 | type ScoreboardOpts func(scoreboard Scoreboard) 4 | 5 | func WithHomeScore(score int) ScoreboardOpts { 6 | return func(scoreboard Scoreboard) { 7 | scoreboard.SetHomeTeamScore(score) 8 | } 9 | } 10 | 11 | func WithAwayScore(score int) ScoreboardOpts { 12 | return func(scoreboard Scoreboard) { 13 | scoreboard.SetAwayTeamScore(score) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/README.md: -------------------------------------------------------------------------------- 1 | Secret Agent Terminal 2 | ===================== 3 | This demo shows off some of what can be done with Teact. 4 | 5 | 6 | 7 | You can interact with the app by: 8 | 9 | 1. Typing or deleting to change the text in a field 10 | 1. Pressing `TAB` to cycle between the fields 11 | 1. Resizing the window so that it reflows (try with a very small terminal size) 12 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/bio_card/bio_card.go: -------------------------------------------------------------------------------- 1 | package bio_card 2 | 3 | import "github.com/mieubrisse/teact/teact/components" 4 | 5 | type BioCard interface { 6 | components.Component 7 | 8 | SetName(name string) BioCard 9 | SetAge(age int) BioCard 10 | } 11 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/bio_card/bio_card_impl.go: -------------------------------------------------------------------------------- 1 | package bio_card 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/demos/secret_agent_terminal/colors" 6 | "github.com/mieubrisse/teact/teact/components" 7 | "github.com/mieubrisse/teact/teact/components/flexbox" 8 | "github.com/mieubrisse/teact/teact/components/flexbox_item" 9 | "github.com/mieubrisse/teact/teact/components/stylebox" 10 | "github.com/mieubrisse/teact/teact/components/text" 11 | "github.com/mieubrisse/teact/teact/utilities" 12 | "strconv" 13 | ) 14 | 15 | const ( 16 | unknownName = "Anonymous Agent" 17 | ) 18 | 19 | var normalTextStyle = utilities.NewStyle( 20 | utilities.WithForeground(colors.Platinum), 21 | ) 22 | var nameStyle = utilities.NewStyle( 23 | utilities.WithForeground(colors.Tomato), 24 | utilities.WithBold(true), 25 | ) 26 | var ageStyle = utilities.NewStyle( 27 | utilities.WithForeground(colors.VividSkyBlue), 28 | utilities.WithBold(true), 29 | ) 30 | 31 | type bioCardImpl struct { 32 | components.Component 33 | 34 | name string 35 | age int 36 | 37 | row flexbox.Flexbox 38 | } 39 | 40 | func New() BioCard { 41 | row := flexbox.NewWithOpts( 42 | []flexbox_item.FlexboxItem{}, 43 | flexbox.WithHorizontalAlignment(flexbox.AlignCenter), 44 | flexbox.WithVerticalAlignment(flexbox.AlignCenter), 45 | ) 46 | result := &bioCardImpl{ 47 | Component: row, 48 | name: "", 49 | age: 0, 50 | row: row, 51 | } 52 | result.updateFlexbox() 53 | return result 54 | } 55 | 56 | func (impl *bioCardImpl) SetName(name string) BioCard { 57 | impl.name = name 58 | impl.updateFlexbox() 59 | return impl 60 | } 61 | 62 | func (impl *bioCardImpl) SetAge(age int) BioCard { 63 | impl.age = age 64 | impl.updateFlexbox() 65 | return impl 66 | } 67 | 68 | func (impl *bioCardImpl) updateFlexbox() { 69 | name := impl.name 70 | if name == "" { 71 | name = unknownName 72 | } 73 | 74 | texts := []string{ 75 | "Hello, ", 76 | name, 77 | ". ", 78 | } 79 | styles := []lipgloss.Style{ 80 | normalTextStyle, 81 | nameStyle, 82 | normalTextStyle, 83 | } 84 | 85 | // TODO we reallyyyyy need an inline element 86 | 87 | if impl.age == 0 { 88 | texts = append(texts, "We don't know how old you are.") 89 | styles = append(styles, normalTextStyle) 90 | } else { 91 | texts = append(texts, 92 | "You are ", 93 | strconv.Itoa(impl.age), 94 | " years old.", 95 | ) 96 | styles = append(styles, 97 | normalTextStyle, 98 | ageStyle, 99 | normalTextStyle, 100 | ) 101 | } 102 | 103 | flexboxItems := make([]flexbox_item.FlexboxItem, len(texts)) 104 | for idx, textFragment := range texts { 105 | fragmentStyle := styles[idx] 106 | flexboxItems[idx] = flexbox_item.New( 107 | stylebox.New( 108 | text.New(textFragment), 109 | stylebox.WithExistingStyle(fragmentStyle), 110 | ), 111 | ) 112 | } 113 | 114 | impl.row.SetChildren(flexboxItems) 115 | } 116 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/colors/reactive_form_colors.go: -------------------------------------------------------------------------------- 1 | package colors 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | // https://coolors.co/f06449-ede6e3-dadad9-36382e-5bc3eb 6 | var Tomato = lipgloss.Color("#f06449") 7 | var Isabelline = lipgloss.Color("#ede6e3") 8 | var Platinum = lipgloss.Color("#dadad9") 9 | var BlackOlive = lipgloss.Color("#36382e") 10 | var VividSkyBlue = lipgloss.Color("#5bc3eb") 11 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/identification_form/identification_form.go: -------------------------------------------------------------------------------- 1 | package identification_form 2 | 3 | import "github.com/mieubrisse/teact/teact/components" 4 | 5 | // TODO build a form component in Teact itself once we have Grid layout 6 | type IdentificationForm interface { 7 | components.InteractiveComponent 8 | 9 | GetName() string 10 | SetName(name string) IdentificationForm 11 | 12 | GetAge() int 13 | SetAge(age int) IdentificationForm 14 | } 15 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/identification_form/identification_form_impl.go: -------------------------------------------------------------------------------- 1 | package identification_form 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/mieubrisse/teact/demos/secret_agent_terminal/colors" 7 | "github.com/mieubrisse/teact/teact/components" 8 | "github.com/mieubrisse/teact/teact/components/flexbox" 9 | "github.com/mieubrisse/teact/teact/components/flexbox_item" 10 | "github.com/mieubrisse/teact/teact/components/stylebox" 11 | "github.com/mieubrisse/teact/teact/components/text" 12 | "github.com/mieubrisse/teact/teact/components/text_input" 13 | "github.com/mieubrisse/teact/teact/utilities" 14 | "strconv" 15 | ) 16 | 17 | type identificationFormImpl struct { 18 | components.Component 19 | 20 | nameInput text_input.TextInput 21 | ageInput text_input.TextInput 22 | 23 | // TDOO extract this into something common, that all components can use?? 24 | focusableItems []components.InteractiveComponent 25 | focusedItemIdx int 26 | 27 | isFocused bool 28 | } 29 | 30 | func New(opts ...IdentificationFormOpts) IdentificationForm { 31 | nameInput := text_input.New() 32 | ageInput := text_input.New() 33 | 34 | root := stylebox.New( 35 | flexbox.NewWithOpts( 36 | []flexbox_item.FlexboxItem{ 37 | flexbox_item.New( 38 | stylebox.New( 39 | text.New("IDENTIFICATION", text.WithAlign(text.AlignCenter)), 40 | stylebox.WithStyle( 41 | utilities.WithBold(true), 42 | utilities.WithForeground(colors.Platinum), 43 | ), 44 | ), 45 | flexbox_item.WithHorizontalGrowthFactor(1), 46 | ), 47 | flexbox_item.New( 48 | flexbox.NewWithOpts( 49 | []flexbox_item.FlexboxItem{ 50 | flexbox_item.New( 51 | stylebox.New( 52 | text.New("Name: "), 53 | stylebox.WithStyle( 54 | utilities.WithForeground(colors.Platinum), 55 | utilities.WithBold(true), 56 | ), 57 | ), 58 | ), 59 | flexbox_item.New(nameInput), 60 | }, 61 | flexbox.WithHorizontalAlignment(flexbox.AlignCenter), 62 | ), 63 | flexbox_item.WithMinWidth(flexbox_item.FixedSize(10)), 64 | ), 65 | flexbox_item.New( 66 | flexbox.NewWithOpts( 67 | []flexbox_item.FlexboxItem{ 68 | flexbox_item.New( 69 | stylebox.New( 70 | text.New("Age: "), 71 | stylebox.WithStyle( 72 | utilities.WithForeground(colors.Platinum), 73 | utilities.WithBold(true), 74 | ), 75 | ), 76 | ), 77 | flexbox_item.New(ageInput), 78 | }, 79 | flexbox.WithHorizontalAlignment(flexbox.AlignCenter), 80 | ), 81 | flexbox_item.WithMinWidth(flexbox_item.FixedSize(10)), 82 | ), 83 | }, 84 | flexbox.WithDirection(flexbox.Column), 85 | flexbox.WithHorizontalAlignment(flexbox.AlignCenter), 86 | ), 87 | stylebox.WithStyle( 88 | utilities.WithPadding(0, 1, 0, 1), 89 | utilities.WithBorder(lipgloss.NormalBorder()), 90 | ), 91 | ) 92 | 93 | result := &identificationFormImpl{ 94 | Component: root, 95 | nameInput: nameInput, 96 | ageInput: ageInput, 97 | focusableItems: []components.InteractiveComponent{ 98 | nameInput, 99 | ageInput, 100 | }, 101 | focusedItemIdx: 0, 102 | isFocused: false, 103 | } 104 | for _, opt := range opts { 105 | opt(result) 106 | } 107 | return result 108 | } 109 | 110 | func (f identificationFormImpl) GetName() string { 111 | return f.nameInput.GetValue() 112 | } 113 | 114 | func (f *identificationFormImpl) SetName(name string) IdentificationForm { 115 | f.nameInput.SetValue(name) 116 | return f 117 | } 118 | 119 | func (f identificationFormImpl) GetAge() int { 120 | ageStr := f.ageInput.GetValue() 121 | result, _ := strconv.ParseInt(ageStr, 10, 64) 122 | return int(result) 123 | } 124 | 125 | func (f *identificationFormImpl) SetAge(age int) IdentificationForm { 126 | f.ageInput.SetValue(strconv.Itoa(age)) 127 | return f 128 | } 129 | 130 | func (f *identificationFormImpl) Update(msg tea.Msg) tea.Cmd { 131 | if !f.isFocused { 132 | return nil 133 | } 134 | 135 | msgStr := utilities.GetMaybeKeyMsgStr(msg) 136 | switch msgStr { 137 | case "tab": 138 | // TODO extract this logic into something else 139 | 140 | newIdx := (f.focusedItemIdx + 1) % len(f.focusableItems) 141 | f.focusableItems[f.focusedItemIdx].SetFocus(false) 142 | f.focusableItems[newIdx].SetFocus(true) 143 | f.focusedItemIdx = newIdx 144 | return nil 145 | } 146 | 147 | return f.focusableItems[f.focusedItemIdx].Update(msg) 148 | } 149 | 150 | func (f *identificationFormImpl) SetFocus(isFocused bool) tea.Cmd { 151 | f.isFocused = isFocused 152 | return f.focusableItems[f.focusedItemIdx].SetFocus(isFocused) 153 | } 154 | 155 | func (f *identificationFormImpl) IsFocused() bool { 156 | return f.isFocused 157 | } 158 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/identification_form/identification_form_opts.go: -------------------------------------------------------------------------------- 1 | package identification_form 2 | 3 | type IdentificationFormOpts func(form IdentificationForm) 4 | 5 | func WithFocus(isFocused bool) IdentificationFormOpts { 6 | return func(form IdentificationForm) { 7 | form.SetFocus(isFocused) 8 | } 9 | } 10 | 11 | func WithName(name string) IdentificationFormOpts { 12 | return func(form IdentificationForm) { 13 | form.SetName(name) 14 | } 15 | } 16 | 17 | func WithAge(age int) IdentificationFormOpts { 18 | return func(form IdentificationForm) { 19 | form.SetAge(age) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/mieubrisse/teact/demos/secret_agent_terminal/secret_agent_terminal" 7 | "github.com/mieubrisse/teact/teact" 8 | "os" 9 | ) 10 | 11 | func main() { 12 | myApp := secret_agent_terminal.New() 13 | if _, err := teact.Run(myApp, tea.WithAltScreen()); err != nil { 14 | fmt.Printf("An error occurred running the program:\n%v", err) 15 | os.Exit(1) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/secret_agent_terminal/agent_terminal.go: -------------------------------------------------------------------------------- 1 | package secret_agent_terminal 2 | 3 | import "github.com/mieubrisse/teact/teact/components" 4 | 5 | type SecretAgentTerminal interface { 6 | components.InteractiveComponent 7 | } 8 | -------------------------------------------------------------------------------- /demos/secret_agent_terminal/secret_agent_terminal/agent_terminal_impl.go: -------------------------------------------------------------------------------- 1 | package secret_agent_terminal 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/mieubrisse/teact/demos/secret_agent_terminal/bio_card" 6 | "github.com/mieubrisse/teact/demos/secret_agent_terminal/colors" 7 | "github.com/mieubrisse/teact/demos/secret_agent_terminal/identification_form" 8 | "github.com/mieubrisse/teact/teact/components" 9 | "github.com/mieubrisse/teact/teact/components/flexbox" 10 | "github.com/mieubrisse/teact/teact/components/flexbox_item" 11 | "github.com/mieubrisse/teact/teact/components/stylebox" 12 | "github.com/mieubrisse/teact/teact/components/text" 13 | "github.com/mieubrisse/teact/teact/utilities" 14 | ) 15 | 16 | const ( 17 | // Below this size, the form & bio card will be stacked vertically 18 | columnSwitchThreshold = 80 19 | ) 20 | 21 | type secretAgentTerminalImpl struct { 22 | components.Component 23 | 24 | form identification_form.IdentificationForm 25 | bioCard bio_card.BioCard 26 | 27 | contentBox flexbox.Flexbox 28 | formBoxItem flexbox_item.FlexboxItem 29 | bioCardBoxItem flexbox_item.FlexboxItem 30 | } 31 | 32 | func New() SecretAgentTerminal { 33 | form := identification_form.New( 34 | identification_form.WithFocus(true), 35 | identification_form.WithName("007"), 36 | identification_form.WithAge(55), 37 | ) 38 | formBoxItem := flexbox_item.New( 39 | form, 40 | flexbox_item.WithMaxWidth(flexbox_item.FixedSize(40)), 41 | // growth factors will be handled upon render, based on viewport size 42 | ) 43 | 44 | bioCard := bio_card.New() 45 | bioCardBoxItem := flexbox_item.New( 46 | bioCard, 47 | flexbox_item.WithHorizontalGrowthFactor(1), 48 | flexbox_item.WithVerticalGrowthFactor(1), 49 | ) 50 | contentBox := flexbox.New(formBoxItem, bioCardBoxItem) 51 | 52 | appTitle := stylebox.New( 53 | text.New("SECRET AGENT TERMINAL APP", text.WithAlign(text.AlignCenter)), 54 | stylebox.WithStyle( 55 | utilities.WithForeground(colors.VividSkyBlue), 56 | utilities.WithBold(true), 57 | ), 58 | ) 59 | 60 | var root components.Component = flexbox.NewWithOpts( 61 | []flexbox_item.FlexboxItem{ 62 | flexbox_item.New( 63 | appTitle, 64 | flexbox_item.WithHorizontalGrowthFactor(1), 65 | ), 66 | flexbox_item.New( 67 | contentBox, 68 | flexbox_item.WithHorizontalGrowthFactor(1), 69 | flexbox_item.WithVerticalGrowthFactor(1), 70 | ), 71 | }, 72 | flexbox.WithDirection(flexbox.Column), 73 | ) 74 | 75 | root = stylebox.New( 76 | root, 77 | stylebox.WithStyle(utilities.WithPadding(1, 2, 1, 2)), 78 | ) 79 | 80 | result := &secretAgentTerminalImpl{ 81 | Component: root, 82 | form: form, 83 | bioCard: bioCard, 84 | contentBox: contentBox, 85 | formBoxItem: formBoxItem, 86 | bioCardBoxItem: bioCardBoxItem, 87 | } 88 | result.updateBioCard() 89 | return result 90 | } 91 | 92 | func (terminal *secretAgentTerminalImpl) SetWidthAndGetDesiredHeight(actualWidth int) int { 93 | if actualWidth < columnSwitchThreshold { 94 | terminal.contentBox.SetDirection(flexbox.Column) 95 | terminal.formBoxItem.SetVerticalGrowthFactor(0) 96 | terminal.formBoxItem.SetHorizontalGrowthFactor(1) 97 | } else { 98 | terminal.contentBox.SetDirection(flexbox.Row) 99 | terminal.formBoxItem.SetVerticalGrowthFactor(1) 100 | terminal.formBoxItem.SetHorizontalGrowthFactor(0) 101 | } 102 | return terminal.Component.SetWidthAndGetDesiredHeight(actualWidth) 103 | } 104 | 105 | func (terminal secretAgentTerminalImpl) Update(msg tea.Msg) tea.Cmd { 106 | result := terminal.form.Update(msg) 107 | terminal.updateBioCard() 108 | return result 109 | } 110 | 111 | func (terminal secretAgentTerminalImpl) SetFocus(isFocused bool) tea.Cmd { 112 | return nil 113 | } 114 | 115 | func (terminal secretAgentTerminalImpl) IsFocused() bool { 116 | return true 117 | } 118 | 119 | // ==================================================================================================== 120 | // 121 | // Private Helper Functions 122 | // 123 | // ==================================================================================================== 124 | func (terminal *secretAgentTerminalImpl) updateBioCard() { 125 | terminal.bioCard.SetName(terminal.form.GetName()) 126 | terminal.bioCard.SetAge(terminal.form.GetAge()) 127 | } 128 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mieubrisse/teact 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.15.0 7 | github.com/charmbracelet/bubbletea v0.23.2 8 | github.com/charmbracelet/lipgloss v0.7.1 9 | github.com/muesli/reflow v0.3.0 10 | github.com/stretchr/testify v1.8.2 11 | ) 12 | 13 | require ( 14 | github.com/atotto/clipboard v0.1.4 // indirect 15 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 16 | github.com/containerd/console v1.0.3 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 19 | github.com/mattn/go-isatty v0.0.17 // indirect 20 | github.com/mattn/go-localereader v0.0.1 // indirect 21 | github.com/mattn/go-runewidth v0.0.14 // indirect 22 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 23 | github.com/muesli/cancelreader v0.2.2 // indirect 24 | github.com/muesli/termenv v0.15.1 // indirect 25 | github.com/pmezard/go-difflib v1.0.0 // indirect 26 | github.com/rivo/uniseg v0.2.0 // indirect 27 | golang.org/x/sync v0.1.0 // indirect 28 | golang.org/x/sys v0.6.0 // indirect 29 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 30 | golang.org/x/text v0.3.7 // indirect 31 | gopkg.in/yaml.v3 v3.0.1 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 4 | github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 5 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 6 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 7 | github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= 8 | github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= 9 | github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= 10 | github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps= 11 | github.com/charmbracelet/bubbletea v0.23.2/go.mod h1:FaP3WUivcTM0xOKNmhciz60M6I+weYLF76mr1JyI7sM= 12 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 13 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 14 | github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= 15 | github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 16 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 17 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 18 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 22 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 23 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 24 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 25 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 26 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 27 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 28 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 29 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 30 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 31 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 32 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 33 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 34 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 35 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 36 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 37 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 38 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 39 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 40 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 41 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 42 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 43 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 44 | github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8= 45 | github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= 46 | github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= 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/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 53 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 54 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 55 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 56 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 57 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 58 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 59 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 60 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 61 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 62 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 63 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 68 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 70 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 71 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 72 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 73 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 74 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 75 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 76 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 77 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 78 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 79 | -------------------------------------------------------------------------------- /teact/component_test/component_assertion.go: -------------------------------------------------------------------------------- 1 | package component_test 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | "testing" 6 | ) 7 | 8 | // An aassertion on a component 9 | type ComponentAssertion interface { 10 | Check(t *testing.T, component components.Component) 11 | } 12 | 13 | // Helper to make working with groups of assertions easier 14 | func FlattenAssertionGroups(assertionGroups ...[]ComponentAssertion) []ComponentAssertion { 15 | numAssertions := 0 16 | for _, group := range assertionGroups { 17 | numAssertions += len(group) 18 | } 19 | 20 | result := make([]ComponentAssertion, 0, numAssertions) 21 | for _, group := range assertionGroups { 22 | result = append(result, group...) 23 | } 24 | return result 25 | } 26 | 27 | // Run a group of assertions against the component 28 | func CheckAll(t *testing.T, assertionGroup []ComponentAssertion, component components.Component) { 29 | for _, assertion := range assertionGroup { 30 | assertion.Check(t, component) 31 | } 32 | } 33 | 34 | func GetDefaultAssertions() []ComponentAssertion { 35 | return FlattenAssertionGroups( 36 | // Every component should be zero height when zero width 37 | GetHeightAtWidthAssertions(0, 0), 38 | 39 | // A zero height or width should always in an empty string 40 | GetRenderedContentAssertion(1, 0, ""), 41 | GetRenderedContentAssertion(0, 1, ""), 42 | GetRenderedContentAssertion(0, 0, ""), 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /teact/component_test/content_size_assertion.go: -------------------------------------------------------------------------------- 1 | package component_test 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | // ContentSizeAssertion asserts the min/max width/height for the component 10 | type ContentSizeAssertion struct { 11 | ExpectedMinWidth int 12 | ExpectedMaxWidth int 13 | ExpectedMinHeight int 14 | ExpectedMaxHeight int 15 | } 16 | 17 | func (assertion ContentSizeAssertion) Check(t *testing.T, component components.Component) { 18 | minWidth, maxWidth, minHeight, maxHeight := component.GetContentMinMax() 19 | require.Equal( 20 | t, 21 | assertion.ExpectedMinWidth, 22 | minWidth, 23 | "Expected the component's minWidth to be %v but was %v", 24 | assertion.ExpectedMinWidth, 25 | minWidth, 26 | ) 27 | require.Equal( 28 | t, 29 | assertion.ExpectedMaxWidth, 30 | maxWidth, 31 | "Expected the component's maxWidth to be %v but was %v", 32 | assertion.ExpectedMaxWidth, 33 | maxWidth, 34 | ) 35 | require.Equal( 36 | t, 37 | assertion.ExpectedMinHeight, 38 | minHeight, 39 | "Expected the component's minHeight to be %v but was %v", 40 | assertion.ExpectedMinHeight, 41 | minHeight, 42 | ) 43 | require.Equal( 44 | t, 45 | assertion.ExpectedMaxHeight, 46 | maxHeight, 47 | "Expected the component's maxHeight to be %v but was %v", 48 | assertion.ExpectedMaxHeight, 49 | maxHeight, 50 | ) 51 | } 52 | 53 | // Helper to create multiple content size assertions 54 | func GetContentSizeAssertions(dimensions ...int) []ComponentAssertion { 55 | if len(dimensions)%4 != 0 { 56 | panic("Must provide dimensions in pairs of (minWidth, maxWidth, minHeight, maxHeight)") 57 | } 58 | 59 | result := make([]ComponentAssertion, 0, len(dimensions)/2) 60 | for i := 0; i < len(dimensions); i += 4 { 61 | result = append( 62 | result, 63 | ContentSizeAssertion{ 64 | ExpectedMinWidth: dimensions[i], 65 | ExpectedMaxWidth: dimensions[i+1], 66 | ExpectedMinHeight: dimensions[i+2], 67 | ExpectedMaxHeight: dimensions[i+3], 68 | }, 69 | ) 70 | } 71 | return result 72 | } 73 | -------------------------------------------------------------------------------- /teact/component_test/height_at_width_assertion.go: -------------------------------------------------------------------------------- 1 | package component_test 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | // HeightAtWidthAssertion asserts that the component has a given height at a given width 10 | type HeightAtWidthAssertion struct { 11 | Width int 12 | ExpectedHeight int 13 | } 14 | 15 | func (assertion HeightAtWidthAssertion) Check(t *testing.T, component components.Component) { 16 | height := component.SetWidthAndGetDesiredHeight(assertion.Width) 17 | require.Equal( 18 | t, 19 | assertion.ExpectedHeight, 20 | height, 21 | "Expected the component to be height %v at width %v, but was %v", 22 | assertion.ExpectedHeight, 23 | assertion.Width, 24 | height, 25 | ) 26 | } 27 | 28 | // Helper to create multiple height-at-width assertions 29 | func GetHeightAtWidthAssertions(dimensions ...int) []ComponentAssertion { 30 | if len(dimensions)%2 != 0 { 31 | panic("Must provide dimensions in pairs of (width, height)") 32 | } 33 | 34 | result := make([]ComponentAssertion, 0, len(dimensions)/2) 35 | for i := 0; i < len(dimensions); i += 2 { 36 | result = append( 37 | result, 38 | HeightAtWidthAssertion{ 39 | Width: dimensions[i], 40 | ExpectedHeight: dimensions[i+1], 41 | }, 42 | ) 43 | } 44 | return result 45 | } 46 | -------------------------------------------------------------------------------- /teact/component_test/rendered_content_assertion.go: -------------------------------------------------------------------------------- 1 | package component_test 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | // RenderedContentAssertion asserts that the component renders the given output 10 | type RenderedContentAssertion struct { 11 | Width int 12 | Height int 13 | ExpectedContent string 14 | } 15 | 16 | func (v RenderedContentAssertion) Check(t *testing.T, component components.Component) { 17 | output := component.View(v.Width, v.Height) 18 | require.Equal(t, v.ExpectedContent, output) 19 | } 20 | 21 | // This returns an array to make it very easy to slot into FlattenAssertionGroups 22 | func GetRenderedContentAssertion(width int, height int, expectedContent string) []ComponentAssertion { 23 | return []ComponentAssertion{ 24 | RenderedContentAssertion{ 25 | Width: width, 26 | Height: height, 27 | ExpectedContent: expectedContent, 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /teact/components/component.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | type Component interface { 4 | // This is used during the X-expansion phase, where each child "expands" its min and max widths up to its parent 5 | // During this stage, each element is growing in the X direction; there is no concept of a viewport 6 | GetContentMinMax() (minWidth, maxWidth, minHeight, maxHeight int) 7 | 8 | // This is used during the Y-expansion phase, where a viewport width is known and now we're determining heights 9 | // This method should do any necessary reflowing, and then get the desired height 10 | // If you want to do any reflowing based on the actual size of the viewport (e.g. maybe stacking a sidebar vertically for small viewports), this is the place to do it 11 | SetWidthAndGetDesiredHeight(actualWidth int) int 12 | 13 | // The 'width' will be the same width that was passed in to GetContentHeightForGivenWidth, allowing for some caching 14 | // of calculation results between the two 15 | // TODO maybe return Optional[string], so that we can indicate "there is no content at all"? 16 | View(actualWidth int, actualHeight int) string 17 | } 18 | -------------------------------------------------------------------------------- /teact/components/dimensions_cache.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | // Simple struct for caching the min/max width/height of anything 4 | type DimensionsCache struct { 5 | MinWidth int 6 | MaxWidth int 7 | MinHeight int 8 | MaxHeight int 9 | } 10 | -------------------------------------------------------------------------------- /teact/components/flexbox/axis_alignment.go: -------------------------------------------------------------------------------- 1 | package flexbox 2 | 3 | // The percentage from the start that alignment should be done 4 | // See lipgloss.Position for more 5 | type AxisAlignment float64 6 | 7 | const ( 8 | // Elements will be at the start of the flexbox (as determined by the Direction) 9 | // Corresponds to "flex-justify: flex-start" 10 | AlignStart AxisAlignment = 0.0 11 | 12 | // NOTE: in order to see this in effect, you must have 13 | // Corresponds to "flex-justify: center" 14 | AlignCenter = 0.5 15 | 16 | // Elements will be pushed to the end of the flexbox (as determined by the Direction) 17 | // Corresponds to "flex-justify: flex-end" 18 | AlignEnd = 1.0 19 | ) 20 | -------------------------------------------------------------------------------- /teact/components/flexbox/axis_min_max_combiners.go: -------------------------------------------------------------------------------- 1 | package flexbox 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/utilities" 5 | ) 6 | 7 | // Reduces all the child values for a given dimension to a single value for the parent 8 | // Used for calculating what the flexbox's min width is based off child min widths, flexbox max height based off childrens', etc. 9 | type axisDimensionMinMaxCombiner func(values []int) int 10 | 11 | // Reduces all the childrens' cross axis dimension values into one for the flexbox parent 12 | // The cross axis uses the max (because elements don't flex on the cross axis, so whichever is biggest dominates) 13 | func crossAxisDimensionReducer(crossAxisDimensionValues []int) int { 14 | max := 0 15 | for _, value := range crossAxisDimensionValues { 16 | max = utilities.GetMaxInt(max, value) 17 | } 18 | return max 19 | } 20 | 21 | // Reduces all the childrens' main axis dimension values into one for the flexbox parent 22 | // The main axis uses the sum, because elements flex on the main axis so the parent size is the size of all the childrens' 23 | // main axis values 24 | func mainAxisDimensionReducer(mainAxisDimensionValues []int) int { 25 | sum := 0 26 | for _, value := range mainAxisDimensionValues { 27 | sum += value 28 | } 29 | return sum 30 | } 31 | -------------------------------------------------------------------------------- /teact/components/flexbox/axis_size_calculators.go: -------------------------------------------------------------------------------- 1 | package flexbox 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/utilities" 5 | ) 6 | 7 | type axisSizeCalculator func( 8 | minContentSizes []int, 9 | maxContentSizes []int, 10 | growthFactors []int, 11 | spaceAvailable int, 12 | ) axisSizeCalculationResults 13 | 14 | type axisSizeCalculationResults struct { 15 | actualSizes []int 16 | 17 | spaceUsedByChildren int 18 | } 19 | 20 | // TODO move to be a function on the axis? 21 | func calculateActualCrossAxisSizes( 22 | minContentSizes []int, 23 | maxContentSizes []int, 24 | growthFactors []int, 25 | // How much space is available in the cross axis 26 | spaceAvailable int, 27 | ) axisSizeCalculationResults { 28 | actualSizes := make([]int, len(maxContentSizes)) 29 | 30 | // The space used in the cross axis is the max across all children 31 | maxSpaceUsed := 0 32 | for idx, max := range maxContentSizes { 33 | // TODO replace this with "align-content" 34 | if growthFactors[idx] > 0 { 35 | max = spaceAvailable 36 | } 37 | 38 | actualSize := utilities.GetMinInt(max, spaceAvailable) 39 | 40 | actualSizes[idx] = actualSize 41 | maxSpaceUsed = utilities.GetMaxInt(actualSize, maxSpaceUsed) 42 | } 43 | return axisSizeCalculationResults{ 44 | actualSizes: actualSizes, 45 | spaceUsedByChildren: maxSpaceUsed, 46 | } 47 | } 48 | 49 | func calculateActualMainAxisSizes( 50 | minContentSizes []int, 51 | maxContentSizes []int, 52 | growthFactors []int, 53 | spaceAvailable int, 54 | ) axisSizeCalculationResults { 55 | actualSizes := make([]int, len(minContentSizes)) 56 | 57 | // First, allocate space from start_child to end_child, trying to getting each child to min-content before 58 | // proceeding to the next 59 | spaceUsedGettingToMin := 0 60 | for idx, min := range minContentSizes { 61 | sizeForItem := utilities.GetMinInt(min, spaceAvailable-spaceUsedGettingToMin) 62 | actualSizes[idx] = sizeForItem 63 | spaceUsedGettingToMin += sizeForItem 64 | } 65 | 66 | // If we used all the space attempting to get to min, we're done 67 | if spaceUsedGettingToMin == spaceAvailable { 68 | return axisSizeCalculationResults{ 69 | actualSizes: actualSizes, 70 | spaceUsedByChildren: spaceUsedGettingToMin, 71 | } 72 | } 73 | 74 | // We still have space, so start to allocate it amongst the items who can grow 75 | minDesiredSize := 0 76 | for _, min := range minContentSizes { 77 | minDesiredSize += min 78 | } 79 | maxDesiredSize := 0 80 | for _, max := range maxContentSizes { 81 | maxDesiredSize += max 82 | } 83 | spaceForGettingToMaxDesired := utilities.GetMinInt(spaceAvailable, maxDesiredSize) - minDesiredSize 84 | 85 | weightsForGettingToMax := make([]int, len(maxContentSizes)) 86 | for idx, max := range maxContentSizes { 87 | min := minContentSizes[idx] 88 | 89 | // Each item gets a proportion of the space weighted by how far they are from their max 90 | weightsForGettingToMax[idx] = max - min 91 | } 92 | actualSizes = utilities.DistributeSpaceByWeight(spaceForGettingToMaxDesired, actualSizes, weightsForGettingToMax) 93 | 94 | // If we used all the space attempting to get to max, we're done 95 | spaceUsedGettingToMax := 0 96 | for _, size := range actualSizes { 97 | spaceUsedGettingToMax += size 98 | } 99 | if spaceUsedGettingToMax == spaceAvailable { 100 | return axisSizeCalculationResults{ 101 | actualSizes: actualSizes, 102 | spaceUsedByChildren: spaceUsedGettingToMax, 103 | } 104 | } 105 | 106 | // At this point, we *still* have space left over so give it to the children who can grow 107 | spaceForFillingBox := spaceAvailable - maxDesiredSize 108 | actualSizes = utilities.DistributeSpaceByWeight(spaceForFillingBox, actualSizes, growthFactors) 109 | 110 | totalSizeUsedByChildren := 0 111 | for _, size := range actualSizes { 112 | totalSizeUsedByChildren += size 113 | } 114 | 115 | return axisSizeCalculationResults{ 116 | actualSizes: actualSizes, 117 | spaceUsedByChildren: totalSizeUsedByChildren, 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /teact/components/flexbox/axis_size_calculators_test.go: -------------------------------------------------------------------------------- 1 | package flexbox 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "testing" 6 | ) 7 | 8 | // ==================================================================================================== 9 | // 10 | // Cross Axis Tests 11 | // 12 | // ==================================================================================================== 13 | func TestCrossAxisTruncationNoGrowth(t *testing.T) { 14 | // These don't do anything for the cross axis 15 | minSizes := []int{ 16 | 3, 17 | 3, 18 | 3, 19 | } 20 | maxSizes := []int{ 21 | 10, 22 | 5, 23 | 7, 24 | } 25 | growFactor := []int{ 26 | 0, 27 | 0, 28 | 0, 29 | } 30 | calcResult := calculateActualCrossAxisSizes(minSizes, maxSizes, growFactor, 6) 31 | 32 | require.Equal(t, 6, calcResult.spaceUsedByChildren) 33 | require.Equal(t, []int{6, 5, 6}, calcResult.actualSizes) 34 | } 35 | 36 | func TestCrossAxisTruncationWithGrowth(t *testing.T) { 37 | // These don't do anything for the cross axis 38 | minSizes := []int{ 39 | 3, 40 | 3, 41 | 3, 42 | } 43 | maxSizes := []int{ 44 | 10, 45 | 5, 46 | 7, 47 | } 48 | growFactor := []int{ 49 | 1, 50 | 1, 51 | 1, 52 | } 53 | calcResult := calculateActualCrossAxisSizes(minSizes, maxSizes, growFactor, 6) 54 | 55 | require.Equal(t, 6, calcResult.spaceUsedByChildren) 56 | require.Equal(t, []int{6, 6, 6}, calcResult.actualSizes) 57 | } 58 | 59 | func TestCrossAxisExtraSpaceNoGrowth(t *testing.T) { 60 | // These don't do anything for the cross axis 61 | minSizes := []int{ 62 | 3, 63 | 3, 64 | 3, 65 | } 66 | maxSizes := []int{ 67 | 10, 68 | 5, 69 | 7, 70 | } 71 | growFactor := []int{ 72 | 0, 73 | 0, 74 | 0, 75 | } 76 | calcResult := calculateActualCrossAxisSizes(minSizes, maxSizes, growFactor, 12) 77 | 78 | require.Equal(t, 10, calcResult.spaceUsedByChildren) 79 | require.Equal(t, []int{10, 5, 7}, calcResult.actualSizes) 80 | } 81 | 82 | func TestCrossAxisExtraSpaceWithGrowth(t *testing.T) { 83 | // These don't do anything for the cross axis 84 | minSizes := []int{ 85 | 3, 86 | 3, 87 | 3, 88 | } 89 | maxSizes := []int{ 90 | 10, 91 | 5, 92 | 7, 93 | } 94 | growFactor := []int{ 95 | 1, 96 | 1, 97 | 1, 98 | } 99 | calcResult := calculateActualCrossAxisSizes(minSizes, maxSizes, growFactor, 12) 100 | 101 | require.Equal(t, 12, calcResult.spaceUsedByChildren) 102 | require.Equal(t, []int{12, 12, 12}, calcResult.actualSizes) 103 | } 104 | 105 | // ==================================================================================================== 106 | // 107 | // Main Axis Tests 108 | // 109 | // ==================================================================================================== 110 | func TestMainAxisTruncation(t *testing.T) { 111 | minSizes := []int{ 112 | 5, 113 | 5, 114 | 5, 115 | } 116 | maxSizes := []int{ 117 | 7, 118 | 7, 119 | 7, 120 | } 121 | growFactor := []int{ 122 | 0, 123 | 0, 124 | 0, 125 | } 126 | calcResult := calculateActualMainAxisSizes(minSizes, maxSizes, growFactor, 7) 127 | 128 | require.Equal(t, 7, calcResult.spaceUsedByChildren) 129 | require.Equal(t, []int{5, 2, 0}, calcResult.actualSizes) 130 | } 131 | 132 | func TestMainAxisAllFixed(t *testing.T) { 133 | minSizes := []int{ 134 | 5, 135 | 5, 136 | 5, 137 | } 138 | maxSizes := []int{ 139 | 5, 140 | 5, 141 | 5, 142 | } 143 | growFactor := []int{ 144 | 0, 145 | 0, 146 | 0, 147 | } 148 | calcResult := calculateActualMainAxisSizes(minSizes, maxSizes, growFactor, 20) 149 | 150 | require.Equal(t, 15, calcResult.spaceUsedByChildren) 151 | require.Equal(t, []int{5, 5, 5}, calcResult.actualSizes) 152 | } 153 | 154 | func TestMainAxisSomeFixed(t *testing.T) { 155 | minSizes := []int{ 156 | 5, 157 | 5, 158 | 5, 159 | } 160 | maxSizes := []int{ 161 | 5, 162 | 10, 163 | 5, 164 | } 165 | growFactor := []int{ 166 | 0, 167 | 0, 168 | 0, 169 | } 170 | calcResult := calculateActualMainAxisSizes(minSizes, maxSizes, growFactor, 22) 171 | 172 | require.Equal(t, 20, calcResult.spaceUsedByChildren) 173 | require.Equal(t, []int{5, 10, 5}, calcResult.actualSizes) 174 | } 175 | 176 | func TestMainAxisEvenGrowth(t *testing.T) { 177 | minSizes := []int{ 178 | 5, 179 | 5, 180 | 5, 181 | } 182 | maxSizes := []int{ 183 | 8, 184 | 8, 185 | 8, 186 | } 187 | growFactor := []int{ 188 | 0, 189 | 0, 190 | 0, 191 | } 192 | calcResult := calculateActualMainAxisSizes(minSizes, maxSizes, growFactor, 18) 193 | 194 | require.Equal(t, 18, calcResult.spaceUsedByChildren) 195 | require.Equal(t, []int{6, 6, 6}, calcResult.actualSizes) 196 | } 197 | 198 | func TestMainAxisExtraSpaceWithEvenGrowth(t *testing.T) { 199 | minSizes := []int{ 200 | 6, 201 | 3, 202 | 3, 203 | } 204 | // Chose these numbers so growth happens evenly 205 | maxSizes := []int{ 206 | 12, 207 | 6, 208 | 6, 209 | } 210 | growFactor := []int{ 211 | 1, 212 | 1, 213 | 1, 214 | } 215 | calcResult := calculateActualMainAxisSizes(minSizes, maxSizes, growFactor, 30) 216 | 217 | require.Equal(t, 30, calcResult.spaceUsedByChildren) 218 | require.Equal(t, []int{14, 8, 8}, calcResult.actualSizes) 219 | } 220 | 221 | func TestMainAxisExtraSpaceWithUnvenGrowth(t *testing.T) { 222 | minSizes := []int{ 223 | 5, 224 | 3, 225 | 3, 226 | } 227 | // Chose these numbers so growth happens evenly 228 | maxSizes := []int{ 229 | 10, 230 | 5, 231 | 5, 232 | } 233 | growFactor := []int{ 234 | 0, 235 | 1, 236 | 0, 237 | } 238 | calcResult := calculateActualMainAxisSizes(minSizes, maxSizes, growFactor, 40) 239 | 240 | require.Equal(t, 40, calcResult.spaceUsedByChildren) 241 | require.Equal(t, []int{10, 25, 5}, calcResult.actualSizes) 242 | } 243 | -------------------------------------------------------------------------------- /teact/components/flexbox/direction.go: -------------------------------------------------------------------------------- 1 | package flexbox 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | // The direction that the flexbox ought to be layed out in 8 | type Direction interface { 9 | reduceChildWidths(childWidths []int) int 10 | 11 | reduceChildHeights(childHeights []int) int 12 | 13 | getActualWidths(minContentWidths []int, maxContentWidths []int, growthFactors []int, widthAvailable int) axisSizeCalculationResults 14 | 15 | getActualHeights(minContentHeights []int, maxContentHeights []int, growthFactors []int, heightAvailable int) axisSizeCalculationResults 16 | 17 | renderContentFragments(contentFragments []string, width int, height int, horizontalAlignment AxisAlignment, verticalAlignment AxisAlignment) string 18 | } 19 | 20 | // Row lays out the flexbox items in a row, left to right 21 | // The flex direction will be horizontal 22 | // Corresponds to "flex-direction: row" in CSS 23 | var Row = &directionImpl{ 24 | actualWidthCalculator: calculateActualMainAxisSizes, 25 | actualHeightCalculator: calculateActualCrossAxisSizes, 26 | widthDimensionReducer: mainAxisDimensionReducer, 27 | heightDimensionReducer: crossAxisDimensionReducer, 28 | contentFragmentRenderer: func(contentFragments []string, width int, height int, horizontalAlign AxisAlignment, verticalAlign AxisAlignment) string { 29 | joined := lipgloss.JoinHorizontal(lipgloss.Position(verticalAlign), contentFragments...) 30 | horizontallyPlaced := lipgloss.PlaceHorizontal(width, lipgloss.Position(horizontalAlign), joined) 31 | return lipgloss.PlaceVertical(height, lipgloss.Position(verticalAlign), horizontallyPlaced) 32 | }, 33 | } 34 | 35 | // Column lays out the flexbox items in a column, top to bottom 36 | // The flex direction will be vertical 37 | // Corresponds to "flex-direction: column" in CSS 38 | var Column = &directionImpl{ 39 | actualWidthCalculator: calculateActualCrossAxisSizes, 40 | actualHeightCalculator: calculateActualMainAxisSizes, 41 | widthDimensionReducer: crossAxisDimensionReducer, 42 | heightDimensionReducer: mainAxisDimensionReducer, 43 | contentFragmentRenderer: func(contentFragments []string, width int, height int, horizontalAlign AxisAlignment, verticalAlign AxisAlignment) string { 44 | joined := lipgloss.JoinVertical(lipgloss.Position(horizontalAlign), contentFragments...) 45 | horizontallyPlaced := lipgloss.PlaceHorizontal(width, lipgloss.Position(horizontalAlign), joined) 46 | return lipgloss.PlaceVertical(height, lipgloss.Position(verticalAlign), horizontallyPlaced) 47 | }, 48 | } 49 | 50 | // ==================================================================================================== 51 | // 52 | // Private 53 | // 54 | // ==================================================================================================== 55 | type directionImpl struct { 56 | actualWidthCalculator axisSizeCalculator 57 | actualHeightCalculator axisSizeCalculator 58 | widthDimensionReducer axisDimensionMinMaxCombiner 59 | heightDimensionReducer axisDimensionMinMaxCombiner 60 | contentFragmentRenderer func(contentFragments []string, width int, height int, horizontalAlign AxisAlignment, verticalAlign AxisAlignment) string 61 | } 62 | 63 | func (a directionImpl) reduceChildWidths(childWidths []int) int { 64 | return a.widthDimensionReducer(childWidths) 65 | } 66 | 67 | func (a directionImpl) reduceChildHeights(childHeights []int) int { 68 | return a.heightDimensionReducer(childHeights) 69 | } 70 | 71 | func (r directionImpl) getActualWidths(minContentWidths []int, maxContentWidths []int, growthFactors []int, widthAvailable int) axisSizeCalculationResults { 72 | return r.actualWidthCalculator( 73 | minContentWidths, 74 | maxContentWidths, 75 | growthFactors, 76 | widthAvailable, 77 | ) 78 | } 79 | 80 | func (r directionImpl) getActualHeights(minContentHeights []int, maxContentHeights []int, growthFactors []int, heightAvailable int) axisSizeCalculationResults { 81 | return r.actualHeightCalculator( 82 | minContentHeights, 83 | maxContentHeights, 84 | growthFactors, 85 | heightAvailable, 86 | ) 87 | } 88 | 89 | func (r directionImpl) renderContentFragments(contentFragments []string, width int, height int, horizontalAlign AxisAlignment, verticalAlign AxisAlignment) string { 90 | return r.contentFragmentRenderer(contentFragments, width, height, horizontalAlign, verticalAlign) 91 | } 92 | -------------------------------------------------------------------------------- /teact/components/flexbox/flexbox.go: -------------------------------------------------------------------------------- 1 | package flexbox 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | "github.com/mieubrisse/teact/teact/components/flexbox_item" 6 | ) 7 | 8 | // NOTE: This class does some stateful caching, so when you're testing methods like "View" make sure you call the 9 | // full flow of GetContentMinMax -> SetWidthAndGetDesiredHeight -> View as necessary 10 | 11 | type Flexbox interface { 12 | components.Component 13 | 14 | GetChildren() []flexbox_item.FlexboxItem 15 | SetChildren(children []flexbox_item.FlexboxItem) Flexbox 16 | 17 | GetDirection() Direction 18 | SetDirection(direction Direction) Flexbox 19 | 20 | GetHorizontalAlignment() AxisAlignment 21 | SetHorizontalAlignment(alignment AxisAlignment) Flexbox 22 | 23 | GetVerticalAlignment() AxisAlignment 24 | SetVerticalAlignment(alignment AxisAlignment) Flexbox 25 | } 26 | -------------------------------------------------------------------------------- /teact/components/flexbox/flexbox_impl.go: -------------------------------------------------------------------------------- 1 | package flexbox 2 | 3 | import "github.com/mieubrisse/teact/teact/components/flexbox_item" 4 | 5 | type minMaxChildDimensionsCache struct { 6 | minWidths []int 7 | maxWidths []int 8 | minHeights []int 9 | maxHeights []int 10 | } 11 | 12 | type flexboxImpl struct { 13 | children []flexbox_item.FlexboxItem 14 | 15 | direction Direction 16 | 17 | horizontalAlignment AxisAlignment 18 | verticalAlignment AxisAlignment 19 | 20 | // -------------------- Calculation Caching ----------------------- 21 | // The min/max widths/heights of children 22 | childDimensionsCache minMaxChildDimensionsCache 23 | 24 | // The actual widths each child will get (cached between GetContentHeightForGivenWidth and View) 25 | actualChildWidthsCache axisSizeCalculationResults 26 | 27 | // The desired height each child wants given its width (cached between GetContentHeightForGivenWidth and View) 28 | desiredChildHeightsGivenWidthCache []int 29 | } 30 | 31 | func New(items ...flexbox_item.FlexboxItem) Flexbox { 32 | return &flexboxImpl{ 33 | children: items, 34 | direction: Row, 35 | horizontalAlignment: AlignStart, 36 | verticalAlignment: AlignStart, 37 | actualChildWidthsCache: axisSizeCalculationResults{}, 38 | desiredChildHeightsGivenWidthCache: nil, 39 | } 40 | } 41 | 42 | func NewWithOpts(items []flexbox_item.FlexboxItem, opts ...FlexboxOpt) Flexbox { 43 | result := New(items...) 44 | for _, opt := range opts { 45 | opt(result) 46 | } 47 | return result 48 | } 49 | 50 | func (b *flexboxImpl) GetChildren() []flexbox_item.FlexboxItem { 51 | return b.children 52 | } 53 | 54 | func (b *flexboxImpl) SetChildren(children []flexbox_item.FlexboxItem) Flexbox { 55 | b.children = children 56 | return b 57 | } 58 | 59 | func (b *flexboxImpl) GetDirection() Direction { 60 | return b.direction 61 | } 62 | 63 | func (b *flexboxImpl) SetDirection(direction Direction) Flexbox { 64 | b.direction = direction 65 | return b 66 | } 67 | 68 | func (b flexboxImpl) GetHorizontalAlignment() AxisAlignment { 69 | return b.horizontalAlignment 70 | } 71 | 72 | func (b *flexboxImpl) SetHorizontalAlignment(alignment AxisAlignment) Flexbox { 73 | b.horizontalAlignment = alignment 74 | return b 75 | } 76 | 77 | func (b flexboxImpl) GetVerticalAlignment() AxisAlignment { 78 | return b.verticalAlignment 79 | } 80 | 81 | func (b *flexboxImpl) SetVerticalAlignment(alignment AxisAlignment) Flexbox { 82 | b.verticalAlignment = alignment 83 | return b 84 | } 85 | 86 | func (b *flexboxImpl) GetContentMinMax() (int, int, int, int) { 87 | numChildren := len(b.children) 88 | childMinWidths := make([]int, numChildren) 89 | childMaxWidths := make([]int, numChildren) 90 | childMinHeights := make([]int, numChildren) 91 | childMaxHeights := make([]int, numChildren) 92 | for idx, item := range b.children { 93 | childMinWidths[idx], childMaxWidths[idx], childMinHeights[idx], childMaxHeights[idx] = item.GetContentMinMax() 94 | } 95 | 96 | // Cache, so that future steps don't need to recalculate this 97 | b.childDimensionsCache = minMaxChildDimensionsCache{ 98 | minWidths: childMinWidths, 99 | maxWidths: childMaxWidths, 100 | minHeights: childMinHeights, 101 | maxHeights: childMaxHeights, 102 | } 103 | 104 | minWidth := b.direction.reduceChildWidths(childMinWidths) 105 | maxWidth := b.direction.reduceChildWidths(childMaxWidths) 106 | 107 | minHeight := b.direction.reduceChildHeights(childMinHeights) 108 | maxHeight := b.direction.reduceChildHeights(childMaxHeights) 109 | 110 | return minWidth, maxWidth, minHeight, maxHeight 111 | } 112 | 113 | func (b *flexboxImpl) SetWidthAndGetDesiredHeight(width int) int { 114 | if width == 0 { 115 | return 0 116 | } 117 | 118 | // Width 119 | growthFactors := make([]int, len(b.children)) 120 | for idx, item := range b.children { 121 | growthFactors[idx] = item.GetHorizontalGrowthFactor() 122 | } 123 | actualWidthsCalcResults := b.direction.getActualWidths( 124 | b.childDimensionsCache.minWidths, 125 | b.childDimensionsCache.maxWidths, 126 | growthFactors, 127 | width, 128 | ) 129 | 130 | // Cache the result, so we don't have to recalculate it in View 131 | b.actualChildWidthsCache = actualWidthsCalcResults 132 | 133 | desiredHeights := make([]int, len(b.children)) 134 | for idx, item := range b.children { 135 | actualWidth := actualWidthsCalcResults.actualSizes[idx] 136 | desiredHeight := item.SetWidthAndGetDesiredHeight(actualWidth) 137 | 138 | desiredHeights[idx] = desiredHeight 139 | } 140 | 141 | // Cache the result, so we don't have to recalculate it in View 142 | b.desiredChildHeightsGivenWidthCache = desiredHeights 143 | 144 | return b.direction.reduceChildHeights(desiredHeights) 145 | } 146 | 147 | func (b *flexboxImpl) View(width int, height int) string { 148 | if width == 0 || height == 0 { 149 | return "" 150 | } 151 | 152 | actualWidths := b.actualChildWidthsCache.actualSizes 153 | // widthNotUsedByChildren := utilities.GetMaxInt(0, width-b.actualChildWidthsCache.spaceUsedByChildren) 154 | 155 | // TODO get rid of this.. doesn't quite make sense 156 | growthFactors := make([]int, len(b.children)) 157 | for idx, item := range b.children { 158 | growthFactors[idx] = item.GetVerticalGrowthFactor() 159 | } 160 | actualHeightsCalcResult := b.direction.getActualHeights( 161 | b.childDimensionsCache.minHeights, 162 | b.desiredChildHeightsGivenWidthCache, 163 | growthFactors, 164 | height, 165 | ) 166 | 167 | actualHeights := actualHeightsCalcResult.actualSizes 168 | // heightNotUsedByChildren := utilities.GetMaxInt(0, height-actualHeightsCalcResult.spaceUsedByChildren) 169 | 170 | // Now render each child 171 | allContentFragments := make([]string, len(b.children)) 172 | for idx, item := range b.children { 173 | childWidth := actualWidths[idx] 174 | childHeight := actualHeights[idx] 175 | childStr := item.View(childWidth, childHeight) 176 | 177 | allContentFragments[idx] = childStr 178 | } 179 | 180 | content := b.direction.renderContentFragments(allContentFragments, width, height, b.horizontalAlignment, b.verticalAlignment) 181 | 182 | return content 183 | } 184 | -------------------------------------------------------------------------------- /teact/components/flexbox/flexbox_opts.go: -------------------------------------------------------------------------------- 1 | package flexbox 2 | 3 | type FlexboxOpt func(Flexbox) 4 | 5 | func WithDirection(direction Direction) FlexboxOpt { 6 | return func(box Flexbox) { 7 | box.SetDirection(direction) 8 | } 9 | } 10 | 11 | func WithHorizontalAlignment(alignment AxisAlignment) FlexboxOpt { 12 | return func(box Flexbox) { 13 | box.SetHorizontalAlignment(alignment) 14 | } 15 | } 16 | 17 | func WithVerticalAlignment(alignment AxisAlignment) FlexboxOpt { 18 | return func(box Flexbox) { 19 | box.SetVerticalAlignment(alignment) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /teact/components/flexbox/flexbox_test.go: -------------------------------------------------------------------------------- 1 | package flexbox 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/component_test" 6 | "github.com/mieubrisse/teact/teact/components/flexbox_item" 7 | "github.com/mieubrisse/teact/teact/components/stylebox" 8 | "github.com/mieubrisse/teact/teact/components/text" 9 | "testing" 10 | ) 11 | 12 | func TestColumnLayout(t *testing.T) { 13 | child1 := text.New("This is child 1") 14 | child2 := stylebox.New(text.New("This is child 2")). 15 | SetStyle(lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder())) 16 | child3 := text.New("This is child 3") 17 | 18 | flexbox := NewWithOpts( 19 | []flexbox_item.FlexboxItem{ 20 | flexbox_item.New(child1), 21 | flexbox_item.New(child2), 22 | flexbox_item.New(child3), 23 | }, 24 | WithHorizontalAlignment(AlignCenter), 25 | WithVerticalAlignment(AlignCenter), 26 | WithDirection(Column), 27 | ) 28 | 29 | width, height := 30, 30 30 | 31 | assertions := component_test.FlattenAssertionGroups( 32 | component_test.GetDefaultAssertions(), 33 | component_test.GetContentSizeAssertions( 34 | 7, 35 | 17, 36 | 5, 37 | 14, 38 | ), 39 | ) 40 | 41 | // Need to populate the caches 42 | flexbox.GetContentMinMax() 43 | flexbox.SetWidthAndGetDesiredHeight(width) 44 | flexbox.View(width, height) 45 | 46 | component_test.CheckAll(t, assertions, flexbox) 47 | } 48 | 49 | /* 50 | func TestAdvancedColumnLayout(t *testing.T) { 51 | var red = lipgloss.Color("#FF0000") 52 | var blue = lipgloss.Color("#0000FF") 53 | var green = lipgloss.Color("#00FF00") 54 | var lightGray = lipgloss.Color("#333333") 55 | var text1Style = lipgloss.NewStyle().Foreground(red).Background(lightGray) 56 | var text2Style = lipgloss.NewStyle().Foreground(green).Border(lipgloss.NormalBorder()) 57 | var text3Style = lipgloss.NewStyle().Foreground(blue).Background(lightGray) 58 | 59 | text1 := stylebox.New(text.New("This is text 1")).SetStyle(text1Style) 60 | text2 := stylebox.New(text.New("This is text 2")).SetStyle(text2Style) 61 | text3 := stylebox.New( 62 | text.New("Four score and seven years ago our fathers brought forth on this continent, " + 63 | "a new nation, conceived in Liberty, and dedicated to the proposition that all men " + 64 | "are created equal."). 65 | SetTextAlignment(text.AlignCenter)).SetStyle(text3Style) 66 | 67 | component := NewWithContents( 68 | flexbox_item.New(text1), 69 | flexbox_item.New(text2), 70 | flexbox_item.New(text3), 71 | ).SetHorizontalAlignment(AlignCenter). 72 | SetVerticalAlignment(AlignCenter).SetDirection(Column) 73 | 74 | width, height := 30, 30 75 | component.GetContentMinMax() 76 | component.SetWidthAndGetDesiredHeight(width) 77 | component.View(width, height) 78 | } 79 | */ 80 | 81 | func TestFixedSizeItem(t *testing.T) { 82 | nameText := text.New("Pizza") 83 | descriptionText := text.New("A description of pizza") 84 | 85 | style := lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()) 86 | 87 | box := New( 88 | flexbox_item.New( 89 | stylebox.New(nameText, stylebox.WithExistingStyle(style)), 90 | flexbox_item.WithMinWidth(flexbox_item.FixedSize(60)), 91 | flexbox_item.WithMaxWidth(flexbox_item.FixedSize(60)), 92 | ), 93 | flexbox_item.New(text.New(" ")), 94 | flexbox_item.New( 95 | stylebox.New(descriptionText, stylebox.WithExistingStyle(style)), 96 | flexbox_item.WithHorizontalGrowthFactor(1), 97 | ), 98 | ) 99 | 100 | width, height := 170, 30 101 | 102 | box.GetContentMinMax() 103 | box.SetWidthAndGetDesiredHeight(width) 104 | box.View(width, height) 105 | } 106 | -------------------------------------------------------------------------------- /teact/components/flexbox_item/flexbox_item.go: -------------------------------------------------------------------------------- 1 | package flexbox_item 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | ) 6 | 7 | type OverflowStyle int 8 | 9 | const ( 10 | Wrap OverflowStyle = iota 11 | Truncate 12 | ) 13 | 14 | type FlexboxItem interface { 15 | components.Component 16 | 17 | GetComponent() components.Component 18 | 19 | GetMinWidth() FlexboxItemDimensionValue 20 | SetMinWidth(min FlexboxItemDimensionValue) FlexboxItem 21 | GetMaxWidth() FlexboxItemDimensionValue 22 | SetMaxWidth(max FlexboxItemDimensionValue) FlexboxItem 23 | 24 | GetMinHeight() FlexboxItemDimensionValue 25 | SetMinHeight(min FlexboxItemDimensionValue) FlexboxItem 26 | GetMaxHeight() FlexboxItemDimensionValue 27 | SetMaxHeight(max FlexboxItemDimensionValue) FlexboxItem 28 | 29 | GetOverflowStyle() OverflowStyle 30 | SetOverflowStyle(style OverflowStyle) FlexboxItem 31 | 32 | // Analogous to "flex-grow" when on the main axis, and "align-items: stretch" when on the cross axis (on a per-item basis) 33 | // 0 means no growth 34 | GetHorizontalGrowthFactor() int 35 | SetHorizontalGrowthFactor(growthFactor int) FlexboxItem 36 | GetVerticalGrowthFactor() int 37 | SetVerticalGrowthFactor(growthFactor int) FlexboxItem 38 | } 39 | -------------------------------------------------------------------------------- /teact/components/flexbox_item/flexbox_item_dimension.go: -------------------------------------------------------------------------------- 1 | package flexbox_item 2 | 3 | type FlexboxItemDimensionValue interface { 4 | // Given a min and a max, gets the corresponding size based on what FlexboxItemDimensionValue this is 5 | getSizeRetriever() func(min, max int) int 6 | } 7 | 8 | // Indicates a size == the minimum content size of the item, which: 9 | // - For width is the size of the item if all wrapping opportunities are taken (basically, the length of the longest word) 10 | // - For height is the height of the item when no word-wrapping is done 11 | var MinContent = &dimensionValueImpl{ 12 | sizeRetriever: func(min, max int) int { 13 | return min 14 | }, 15 | } 16 | 17 | // Indicates a size == the maximum content of the item, which is the size of the item without any wrapping applied 18 | // - For width, this is basically, the length of the longest line 19 | // - For height, this is the height of the item when the maximum possible word-wrapping is done 20 | var MaxContent = &dimensionValueImpl{ 21 | sizeRetriever: func(min, max int) int { 22 | return max 23 | }, 24 | } 25 | 26 | // Indicates a fixed size 27 | func FixedSize(size int) FlexboxItemDimensionValue { 28 | return &dimensionValueImpl{ 29 | sizeRetriever: func(min, max int) int { 30 | return size 31 | }, 32 | } 33 | } 34 | 35 | // ==================================================================================================== 36 | // 37 | // Private 38 | // 39 | // ==================================================================================================== 40 | // This type represents values for a flexbox item dimension (height or width) 41 | type dimensionValueImpl struct { 42 | // Given a min and a max, gets the corresponding size based on what FlexboxItemDimensionValue this is 43 | sizeRetriever func(min, max int) int 44 | } 45 | 46 | func (impl dimensionValueImpl) getSizeRetriever() func(min int, max int) int { 47 | return impl.sizeRetriever 48 | } 49 | -------------------------------------------------------------------------------- /teact/components/flexbox_item/flexbox_item_impl.go: -------------------------------------------------------------------------------- 1 | package flexbox_item 2 | 3 | import ( 4 | "fmt" 5 | "github.com/mieubrisse/teact/teact/components" 6 | "github.com/mieubrisse/teact/teact/utilities" 7 | ) 8 | 9 | type flexboxItemImpl struct { 10 | component components.Component 11 | 12 | // These determine how the item flexes 13 | // This is analogous to both "flex-basis" and "flex-grow", where: 14 | // - MaxAvailable indicates "flex-grow: >1" (see weight below) 15 | // - Anything else indicates "flex-grow: 0", and sets the "flex-basis" 16 | minWidth FlexboxItemDimensionValue 17 | maxWidth FlexboxItemDimensionValue 18 | minHeight FlexboxItemDimensionValue 19 | maxHeight FlexboxItemDimensionValue 20 | 21 | overflowStyle OverflowStyle 22 | 23 | // Analogous to "flex-grow" when on the main axis, and "align-items: stretch" when on the cross axis (on a per-item basis) 24 | horizontalGrowthFactor int 25 | 26 | // Analogous to "flex-grow" when on the main axis, and "align-items: stretch" when on the cross axis (on a per-item basis) 27 | verticalGrowthFactor int 28 | } 29 | 30 | // TODO add varargs Opts to make it easier to adjust 31 | func New(component components.Component, opts ...FlexboxItemOpt) FlexboxItem { 32 | result := &flexboxItemImpl{ 33 | component: component, 34 | // TODO move a lot of these out into its own class???? 35 | minWidth: MinContent, 36 | maxWidth: MaxContent, 37 | minHeight: MinContent, 38 | maxHeight: MaxContent, 39 | overflowStyle: Wrap, 40 | horizontalGrowthFactor: 0, 41 | verticalGrowthFactor: 0, 42 | } 43 | 44 | for _, opt := range opts { 45 | opt(result) 46 | } 47 | return result 48 | } 49 | 50 | func (item *flexboxItemImpl) GetContentMinMax() (minWidth int, maxWidth int, minHeight int, maxHeight int) { 51 | innerMinWidth, innerMaxWidth, innerMinHeight, innerMaxHeight := item.GetComponent().GetContentMinMax() 52 | itemMinWidth, itemMaxWidth, itemMinHeight, itemMaxHeight := calculateFlexboxItemContentSizesFromInnerContentSizes( 53 | innerMinWidth, 54 | innerMaxWidth, 55 | innerMinHeight, 56 | innerMaxHeight, 57 | item, 58 | ) 59 | 60 | return itemMinWidth, itemMaxWidth, itemMinHeight, itemMaxHeight 61 | } 62 | 63 | func (item *flexboxItemImpl) SetWidthAndGetDesiredHeight(width int) int { 64 | // TODO we're redoing this calculation when we've already done it - if we cache it, we'll save extra work 65 | return item.component.SetWidthAndGetDesiredHeight(width) 66 | } 67 | 68 | func (item *flexboxItemImpl) View(width int, height int) string { 69 | if width == 0 || height == 0 { 70 | return "" 71 | } 72 | 73 | component := item.GetComponent() 74 | 75 | var widthWhenRendering int 76 | switch item.GetOverflowStyle() { 77 | case Wrap: 78 | widthWhenRendering = width 79 | case Truncate: 80 | // If truncating, the child will _think_ they have infinite space available 81 | // and then we'll truncate them later 82 | _, maxWidth, _, _ := component.GetContentMinMax() 83 | widthWhenRendering = maxWidth 84 | default: 85 | panic(fmt.Sprintf("Unknown item overflow style: %v", item.GetOverflowStyle())) 86 | } 87 | 88 | // TODO allow column format 89 | result := component.View(widthWhenRendering, height) 90 | 91 | return utilities.Coerce(result, width, height) 92 | } 93 | 94 | func (item *flexboxItemImpl) GetComponent() components.Component { 95 | return item.component 96 | } 97 | 98 | func (item *flexboxItemImpl) GetMinWidth() FlexboxItemDimensionValue { 99 | return item.minWidth 100 | } 101 | 102 | func (item *flexboxItemImpl) SetMinWidth(min FlexboxItemDimensionValue) FlexboxItem { 103 | item.minWidth = min 104 | return item 105 | } 106 | 107 | func (item *flexboxItemImpl) GetMaxWidth() FlexboxItemDimensionValue { 108 | return item.maxWidth 109 | } 110 | 111 | func (item *flexboxItemImpl) SetMaxWidth(max FlexboxItemDimensionValue) FlexboxItem { 112 | item.maxWidth = max 113 | return item 114 | } 115 | 116 | func (item *flexboxItemImpl) GetMinHeight() FlexboxItemDimensionValue { 117 | return item.minHeight 118 | } 119 | 120 | func (item *flexboxItemImpl) SetMinHeight(min FlexboxItemDimensionValue) FlexboxItem { 121 | item.minHeight = min 122 | return item 123 | } 124 | 125 | func (item *flexboxItemImpl) GetMaxHeight() FlexboxItemDimensionValue { 126 | return item.maxHeight 127 | } 128 | 129 | func (item *flexboxItemImpl) SetMaxHeight(max FlexboxItemDimensionValue) FlexboxItem { 130 | item.maxHeight = max 131 | return item 132 | } 133 | 134 | func (item *flexboxItemImpl) GetOverflowStyle() OverflowStyle { 135 | return item.overflowStyle 136 | } 137 | 138 | func (item *flexboxItemImpl) SetOverflowStyle(style OverflowStyle) FlexboxItem { 139 | item.overflowStyle = style 140 | return item 141 | } 142 | 143 | func (item *flexboxItemImpl) GetHorizontalGrowthFactor() int { 144 | return item.horizontalGrowthFactor 145 | } 146 | 147 | func (item *flexboxItemImpl) SetHorizontalGrowthFactor(growFactor int) FlexboxItem { 148 | item.horizontalGrowthFactor = growFactor 149 | return item 150 | } 151 | 152 | func (item *flexboxItemImpl) GetVerticalGrowthFactor() int { 153 | return item.verticalGrowthFactor 154 | } 155 | 156 | func (item *flexboxItemImpl) SetVerticalGrowthFactor(growthFactor int) FlexboxItem { 157 | item.verticalGrowthFactor = growthFactor 158 | return item 159 | } 160 | 161 | // ==================================================================================================== 162 | // Private Helper Functions 163 | // ==================================================================================================== 164 | 165 | // Rescales an item's content size based on the per-item configuration the user has set 166 | // Max is guaranteed to be >= min 167 | func calculateFlexboxItemContentSizesFromInnerContentSizes( 168 | innerMinWidth, 169 | innertMaxWidth, 170 | innerMinHeight, 171 | innerMaxHeight int, 172 | item FlexboxItem, 173 | ) (itemMinWidth, itemMaxWidth, itemMinHeight, itemMaxHeight int) { 174 | itemMinWidth = item.GetMinWidth().getSizeRetriever()(innerMinWidth, innertMaxWidth) 175 | itemMaxWidth = item.GetMaxWidth().getSizeRetriever()(innerMinWidth, innertMaxWidth) 176 | 177 | if itemMaxWidth < itemMinWidth { 178 | itemMaxWidth = itemMinWidth 179 | } 180 | 181 | // TODO there's a very minor bug here where if we use a fixed-size width, the height min-content should go down 182 | // but it doesn't because we don't recalculate the actual height based on the actual width 183 | // The way to fix this is to figure out how extrinsic width/height settings (e.g. 60px, 20%, etc.) can be factored 184 | // into our calculations 185 | 186 | itemMinHeight = item.GetMinHeight().getSizeRetriever()(innerMinHeight, innerMaxHeight) 187 | itemMaxHeight = item.GetMaxHeight().getSizeRetriever()(innerMinHeight, innerMaxHeight) 188 | 189 | if itemMaxHeight < itemMinHeight { 190 | itemMaxHeight = itemMinHeight 191 | } 192 | 193 | return 194 | } 195 | -------------------------------------------------------------------------------- /teact/components/flexbox_item/flexbox_item_opts.go: -------------------------------------------------------------------------------- 1 | package flexbox_item 2 | 3 | // These are simply conveniences for the flexbox.NewWithContent , so that it's super easy to declare a single-item box 4 | type FlexboxItemOpt func(item FlexboxItem) 5 | 6 | func WithMinWidth(min FlexboxItemDimensionValue) FlexboxItemOpt { 7 | return func(item FlexboxItem) { 8 | item.SetMinWidth(min) 9 | } 10 | } 11 | 12 | func WithMaxWidth(max FlexboxItemDimensionValue) FlexboxItemOpt { 13 | return func(item FlexboxItem) { 14 | item.SetMaxWidth(max) 15 | } 16 | } 17 | 18 | func WithMinHeight(min FlexboxItemDimensionValue) FlexboxItemOpt { 19 | return func(item FlexboxItem) { 20 | item.SetMinHeight(min) 21 | } 22 | } 23 | 24 | func WithMaxHeight(max FlexboxItemDimensionValue) FlexboxItemOpt { 25 | return func(item FlexboxItem) { 26 | item.SetMaxHeight(max) 27 | } 28 | } 29 | 30 | func WithOverflowStyle(style OverflowStyle) FlexboxItemOpt { 31 | return func(item FlexboxItem) { 32 | item.SetOverflowStyle(style) 33 | } 34 | } 35 | 36 | func WithHorizontalGrowthFactor(growthFactor int) FlexboxItemOpt { 37 | return func(item FlexboxItem) { 38 | item.SetHorizontalGrowthFactor(growthFactor) 39 | } 40 | } 41 | 42 | func WithVerticalGrowthFactor(growthFactor int) FlexboxItemOpt { 43 | return func(item FlexboxItem) { 44 | item.SetVerticalGrowthFactor(growthFactor) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /teact/components/flexbox_item/flexbox_item_test.go: -------------------------------------------------------------------------------- 1 | package flexbox_item 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/component_test" 6 | "github.com/mieubrisse/teact/teact/components/stylebox" 7 | "github.com/mieubrisse/teact/teact/components/text" 8 | "testing" 9 | ) 10 | 11 | var inner = text.New("\nThis is a\nmultiline string\n\n") 12 | var innerMinWidth, innerMaxWidth, innerMinHeight, innerMaxHeight = inner.GetContentMinMax() 13 | var noChangeAssertion = component_test.RenderedContentAssertion{ 14 | Width: innerMaxWidth, 15 | Height: innerMinHeight, 16 | ExpectedContent: inner.View(innerMaxWidth, innerMaxHeight), 17 | } 18 | 19 | func TestBasic(t *testing.T) { 20 | component := New(inner) 21 | assertions := component_test.FlattenAssertionGroups( 22 | component_test.GetDefaultAssertions(), 23 | component_test.GetContentSizeAssertions(innerMinWidth, innerMaxWidth, innerMinHeight, innerMaxHeight), 24 | component_test.GetHeightAtWidthAssertions( 25 | 1, inner.SetWidthAndGetDesiredHeight(1), 26 | 2, inner.SetWidthAndGetDesiredHeight(2), 27 | 10, inner.SetWidthAndGetDesiredHeight(10), 28 | ), 29 | []component_test.ComponentAssertion{noChangeAssertion}, 30 | ) 31 | 32 | component_test.CheckAll(t, assertions, component) 33 | } 34 | 35 | func TestTruncate(t *testing.T) { 36 | component := New(inner).SetOverflowStyle(Truncate) 37 | assertions := component_test.FlattenAssertionGroups( 38 | component_test.GetRenderedContentAssertion(4, 4, " \nThis\nmult\n "), 39 | ) 40 | 41 | component_test.CheckAll(t, assertions, component) 42 | } 43 | 44 | func TestWrap(t *testing.T) { 45 | component := New(inner).SetOverflowStyle(Wrap) 46 | assertions := component_test.FlattenAssertionGroups( 47 | component_test.GetRenderedContentAssertion(4, 4, " \nThis\nis a\nmult"), 48 | ) 49 | 50 | component_test.CheckAll(t, assertions, component) 51 | } 52 | 53 | func TestStyleboxInside(t *testing.T) { 54 | contained := stylebox.New(text.New("This is child 2")). 55 | SetStyle(lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder())) 56 | component := New(contained) 57 | 58 | assertions := component_test.FlattenAssertionGroups( 59 | component_test.GetContentSizeAssertions(7, 17, 3, 6), 60 | ) 61 | 62 | component_test.CheckAll(t, assertions, component) 63 | 64 | } 65 | 66 | func TestFixedSize(t *testing.T) { 67 | fixedWidth := 60 68 | 69 | contained := text.New("A description of pizza") 70 | item := New(contained). 71 | SetMinWidth(FixedSize(fixedWidth)). 72 | SetMaxWidth(FixedSize(fixedWidth)) 73 | 74 | assertions := component_test.FlattenAssertionGroups( 75 | component_test.GetContentSizeAssertions(fixedWidth, fixedWidth, 1, 1), 76 | ) 77 | 78 | component_test.CheckAll(t, assertions, item) 79 | } 80 | -------------------------------------------------------------------------------- /teact/components/highlightable_list/highlightable_component.go: -------------------------------------------------------------------------------- 1 | package highlightable_list 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | ) 6 | 7 | type HighlightableComponent interface { 8 | components.Component 9 | 10 | IsHighlighted() bool 11 | SetHighlight(isHighlighted bool) HighlightableComponent 12 | } 13 | -------------------------------------------------------------------------------- /teact/components/highlightable_list/highlightable_list.go: -------------------------------------------------------------------------------- 1 | package highlightable_list 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | "github.com/mieubrisse/teact/teact/components/list" 6 | ) 7 | 8 | type HighlightableList[T HighlightableComponent] interface { 9 | list.List[T] 10 | components.InteractiveComponent 11 | 12 | GetHighlightedIdx() int 13 | SetHighlightedIdx(idx int) HighlightableList[T] 14 | // Scrolls the highlighted item, with safeguards to prevent scrolling off the end of the list 15 | Scroll(offset int) HighlightableList[T] 16 | 17 | // TODO something about keeping items highlighted when losing focus 18 | } 19 | -------------------------------------------------------------------------------- /teact/components/highlightable_list/highlightable_list_impl.go: -------------------------------------------------------------------------------- 1 | package highlightable_list 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/mieubrisse/teact/teact/components/list" 6 | "github.com/mieubrisse/teact/teact/utilities" 7 | ) 8 | 9 | type highlightableListImpl[T HighlightableComponent] struct { 10 | list.List[T] 11 | 12 | highlightedIdx int 13 | 14 | isFocused bool 15 | } 16 | 17 | func New[T HighlightableComponent]() HighlightableList[T] { 18 | return &highlightableListImpl[T]{ 19 | List: list.New[T](), 20 | } 21 | } 22 | 23 | func (impl *highlightableListImpl[T]) GetHighlightedIdx() int { 24 | return impl.highlightedIdx 25 | } 26 | 27 | func (impl *highlightableListImpl[T]) SetHighlightedIdx(newIdx int) HighlightableList[T] { 28 | if impl.highlightedIdx == newIdx { 29 | return impl 30 | } 31 | 32 | items := impl.List.GetItems() 33 | if len(items) == 0 { 34 | return impl 35 | } 36 | 37 | items[impl.highlightedIdx].SetHighlight(false) 38 | items[newIdx].SetHighlight(true) 39 | impl.highlightedIdx = newIdx 40 | return impl 41 | } 42 | 43 | func (impl *highlightableListImpl[T]) Scroll(offset int) HighlightableList[T] { 44 | newIdx := utilities.Clamp(impl.highlightedIdx+offset, 0, len(impl.List.GetItems())-1) 45 | impl.SetHighlightedIdx(newIdx) 46 | return impl 47 | } 48 | 49 | func (impl *highlightableListImpl[T]) SetItems(newItems []T) list.List[T] { 50 | items := impl.List.GetItems() 51 | if len(items) > 0 { 52 | items[impl.highlightedIdx].SetHighlight(false) 53 | } 54 | impl.List.SetItems(newItems) 55 | 56 | impl.highlightedIdx = 0 57 | if len(newItems) > 0 { 58 | newItems[impl.highlightedIdx].SetHighlight(true) 59 | } 60 | 61 | return impl 62 | } 63 | 64 | func (impl *highlightableListImpl[T]) Update(msg tea.Msg) tea.Cmd { 65 | if !impl.isFocused { 66 | return nil 67 | } 68 | 69 | // TDOO extract this to a helper (or embedded struct?) or something 70 | switch msg := msg.(type) { 71 | case tea.KeyMsg: 72 | switch msg.String() { 73 | case "ctrl+j", "down": 74 | impl.Scroll(1) 75 | return nil 76 | case "ctrl+k", "up": 77 | impl.Scroll(-1) 78 | return nil 79 | } 80 | } 81 | 82 | items := impl.GetItems() 83 | if len(items) == 0 { 84 | return nil 85 | } 86 | 87 | return utilities.TryUpdate(items[impl.highlightedIdx], msg) 88 | } 89 | 90 | func (impl *highlightableListImpl[T]) SetFocus(isFocused bool) tea.Cmd { 91 | impl.isFocused = isFocused 92 | 93 | items := impl.GetItems() 94 | if len(items) == 0 { 95 | return nil 96 | } 97 | 98 | items[impl.highlightedIdx].SetHighlight(isFocused) 99 | return nil 100 | } 101 | 102 | func (impl *highlightableListImpl[T]) IsFocused() bool { 103 | return impl.isFocused 104 | } 105 | -------------------------------------------------------------------------------- /teact/components/interactive_component.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import tea "github.com/charmbracelet/bubbletea" 4 | 5 | type InteractiveComponent interface { 6 | Component 7 | 8 | // Update updates the model based on the given message 9 | // We do this by-reference, because by-value is just too messy (see README of this repo) 10 | // BLUF: you get into weird situations with generic interfaces 11 | Update(msg tea.Msg) tea.Cmd 12 | 13 | SetFocus(isFocused bool) tea.Cmd 14 | IsFocused() bool 15 | } 16 | -------------------------------------------------------------------------------- /teact/components/list/list.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | "github.com/mieubrisse/teact/teact/components/flexbox" 6 | ) 7 | 8 | // Very simple container around a vertically-oriented flexbox 9 | type List[T components.Component] interface { 10 | flexbox.Flexbox 11 | 12 | GetItems() []T 13 | SetItems(items []T) List[T] 14 | } 15 | -------------------------------------------------------------------------------- /teact/components/list/list_impl.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/components" 5 | "github.com/mieubrisse/teact/teact/components/flexbox" 6 | "github.com/mieubrisse/teact/teact/components/flexbox_item" 7 | ) 8 | 9 | type impl[T components.Component] struct { 10 | flexbox.Flexbox 11 | 12 | items []T 13 | } 14 | 15 | func New[T components.Component]() List[T] { 16 | root := flexbox.New().SetDirection(flexbox.Column) 17 | return &impl[T]{ 18 | items: []T{}, 19 | Flexbox: root, 20 | } 21 | } 22 | 23 | func NewWithContents[T components.Component](contents ...T) List[T] { 24 | root := flexbox.New().SetDirection(flexbox.Column) 25 | return &impl[T]{ 26 | items: contents, 27 | Flexbox: root, 28 | } 29 | } 30 | 31 | func (i impl[T]) GetItems() []T { 32 | return i.items 33 | } 34 | 35 | func (i *impl[T]) SetItems(items []T) List[T] { 36 | i.items = items 37 | 38 | flexboxItems := make([]flexbox_item.FlexboxItem, len(items)) 39 | for idx, item := range items { 40 | flexboxItems[idx] = flexbox_item.New(item).SetHorizontalGrowthFactor(1) 41 | } 42 | i.Flexbox.SetChildren(flexboxItems) 43 | 44 | return i 45 | } 46 | 47 | func (i impl[T]) GetContentMinMax() (minWidth, maxWidth, minHeight, maxHeight int) { 48 | return i.Flexbox.GetContentMinMax() 49 | } 50 | 51 | func (i impl[T]) SetWidthAndGetDesiredHeight(width int) int { 52 | return i.Flexbox.SetWidthAndGetDesiredHeight(width) 53 | } 54 | 55 | func (i impl[T]) View(width int, height int) string { 56 | return i.Flexbox.View(width, height) 57 | } 58 | -------------------------------------------------------------------------------- /teact/components/stylebox/stylebox.go: -------------------------------------------------------------------------------- 1 | package stylebox 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/components" 6 | ) 7 | 8 | // Stylebox is a box explicitly for controlling an element's style 9 | // No other elements control style; this is intentional so that 10 | // it's clear where exactly style is getting changed 11 | type Stylebox interface { 12 | components.Component 13 | 14 | GetStyle() lipgloss.Style 15 | // NOTE: all layout-affecting properties (height, width, alignment, margin, inline) are ignored 16 | // The only layout-affecting property left in place are border and padding 17 | SetStyle(style lipgloss.Style) Stylebox 18 | } 19 | -------------------------------------------------------------------------------- /teact/components/stylebox/stylebox_impl.go: -------------------------------------------------------------------------------- 1 | package stylebox 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/components" 6 | "github.com/mieubrisse/teact/teact/utilities" 7 | ) 8 | 9 | type styleboxImpl struct { 10 | component components.Component 11 | 12 | style lipgloss.Style 13 | } 14 | 15 | func New(component components.Component, opts ...StyleboxOpt) Stylebox { 16 | result := &styleboxImpl{ 17 | component: component, 18 | style: lipgloss.NewStyle(), 19 | } 20 | for _, opt := range opts { 21 | opt(result) 22 | } 23 | return result 24 | } 25 | 26 | func (s styleboxImpl) GetStyle() lipgloss.Style { 27 | return s.style 28 | } 29 | 30 | func (s *styleboxImpl) SetStyle(style lipgloss.Style) Stylebox { 31 | s.style = style.Copy(). 32 | UnsetMargins(). 33 | UnsetAlign(). 34 | UnsetAlignHorizontal(). 35 | UnsetAlignVertical(). 36 | UnsetWidth(). 37 | UnsetMaxWidth(). 38 | UnsetHeight(). 39 | UnsetMaxHeight(). 40 | UnsetInline() 41 | return s 42 | } 43 | 44 | func (s styleboxImpl) GetContentMinMax() (minWidth, maxWidth, minHeight, maxHeight int) { 45 | // TODO cache the results? 46 | innerMinWidth, innerMaxWidth, innerMinHeight, innerMaxHeight := s.component.GetContentMinMax() 47 | 48 | minWidth = innerMinWidth + s.style.GetHorizontalFrameSize() 49 | maxWidth = innerMaxWidth + s.style.GetHorizontalFrameSize() 50 | 51 | minHeight = innerMinHeight + s.style.GetVerticalFrameSize() 52 | maxHeight = innerMaxHeight + s.style.GetVerticalFrameSize() 53 | return 54 | } 55 | 56 | func (s styleboxImpl) SetWidthAndGetDesiredHeight(width int) int { 57 | innerWidth := utilities.GetMaxInt(0, width-s.style.GetHorizontalFrameSize()) 58 | return s.component.SetWidthAndGetDesiredHeight(innerWidth) + s.style.GetVerticalFrameSize() 59 | } 60 | 61 | func (s styleboxImpl) View(width int, height int) string { 62 | if width == 0 || height == 0 { 63 | return "" 64 | } 65 | 66 | innerWidth := utilities.GetMaxInt(0, width-s.style.GetHorizontalFrameSize()) 67 | innerHeight := utilities.GetMaxInt(0, height-s.style.GetVerticalFrameSize()) 68 | innerStr := s.component.View(innerWidth, innerHeight) 69 | 70 | // First truncate to ensure none of the children have overflowed 71 | truncatedInnerStr := lipgloss.NewStyle(). 72 | MaxWidth(innerWidth). 73 | MaxHeight(innerHeight). 74 | Render(innerStr) 75 | 76 | // Then expand the child to the right height & width (in case the child is erroneously a smaller block) 77 | expandedInnerStr := lipgloss.NewStyle().Width(innerWidth).Height(innerHeight).Render(truncatedInnerStr) 78 | 79 | // Apply our styles... 80 | styled := s.style.Render(expandedInnerStr) 81 | 82 | // ...and then truncate down again in case our styles caused an exceeding of the box 83 | result := lipgloss.NewStyle(). 84 | MaxWidth(width). 85 | MaxHeight(height). 86 | Render(styled) 87 | 88 | return result 89 | } 90 | -------------------------------------------------------------------------------- /teact/components/stylebox/stylebox_opts.go: -------------------------------------------------------------------------------- 1 | package stylebox 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/utilities" 6 | ) 7 | 8 | type StyleboxOpt func(Stylebox) 9 | 10 | // Convenience function for styling the stylebox with a new lipgloss.Style 11 | func WithStyle(styleOpts ...utilities.StyleOpt) StyleboxOpt { 12 | return func(box Stylebox) { 13 | newStyle := lipgloss.NewStyle() 14 | for _, opt := range styleOpts { 15 | newStyle = opt(newStyle) 16 | } 17 | box.SetStyle(newStyle) 18 | } 19 | } 20 | 21 | // Set the stylebox's style to an existing lipgloss.Style 22 | func WithExistingStyle(newStyle lipgloss.Style) StyleboxOpt { 23 | return func(box Stylebox) { 24 | box.SetStyle(newStyle) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /teact/components/stylebox/stylebox_test.go: -------------------------------------------------------------------------------- 1 | package stylebox 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/component_test" 6 | "github.com/mieubrisse/teact/teact/components/text" 7 | "testing" 8 | ) 9 | 10 | var inner = text.New("\nThis is a\nmultiline string\n\n") 11 | var innerMinWidth, innerMaxWidth, innerMinHeight, innerMaxHeight = inner.GetContentMinMax() 12 | var noChangeAssertion = component_test.GetRenderedContentAssertion( 13 | innerMaxWidth, 14 | innerMinHeight, 15 | inner.View(innerMaxWidth, innerMaxHeight), 16 | ) 17 | 18 | func TestUnstyled(t *testing.T) { 19 | component := New(inner) 20 | 21 | assertions := component_test.FlattenAssertionGroups( 22 | component_test.GetDefaultAssertions(), 23 | component_test.GetContentSizeAssertions(innerMinWidth, innerMaxWidth, innerMinHeight, innerMaxHeight), 24 | noChangeAssertion, 25 | ) 26 | component_test.CheckAll( 27 | t, 28 | assertions, 29 | component, 30 | ) 31 | } 32 | 33 | func TestPadding(t *testing.T) { 34 | // Even padding 35 | padding := 2 36 | component := New(inner).SetStyle(lipgloss.NewStyle().Padding(padding)) 37 | 38 | assertions := component_test.FlattenAssertionGroups( 39 | component_test.GetContentSizeAssertions( 40 | 2*padding+innerMinWidth, 41 | 2*padding+innerMaxWidth, 42 | 2*padding+innerMinHeight, 43 | 2*padding+innerMaxHeight, 44 | ), 45 | // Should be only padding when there's no place for content 46 | component_test.GetRenderedContentAssertion(3, 3, " \n \n "), 47 | component_test.GetRenderedContentAssertion(5, 6, " \n \n \n T \n \n "), 48 | ) 49 | 50 | component_test.CheckAll( 51 | t, 52 | assertions, 53 | component, 54 | ) 55 | } 56 | 57 | func TestBorder(t *testing.T) { 58 | style := lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()) 59 | component := New(inner).SetStyle(style) 60 | 61 | assertions := component_test.FlattenAssertionGroups( 62 | component_test.GetContentSizeAssertions( 63 | innerMinWidth+style.GetHorizontalBorderSize(), 64 | innerMaxWidth+style.GetHorizontalBorderSize(), 65 | innerMinHeight+style.GetVerticalBorderSize(), 66 | innerMaxHeight+style.GetVerticalBorderSize(), 67 | ), 68 | component_test.GetHeightAtWidthAssertions( 69 | innerMaxWidth+style.GetVerticalBorderSize(), 70 | innerMinHeight+style.GetVerticalBorderSize(), 71 | ), 72 | component_test.GetHeightAtWidthAssertions( 73 | innerMinWidth+style.GetVerticalBorderSize(), 74 | innerMaxHeight+style.GetVerticalBorderSize(), 75 | ), 76 | ) 77 | 78 | component_test.CheckAll( 79 | t, 80 | assertions, 81 | component, 82 | ) 83 | } 84 | 85 | func TestColorStylesMaintainSize(t *testing.T) { 86 | styles := []lipgloss.Style{ 87 | lipgloss.NewStyle(), 88 | lipgloss.NewStyle(). 89 | Foreground(lipgloss.Color("#FF22FF")). 90 | Background(lipgloss.Color("#333333")). 91 | Bold(true). 92 | Faint(true). 93 | Blink(true). 94 | UnderlineSpaces(true). 95 | Underline(true). 96 | Italic(true). 97 | ColorWhitespace(true), 98 | } 99 | 100 | for _, style := range styles { 101 | component := New(inner).SetStyle(style) 102 | component_test.CheckAll(t, noChangeAssertion, component) 103 | } 104 | } 105 | 106 | func TestProhibitedStylesAreRemoved(t *testing.T) { 107 | prohibitedStyles := []lipgloss.Style{ 108 | lipgloss.NewStyle().Margin(2), 109 | lipgloss.NewStyle().Align(lipgloss.Center), 110 | lipgloss.NewStyle().AlignHorizontal(lipgloss.Center), 111 | lipgloss.NewStyle().AlignVertical(lipgloss.Center), 112 | lipgloss.NewStyle().Width(1), 113 | lipgloss.NewStyle().MaxWidth(1), 114 | lipgloss.NewStyle().Height(1), 115 | lipgloss.NewStyle().MaxHeight(1), 116 | lipgloss.NewStyle().Inline(true), 117 | } 118 | 119 | for _, style := range prohibitedStyles { 120 | component := New(inner).SetStyle(style) 121 | component_test.CheckAll(t, noChangeAssertion, component) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /teact/components/text/text.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/components" 6 | ) 7 | 8 | type TextAlignment lipgloss.Position 9 | 10 | const ( 11 | AlignLeft = TextAlignment(lipgloss.Left) 12 | AlignCenter = TextAlignment(lipgloss.Center) 13 | AlignRight = TextAlignment(lipgloss.Right) 14 | ) 15 | 16 | // Analogous to the

    tag in HTML 17 | type Text interface { 18 | components.Component 19 | 20 | GetContents() string 21 | SetContents(str string) Text 22 | 23 | GetTextAlignment() TextAlignment 24 | SetTextAlignment(alignment TextAlignment) Text 25 | } 26 | -------------------------------------------------------------------------------- /teact/components/text/text_impl.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | "github.com/mieubrisse/teact/teact/utilities" 6 | "github.com/muesli/reflow/ansi" 7 | "github.com/muesli/reflow/wordwrap" 8 | "strings" 9 | ) 10 | 11 | type textImpl struct { 12 | contents string 13 | 14 | alignment TextAlignment 15 | } 16 | 17 | func New(contents string, opts ...TextOpt) Text { 18 | result := &textImpl{ 19 | contents: contents, 20 | alignment: AlignLeft, 21 | } 22 | for _, opt := range opts { 23 | opt(result) 24 | } 25 | return result 26 | } 27 | 28 | func (t textImpl) GetContents() string { 29 | return t.contents 30 | } 31 | 32 | func (t *textImpl) SetContents(str string) Text { 33 | t.contents = str 34 | return t 35 | } 36 | 37 | func (t textImpl) GetTextAlignment() TextAlignment { 38 | return t.alignment 39 | } 40 | 41 | func (t *textImpl) SetTextAlignment(align TextAlignment) Text { 42 | t.alignment = align 43 | return t 44 | } 45 | 46 | func (t *textImpl) GetContentMinMax() (minWidth int, maxWidth int, minHeight int, maxHeight int) { 47 | minWidth = 0 48 | for _, field := range strings.Fields(t.contents) { 49 | printableWidth := ansi.PrintableRuneWidth(field) 50 | if printableWidth > minWidth { 51 | minWidth = printableWidth 52 | } 53 | } 54 | 55 | maxWidth = lipgloss.Width(t.contents) 56 | 57 | maxHeight = t.SetWidthAndGetDesiredHeight(minWidth) 58 | minHeight = t.SetWidthAndGetDesiredHeight(maxWidth) 59 | 60 | return 61 | } 62 | 63 | func (t textImpl) SetWidthAndGetDesiredHeight(width int) int { 64 | if width == 0 { 65 | return 0 66 | } 67 | 68 | // TODO cache this? 69 | wrapped := wordwrap.String(t.contents, width) 70 | return lipgloss.Height(wrapped) 71 | } 72 | 73 | func (t textImpl) View(width int, height int) string { 74 | if width == 0 || height == 0 { 75 | return "" 76 | } 77 | 78 | result := wordwrap.String(t.contents, width) 79 | 80 | // Ensure we have a string no more than max (though it may still be short) 81 | result = lipgloss.NewStyle().MaxWidth(width).MaxHeight(height).Render(result) 82 | 83 | // Now align (the string may still be short) 84 | result = lipgloss.NewStyle().Align(lipgloss.Position(t.alignment)).Render(result) 85 | 86 | // Place in the correct location 87 | result = lipgloss.PlaceHorizontal(width, lipgloss.Position(t.alignment), result) 88 | 89 | // Finally, coerce to expand if necessary 90 | result = utilities.Coerce(result, width, height) 91 | 92 | return result 93 | } 94 | 95 | // ==================================================================================================== 96 | // Private Helper Functions 97 | // ==================================================================================================== 98 | -------------------------------------------------------------------------------- /teact/components/text/text_opts.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | type TextOpt func(Text) 4 | 5 | func WithAlign(align TextAlignment) TextOpt { 6 | return func(text Text) { 7 | text.SetTextAlignment(align) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /teact/components/text/text_test.go: -------------------------------------------------------------------------------- 1 | package text 2 | 3 | import ( 4 | "github.com/mieubrisse/teact/teact/component_test" 5 | "testing" 6 | ) 7 | 8 | func TestShortString(t *testing.T) { 9 | str := "This is a short string" 10 | minWidth := 6 11 | maxWidth := 22 12 | minHeight := 1 13 | maxHeight := 4 14 | 15 | sizeAssertions := component_test.FlattenAssertionGroups( 16 | component_test.GetDefaultAssertions(), 17 | component_test.GetContentSizeAssertions(minWidth, maxWidth, minHeight, maxHeight), 18 | component_test.GetHeightAtWidthAssertions( 19 | minWidth, maxHeight, // min content width 20 | 8, 3, // in the middle 21 | maxWidth, minHeight, // max content width 22 | 100, minHeight, // beyond max content width 23 | ), 24 | ) 25 | 26 | // Verify that the size assertions are valid at all alignments 27 | for _, alignment := range []TextAlignment{AlignLeft, AlignCenter, AlignRight} { 28 | component := New(str).SetTextAlignment(alignment) 29 | component_test.CheckAll(t, sizeAssertions, component) 30 | } 31 | } 32 | 33 | func TestStringWithNewlines(t *testing.T) { 34 | str := "This is the first line\nHere's a second\nAnd a third" 35 | minWidth := 6 36 | maxWidth := 22 37 | minHeight := 3 38 | maxHeight := 9 39 | 40 | sizeAssertions := component_test.FlattenAssertionGroups( 41 | component_test.GetDefaultAssertions(), 42 | component_test.GetContentSizeAssertions(minWidth, maxWidth, minHeight, maxHeight), 43 | component_test.GetHeightAtWidthAssertions( 44 | 0, 0, // invisible 45 | minWidth, maxHeight, // min content width 46 | 10, 7, // in the middle 47 | maxWidth, minHeight, // max content width 48 | 100, minHeight, // beyond max content width 49 | ), 50 | ) 51 | 52 | // Verify that the size assertions are valid at all alignments 53 | for _, alignment := range []TextAlignment{AlignLeft, AlignCenter, AlignRight} { 54 | component := New(str).SetTextAlignment(alignment) 55 | component_test.CheckAll(t, sizeAssertions, component) 56 | } 57 | } 58 | 59 | func TestInvisibleString(t *testing.T) { 60 | str := "" 61 | 62 | sizeAssertions := component_test.FlattenAssertionGroups( 63 | component_test.GetDefaultAssertions(), 64 | component_test.GetContentSizeAssertions(0, 0, 1, 1), 65 | component_test.GetHeightAtWidthAssertions( 66 | 0, 1, 67 | 1, 1, 68 | 10, 1, 69 | ), 70 | ) 71 | 72 | component_test.CheckAll(t, sizeAssertions, New(str)) 73 | } 74 | 75 | func TestSmallWidths(t *testing.T) { 76 | text := "\nThis is a\nmultiline string\n\n" 77 | component := New(text) 78 | 79 | assertions := component_test.FlattenAssertionGroups( 80 | component_test.GetRenderedContentAssertion(2, 2, " \nTh"), 81 | component_test.GetRenderedContentAssertion(2, 5, " \nTh\nis\nis\na "), 82 | component_test.GetRenderedContentAssertion(5, 5, " \nThis \nis a \nmulti\nline "), 83 | ) 84 | 85 | component_test.CheckAll(t, assertions, component) 86 | } 87 | -------------------------------------------------------------------------------- /teact/components/text_input/text_input.go: -------------------------------------------------------------------------------- 1 | package text_input 2 | 3 | import "github.com/mieubrisse/teact/teact/components" 4 | 5 | type TextInput interface { 6 | components.InteractiveComponent 7 | 8 | GetValue() string 9 | SetValue(value string) TextInput 10 | } 11 | -------------------------------------------------------------------------------- /teact/components/text_input/text_input_impl.go: -------------------------------------------------------------------------------- 1 | package text_input 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/textinput" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/mieubrisse/teact/teact/utilities" 8 | "strings" 9 | ) 10 | 11 | type textInputImpl struct { 12 | innerInput textinput.Model 13 | 14 | isFocused bool 15 | } 16 | 17 | func New(opts ...TextInputOpt) TextInput { 18 | innerInput := textinput.New() 19 | innerInput.Prompt = "" 20 | result := &textInputImpl{ 21 | innerInput: innerInput, 22 | isFocused: false, 23 | } 24 | for _, opt := range opts { 25 | opt(result) 26 | } 27 | return result 28 | } 29 | 30 | func (i *textInputImpl) GetContentMinMax() (int, int, int, int) { 31 | value := i.innerInput.Value() 32 | 33 | maxWidth := lipgloss.Width(value) 34 | minWidth := 0 35 | for _, field := range strings.Fields(value) { 36 | minWidth = utilities.GetMaxInt(minWidth, lipgloss.Width(field)) 37 | } 38 | 39 | // Add one to each to account for the cursor 40 | minWidth += 1 41 | maxWidth += 1 42 | 43 | return minWidth, maxWidth, 1, 1 44 | } 45 | 46 | func (i *textInputImpl) SetWidthAndGetDesiredHeight(actualWidth int) int { 47 | i.innerInput.Width = actualWidth 48 | return 1 49 | } 50 | 51 | func (i *textInputImpl) View(actualWidth int, actualHeight int) string { 52 | innerView := i.innerInput.View() 53 | 54 | return utilities.Coerce(innerView, actualWidth, actualHeight) 55 | } 56 | 57 | func (i *textInputImpl) Update(msg tea.Msg) tea.Cmd { 58 | if !i.isFocused { 59 | return nil 60 | } 61 | 62 | var cmd tea.Cmd 63 | i.innerInput, cmd = i.innerInput.Update(msg) 64 | return cmd 65 | } 66 | 67 | func (i *textInputImpl) SetFocus(isFocused bool) tea.Cmd { 68 | i.isFocused = isFocused 69 | if isFocused { 70 | return i.innerInput.Focus() 71 | } 72 | i.innerInput.Blur() 73 | return nil 74 | } 75 | 76 | func (i *textInputImpl) IsFocused() bool { 77 | return i.isFocused 78 | } 79 | 80 | func (i *textInputImpl) GetValue() string { 81 | return i.innerInput.Value() 82 | } 83 | 84 | func (i *textInputImpl) SetValue(value string) TextInput { 85 | i.innerInput.SetValue(value) 86 | return i 87 | } 88 | -------------------------------------------------------------------------------- /teact/components/text_input/text_input_opts.go: -------------------------------------------------------------------------------- 1 | package text_input 2 | 3 | type TextInputOpt func(input TextInput) 4 | 5 | func WithFocus(isFocused bool) TextInputOpt { 6 | return func(input TextInput) { 7 | input.SetFocus(isFocused) 8 | } 9 | } 10 | 11 | func WithValue(value string) TextInputOpt { 12 | return func(input TextInput) { 13 | input.SetValue(value) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /teact/run.go: -------------------------------------------------------------------------------- 1 | package teact 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/mieubrisse/teact/teact/components" 6 | ) 7 | 8 | // Simple way to run a Teact program 9 | // If you need more complex configuration, use RunTeactFromModel 10 | func Run[T components.Component]( 11 | yourApp T, 12 | bubbleTeaOpts ...tea.ProgramOption, 13 | ) (T, error) { 14 | model := New(yourApp) 15 | finalModel, err := tea.NewProgram(model, bubbleTeaOpts...).Run() 16 | castedModel := finalModel.(TeactModel) 17 | castedUserComponent := castedModel.GetInnerComponent().(T) 18 | return castedUserComponent, err 19 | } 20 | -------------------------------------------------------------------------------- /teact/teact_model.go: -------------------------------------------------------------------------------- 1 | package teact 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/mieubrisse/teact/teact/components" 6 | "github.com/mieubrisse/teact/teact/components/flexbox" 7 | "github.com/mieubrisse/teact/teact/components/flexbox_item" 8 | "github.com/mieubrisse/teact/teact/utilities" 9 | ) 10 | 11 | // The root tea.Model that runs the Teact framework 12 | type TeactModel interface { 13 | tea.Model 14 | 15 | // The user's component being controlled as the root of this Teact app 16 | GetInnerComponent() components.Component 17 | 18 | // "Set" of quit sequences 19 | GetQuitSequences() map[string]bool 20 | SetQuitSequences(sequences map[string]bool) TeactModel 21 | } 22 | 23 | var defaultQuitSequenceSet = map[string]bool{ 24 | "ctrl+c": true, 25 | "ctrl+d": true, 26 | } 27 | 28 | type teactAppModelImpl struct { 29 | // The tea.Cmd that will be fired upon initialization 30 | initCmd tea.Cmd 31 | 32 | // Sequences matching String() of tea.KeyMsg that will quit the program 33 | quitSequenceSet map[string]bool 34 | 35 | appBox components.Component 36 | 37 | app components.Component 38 | 39 | width int 40 | height int 41 | } 42 | 43 | // New creates a new tea.Model for tea.NewProgram running the given components.Component 44 | func New[T components.Component](app T, options ...TeactModelOpt) TeactModel { 45 | // We put the user's app in a box here so that we can get their app auto-resizing with the terminal 46 | appBox := flexbox.New( 47 | flexbox_item.New( 48 | app, 49 | flexbox_item.WithHorizontalGrowthFactor(1), 50 | flexbox_item.WithVerticalGrowthFactor(1), 51 | ), 52 | ) 53 | result := &teactAppModelImpl{ 54 | initCmd: nil, 55 | quitSequenceSet: defaultQuitSequenceSet, 56 | appBox: appBox, 57 | app: app, 58 | width: 0, 59 | height: 0, 60 | } 61 | for _, opt := range options { 62 | opt(result) 63 | } 64 | return result 65 | } 66 | 67 | func (impl teactAppModelImpl) Init() tea.Cmd { 68 | return impl.initCmd 69 | } 70 | 71 | func (impl *teactAppModelImpl) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 72 | switch msg := msg.(type) { 73 | case tea.KeyMsg: 74 | if _, found := impl.quitSequenceSet[msg.String()]; found { 75 | return impl, tea.Quit 76 | 77 | } 78 | case tea.WindowSizeMsg: 79 | impl.width = msg.Width 80 | impl.height = msg.Height 81 | return impl, nil 82 | } 83 | 84 | // Pass the message down to the app, if it's interactive 85 | cmd := utilities.TryUpdate(impl.app, msg) 86 | 87 | return impl, cmd 88 | } 89 | 90 | func (impl *teactAppModelImpl) View() string { 91 | // We call these without using the results because: 92 | // 1) this is the three-phase cycle of our component rendering 93 | // 2) some components do caching of the phases, so to kick the cycle off we want to make sure we call them all 94 | impl.appBox.GetContentMinMax() 95 | impl.appBox.SetWidthAndGetDesiredHeight(impl.width) 96 | return impl.appBox.View(impl.width, impl.height) 97 | } 98 | 99 | func (impl teactAppModelImpl) GetQuitSequences() map[string]bool { 100 | return impl.quitSequenceSet 101 | } 102 | 103 | func (impl *teactAppModelImpl) SetQuitSequences(sequences map[string]bool) TeactModel { 104 | impl.quitSequenceSet = sequences 105 | return impl 106 | } 107 | 108 | func (impl teactAppModelImpl) GetInnerComponent() components.Component { 109 | return impl.app 110 | } 111 | -------------------------------------------------------------------------------- /teact/teact_model_opts.go: -------------------------------------------------------------------------------- 1 | package teact 2 | 3 | import tea "github.com/charmbracelet/bubbletea" 4 | 5 | type TeactModelOpt func(*teactAppModelImpl) 6 | 7 | func WithInitCmd(cmd tea.Cmd) TeactModelOpt { 8 | return func(model *teactAppModelImpl) { 9 | model.initCmd = cmd 10 | } 11 | } 12 | 13 | func WithQuitSequences(quitSequenceSet map[string]bool) TeactModelOpt { 14 | return func(model *teactAppModelImpl) { 15 | model.quitSequenceSet = quitSequenceSet 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /teact/utilities/layout_utils.go: -------------------------------------------------------------------------------- 1 | package utilities 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | func GetMaxInt(a, b int) int { 6 | if a > b { 7 | return a 8 | } 9 | return b 10 | } 11 | 12 | func GetMinInt(a, b int) int { 13 | if a < b { 14 | return a 15 | } 16 | return b 17 | } 18 | 19 | func Clamp(value, low, high int) int { 20 | if high < low { 21 | low, high = high, low 22 | } 23 | return GetMinInt(high, GetMaxInt(low, value)) 24 | } 25 | 26 | // Distributes the space (which can be negative) across the children, using the weight as a bias for how to allocate 27 | // The only scenario where no space will be distributed is if there is no total weight 28 | // If the space does get distributed, it's guaranteed to be done exactly (no more or less will remain) 29 | func DistributeSpaceByWeight(spaceToAllocate int, inputSizes []int, weights []int) []int { 30 | result := make([]int, len(inputSizes)) 31 | for idx, inputSize := range inputSizes { 32 | result[idx] = inputSize 33 | } 34 | 35 | totalWeight := 0 36 | for _, weight := range weights { 37 | totalWeight += weight 38 | } 39 | 40 | // watch out for divide-by-zero 41 | if totalWeight == 0 { 42 | return result 43 | } 44 | 45 | desiredSpaceAllocated := float64(0) 46 | actualSpaceAllocated := 0 47 | for idx, size := range inputSizes { 48 | result[idx] = size 49 | 50 | // Dump any remaining space for the last item (it should always be at most 1 51 | // in any direction) 52 | if idx == len(inputSizes)-1 { 53 | result[idx] += spaceToAllocate - actualSpaceAllocated 54 | break 55 | } 56 | 57 | weight := weights[idx] 58 | share := float64(weight) / float64(totalWeight) 59 | 60 | // Because we can only display lines in integer numbers, but flexing 61 | // will yield float scale ratios, no matter what space we give each item 62 | // our integer value will always be off from the float value 63 | // This algorithm is to ensure that we're always rounding in the direction 64 | // that pushes us closer to our desired allocation (rather than naively rounding up or down) 65 | desiredSpaceForItem := share * float64(spaceToAllocate) 66 | var actualSpaceForItem int 67 | if desiredSpaceAllocated < float64(actualSpaceAllocated) { 68 | // If we're under our desired allocation, round up to try and get closer 69 | actualSpaceForItem = int(desiredSpaceForItem + 1) 70 | } else { 71 | // If we're at or over our desired allocation, round down (so we either stay there or get closer by undershooting) 72 | actualSpaceForItem = int(desiredSpaceForItem) 73 | } 74 | 75 | result[idx] += actualSpaceForItem 76 | desiredSpaceAllocated += desiredSpaceForItem 77 | actualSpaceAllocated += actualSpaceForItem 78 | } 79 | 80 | return result 81 | } 82 | 83 | // Procrustean coersion to make the given string exactly fit the dimensions we want 84 | func Coerce(str string, actualWidth int, actualHeight int) string { 85 | // Truncate, in case we're too long 86 | truncated := lipgloss.NewStyle().MaxWidth(actualWidth).MaxHeight(actualHeight).Render(str) 87 | 88 | // Expand to fill (in case we're small) 89 | return lipgloss.NewStyle().Width(actualWidth).Height(actualHeight).Render(truncated) 90 | } 91 | -------------------------------------------------------------------------------- /teact/utilities/style.go: -------------------------------------------------------------------------------- 1 | package utilities 2 | 3 | import "github.com/charmbracelet/lipgloss" 4 | 5 | // Contains wrappers around Lipgloss' style, to turn Lipgloss' long, fluent methods into more concise ones 6 | 7 | type StyleOpt func(style lipgloss.Style) lipgloss.Style 8 | 9 | func WithForeground(color lipgloss.Color) StyleOpt { 10 | return func(style lipgloss.Style) lipgloss.Style { 11 | return style.Foreground(color) 12 | } 13 | } 14 | 15 | func WithBackground(color lipgloss.Color) StyleOpt { 16 | return func(style lipgloss.Style) lipgloss.Style { 17 | return style.Background(color) 18 | } 19 | } 20 | 21 | func WithBold(isBold bool) StyleOpt { 22 | return func(style lipgloss.Style) lipgloss.Style { 23 | return style.Bold(isBold) 24 | } 25 | } 26 | 27 | func WithItalic(isItalic bool) StyleOpt { 28 | return func(style lipgloss.Style) lipgloss.Style { 29 | return style.Italic(isItalic) 30 | } 31 | } 32 | 33 | func WithUnderline(isUnderline bool) StyleOpt { 34 | return func(style lipgloss.Style) lipgloss.Style { 35 | return style.Underline(isUnderline) 36 | } 37 | } 38 | 39 | func WithUnderlineSpaces(isUnderlineSpaces bool) StyleOpt { 40 | return func(style lipgloss.Style) lipgloss.Style { 41 | return style.UnderlineSpaces(isUnderlineSpaces) 42 | } 43 | } 44 | 45 | func WithStrikethrough(isStrikethrough bool) StyleOpt { 46 | return func(style lipgloss.Style) lipgloss.Style { 47 | return style.Strikethrough(isStrikethrough) 48 | } 49 | } 50 | 51 | func WithStrikethroughSpaces(isStrikethroughSpaces bool) StyleOpt { 52 | return func(style lipgloss.Style) lipgloss.Style { 53 | return style.StrikethroughSpaces(isStrikethroughSpaces) 54 | } 55 | } 56 | 57 | func WithFaint(isFaint bool) StyleOpt { 58 | return func(style lipgloss.Style) lipgloss.Style { 59 | return style.Faint(isFaint) 60 | } 61 | } 62 | 63 | func WithBlink(isBlink bool) StyleOpt { 64 | return func(style lipgloss.Style) lipgloss.Style { 65 | return style.Blink(isBlink) 66 | } 67 | } 68 | 69 | func WithBorder(border lipgloss.Border, sides ...bool) StyleOpt { 70 | return func(style lipgloss.Style) lipgloss.Style { 71 | return style.Border(border, sides...) 72 | } 73 | } 74 | 75 | func WithPadding(padding ...int) StyleOpt { 76 | return func(style lipgloss.Style) lipgloss.Style { 77 | return style.Padding(padding...) 78 | } 79 | } 80 | 81 | func NewStyle(opts ...StyleOpt) lipgloss.Style { 82 | result := lipgloss.NewStyle() 83 | for _, opt := range opts { 84 | result = opt(result) 85 | } 86 | return result 87 | } 88 | 89 | // TODO from existing style 90 | -------------------------------------------------------------------------------- /teact/utilities/update_utils.go: -------------------------------------------------------------------------------- 1 | package utilities 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/mieubrisse/teact/teact/components" 6 | ) 7 | 8 | // Utility for dealing with tea.Msg events 9 | // Returns an emptystring if the object isn't a tea.KeyMsg 10 | func GetMaybeKeyMsgStr(msg tea.Msg) string { 11 | switch msg := msg.(type) { 12 | case tea.KeyMsg: 13 | return msg.String() 14 | } 15 | return "" 16 | } 17 | 18 | // Tries to pass a tea.Msg to a component, doing nothing if it's not interactive 19 | // This is useful when we're not sure if we'll get an InteractiveComponent or not 20 | func TryUpdate(component components.Component, msg tea.Msg) tea.Cmd { 21 | switch castedComponent := component.(type) { 22 | case components.InteractiveComponent: 23 | return castedComponent.Update(msg) 24 | } 25 | return nil 26 | } 27 | --------------------------------------------------------------------------------