├── .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 | [![Latest](https://img.shields.io/github/v/tag/londek/reactea?label=latest)](https://img.shields.io/github/v/tag/londek/reactea?label=latest) 6 | [![build](https://github.com/londek/reactea/actions/workflows/build.yml/badge.svg)](https://github.com/londek/reactea/actions/workflows/build.yml) 7 | ![Codecov](https://img.shields.io/codecov/c/github/Londek/reactea) 8 | [![Go Reference](https://pkg.go.dev/badge/github.com/londek/reactea.svg)](https://pkg.go.dev/github.com/londek/reactea) 9 | [![Go Report Card](https://goreportcard.com/badge/github.com/londek/reactea)](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 | ![Component lifecycle image](.github/lifecycle-diagram.png) 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 | --------------------------------------------------------------------------------