`, 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 |
--------------------------------------------------------------------------------