├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── dependabot.yml
├── lifecycle-diagram.png
└── workflows
│ ├── autoRelease.yml
│ ├── build.yml
│ ├── lint.yml
│ └── test.yml
├── .gitignore
├── LICENSE
├── README.md
├── component.go
├── component_test.go
├── examples
├── dynamicRoutes
│ ├── app
│ │ └── app.go
│ ├── data
│ │ ├── player.go
│ │ └── players.go
│ ├── main.go
│ └── pages
│ │ ├── displayplayer
│ │ └── displayplayer.go
│ │ └── input
│ │ └── input.go
└── quickstart
│ ├── app
│ └── app.go
│ ├── main.go
│ └── pages
│ ├── displayname
│ └── displayname.go
│ └── input
│ └── input.go
├── go.mod
├── go.sum
├── modal
├── README.md
├── component.go
├── controller.go
├── modal.go
├── result.go
└── waiter.go
├── model.go
├── options.go
├── options_test.go
├── reactea.go
├── reactea_test.go
├── route.go
├── route_test.go
├── router
├── router.go
└── router_test.go
├── size.go
├── util.go
└── util_test.go
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "gomod" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/.github/lifecycle-diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/londek/reactea/690350bb9df0f3bdbe0481e3b8fb2575daf00d1e/.github/lifecycle-diagram.png
--------------------------------------------------------------------------------
/.github/workflows/autoRelease.yml:
--------------------------------------------------------------------------------
1 | name: "tagged-release"
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 |
8 | jobs:
9 | tagged-release:
10 | name: "Tagged Release"
11 | runs-on: "ubuntu-latest"
12 |
13 | steps:
14 | - uses: "marvinpinto/action-automatic-releases@latest"
15 | with:
16 | repo_token: "${{ secrets.GITHUB_TOKEN }}"
17 | prerelease: false
18 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 | on: [push, pull_request]
3 |
4 | permissions:
5 | contents: read
6 | pull-requests: read
7 |
8 | jobs:
9 | build:
10 | strategy:
11 | matrix:
12 | os: [ubuntu-latest, macos-latest, windows-latest]
13 | runs-on: ${{ matrix.os }}
14 | steps:
15 | - name: Checkout reactea
16 | uses: actions/checkout@v3
17 |
18 | - name: Set up Go
19 | uses: actions/setup-go@v3
20 | with:
21 | go-version: ">=1.18"
22 | check-latest: true
23 |
24 | - name: Build
25 | run: go build -v ./...
26 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: lint
2 | on: [push, pull_request]
3 |
4 | permissions:
5 | contents: read
6 | pull-requests: read
7 |
8 | jobs:
9 | lint:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout reactea
13 | uses: actions/checkout@v3
14 |
15 | - name: Set up Go
16 | uses: actions/setup-go@v3
17 | with:
18 | go-version: ">=1.18"
19 | check-latest: true
20 |
21 | - name: Lint
22 | # Its basically 3.2.0 just with schema fixes
23 | uses: golangci/golangci-lint-action@5acb063f68ce921cd1e8380310270f54ccbca0e4
24 | with:
25 | only-new-issues: true
26 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on: [push, pull_request]
3 |
4 | permissions:
5 | contents: read
6 | pull-requests: read
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout reactea
13 | uses: actions/checkout@v3
14 |
15 | - name: Set up Go
16 | uses: actions/setup-go@v3
17 | with:
18 | go-version: ">=1.18"
19 | check-latest: true
20 |
21 | - name: Run tests
22 | run: go test -race -v -coverprofile=coverage.out ./...
23 |
24 | - name: Upload coverage
25 | uses: codecov/codecov-action@v3
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # If you prefer the allow list template instead of the deny list, see community template:
2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3 | #
4 | # Binaries for programs and plugins
5 | *.exe
6 | *.exe~
7 | *.dll
8 | *.so
9 | *.dylib
10 |
11 | # Test binary, built with `go test -c`
12 | *.test
13 |
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 |
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 |
20 | # Go workspace file
21 | go.work
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Wiktor Molak
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | #
Reactea
2 |
3 |
4 |
5 | [](https://img.shields.io/github/v/tag/londek/reactea?label=latest)
6 | [](https://github.com/londek/reactea/actions/workflows/build.yml)
7 | 
8 | [](https://pkg.go.dev/github.com/londek/reactea)
9 | [](https://goreportcard.com/report/github.com/londek/reactea)
10 |
11 |
⚠️ I'm rewriting project for 2025! ⚠️
12 |
13 | Rather simple **Bubbletea companion** for **handling hierarchy**, support for **lifting state up.**\
14 | It Reactifies Bubbletea philosophy and makes it especially easy to work with in bigger projects.
15 |
16 | For me, personally - **It's a must** in project with multiple pages and component communication
17 |
18 | Check our quickstart [right here](#quickstart) or other examples [here!](/examples)
19 |
20 | `go get -u github.com/londek/reactea`
21 |
22 |
23 | ## General info
24 |
25 | The goal is to create components that are
26 |
27 | - dimensions-aware (especially unify all setSize conventions)
28 | - scallable
29 | - more robust
30 | - easier to code
31 | - all of that without code duplication
32 |
33 | Extreme performance is not main goal of this package, however it should not be
34 | that far off actual Bubbletea which is already blazing fast.
35 | Most info is currently in source code so I suggest checking it out
36 |
37 | Always return `reactea.Destroy` instead of `tea.Quit` in order to follow our convention (that way Destroy() will be called on your components)
38 |
39 | As of now Go doesn't support type aliases for generics, so `Renderer[TProps]` has to be explicitly casted.
40 |
41 | ## [Quickstart](/examples/quickstart)
42 |
43 | Reactea unlike Bubbletea implements two-way communication, very React-like communication.\
44 | If you have experience with React you are gonna love Reactea straight away!
45 |
46 | In this tutorial we are going to make application that consists of 2 pages.
47 |
48 | - The `/input` (aka `index`, in reactea `default`) page for inputting your name
49 | - The `/displayname` page for displaying your name
50 |
51 | ### [Lifecycle](#component-lifecycle)
52 |
53 | More detailed docs about component lifecycle can be found [here](#component-lifecycle), we are only gonna go through basics.
54 |
55 | Reactea component lifecycle consists of 6 methods (while Bubbletea only 3)
56 | |Method|Purpose|
57 | |-|-|
58 | | `Init() tea.Cmd` | It's called first. All critical stuff should happen here. It also supports IO through tea.Cmd |
59 | | `Update(tea.Msg) tea.Cmd` | It reacts to Bubbletea IO and updates state accordingly |
60 | | `Render(int, int) string` | It renders the UI. The two arguments are width and height, they should be calculated by parent |
61 | | `Destroy()` | It's called whenever Component is about to end it's lifecycle. Please note that it's parent's responsibility to call `Destroy()` |
62 |
63 | Let's get to work!
64 |
65 | ### The `/input` page
66 |
67 | `/pages/input/input.go`
68 |
69 | ```go
70 | type Component struct {
71 | reactea.BasicComponent // It implements all reactea's core functionalities
72 |
73 | // Props
74 | SetText func(string)
75 |
76 | textinput textinput.Model // Input for inputting name
77 | }
78 |
79 | func New() *Component {
80 | return &Component{textinput: textinput.New()}
81 | }
82 |
83 | func (c *Component) Init() tea.Cmd {
84 | return c.textinput.Focus()
85 | }
86 |
87 | func (c *Component) Update(msg tea.Msg) tea.Cmd {
88 | switch msg := msg.(type) {
89 | case tea.KeyMsg:
90 | if msg.Type == tea.KeyEnter {
91 | // Lifted state power! Woohooo
92 | c.SetText(c.textinput.Value())
93 |
94 | // Navigate to displayname, please
95 | reactea.SetRoute("/displayname")
96 | return nil
97 | }
98 | }
99 |
100 | var cmd tea.Cmd
101 | c.textinput, cmd = c.textinput.Update(msg)
102 | return cmd
103 | }
104 |
105 | // Here we are not using width and height, but you can!
106 | func (c *Component) Render(int, int) string {
107 | return fmt.Sprintf("Enter your name: %s\nAnd press [ Enter ]", c.textinput.View())
108 | }
109 | ```
110 |
111 | #### The `/displayname` page
112 |
113 | `/pages/displayname/displayname.go`
114 |
115 | ```go
116 | import (
117 | "fmt"
118 | )
119 |
120 | // Our prop(s) is a string itself!
121 | type Props = string
122 |
123 | // Stateless components?!?!
124 | func Renderer(text Props, width, height int) string {
125 | return fmt.Sprintf("OMG! Hello %s!", text)
126 | }
127 | ```
128 |
129 | ### Main component
130 |
131 | `/app/app.go`
132 |
133 | ```go
134 | type Component struct {
135 | reactea.BasicComponent // It implements all reactea's core functionalities
136 |
137 | mainRouter reactea.Component[router.Props] // Our router
138 |
139 | text string // The name
140 | }
141 |
142 | func New() *Component {
143 | return &Component{
144 | mainRouter: router.New(),
145 | }
146 | }
147 |
148 | func (c *Component) Init(reactea.NoProps) tea.Cmd {
149 | // Does it remind you of something? react-router!
150 | return c.mainRouter.Init(map[string]router.RouteInitializer{
151 | "default": func(router.Params) reactea.Component {
152 | component := input.New()
153 |
154 | component.SetText = c.setText
155 |
156 | return component
157 | },
158 | "/displayname": func(router.Params) reactea.Component {
159 | // RouteInitializer requires Component so we have to convert
160 | // Stateless component (renderer) to Component
161 | return reactea.Componentify(displayname.Render, c.text)
162 | },
163 | })
164 | }
165 |
166 | func (c *Component) Update(msg tea.Msg) tea.Cmd {
167 | switch msg := msg.(type) {
168 | case tea.KeyMsg:
169 | // ctrl+c support
170 | if msg.String() == "ctrl+c" {
171 | return reactea.Destroy
172 | }
173 | }
174 |
175 | return c.mainRouter.Update(msg)
176 | }
177 |
178 | func (c *Component) Render(width, height int) string {
179 | return c.mainRouter.Render(width, height)
180 | }
181 |
182 | func (c *Component) setText(text string) {
183 | c.text = text
184 | }
185 | ```
186 |
187 | #### Main
188 |
189 | `main.go`
190 |
191 | ```go
192 | // reactea.NewProgram initializes program with
193 | // "translation layer", so Reactea components work
194 | program := reactea.NewProgram(app.New())
195 |
196 | if _, err := program.Run(); err != nil {
197 | panic(err)
198 | }
199 | ```
200 |
201 | ## Component lifecycle
202 |
203 | 
204 |
205 | Reactea component lifecycle consists of 6 methods (while Bubbletea only 3)
206 | |Method|Purpose|
207 | |-|-|
208 | | `Init() tea.Cmd` | It's called first. All critical stuff should happen here. It also supports IO through tea.Cmd |
209 | | `Update(tea.Msg) tea.Cmd` | It reacts to Bubbletea IO and updates state accordingly |
210 | | `Render(int, int) string` | It renders the UI. The two arguments are width and height, they should be calculated by parent |
211 | | `Destroy()` | It's called whenever Component is about to end it's lifecycle. Please note that it's parent's responsibility to call `Destroy()` |
212 |
213 | Reactea takes pointer approach for components making state modifiable in any lifecycle method\
214 |
215 | ### Notes
216 |
217 | `Update()` **IS NOT** guaranteed to be called on first-run, `Init()` for most part is, and critical logic should be there
218 |
219 | ## Stateless components
220 |
221 | Stateless components are represented by following function types
222 |
223 | | | Renderer[TProps any] | ProplessRenderer | DumbRenderer |
224 | |----------------|:------------------------:|:------------------:|:-------------:|
225 | | **Properties** | ✅ | ❌ | ❌ |
226 | | **Dimensions** | ✅ | ✅ | ❌ |
227 | | **Arguments** | `TProps, int, int` | `int, int` | ❌ |
228 |
229 | There are many utility functions for transforming stateless into stateful components or for rendering any component without knowing its type (`reactea.RenderAny`)
230 |
231 | ## Routes API
232 |
233 | Routes API allows developers for easy development of multi-page apps.
234 | They are kind of substitute for window.Location inside Bubbletea
235 |
236 | ### reactea.CurrentRoute() Route
237 |
238 | Returns current route
239 |
240 | ### reactea.LastRoute() Route
241 |
242 | Returns last route
243 |
244 | ### reactea.WasRouteChanged() bool
245 |
246 | returns `LastRoute() != CurrentRoute()`
247 |
248 | ## Reactea Routes now support params
249 |
250 | Params have been introduced in order to allow routes like: `/teams/123/player/4`
251 |
252 | Params have to follow regex `^:.*$`\
253 | `^` being beginning of current path level (`/^level/`)\
254 | `$`being end of current path level (`/level$/`)
255 |
256 | Note that params support wildcards with single `:`, like `/teams/:/player`. `/teams/123/player`, `/teams/456/player` etc will be matched no matter what and param will be ignored in param map.
257 |
258 | ## Router Component
259 |
260 | Router Component is basic implementation of how routing could look in your application.
261 | It doesn't support wildcards yet or relative pathing. All data is provided from within props
262 |
263 | ### router.Props
264 |
265 | router.Props is a map of route initializers keyed by routes
266 |
267 | What is `RouteInitializer`?
268 |
269 | `RouteInitializer` is function that initializes the current route component
270 |
--------------------------------------------------------------------------------
/component.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import tea "github.com/charmbracelet/bubbletea"
4 |
5 | // The lifecycle is
6 | //
7 | // \/ Usually won't be called on first render
8 | // Init ---> Update -> Render ---> Destroy?
9 | // | | /\ implementation detail and
10 | // |---------------------| therefore doesn't return tea.Cmd
11 | //
12 | // Reactea takes pointer approach for components
13 | // making state mutable in any lifecycle method
14 | //
15 | // Note: Lifecycle is fully controlled by parent component
16 | // making graph above fully theoretical and possibly
17 | // invalid for third-party components
18 |
19 | type Component interface {
20 | // Common lifecycle methods
21 |
22 | // Init() Is meant to both initialize subcomponents and run
23 | // long IO operations through tea.Cmd
24 | Init() tea.Cmd
25 |
26 | // It's called when component is about to be destroyed
27 | Destroy()
28 |
29 | // Typical tea.Model Update(), we handle all IO events here
30 | Update(tea.Msg) tea.Cmd
31 |
32 | // Render() is called when component should render itself
33 | // Provided width and height are target dimensions
34 | Render(int, int) string
35 | }
36 |
37 | // Why not Renderer[TProps]? It would have to be a type alias
38 | // there are no type aliases yet for generics, but they are
39 | // planned for some time soon. Something to keep in mind for future
40 | type AnyRenderer[TProps any] interface {
41 | func(TProps, int, int) string | AnyProplessRenderer
42 | }
43 |
44 | type AnyProplessRenderer interface {
45 | ProplessRenderer | DumbRenderer
46 | }
47 |
48 | // Ultra shorthand for components = just renderer
49 | // One could say it's a stateless component
50 | // Also note that it doesn't handle any IO by itself
51 | //
52 | // TODO: Change to type alias after type aliases for generics
53 | // support is implemented. For now explicit
54 | // type conversion is required
55 | type Renderer[TProps any] func(TProps, int, int) string
56 |
57 | // SUPEEEEEER shorthand for components
58 | type ProplessRenderer = func(int, int) string
59 |
60 | // Doesn't have state, props, even scalling for
61 | // target dimensions = DumbRenderer, or Stringer
62 | type DumbRenderer = func() string
63 |
64 | // Alias for no props
65 | type NoProps = struct{}
66 |
67 | // Basic component that implements all methods
68 | // required by reactea.Component
69 | // except Render(int, int)
70 | type BasicComponent struct{}
71 |
72 | func (c *BasicComponent) Init() tea.Cmd { return nil }
73 | func (c *BasicComponent) Destroy() {}
74 | func (c *BasicComponent) Update(msg tea.Msg) tea.Cmd { return nil }
75 |
76 | // Utility component for displaying empty string on Render()
77 | type InvisibleComponent struct{}
78 |
79 | func (c *InvisibleComponent) Render(int, int) string { return "" }
80 |
81 | // Destroys app before quiting
82 | func Destroy() tea.Msg {
83 | return destroyAppMsg{}
84 | }
85 |
86 | type destroyAppMsg struct{}
87 |
--------------------------------------------------------------------------------
/component_test.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import (
4 | "bytes"
5 | "strings"
6 | "testing"
7 | "time"
8 |
9 | tea "github.com/charmbracelet/bubbletea"
10 | )
11 |
12 | func TestDefaultComponent(t *testing.T) {
13 | var out bytes.Buffer
14 |
15 | component := &mockComponent[struct{}]{
16 | renderFunc: func(c Component, s *struct{}, width, height int) string {
17 | return "test passed"
18 | },
19 | }
20 |
21 | program := NewProgram(component, WithoutInput(), tea.WithOutput(&out))
22 |
23 | go func() {
24 | time.Sleep(20 * time.Millisecond)
25 |
26 | program.Quit()
27 | }()
28 |
29 | if _, err := program.Run(); err != nil {
30 | t.Fatal(err)
31 | }
32 |
33 | if !strings.Contains(out.String(), "test passed") {
34 | t.Errorf("invalid output, got \"%s\"", out.String())
35 | }
36 | }
37 |
38 | func TestInvisibleComponent(t *testing.T) {
39 | component := &InvisibleComponent{}
40 |
41 | if result := component.Render(1, 1); result != "" {
42 | t.Errorf("expected empty string, got \"%s\"", result)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/examples/dynamicRoutes/app/app.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 |
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/londek/reactea"
9 |
10 | "github.com/londek/reactea/examples/dynamicRoutes/pages/displayplayer"
11 | "github.com/londek/reactea/examples/dynamicRoutes/pages/input"
12 | "github.com/londek/reactea/router"
13 | )
14 |
15 | type Component struct {
16 | reactea.BasicComponent
17 |
18 | mainRouter reactea.Component
19 | }
20 |
21 | func New() *Component {
22 | return &Component{
23 | mainRouter: router.NewWithRoutes(map[string]router.RouteInitializer{
24 | "default": func(router.Params) reactea.Component {
25 | return input.New()
26 | },
27 | // We are using dynamic routes (route params) in this example
28 | "/players/:playerId": func(params router.Params) reactea.Component {
29 | playerId, _ := strconv.Atoi(params["playerId"])
30 |
31 | return reactea.Componentify(displayplayer.Render, playerId)
32 | },
33 | }),
34 | }
35 | }
36 |
37 | func (c *Component) Init() tea.Cmd {
38 | return c.mainRouter.Init()
39 | }
40 |
41 | func (c *Component) Update(msg tea.Msg) tea.Cmd {
42 | switch msg := msg.(type) {
43 | case tea.KeyMsg:
44 | switch msg.String() {
45 | case "ctrl+c":
46 | return reactea.Destroy
47 | case "u":
48 | reactea.SetRoute("/")
49 | }
50 | }
51 |
52 | return c.mainRouter.Update(msg)
53 | }
54 |
55 | func (c *Component) Render(width, height int) string {
56 | return fmt.Sprintf("Current route: \"%s\"\n\n%s", reactea.CurrentRoute(), c.mainRouter.Render(width, height))
57 | }
58 |
--------------------------------------------------------------------------------
/examples/dynamicRoutes/data/player.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | type Player struct {
4 | Name string
5 | YearOfBirth int
6 | Team string
7 | }
8 |
--------------------------------------------------------------------------------
/examples/dynamicRoutes/data/players.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | var Players = map[int]Player{
4 | 12: {"Sally Bradley", 1962, "Team1"},
5 | 17: {"Terry Cody", 1978, "Team1"},
6 | 8: {"Georgia Bracy", 1990, "Team1"},
7 |
8 | 23: {"Daniel Drew", 1989, "Team2"},
9 | 14: {"Linda Lupo", 1999, "Team2"},
10 | 5: {"George Stengel", 1979, "Team2"},
11 | }
12 |
--------------------------------------------------------------------------------
/examples/dynamicRoutes/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/londek/reactea"
5 | "github.com/londek/reactea/examples/dynamicRoutes/app"
6 | )
7 |
8 | func main() {
9 | program := reactea.NewProgram(app.New())
10 |
11 | if _, err := program.Run(); err != nil {
12 | panic(err)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/examples/dynamicRoutes/pages/displayplayer/displayplayer.go:
--------------------------------------------------------------------------------
1 | package displayplayer
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/londek/reactea/examples/dynamicRoutes/data"
7 | )
8 |
9 | type Props = int
10 |
11 | func Render(playerId Props, width, height int) string {
12 | if player, ok := data.Players[playerId]; ok {
13 | return fmt.Sprintf("Name: %s. Year of birth: %d. Team: %s.\nPress ctrl+c to exit or U to go back!", player.Name, player.YearOfBirth, player.Team)
14 | } else {
15 | return fmt.Sprintf("Player with ID %d not found!\nPress ctrl+c to exit or U to go back!", playerId)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/examples/dynamicRoutes/pages/input/input.go:
--------------------------------------------------------------------------------
1 | package input
2 |
3 | import (
4 | "fmt"
5 | "strconv"
6 |
7 | "github.com/charmbracelet/bubbles/textinput"
8 | tea "github.com/charmbracelet/bubbletea"
9 | "github.com/londek/reactea"
10 | "github.com/londek/reactea/examples/dynamicRoutes/data"
11 | )
12 |
13 | type Component struct {
14 | reactea.BasicComponent
15 |
16 | textinput textinput.Model
17 |
18 | ids []int
19 | }
20 |
21 | func New() *Component {
22 | var ids []int
23 | for id := range data.Players {
24 | ids = append(ids, id)
25 | }
26 |
27 | return &Component{
28 | textinput: textinput.New(),
29 | ids: ids,
30 | }
31 | }
32 |
33 | func (c *Component) Init() tea.Cmd {
34 | return c.textinput.Focus()
35 | }
36 |
37 | func (c *Component) Update(msg tea.Msg) tea.Cmd {
38 | switch msg := msg.(type) {
39 | case tea.KeyMsg:
40 | if msg.Type == tea.KeyEnter {
41 | // Validate input
42 | n, err := strconv.Atoi(c.textinput.Value())
43 | if err != nil {
44 | c.textinput.SetValue("Error")
45 | return nil
46 | }
47 |
48 | reactea.SetRoute(fmt.Sprintf("/players/%d", n))
49 | return nil
50 | }
51 | }
52 |
53 | var cmd tea.Cmd
54 | c.textinput, cmd = c.textinput.Update(msg)
55 | return cmd
56 | }
57 |
58 | func (c *Component) Render(int, int) string {
59 | return fmt.Sprintf("Found players with ids %v\nEnter player id: %s\nAnd press [ Enter ]", c.ids, c.textinput.View())
60 | }
61 |
--------------------------------------------------------------------------------
/examples/quickstart/app/app.go:
--------------------------------------------------------------------------------
1 | package app
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | "github.com/londek/reactea"
6 |
7 | "github.com/londek/reactea/examples/quickstart/pages/displayname"
8 | "github.com/londek/reactea/examples/quickstart/pages/input"
9 | "github.com/londek/reactea/router"
10 | )
11 |
12 | type Component struct {
13 | reactea.BasicComponent
14 |
15 | mainRouter *router.Component
16 | text string
17 | }
18 |
19 | func New() *Component {
20 | c := &Component{}
21 |
22 | c.mainRouter = router.NewWithRoutes(router.Routes{
23 | "default": func(router.Params) reactea.Component {
24 | component := input.New()
25 |
26 | component.SetText = c.setText
27 |
28 | return component
29 | },
30 | "/displayname": func(router.Params) reactea.Component {
31 | return reactea.Componentify(displayname.Render, c.text)
32 | },
33 | })
34 |
35 | return c
36 | }
37 |
38 | func (c *Component) Init() tea.Cmd {
39 | // Does it remind you of something? react-router!
40 | return c.mainRouter.Init()
41 | }
42 |
43 | func (c *Component) Update(msg tea.Msg) tea.Cmd {
44 | switch msg := msg.(type) {
45 | case tea.KeyMsg:
46 | if msg.String() == "ctrl+c" {
47 | return reactea.Destroy
48 | }
49 | }
50 |
51 | return c.mainRouter.Update(msg)
52 | }
53 |
54 | func (c *Component) Render(width, height int) string {
55 | return c.mainRouter.Render(width, height)
56 | }
57 |
58 | func (c *Component) setText(text string) {
59 | c.text = text
60 | }
61 |
--------------------------------------------------------------------------------
/examples/quickstart/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/londek/reactea"
5 | "github.com/londek/reactea/examples/quickstart/app"
6 | )
7 |
8 | func main() {
9 | // reactea.NewProgram initializes program with
10 | // "translation layer", so Reactea components work
11 | program := reactea.NewProgram(app.New())
12 |
13 | if _, err := program.Run(); err != nil {
14 | panic(err)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/examples/quickstart/pages/displayname/displayname.go:
--------------------------------------------------------------------------------
1 | package displayname
2 |
3 | import (
4 | "fmt"
5 | )
6 |
7 | // Our prop(s) is a string itself!
8 | type Props = string
9 |
10 | // Stateless components?!?!
11 | // Here we are not using width and height, but you can!
12 | // Using lipgloss styles for example
13 | func Render(text Props, width, height int) string {
14 | return fmt.Sprintf("OMG! Hello %s!", text)
15 | }
16 |
--------------------------------------------------------------------------------
/examples/quickstart/pages/input/input.go:
--------------------------------------------------------------------------------
1 | package input
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/charmbracelet/bubbles/textinput"
7 | tea "github.com/charmbracelet/bubbletea"
8 | "github.com/londek/reactea"
9 | )
10 |
11 | type Component struct {
12 | reactea.BasicComponent
13 |
14 | SetText func(string)
15 |
16 | textinput textinput.Model
17 | }
18 |
19 | func New() *Component {
20 | return &Component{
21 | textinput: textinput.New(),
22 | }
23 | }
24 |
25 | func (c *Component) Init() tea.Cmd {
26 | return c.textinput.Focus()
27 | }
28 |
29 | func (c *Component) Update(msg tea.Msg) tea.Cmd {
30 | switch msg := msg.(type) {
31 | case tea.KeyMsg:
32 | if msg.Type == tea.KeyEnter {
33 | // Lifted state power! Woohooo
34 | c.SetText(c.textinput.Value())
35 |
36 | reactea.SetRoute("/displayname")
37 |
38 | return nil
39 | }
40 | }
41 |
42 | var cmd tea.Cmd
43 | c.textinput, cmd = c.textinput.Update(msg)
44 | return cmd
45 | }
46 |
47 | // Here we are not using width and height, but you can!
48 | // Using lipgloss styles for example
49 | func (c *Component) Render(int, int) string {
50 | return fmt.Sprintf("Enter your name: %s\nAnd press [ Enter ]", c.textinput.View())
51 | }
52 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/londek/reactea
2 |
3 | go 1.23.0
4 |
5 | toolchain go1.24.1
6 |
7 | require github.com/charmbracelet/bubbletea v1.3.4
8 |
9 | require (
10 | github.com/atotto/clipboard v0.1.4 // indirect
11 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
12 | github.com/charmbracelet/lipgloss v1.1.0 // indirect
13 | github.com/charmbracelet/x/ansi v0.8.0 // indirect
14 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
15 | github.com/charmbracelet/x/term v0.2.1 // indirect
16 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
17 | )
18 |
19 | require (
20 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
21 | github.com/charmbracelet/bubbles v0.18.0
22 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
23 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
24 | github.com/mattn/go-isatty v0.0.20 // indirect
25 | github.com/mattn/go-localereader v0.0.1 // indirect
26 | github.com/mattn/go-runewidth v0.0.16 // indirect
27 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
28 | github.com/muesli/cancelreader v0.2.2 // indirect
29 | github.com/muesli/reflow v0.3.0 // indirect
30 | github.com/muesli/termenv v0.16.0 // indirect
31 | github.com/rivo/uniseg v0.4.7 // indirect
32 | golang.org/x/sync v0.12.0 // indirect
33 | golang.org/x/sys v0.31.0 // indirect
34 | golang.org/x/term v0.30.0 // indirect
35 | golang.org/x/text v0.23.0 // indirect
36 | )
37 |
--------------------------------------------------------------------------------
/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/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
6 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
7 | github.com/charmbracelet/bubbletea v0.26.2 h1:Eeb+n75Om9gQ+I6YpbCXQRKHt5Pn4vMwusQpwLiEgJQ=
8 | github.com/charmbracelet/bubbletea v0.26.2/go.mod h1:6I0nZ3YHUrQj7YHIHlM8RySX4ZIthTliMY+W8X8b+Gs=
9 | github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
10 | github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
11 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
12 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
13 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
14 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
15 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
16 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
17 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
18 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
19 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
20 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
21 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
22 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
23 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
24 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
25 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
26 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
27 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
28 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
29 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
30 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
31 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
32 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
33 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
34 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
35 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
36 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
37 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
38 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
39 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
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.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
43 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
44 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
45 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
46 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
47 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
48 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
49 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
50 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
51 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
52 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
53 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
54 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
55 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
56 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
57 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
58 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
59 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
60 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
61 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
62 | golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
63 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
64 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
65 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
66 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
67 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
68 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
69 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
70 |
--------------------------------------------------------------------------------
/modal/README.md:
--------------------------------------------------------------------------------
1 | # Modal
2 |
3 | Modals are simply a dimension to a world in which components can return values without any setters, as simple as
4 |
5 | ```go
6 |
7 | name := c.modal.Show(&NameSelection{})
8 |
9 | // or
10 |
11 | task := c.modal.ShowAsync(&NameSelection{})
12 | // Compute something
13 | name := task.Result()
14 |
15 | ```
16 |
--------------------------------------------------------------------------------
/modal/component.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | "github.com/londek/reactea"
6 | )
7 |
8 | type ModalComponent[TReturn any] interface {
9 | reactea.Component
10 |
11 | initModal(chan<- ModalResult[TReturn], *Controller)
12 | Return(ModalResult[TReturn]) tea.Cmd
13 | }
14 |
--------------------------------------------------------------------------------
/modal/controller.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | "sync"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | "github.com/londek/reactea"
8 | )
9 |
10 | type Controller struct {
11 | reactea.BasicComponent
12 |
13 | initFunc func(*Controller) func() tea.Cmd
14 | escapeFunc func() tea.Cmd // Called when modal flow is ended
15 |
16 | rendered string
17 | initCmd tea.Cmd
18 | shouldDestruct bool
19 |
20 | modal reactea.Component
21 | locked bool
22 | cond *sync.Cond
23 | w waiter
24 | runMutex sync.Mutex
25 | }
26 |
27 | func NewController(initFunc func(*Controller) func() tea.Cmd) *Controller {
28 | return &Controller{
29 | initFunc: initFunc,
30 | cond: sync.NewCond(&sync.Mutex{}),
31 | w: make(waiter),
32 | }
33 | }
34 |
35 | func (c *Controller) Init() tea.Cmd {
36 | return c.Run(c.initFunc)
37 | }
38 |
39 | func (c *Controller) Update(msg tea.Msg) tea.Cmd {
40 | var initCmd, updateCmd, escapeCmd tea.Cmd
41 |
42 | c.cond.L.Lock()
43 | c.locked = true
44 | for c.modal == nil && c.escapeFunc == nil {
45 | c.cond.Wait()
46 | }
47 |
48 | if c.modal != nil {
49 | updateCmd = c.modal.Update(msg)
50 | }
51 |
52 | if c.escapeFunc != nil {
53 | escapeFunc := c.escapeFunc
54 | c.escapeFunc = nil
55 | escapeCmd = escapeFunc()
56 | }
57 |
58 | if c.initCmd != nil {
59 | initCmd = c.initCmd
60 | c.initCmd = nil
61 | }
62 |
63 | return tea.Batch(initCmd, updateCmd, escapeCmd)
64 | }
65 |
66 | func (c *Controller) Render(width, height int) string {
67 | if c.locked {
68 | defer c.cond.L.Unlock()
69 | c.locked = false
70 | }
71 |
72 | if c.modal != nil {
73 | c.rendered = c.modal.Render(width, height)
74 | } else {
75 | c.rendered = ""
76 | }
77 |
78 | if c.shouldDestruct {
79 | c.modal.Destroy()
80 | c.modal = nil
81 | c.shouldDestruct = false
82 | }
83 |
84 | return c.rendered
85 | }
86 |
87 | func (c *Controller) Run(f func(*Controller) func() tea.Cmd) tea.Cmd {
88 | go func() tea.Msg {
89 | c.runMutex.Lock()
90 | defer c.runMutex.Unlock()
91 |
92 | c.escapeFunc = f(c)
93 | c.cond.Broadcast()
94 | c.w.Signal()
95 |
96 | return nil
97 | }()
98 |
99 | return func() tea.Msg {
100 | return nil
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/modal/modal.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | "github.com/londek/reactea"
6 | )
7 |
8 | type Modal[T any] struct {
9 | ch chan<- ModalResult[T]
10 | c *Controller
11 | }
12 |
13 | //lint:ignore U1000 This function is used, but through interface
14 | func (modal *Modal[T]) initModal(resultChan chan<- ModalResult[T], controller *Controller) {
15 | modal.ch = resultChan
16 | modal.c = controller
17 | }
18 |
19 | func (modal *Modal[T]) Return(result ModalResult[T]) tea.Cmd {
20 | modal.ch <- result
21 | modal.c.w.Wait()
22 | return reactea.Rerender
23 | }
24 |
25 | func (modal *Modal[T]) Ok(result T) tea.Cmd {
26 | return modal.Return(Ok(result))
27 | }
28 |
29 | func (modal *Modal[T]) Error(err error) tea.Cmd {
30 | return modal.Return(Error[T](err))
31 | }
32 |
33 | func Show[T any](c *Controller, modal ModalComponent[T]) ModalResult[T] {
34 | c.w.Signal()
35 |
36 | resultChan := make(chan ModalResult[T])
37 |
38 | modal.initModal(resultChan, c)
39 |
40 | c.cond.L.Lock()
41 |
42 | c.modal = modal
43 | c.initCmd = modal.Init()
44 |
45 | c.cond.Broadcast()
46 | c.cond.L.Unlock()
47 |
48 | result := <-resultChan
49 |
50 | c.shouldDestruct = true
51 |
52 | return result
53 | }
54 |
55 | func Get[T any](c *Controller, modal ModalComponent[T]) T {
56 | c.w.Signal()
57 |
58 | resultChan := make(chan ModalResult[T])
59 |
60 | modal.initModal(resultChan, c)
61 |
62 | c.cond.L.Lock()
63 |
64 | c.modal = modal
65 | c.initCmd = modal.Init()
66 |
67 | c.cond.Broadcast()
68 | c.cond.L.Unlock()
69 |
70 | result := <-resultChan
71 |
72 | c.shouldDestruct = true
73 |
74 | return result.Return
75 | }
76 |
--------------------------------------------------------------------------------
/modal/result.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | type ModalResult[T any] struct {
4 | Return T
5 | Err error
6 | }
7 |
8 | // Allows for value, err := result.Get() syntactic sugar
9 | // instead of manually destructuring fields
10 | func (result *ModalResult[T]) Get() (T, error) {
11 | return result.Return, result.Err
12 | }
13 |
14 | func Ok[T any](ret T) ModalResult[T] {
15 | return ModalResult[T]{ret, nil}
16 | }
17 |
18 | func Error[T any](err error) ModalResult[T] {
19 | var ret T
20 | return ModalResult[T]{ret, err}
21 | }
22 |
--------------------------------------------------------------------------------
/modal/waiter.go:
--------------------------------------------------------------------------------
1 | package modal
2 |
3 | type waiter chan struct{}
4 |
5 | func (w waiter) Wait() {
6 | w <- struct{}{}
7 | }
8 |
9 | func (w waiter) Signal() {
10 | select {
11 | case <-w:
12 | return
13 | default:
14 | return
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/model.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | )
6 |
7 | // Useful for constraining some actions to update-stage only
8 | var isUpdate bool
9 |
10 | type model struct {
11 | program *tea.Program
12 | root Component
13 |
14 | width, height int
15 | }
16 |
17 | func (m model) Init() tea.Cmd {
18 | return m.root.Init()
19 | }
20 |
21 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
22 | wasRouteChanged = false
23 |
24 | switch msg := msg.(type) {
25 | // We want component to know at what size should it render
26 | // and unify size handling across all Reactea components
27 | // We pass WindowSizeMsg to root component just for
28 | // sake of utility.
29 | case tea.WindowSizeMsg:
30 | m.width, m.height = msg.Width, msg.Height
31 | }
32 |
33 | isUpdate = true
34 |
35 | m.execute(m.root.Update(msg))
36 |
37 | isUpdate = false
38 |
39 | // Guarantee rerender if route was changed
40 | if wasRouteChanged {
41 | return m, updatedRoute(lastRoute)
42 | }
43 |
44 | return m, nil
45 | }
46 |
47 | func (m model) View() string {
48 | return m.root.Render(m.width, m.height)
49 | }
50 |
51 | func (m model) execute(cmd tea.Cmd) {
52 | if cmd == nil {
53 | return
54 | }
55 |
56 | go func() {
57 | msg := cmd()
58 | switch msg := msg.(type) {
59 | case destroyAppMsg:
60 | m.root.Destroy()
61 | m.program.Send(tea.QuitMsg{})
62 | case tea.BatchMsg:
63 | for _, cmd := range msg {
64 | m.execute(cmd)
65 | }
66 | default:
67 | m.program.Send(msg)
68 | }
69 | }()
70 | }
71 |
--------------------------------------------------------------------------------
/options.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import (
4 | tea "github.com/charmbracelet/bubbletea"
5 | )
6 |
7 | type nilReader struct{}
8 |
9 | func (nilReader) Read([]byte) (int, error) {
10 | return 0, nil
11 | }
12 |
13 | // Useful for testing on Github Actions, by default Bubbletea
14 | // would try reading from /dev/tty, but on Github Actions
15 | // it's restricted resulting in error
16 | func WithoutInput() func(*tea.Program) {
17 | return tea.WithInput(nilReader{})
18 | }
19 |
20 | func WithRoute(route string) func(*tea.Program) {
21 | return func(*tea.Program) {
22 | if len(route) != 0 || route[0] == '/' {
23 | currentRoute = route
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/options_test.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import "testing"
4 |
5 | func TestOptions(t *testing.T) {
6 | t.Run("WithRoute", func(t *testing.T) {
7 | root := &mockComponent[struct{}]{}
8 |
9 | NewProgram(root, WithRoute("/testRoute"))
10 |
11 | if CurrentRoute() != "/testRoute" {
12 | t.Errorf("expected current route \"/testRoute\", but got \"%s\"", CurrentRoute())
13 | }
14 | })
15 |
16 | t.Run("WithoutInput", func(t *testing.T) {
17 | root := &mockComponent[struct{}]{
18 | renderFunc: func(c Component, s *struct{}, width, height int) string {
19 | return "test passed"
20 | },
21 | }
22 |
23 | program := NewProgram(root, WithoutInput())
24 |
25 | go program.Quit()
26 |
27 | if _, err := program.Run(); err != nil {
28 | t.Fatal(err)
29 | }
30 | })
31 | }
32 |
--------------------------------------------------------------------------------
/reactea.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import tea "github.com/charmbracelet/bubbletea"
4 |
5 | // Note: Return type is *tea.Program, Reactea doesn't have
6 | // it's own wrapper (reactea.Program) type, yet (?)
7 | func NewProgram(root Component, options ...tea.ProgramOption) *tea.Program {
8 | // Ensure globals are default, useful for tests and
9 | // running programs SEQUENTIALLY during runtime
10 | isUpdate = false
11 | currentRoute = "/"
12 | lastRoute = "/"
13 | wasRouteChanged = false
14 |
15 | m := &model{
16 | program: nil,
17 | root: root,
18 | width: 0,
19 | height: 0,
20 | }
21 |
22 | program := tea.NewProgram(m, options...)
23 |
24 | m.program = program
25 |
26 | return program
27 | }
28 |
--------------------------------------------------------------------------------
/reactea_test.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import (
4 | "bytes"
5 | "strings"
6 | "testing"
7 | "time"
8 |
9 | tea "github.com/charmbracelet/bubbletea"
10 | )
11 |
12 | func TestComponent(t *testing.T) {
13 | var in, out bytes.Buffer
14 |
15 | in.WriteString("~~~")
16 |
17 | type testState struct {
18 | echoKey string
19 | lastWidth, lastHeight int
20 | }
21 |
22 | root := &mockComponent[testState]{
23 | updateFunc: func(c Component, s *testState, msg tea.Msg) tea.Cmd {
24 | switch msg := msg.(type) {
25 | case tea.KeyMsg:
26 | if msg.String() == "x" {
27 | return Destroy
28 | }
29 |
30 | s.echoKey = msg.String()
31 | }
32 |
33 | SetRoute("/test/test/test")
34 |
35 | return nil
36 | },
37 | renderFunc: func(c Component, s *testState, width, height int) string {
38 | s.lastWidth, s.lastHeight = width, height
39 |
40 | return s.echoKey
41 | },
42 | }
43 |
44 | program := NewProgram(root, tea.WithInput(&in), tea.WithOutput(&out))
45 |
46 | // Test for window size
47 | go func() {
48 | // Simulate initial window size
49 | program.Send(tea.WindowSizeMsg{Width: 1, Height: 1})
50 |
51 | // Give time to catch up
52 | time.Sleep(50 * time.Millisecond)
53 |
54 | // Simulate pressing X
55 | program.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}, Alt: false})
56 | }()
57 |
58 | if _, err := program.Run(); err != nil {
59 | t.Fatal(err)
60 | }
61 |
62 | if strings.Contains(out.String(), "default") {
63 | t.Errorf("did not echo")
64 | }
65 |
66 | if !strings.Contains(out.String(), "~") {
67 | t.Errorf("invalid echo")
68 | }
69 |
70 | if WasRouteChanged() {
71 | t.Errorf("current route was changed")
72 | }
73 |
74 | if CurrentRoute() != "/test/test/test" {
75 | t.Errorf("current route is wrong, expected \"/test/test/test\", got \"%s\"", CurrentRoute())
76 | }
77 |
78 | if root.state.lastWidth != 1 {
79 | t.Errorf("expected lastWidth 1, but got %d", root.state.lastWidth)
80 | }
81 |
82 | if root.state.lastHeight != 1 {
83 | t.Errorf("expected lastHeigth 1, but got %d", root.state.lastWidth)
84 | }
85 | }
86 |
87 | func TestNew(t *testing.T) {
88 | t.Run("NewProgram", func(t *testing.T) {
89 | root := &mockComponent[struct{}]{
90 | renderFunc: func(c Component, s *struct{}, width, height int) string {
91 | return "test passed"
92 | },
93 | }
94 |
95 | program := NewProgram(root, WithoutInput(), tea.WithoutRenderer())
96 |
97 | go program.Quit()
98 |
99 | if _, err := program.Run(); err != nil {
100 | t.Fatal(err)
101 | }
102 | })
103 | }
104 |
--------------------------------------------------------------------------------
/route.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import (
4 | "strings"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | )
8 |
9 | type RouteUpdatedMsg struct {
10 | Original string
11 | }
12 |
13 | // Global Route object, substitute of window.location
14 | // Feel free to use standard path package
15 | var (
16 | currentRoute = "/"
17 | lastRoute = "/"
18 | wasRouteChanged = false
19 | )
20 |
21 | func CurrentRoute() string {
22 | return currentRoute
23 | }
24 |
25 | func LastRoute() string {
26 | return lastRoute
27 | }
28 |
29 | func WasRouteChanged() bool {
30 | return wasRouteChanged
31 | }
32 |
33 | func SetRoute(target string) tea.Cmd {
34 | if !isUpdate {
35 | panic("tried updating global route not in update")
36 | }
37 |
38 | if len(target) == 0 || target[0] != '/' {
39 | panic("can't set route to non-root path")
40 | }
41 |
42 | if !wasRouteChanged {
43 | wasRouteChanged = currentRoute != target
44 | lastRoute = currentRoute
45 | }
46 |
47 | currentRoute = target
48 | return nil
49 | }
50 |
51 | func Navigate(target string) tea.Cmd {
52 | var currentRouteLevels []string
53 |
54 | if len(target) == 0 {
55 | // Just don't navigate if no target was given
56 | return nil
57 | }
58 |
59 | // Check whether target is absolute
60 | if target[0] == '/' {
61 | currentRouteLevels = []string{}
62 | } else {
63 | currentRouteLevels = strings.Split(currentRoute, "/")
64 | currentRouteLevels = currentRouteLevels[1 : len(currentRouteLevels)-1]
65 | }
66 |
67 | for _, targetLevel := range strings.Split(target, "/") {
68 | switch targetLevel {
69 | case ".", "":
70 | case "..":
71 | if len(currentRouteLevels) > 0 {
72 | currentRouteLevels = currentRouteLevels[:len(currentRouteLevels)-1]
73 | }
74 | default:
75 | currentRouteLevels = append(currentRouteLevels, targetLevel)
76 | }
77 | }
78 |
79 | return SetRoute("/" + strings.Join(currentRouteLevels, "/"))
80 | }
81 |
82 | // Checks whether route (e.g. /teams/123/12) matches
83 | // placeholder (e.g /teams/:teamId/:playerId)
84 | // Returns map of found params and if it matches
85 | // Params have to follow regex ^:.*$
86 | // ^ being beginning of current path level (/^level/)
87 | // $ being end of current path level (/level$/)
88 | //
89 | // Note: Entire matched route can be accessed with key "$"
90 | // Note: Placeholders can be optional => /foo/?:/?: will match foo/bar and foo and /foo/bar/baz
91 | // Note: The most outside placeholders can be optional recursive => /foo/+?: will match /foo/bar and foo and /foo/bar/baz
92 | // Note: It allows for defining wildcards with /foo/:/bar
93 | // Note: Duplicate params will result in overwrite of first param
94 | func RouteMatchesPlaceholder(route string, placeholder string) (map[string]string, bool) {
95 | var (
96 | routeLevels = strings.Split(route, "/")
97 | placeholderLevels = strings.Split(placeholder, "/")
98 | )
99 |
100 | if len(route) > 0 && route[0] == '/' {
101 | routeLevels = routeLevels[1:]
102 | } else {
103 | // Checking against non-root route is forbidden
104 | return nil, false
105 | }
106 |
107 | if len(placeholder) > 0 && placeholder[0] == '/' {
108 | placeholderLevels = placeholderLevels[1:]
109 | } else {
110 | // Checking against non-root placeholder is forbidden
111 | return nil, false
112 | }
113 |
114 | if len(routeLevels) > len(placeholderLevels) && !strings.HasPrefix(placeholderLevels[len(placeholderLevels)-1], "+?:") {
115 | return nil, false
116 | }
117 |
118 | params := make(map[string]string, len(placeholderLevels)+1)
119 |
120 | params["$"] = route
121 |
122 | for i, placeholderLevel := range placeholderLevels {
123 | if i > len(routeLevels)-1 {
124 | if strings.HasPrefix(placeholderLevel, "?:") {
125 | if placeholderLevel == "?:" {
126 | continue // wildcard
127 | }
128 |
129 | paramName := placeholderLevel[2:]
130 | params[paramName] = ""
131 | continue
132 | } else if strings.HasPrefix(placeholderLevel, "+?:") {
133 | if placeholderLevel == "+?:" {
134 | break
135 | }
136 |
137 | paramName := placeholderLevel[3:]
138 | params[paramName] = ""
139 | break
140 | } else {
141 | // We are out of bounds and placeholder doesn't want optional data
142 | return nil, false
143 | }
144 | }
145 |
146 | routeLevel := routeLevels[i]
147 |
148 | if strings.HasPrefix(placeholderLevel, ":") {
149 | if placeholderLevel == ":" {
150 | continue // wildcard
151 | }
152 |
153 | paramName := placeholderLevel[1:]
154 | params[paramName] = routeLevel
155 | } else if strings.HasPrefix(placeholderLevel, "?:") {
156 | if placeholderLevel == "?:" {
157 | continue // wildcard
158 | }
159 |
160 | paramName := placeholderLevel[2:]
161 | params[paramName] = routeLevel
162 | } else if strings.HasPrefix(placeholderLevel, "+?:") {
163 | if placeholderLevel == "+?:" {
164 | break
165 | }
166 |
167 | paramName := placeholderLevel[3:]
168 | params[paramName] = strings.Join(routeLevels[i:], "/")
169 | break
170 | } else {
171 | if routeLevel != placeholderLevel {
172 | return nil, false
173 | }
174 | }
175 | }
176 |
177 | return params, true
178 | }
179 |
180 | // It might be important to do so in some scenarios.
181 | // Basically causes rerender so ALL components are
182 | // aware of changed routes
183 | func updatedRoute(original string) tea.Cmd {
184 | return func() tea.Msg {
185 | return RouteUpdatedMsg{original}
186 | }
187 | }
188 |
--------------------------------------------------------------------------------
/route_test.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import (
4 | "reflect"
5 | "strings"
6 | "testing"
7 |
8 | tea "github.com/charmbracelet/bubbletea"
9 | )
10 |
11 | func TestRoutePanic(t *testing.T) {
12 | defer func() {
13 | if r := recover(); r == nil {
14 | t.Errorf("expected panic, but it didn't")
15 | } else {
16 | if r != "tried updating global route not in update" {
17 | t.Errorf("expected panic, got it, but with invalid message, got \"%s\", expected \"tried updating global route not in update\"", r)
18 | }
19 | }
20 | }()
21 |
22 | root := &mockComponent[struct{}]{}
23 |
24 | NewProgram(root, tea.WithoutRenderer())
25 |
26 | SetRoute("/shouldFail")
27 | }
28 |
29 | // Expecting:
30 | // / -> /foo -> /foo/bar -> /baz -> / -> /foo -> /bar -> /test -> / -> / -> /foo -> /bar
31 | func TestNavigate(t *testing.T) {
32 | type testState struct {
33 | routeHistory []string
34 | step int
35 | }
36 |
37 | root := &mockComponent[testState]{
38 | initFunc: func(Component, *testState) tea.Cmd {
39 | return Rerender
40 | },
41 | updateFunc: func(c Component, s *testState, msg tea.Msg) tea.Cmd {
42 | switch s.step {
43 | case 0:
44 | // Don't navigate
45 | case 1:
46 | Navigate("foo")
47 | case 2:
48 | Navigate("foo/bar")
49 | case 3:
50 | Navigate("/baz")
51 | case 4:
52 | Navigate("..")
53 | case 5:
54 | Navigate("./foo")
55 | case 6:
56 | Navigate(".//bar")
57 | case 7:
58 | Navigate("../../test")
59 | case 8:
60 | Navigate("/")
61 | case 9:
62 | Navigate("")
63 | case 10:
64 | Navigate(".")
65 | case 11:
66 | Navigate("foo")
67 | case 12:
68 | Navigate("foo/bar")
69 | case 13:
70 | Navigate("baz")
71 | default:
72 | return Destroy
73 | }
74 |
75 | s.routeHistory = append(s.routeHistory, CurrentRoute())
76 |
77 | s.step += 1
78 |
79 | return Rerender
80 | },
81 | }
82 |
83 | program := NewProgram(root, tea.WithoutRenderer(), WithoutInput())
84 |
85 | if _, err := program.Run(); err != nil {
86 | t.Fatal(err)
87 | }
88 |
89 | expectedRouteHistory := []string{
90 | "/",
91 | "/foo",
92 | "/foo/bar",
93 | "/baz",
94 | "/",
95 | "/foo",
96 | "/bar",
97 | "/test",
98 | "/",
99 | "/",
100 | "/",
101 | "/foo",
102 | "/foo/bar",
103 | "/foo/baz",
104 | }
105 |
106 | if strings.Join(root.state.routeHistory, " - ") != strings.Join(expectedRouteHistory, " - ") {
107 | t.Errorf("wrong route history, expected \"%s\", got \"%s\". Note that routes are delimited by \" - \"", strings.Join(expectedRouteHistory, " - "), strings.Join(root.state.routeHistory, " - "))
108 | }
109 | }
110 |
111 | func TestRoutePlaceholderMatching(t *testing.T) {
112 | testCases := []struct {
113 | route, placeholder string
114 | expected map[string]string
115 | }{
116 | // Matching against non-root routes is forbidden
117 | {"", "", nil},
118 | {"invalidRoute", "", nil},
119 | {"", "invalidPlaceholder", nil},
120 | {"/invalidRoute", "invalidPlaceholder", nil},
121 |
122 | {"/teams/foo", "/teams", nil},
123 | {"/teams", "/teams/foo", nil},
124 | {"/", "/teams", nil},
125 | {"/teams", "/", nil},
126 | {"/teams", "/teams", map[string]string{"$": "/teams"}},
127 |
128 | {"/teams", "/teams/?:", map[string]string{"$": "/teams"}},
129 | {"/teams/123", "/teams/?:", map[string]string{"$": "/teams/123"}},
130 | {"/teams/123/456", "/teams/?:", nil},
131 | {"/teams", "/teams/?:teamId", map[string]string{"$": "/teams", "teamId": ""}},
132 | {"/teams/123", "/teams/?:teamId", map[string]string{"$": "/teams/123", "teamId": "123"}},
133 | {"/teams/123/456", "/teams/?:teamId", nil},
134 |
135 | {"/teams/123/456", "/teams/123/456/+?:foo", map[string]string{"$": "/teams/123/456", "foo": ""}},
136 | {"/teams/123/456", "/teams/+?:foo", map[string]string{"$": "/teams/123/456", "foo": "123/456"}},
137 | {"/teams/123/456", "/teams/+?:", map[string]string{"$": "/teams/123/456"}},
138 | {"/teams/123", "/teams/+?:", map[string]string{"$": "/teams/123"}},
139 | {"/teams", "/teams/+?:", map[string]string{"$": "/teams"}},
140 |
141 | {"/teams/123", "/teams/:teamId", map[string]string{"$": "/teams/123", "teamId": "123"}},
142 | {"/teams/foo/234", "/teams/:/:teamId", map[string]string{"$": "/teams/foo/234", "teamId": "234"}},
143 | {"/teams/123/234", "/teams/:teamId/:teamId", map[string]string{"$": "/teams/123/234", "teamId": "234"}},
144 | {"/teams/123/234", "/teams/:teamId/:playerId", map[string]string{"$": "/teams/123/234", "teamId": "123", "playerId": "234"}},
145 |
146 | {"/detail/abcgsd-dsfhh2342-sdfhs-234", "/detail/:id", map[string]string{"$": "/detail/abcgsd-dsfhh2342-sdfhs-234", "id": "abcgsd-dsfhh2342-sdfhs-234"}},
147 | }
148 |
149 | for _, testCase := range testCases {
150 | got, ok := RouteMatchesPlaceholder(testCase.route, testCase.placeholder)
151 |
152 | if testCase.expected == nil {
153 | if !ok {
154 | continue
155 | }
156 |
157 | t.Errorf("Bad result. Route: \"%s\", Placeholder: \"%s\". Expected not ok, got ok", testCase.route, testCase.placeholder)
158 | continue
159 | }
160 |
161 | if !reflect.DeepEqual(got, testCase.expected) {
162 | t.Errorf("Bad result. Route: \"%s\", Placeholder: \"%s\". Expected %v, got %v", testCase.route, testCase.placeholder, testCase.expected, got)
163 | }
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/router/router.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "fmt"
5 |
6 | tea "github.com/charmbracelet/bubbletea"
7 | "github.com/londek/reactea"
8 | )
9 |
10 | type Params = map[string]string
11 | type RouteInitializer func(Params) reactea.Component
12 | type Routes = map[string]RouteInitializer
13 |
14 | type Component struct {
15 | reactea.BasicComponent
16 |
17 | Routes Routes
18 |
19 | currentComponent reactea.Component
20 | }
21 |
22 | func New() *Component {
23 | return &Component{}
24 | }
25 |
26 | func NewWithRoutes(routes Routes) *Component {
27 | return &Component{Routes: routes}
28 | }
29 |
30 | func (c *Component) Init() tea.Cmd {
31 | return c.initRoute()
32 | }
33 |
34 | func (c *Component) Update(msg tea.Msg) tea.Cmd {
35 | var initCmd, updateCmd tea.Cmd
36 |
37 | switch msg.(type) {
38 | case reactea.RouteUpdatedMsg:
39 | if c.currentComponent != nil {
40 | c.currentComponent.Destroy()
41 | }
42 |
43 | initCmd = c.initRoute()
44 | }
45 |
46 | if c.currentComponent != nil {
47 | updateCmd = c.currentComponent.Update(msg)
48 | }
49 |
50 | return tea.Batch(initCmd, updateCmd)
51 | }
52 |
53 | func (c *Component) Render(width, height int) string {
54 | if c.currentComponent != nil {
55 | return c.currentComponent.Render(width, height)
56 | }
57 |
58 | return fmt.Sprintf("Couldn't route for \"%s\"", reactea.CurrentRoute())
59 | }
60 |
61 | func (c *Component) initRoute() tea.Cmd {
62 | if initializer, params, ok := c.findMatchingRouteInitializer(); ok {
63 | c.currentComponent = initializer(params)
64 | return c.currentComponent.Init()
65 | }
66 |
67 | if initializer, ok := c.Routes["default"]; ok {
68 | c.currentComponent = initializer(nil)
69 | return c.currentComponent.Init()
70 | }
71 |
72 | return nil
73 | }
74 |
75 | func (c *Component) findMatchingRouteInitializer() (RouteInitializer, Params, bool) {
76 | currentRoute := reactea.CurrentRoute()
77 |
78 | for placeholder, initializer := range c.Routes {
79 | if params, ok := reactea.RouteMatchesPlaceholder(currentRoute, placeholder); ok {
80 | return initializer, params, true
81 | }
82 | }
83 |
84 | return nil, nil, false
85 | }
86 |
--------------------------------------------------------------------------------
/router/router_test.go:
--------------------------------------------------------------------------------
1 | package router
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "strings"
7 | "testing"
8 |
9 | tea "github.com/charmbracelet/bubbletea"
10 | "github.com/londek/reactea"
11 | )
12 |
13 | type testComponenent struct {
14 | reactea.BasicComponent
15 |
16 | router *Component
17 |
18 | testUpdater func(*testComponenent) tea.Cmd
19 |
20 | updateN int
21 | }
22 |
23 | func (c *testComponenent) Init() tea.Cmd {
24 | return c.router.Init()
25 | }
26 |
27 | func (c *testComponenent) Update(msg tea.Msg) tea.Cmd {
28 | defer func() {
29 | c.updateN++
30 | }()
31 |
32 | if c.testUpdater != nil {
33 | return tea.Batch(c.router.Update(c.router.Update(msg)), c.testUpdater(c))
34 | }
35 |
36 | return tea.Batch(c.router.Update(msg), reactea.Destroy)
37 | }
38 |
39 | func (c *testComponenent) Render(width, height int) string {
40 | return c.router.Render(width, height)
41 | }
42 |
43 | func TestDefault(t *testing.T) {
44 | var in, out bytes.Buffer
45 |
46 | in.WriteString("123")
47 |
48 | root := &testComponenent{
49 | router: NewWithRoutes(map[string]RouteInitializer{
50 | "default": func(Params) reactea.Component {
51 | renderer := func() string {
52 | return "Hello Default!"
53 | }
54 |
55 | return reactea.ComponentifyDumb(renderer)
56 | },
57 | }),
58 | }
59 |
60 | program := reactea.NewProgram(root, tea.WithInput(&in), tea.WithOutput(&out))
61 |
62 | if _, err := program.Run(); err != nil {
63 | t.Fatal(err)
64 | }
65 |
66 | if !strings.Contains(out.String(), "Hello Default!") {
67 | t.Fatalf("no default route message")
68 | }
69 | }
70 |
71 | func TestNonDefault(t *testing.T) {
72 | var in, out bytes.Buffer
73 |
74 | in.WriteString("123")
75 |
76 | root := &testComponenent{
77 | router: NewWithRoutes(map[string]RouteInitializer{
78 | "default": func(Params) reactea.Component {
79 | renderer := func() string {
80 | return "Hello Default!"
81 | }
82 |
83 | return reactea.ComponentifyDumb(renderer)
84 | },
85 | "/test/test": func(Params) reactea.Component {
86 | renderer := func() string {
87 | return "Hello Tests!"
88 | }
89 |
90 | return reactea.ComponentifyDumb(renderer)
91 | },
92 | }),
93 | }
94 |
95 | program := reactea.NewProgram(root, reactea.WithRoute("/test/test"), tea.WithInput(&in), tea.WithOutput(&out))
96 |
97 | if _, err := program.Run(); err != nil {
98 | t.Fatal(err)
99 | }
100 |
101 | if strings.Contains(out.String(), "Hello Default!") {
102 | t.Fatalf("got default route message")
103 | }
104 |
105 | if !strings.Contains(out.String(), "Hello Tests!") {
106 | t.Fatalf("got invalid route message")
107 | }
108 | }
109 |
110 | func TestRouteChange(t *testing.T) {
111 | var in, out bytes.Buffer
112 |
113 | in.WriteString("123")
114 |
115 | root := &testComponenent{
116 | testUpdater: func(c *testComponenent) tea.Cmd {
117 | if c.updateN == 0 {
118 | reactea.SetRoute("/test/test")
119 |
120 | return nil
121 | } else {
122 | return reactea.Destroy
123 | }
124 | },
125 | router: NewWithRoutes(map[string]RouteInitializer{
126 | "default": func(Params) reactea.Component {
127 | renderer := func() string {
128 | return "Hello Default!"
129 | }
130 |
131 | return reactea.ComponentifyDumb(renderer)
132 | },
133 | "/test/test": func(Params) reactea.Component {
134 | renderer := func() string {
135 | return "Hello Tests!"
136 | }
137 |
138 | return reactea.ComponentifyDumb(renderer)
139 | },
140 | }),
141 | }
142 |
143 | program := reactea.NewProgram(root, tea.WithInput(&in), tea.WithOutput(&out))
144 |
145 | if _, err := program.Run(); err != nil {
146 | t.Fatal(err)
147 | }
148 |
149 | if strings.Contains(out.String(), "Hello Default!") {
150 | t.Fatalf("got default route message")
151 | }
152 |
153 | if !strings.Contains(out.String(), "Hello Tests!") {
154 | t.Fatalf("got invalid route message")
155 | }
156 | }
157 |
158 | func TestNotFound(t *testing.T) {
159 | var in, out bytes.Buffer
160 |
161 | in.WriteString("123")
162 |
163 | root := &testComponenent{
164 | router: New(),
165 | }
166 |
167 | program := reactea.NewProgram(root, tea.WithInput(&in), tea.WithOutput(&out))
168 |
169 | if _, err := program.Run(); err != nil {
170 | t.Fatal(err)
171 | }
172 |
173 | if !strings.Contains(out.String(), "Couldn't route for") {
174 | t.Fatalf("got invalid route message")
175 | }
176 | }
177 |
178 | func TestRouteWithParam(t *testing.T) {
179 | var in, out bytes.Buffer
180 |
181 | in.WriteString("123")
182 |
183 | root := &testComponenent{
184 | testUpdater: func(c *testComponenent) tea.Cmd {
185 | if c.updateN == 0 {
186 | reactea.SetRoute("/test/wellDone")
187 |
188 | return nil
189 | } else {
190 | return reactea.Destroy
191 | }
192 | },
193 | router: NewWithRoutes(map[string]RouteInitializer{
194 | "default": func(Params) reactea.Component {
195 | renderer := func() string {
196 | return "Hello Default!"
197 | }
198 |
199 | return reactea.ComponentifyDumb(renderer)
200 | },
201 | "/test/:foo": func(params Params) reactea.Component {
202 | renderer := func() string {
203 | return fmt.Sprintf("Hello Tests! Param foo is %s", params["foo"])
204 | }
205 |
206 | return reactea.ComponentifyDumb(renderer)
207 | },
208 | }),
209 | }
210 |
211 | program := reactea.NewProgram(root, tea.WithInput(&in), tea.WithOutput(&out))
212 |
213 | if _, err := program.Run(); err != nil {
214 | t.Fatal(err)
215 | }
216 |
217 | if strings.Contains(out.String(), "Hello Default!") {
218 | t.Fatalf("got default route message")
219 | }
220 |
221 | if !strings.Contains(out.String(), "Hello Tests!") {
222 | t.Fatalf("got invalid route message")
223 | }
224 |
225 | if !strings.Contains(out.String(), "Hello Tests! Param foo is wellDone") {
226 | t.Fatalf("got valid route message, but most likely wrong param")
227 | }
228 | }
229 |
--------------------------------------------------------------------------------
/size.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | // Currently used as utility
4 |
5 | type Size int
6 |
7 | func (size *Size) Take(n Size) Size {
8 | if *size < Size(n) {
9 | taken := *size
10 | *size = 0
11 | return taken
12 | }
13 |
14 | *size -= Size(n)
15 |
16 | return n
17 | }
18 |
19 | func (size *Size) TakeUnsafe(n Size) Size {
20 | *size -= Size(n)
21 | return n
22 | }
23 |
24 | func (size *Size) Sub(n Size) *Size {
25 | if *size < Size(n) {
26 | *size = 0
27 | return size
28 | }
29 |
30 | *size -= Size(n)
31 | return size
32 | }
33 |
34 | func (size *Size) SubUnsafe(n Size) *Size {
35 | *size -= Size(n)
36 | return size
37 | }
38 |
39 | func (size *Size) Remaining() Size {
40 | return *size
41 | }
42 |
--------------------------------------------------------------------------------
/util.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import tea "github.com/charmbracelet/bubbletea"
4 |
5 | type RerenderMsg struct{}
6 |
7 | // Utility tea.Cmd for requesting rerender
8 | func Rerender() tea.Msg {
9 | return RerenderMsg{}
10 | }
11 |
12 | // Renders all AnyRenderers in one function
13 | //
14 | // Note: If you are using ProplessRenderer/DumbRenderer just pass
15 | // reactea.NoProps{} or struct{}{}
16 | //
17 | // Note: Using named return type for 100% coverage
18 | func RenderAny[TProps any, TRenderer AnyRenderer[TProps]](renderer TRenderer, props TProps, width, height int) (result string) {
19 | switch renderer := any(renderer).(type) {
20 | // TODO: Change to Renderer[TProps] along with
21 | // generics type-aliases feature (Planned Go 1.20)
22 | case func(TProps, int, int) string:
23 | result = renderer(props, width, height)
24 | case ProplessRenderer:
25 | result = renderer(width, height)
26 | case DumbRenderer:
27 | result = renderer()
28 | }
29 |
30 | return
31 | }
32 |
33 | // Handles rendering of all AnyProplessRenderers in one function
34 | //
35 | // Note: Using named return type for 100% coverage
36 | func RenderDumb[TRenderer AnyProplessRenderer](renderer TRenderer, width, height int) (result string) {
37 | switch renderer := any(renderer).(type) {
38 | case ProplessRenderer:
39 | result = renderer(width, height)
40 | case DumbRenderer:
41 | result = renderer()
42 | }
43 |
44 | return
45 | }
46 |
47 | // Wraps propful into propless renderer
48 | func PropfulToLess[TProps any](renderer Renderer[TProps], props TProps) ProplessRenderer {
49 | return func(width, height int) string {
50 | return renderer(props, width, height)
51 | }
52 | }
53 |
54 | // Static component for displaying static text
55 | type staticComponent struct {
56 | BasicComponent
57 |
58 | content string
59 | }
60 |
61 | func (c *staticComponent) Render(int, int) string { return c.content }
62 |
63 | func StaticComponent(content string) Component {
64 | return &staticComponent{content: content}
65 | }
66 |
67 | // Transformer for AnyRenderer -> Component
68 | type componentTransformer[TProps any, TRenderer AnyRenderer[TProps]] struct {
69 | BasicComponent
70 |
71 | props TProps
72 | renderer TRenderer
73 | }
74 |
75 | func (c *componentTransformer[TProps, TRenderer]) Render(width, height int) string {
76 | return RenderAny(c.renderer, c.props, width, height)
77 | }
78 |
79 | // Componentifies AnyRenderer
80 | // Returns uninitialized component with renderer taking care of .Render()
81 | func Componentify[TProps any, TRenderer AnyRenderer[TProps]](renderer TRenderer, props TProps) Component {
82 | return &componentTransformer[TProps, TRenderer]{renderer: renderer, props: props}
83 | }
84 |
85 | // Transformer for AnyProplessRenderer -> Component
86 | type dumbComponentTransformer[TRenderer AnyProplessRenderer] struct {
87 | BasicComponent
88 |
89 | renderer TRenderer
90 | }
91 |
92 | func (c *dumbComponentTransformer[T]) Render(width, height int) string {
93 | return RenderDumb(c.renderer, width, height)
94 | }
95 |
96 | // Componentifies AnyProplessRenderer
97 | // Returns uninitialized component with renderer taking care of .Render()
98 | func ComponentifyDumb[TRenderer AnyProplessRenderer](renderer TRenderer) Component {
99 | return &dumbComponentTransformer[TRenderer]{renderer: renderer}
100 | }
101 |
102 | // Reactifies Bubbletea's models into reactea's components
103 | type Reactified[TModel tea.Model] struct {
104 | Model TModel
105 | }
106 |
107 | // Used for tests
108 | type mockComponent[TState any] struct {
109 | initFunc func(Component, *TState) tea.Cmd
110 | updateFunc func(Component, *TState, tea.Msg) tea.Cmd
111 | renderFunc func(Component, *TState, int, int) string
112 | destroyFunc func(Component, *TState)
113 |
114 | state TState
115 | }
116 |
117 | func (c *mockComponent[TState]) Init() tea.Cmd {
118 | if c.initFunc == nil {
119 | return nil
120 | }
121 |
122 | return c.initFunc(c, &c.state)
123 | }
124 |
125 | func (c *mockComponent[TState]) Update(msg tea.Msg) tea.Cmd {
126 | if c.updateFunc == nil {
127 | return nil
128 | }
129 |
130 | return c.updateFunc(c, &c.state, msg)
131 | }
132 |
133 | func (c *mockComponent[TState]) Render(width, height int) string {
134 | if c.renderFunc == nil {
135 | return ""
136 | }
137 |
138 | return c.renderFunc(c, &c.state, width, height)
139 | }
140 |
141 | func (c *mockComponent[TState]) Destroy() {
142 | if c.destroyFunc == nil {
143 | return
144 | }
145 |
146 | c.destroyFunc(c, &c.state)
147 | }
148 |
--------------------------------------------------------------------------------
/util_test.go:
--------------------------------------------------------------------------------
1 | package reactea
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestRenderAny(t *testing.T) {
8 | t.Run("renderer", func(t *testing.T) {
9 | renderer := func(struct{}, int, int) string {
10 | return "working"
11 | }
12 |
13 | if result := RenderAny(renderer, struct{}{}, 1, 1); result != "working" {
14 | t.Errorf("invalid result, expected \"working\", got \"%s\"", result)
15 | }
16 | })
17 |
18 | t.Run("proplessRenderer", func(t *testing.T) {
19 | proplessRenderer := func(int, int) string {
20 | return "working"
21 | }
22 |
23 | if result := RenderAny(proplessRenderer, struct{}{}, 1, 1); result != "working" {
24 | t.Errorf("invalid result, expected \"working\", got \"%s\"", result)
25 | }
26 | })
27 |
28 | t.Run("dumbRenderer", func(t *testing.T) {
29 | dumbRenderer := func() string {
30 | return "working"
31 | }
32 |
33 | if result := RenderAny(dumbRenderer, struct{}{}, 1, 1); result != "working" {
34 | t.Errorf("invalid result, expected \"working\", got \"%s\"", result)
35 | }
36 | })
37 | }
38 |
39 | func TestPropfulToLess(t *testing.T) {
40 | renderer := func(struct{}, int, int) string {
41 | return "working"
42 | }
43 |
44 | proplessRenderer := PropfulToLess(renderer, struct{}{})
45 |
46 | if result := proplessRenderer(1, 1); result != "working" {
47 | t.Errorf("wrapped value doesn't render correctly, expected \"working\", got \"%s\"", result)
48 | }
49 | }
50 |
51 | func TestComponentify(t *testing.T) {
52 | t.Run("renderer", func(t *testing.T) {
53 | renderer := func(struct{}, int, int) string {
54 | return "working"
55 | }
56 |
57 | if result := Componentify(renderer, struct{}{}).Render(1, 1); result != "working" {
58 | t.Errorf("transformed value doesn't render correctly, expected \"working\", got \"%s\"", result)
59 | }
60 | })
61 |
62 | t.Run("proplessRenderer", func(t *testing.T) {
63 | proplessRenderer := func(int, int) string {
64 | return "working"
65 | }
66 |
67 | if result := Componentify(proplessRenderer, struct{}{}).Render(1, 1); result != "working" {
68 | t.Errorf("transformed value doesn't render correctly, expected \"working\", got \"%s\"", result)
69 | }
70 | })
71 |
72 | t.Run("dumbRenderer", func(t *testing.T) {
73 | dumbRenderer := func() string {
74 | return "working"
75 | }
76 |
77 | if result := Componentify(dumbRenderer, struct{}{}).Render(1, 1); result != "working" {
78 | t.Errorf("transformed value doesn't render correctly, expected \"working\", got \"%s\"", result)
79 | }
80 | })
81 | }
82 |
--------------------------------------------------------------------------------