├── .gitattributes ├── .github └── workflows │ ├── build.yml │ └── vhs.yml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── Taskfile.yml ├── breadcrumb └── model.go ├── bubbleo.go ├── bubbleo_test.go ├── examples ├── deeper │ ├── README.md │ ├── artistpaintings │ │ ├── events.go │ │ └── model.go │ ├── color │ │ ├── events.go │ │ └── model.go │ ├── data │ │ └── data.go │ ├── demo.gif │ ├── demo.tape │ ├── main.go │ └── paintingcolors │ │ ├── events.go │ │ └── model.go ├── go.mod ├── go.sum └── simple │ ├── README.md │ ├── color │ ├── events.go │ └── model.go │ ├── demo.gif │ ├── demo.tape │ └── main.go ├── go.mod ├── go.sum ├── go.work ├── go.work.sum ├── menu ├── keys.go └── model.go ├── navstack ├── messages.go ├── model.go └── navitem.go ├── shell └── model.go ├── styles └── styles.go ├── utils └── utils.go └── window └── model.go /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behaviour to automatically normalize line endings. 2 | * text=auto 3 | 4 | # Force batch scripts to always use CRLF line endings so that if a repo is accessed 5 | # in Windows via a file share from Linux, the scripts will work. 6 | *.{cmd,[cC][mM][dD]} text eol=crlf 7 | *.{bat,[bB][aA][tT]} text eol=crlf 8 | 9 | # Force bash scripts to always use LF line endings so that if a repo is accessed 10 | # in Unix via a file share from Windows, the scripts will work. 11 | *.sh text eol=lf 12 | 13 | ############################### 14 | # Git Large File System (LFS) # 15 | ############################### 16 | 17 | *.gif filter=lfs diff=lfs merge=lfs -text 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [^1] 8 | os: [ubuntu-latest, macos-latest, windows-latest] 9 | runs-on: ${{ matrix.os }} 10 | env: 11 | GO111MODULE: "on" 12 | steps: 13 | - name: Install Go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: ${{ matrix.go-version }} 17 | 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Download Go modules 22 | run: go mod download 23 | 24 | - name: Build 25 | run: go build -v ./... 26 | 27 | - name: Test 28 | run: go test ./... 29 | -------------------------------------------------------------------------------- /.github/workflows/vhs.yml: -------------------------------------------------------------------------------- 1 | name: vhs 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | paths: 9 | - "**/*.tape" 10 | 11 | jobs: 12 | vhs: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - name: Install Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: 1.22.0 21 | 22 | - name: Build Examples 23 | run: go build -o ./bin/deeper ./examples/deeper/*.go && go build -o ./bin/simple ./examples/simple/*.go 24 | 25 | - name: Create deeper demo GIF 26 | uses: charmbracelet/vhs-action@v2 27 | env: 28 | TERM: xterm-256color 29 | with: 30 | path: ./examples/deeper/demo.tape 31 | 32 | - name: Move deeper demo GIF to examples/deeper 33 | run: mv demo.gif examples/deeper/demo.gif 34 | 35 | - name: Create simple demo GIF 36 | uses: charmbracelet/vhs-action@v2 37 | env: 38 | TERM: xterm-256color 39 | with: 40 | path: ./examples/simple/demo.tape 41 | 42 | - name: Move simple demo GIF to examples/simple 43 | run: mv demo.gif examples/simple/demo.gif 44 | 45 | - name: Commit and push demo.gif 46 | uses: stefanzweifel/git-auto-commit-action@v4 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | commit_message: Generated demo VHS GIFs 51 | branch: main 52 | commit_user_name: vhs-action 📼 53 | commit_user_email: actions@github.com 54 | commit_author: vhs-action 📼 55 | file_pattern: "*.gif" 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | bin/* 3 | .task -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach to Simple Example", 9 | "type": "go", 10 | "debugAdapter": "dlv-dap", 11 | "request": "attach", 12 | "mode": "remote", 13 | "remotePath": "${workspaceFolder}/examples/simple/", 14 | "port": 2345, 15 | "host": "127.0.0.1", 16 | "preLaunchTask": "Run simple headless dlv" // Here ! 17 | }, 18 | { 19 | "name": "Attach to Deeper Example", 20 | "type": "go", 21 | "debugAdapter": "dlv-dap", 22 | "request": "attach", 23 | "mode": "remote", 24 | "remotePath": "${workspaceFolder}/examples/deeper/", 25 | "port": 2345, 26 | "host": "127.0.0.1", 27 | "preLaunchTask": "Run deeper headless dlv" // Here ! 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // .vscode/tasks.json 2 | { 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "label": "Run simple headless dlv", 7 | "type": "process", 8 | "command": ["dlv"], 9 | "args": [ 10 | "debug", 11 | "--headless", 12 | "--listen=:2345", 13 | "--api-version=2", 14 | "${workspaceFolder}/examples/simple/main.go" 15 | ], 16 | "isBackground": true, 17 | "problemMatcher": { 18 | "owner": "go", 19 | "fileLocation": "relative", 20 | "pattern": { 21 | "regexp": "^couldn't start listener:" // error if matched 22 | }, 23 | "background": { 24 | "activeOnStart": true, 25 | "beginsPattern": "^API server listening at:", 26 | "endsPattern": "^Got a connection, launched process" // success if matched 27 | } 28 | } 29 | }, 30 | { 31 | "label": "Run deeper headless dlv", 32 | "type": "process", 33 | "command": ["dlv"], 34 | "args": [ 35 | "debug", 36 | "--headless", 37 | "--listen=:2345", 38 | "--api-version=2", 39 | "${workspaceFolder}/examples/deeper/main.go" 40 | ], 41 | "isBackground": true, 42 | "problemMatcher": { 43 | "owner": "go", 44 | "fileLocation": "relative", 45 | "pattern": { 46 | "regexp": "^couldn't start listener:" // error if matched 47 | }, 48 | "background": { 49 | "activeOnStart": true, 50 | "beginsPattern": "^API server listening at:", 51 | "endsPattern": "^Got a connection, launched process" // success if matched 52 | } 53 | } 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 PBGB Creative LLC, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BubbleO 2 | 3 | BubbleO is a collection of components for the excellent terminal UI tool [bubbletea](https://github.com/charmbracelet/bubbletea). 4 | 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/kevm/bubbleo.svg)](https://pkg.go.dev/github.com/kevm/bubbleo) 6 | 7 | 8 | ```bash 9 | go get "github.com/kevm/bubbleo" 10 | ``` 11 | ## [Navstack](https://github.com/KevM/bubbleo/blob/main/navstack/model.go) 12 | 13 | Add support to your bubble tea application to easily transition between component models. The example below uses the [menu component](https://github.com/KevM/bubbleo/blob/main/menu/model.go) to let a user pick a color from a list of artist paintings. 14 | 15 | Recording of the deeper example demo 16 | 17 | ```go 18 | s := shell.New() 19 | s.Navstack.Push(navstack.NavigationItem{Model: m, Title: "Colors"}) 20 | p := tea.NewProgram(s, tea.WithAltScreen()) 21 | 22 | _, err := p.Run() 23 | if err != nil { 24 | fmt.Println("Error running program:", err) 25 | os.Exit(1) 26 | } 27 | ``` 28 | 29 | Push, well, pushes a new navigation item on the nav stack. The title is used for breadcrumbs (more later). The model at the top of the navstack has it's Update and View funcs used effectively making it the presented component on the stack. 30 | 31 | Popping the stack will remove the topmost navigation item from the stack. 32 | 33 | ### Navigation 34 | 35 | Navigation is accomplished by your components when they publish messages like `navstack.PushNavigation{}` or `navstack.PopNavigation{}`. 36 | 37 | #### Pushing a new component onto the stack 38 | 39 | This example is from the included [menu component](https://github.com/KevM/bubbleo/blob/main/menu/model.go) which presents a list of choices. When a menu item is selected by pressing `enter` the choice's model is pushed onto the stack by publishing `navstack.PushNavigation`. 40 | 41 | ```go 42 | case tea.KeyEnter.String(): 43 | choice, ok := m.list.SelectedItem().(choiceItem) 44 | if ok { 45 | m.selected = &choice.key 46 | item := navstack.NavigationItem{Title: choice.title, Model: choice.key.Model} 47 | cmd := utils.Cmdize(navstack.PushNavigation{Item: item}) 48 | return m, cmd 49 | } 50 | ``` 51 | 52 | There is no limit to the depth of the navigation stack. And the stack components may be dynamic based on your application and user needs. 53 | 54 | > **Note:** [Cmdize](https://github.com/KevM/bubbleo/tree/main/utils/utils.go) simply wraps the given arg in a `tea.Cmd` (func that returns a `tea.Msg`) 55 | 56 | #### Popping the stack 57 | 58 | To pop a component off the stack you might do the following in your bubbletea `Update` func. 59 | 60 | ```go 61 | case color.ColorSelected: 62 | pop := utils.Cmdize(navstack.PopNavigation{}) 63 | cmd := utils.Cmdize(ArtistSelected{Name: m.Artist.Name, Color: msg.RGB}) 64 | return m, tea.Sequence(pop, cmd) 65 | ``` 66 | 67 | The first cmd pops the current component off the stack while the second command is received by the previous component on the stack. This allows you to communicate the actions taken by the user up the nav stack. 68 | 69 | If there are no items on the stack `tea.Quit` command is sent and the applicaiton exits. 70 | 71 | > **Important:** In this example we are using `tea.Sequence` rather than the normal `tea.Batch` to ensure the messages are played in the correct order. This ensures the pop is played before the `ArtistSelected` which means the component below on the stack will get the message after the navstack is update. 72 | 73 | ## Menu 74 | 75 | The menu component wraps the excellent The [bubble/list component](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-default) to let the user select from a menu of choices. Each choice is a model, title, and optional description which upon selection will take the user to this component's view. Menu expects to be used within a navigation stack. See the [simple](https://github.com/KevM/bubbleo/tree/main/examples/simple) and [deeper](https://github.com/KevM/bubbleo/tree/main/examples/deeper) examples for detailed usage. 76 | 77 | ## Breadcrumb 78 | 79 | It is handy to give the user a sense of place by presenting a [breadcrumb view](https://www.smashingmagazine.com/2022/04/breadcrumbs-ux-design/) showing them where they come from. 80 | 81 | Checkout the deeper example for usage of breadcrumbs. 82 | 83 | ## Shell 84 | 85 | Finally the shell component emcapsulates the Breadcrumb and Navstack components allowing them to work together. See the [simple](https://github.com/KevM/bubbleo/tree/main/examples/simple) and [deeper](https://github.com/KevM/bubbleo/tree/main/examples/deeper) examples to see how it works. 86 | 87 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | tasks: 4 | default: 5 | deps: 6 | - go-build 7 | 8 | clean: 9 | deps: 10 | - go-clean 11 | 12 | # Things we need to install to do dev work 13 | # brew install ffmpeg 14 | dev-deps: 15 | cmds: 16 | - go install github.com/charmbracelet/vhs@latest 17 | 18 | ########################################################### 19 | ## Golang 20 | 21 | go-tidy: 22 | cmds: 23 | - go mod tidy 24 | 25 | go-build: 26 | deps: 27 | - deeper 28 | - simple 29 | 30 | go-clean: 31 | cmds: 32 | - rm -f bin/* 33 | 34 | go-update: 35 | cmds: 36 | - go get -u ./... 37 | 38 | deeper: 39 | deps: [go-tidy] 40 | cmds: 41 | - go build -o bin/deeper examples/deeper/*.go 42 | sources: 43 | - ./**/*.go 44 | generates: 45 | - bin/deeper 46 | 47 | simple: 48 | deps: [go-tidy] 49 | cmds: 50 | - go build -o bin/simple examples/simple/*.go 51 | sources: 52 | - ./**/*.go 53 | generates: 54 | - bin/simple 55 | 56 | -------------------------------------------------------------------------------- /breadcrumb/model.go: -------------------------------------------------------------------------------- 1 | // Package Breadcrumb is a component that consumes a pointer ot a navstack.Model 2 | // and renders a breadcrumb trail. It is used to give the user a sense of where 3 | // they are in the application. 4 | package breadcrumb 5 | 6 | import ( 7 | "strings" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | "github.com/kevm/bubbleo/navstack" 12 | "github.com/kevm/bubbleo/styles" 13 | ) 14 | 15 | type BreadcrumbStyles struct { 16 | Frame lipgloss.Style 17 | Delimiter string 18 | } 19 | 20 | type Model struct { 21 | Navstack *navstack.Model 22 | Styles BreadcrumbStyles 23 | } 24 | 25 | func New(n *navstack.Model) Model { 26 | return Model{ 27 | Navstack: n, 28 | Styles: DefaultStyles(), 29 | } 30 | } 31 | 32 | func (m Model) Init() tea.Cmd { 33 | return nil 34 | } 35 | 36 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 37 | return m, nil 38 | } 39 | 40 | func (m Model) View() string { 41 | b := strings.Builder{} 42 | 43 | for i, c := range m.Navstack.StackSummary() { 44 | if i != 0 { 45 | b.WriteString(m.Styles.Delimiter) 46 | } 47 | b.WriteString(c) 48 | } 49 | crumbs := b.String() 50 | return m.Styles.Frame.Render(crumbs) 51 | } 52 | 53 | func DefaultStyles() BreadcrumbStyles { 54 | return BreadcrumbStyles{ 55 | Frame: styles.BreadCrumbFrameStyle, 56 | Delimiter: " > ", 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /bubbleo.go: -------------------------------------------------------------------------------- 1 | // Bubbleo is a collection of [BubbleTea] components for building robust terminal user interfaces. 2 | // Initially we are supporting hierarchical menus, via a navigation stack and supporting components such as 3 | // breadcrumbs, menus, and a composit component called shell which puts them all together. 4 | // [BubbleTea]: https://github.com/charmbracelet/bubbletea 5 | package bubbleo 6 | -------------------------------------------------------------------------------- /bubbleo_test.go: -------------------------------------------------------------------------------- 1 | package bubbleo 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestThings(t *testing.T) { 8 | t.Run("todo", func(t *testing.T) { 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /examples/deeper/README.md: -------------------------------------------------------------------------------- 1 | # A "Deeper" Hierarchical Menu 2 | 3 | This example demonstrates using the shell component which wraps the navstack and breadcrumbs in a handy application container. There are 3 levels of menus navigating the user through the application. In this case we will go from _Artists_ to their _Paintings_ to the prominent _Colors_ used in that painting. 4 | 5 | ``` 6 | Artists -> Paintings -> Colors 7 | ``` 8 | 9 | The user can select and preview the desired color and is told which artist and painting it comes from. While this applicaton is simple it demonstrates how each level of navigation could be a more robust complex user experience. The breadcrumbs give the user a sense of place so they do not get lost within a deep hierarchy of actions. 10 | 11 | ## Pushing onto the NavStack 12 | 13 | The artists component will push paintings onto the navstack. Each Painting will push its Colors onto the navstack. 14 | 15 | ## Popping off the NavStack 16 | 17 | When a color is selected it will be popped off the navstack. But it will also emit a `ColorSelected` message. Which the Paintings component will handle and follow a similar pattern popping off the navstack and then emitting a `Painting Selected` msg. Likewise the Artist component will do the same and the menu will have all it's selections. 18 | 19 | vhs recording of this TUI example 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /examples/deeper/artistpaintings/events.go: -------------------------------------------------------------------------------- 1 | package artistpaintings 2 | 3 | type ArtistPaintingColorSelected struct { 4 | Name string 5 | Painting string 6 | Color string 7 | } 8 | -------------------------------------------------------------------------------- /examples/deeper/artistpaintings/model.go: -------------------------------------------------------------------------------- 1 | package artistpaintings 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/kevm/bubbleo/examples/deeper/data" 8 | "github.com/kevm/bubbleo/examples/deeper/paintingcolors" 9 | "github.com/kevm/bubbleo/menu" 10 | "github.com/kevm/bubbleo/navstack" 11 | "github.com/kevm/bubbleo/utils" 12 | ) 13 | 14 | type Model struct { 15 | Artist data.Artist 16 | 17 | menu menu.Model 18 | } 19 | 20 | func New(a data.Artist) Model { 21 | 22 | choices := []menu.Choice{} 23 | for _, p := range a.Paintings { 24 | choice := menu.Choice{ 25 | Title: p.Title, 26 | Description: p.Description, 27 | Model: paintingcolors.New(p), 28 | } 29 | choices = append(choices, choice) 30 | } 31 | 32 | title := fmt.Sprintf(" 🖼️ Paintings by %s", a.Name) 33 | menu := menu.New(title, choices, nil) 34 | 35 | return Model{ 36 | Artist: a, 37 | menu: menu, 38 | } 39 | } 40 | 41 | func (m Model) Init() tea.Cmd { 42 | return nil 43 | } 44 | 45 | func (m Model) Close() error { 46 | // This is called when the model is pushed and popped from the navigation stack. 47 | return nil 48 | } 49 | 50 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 51 | 52 | switch msg := msg.(type) { 53 | case tea.WindowSizeMsg: 54 | m.menu.SetSize(msg) 55 | case paintingcolors.PaintingColorSelected: 56 | pop := utils.Cmdize(navstack.PopNavigation{}) 57 | cmd := utils.Cmdize(ArtistPaintingColorSelected{Name: m.Artist.Name, Painting: msg.Painting, Color: msg.Color}) 58 | return m, tea.Sequence(pop, cmd) 59 | } 60 | 61 | updatedmenu, cmd := m.menu.Update(msg) 62 | m.menu = updatedmenu.(menu.Model) 63 | return m, cmd 64 | } 65 | 66 | func (m Model) View() string { 67 | return m.menu.View() 68 | } 69 | -------------------------------------------------------------------------------- /examples/deeper/color/events.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | type ColorSelected struct { 4 | RGB string 5 | } 6 | -------------------------------------------------------------------------------- /examples/deeper/color/model.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/kevm/bubbleo/navstack" 7 | "github.com/kevm/bubbleo/utils" 8 | ) 9 | 10 | type Model struct { 11 | RGB string 12 | Sample string 13 | } 14 | 15 | func (m Model) Init() tea.Cmd { 16 | return nil 17 | } 18 | 19 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 20 | 21 | switch msg := msg.(type) { 22 | case tea.KeyMsg: 23 | switch msg.String() { 24 | case "esc": 25 | return m, utils.Cmdize(navstack.PopNavigation{}) 26 | case "enter": 27 | pop := utils.Cmdize(navstack.PopNavigation{}) 28 | selected := utils.Cmdize(ColorSelected{RGB: m.RGB}) 29 | return m, tea.Sequence(pop, selected) 30 | } 31 | } 32 | 33 | return m, nil 34 | } 35 | 36 | func (m Model) View() string { 37 | sample := lipgloss.NewStyle(). 38 | Foreground(lipgloss.Color(m.RGB)). 39 | Render(m.Sample) 40 | 41 | return "\n" + sample + "\n\n\n\n" + "enter: select, esc: back\n" 42 | } 43 | -------------------------------------------------------------------------------- /examples/deeper/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | type Artist struct { 4 | Name string 5 | Description string 6 | Paintings []Painting 7 | } 8 | 9 | type Painting struct { 10 | Title string 11 | Description string 12 | Colors []Color 13 | } 14 | 15 | type Color struct { 16 | RGB string 17 | Sample string 18 | } 19 | 20 | func GetArtists() []Artist { 21 | pp := Artist{ 22 | Name: "Pablo Picasso", 23 | Description: "Pablo Picasso was a Spanish painter, sculptor, printmaker, ceramicist and theatre designer who spent most of his adult life in France.", 24 | Paintings: []Painting{ 25 | { 26 | Title: "Guernica", 27 | Description: "Guernica is a large 1937 oil painting on canvas by Spanish artist Pablo Picasso. One of Picasso's best known works, Guernica is regarded by many art critics as one of the most moving and powerful anti-war paintings in history.", 28 | Colors: []Color{ 29 | { 30 | RGB: "#000000", //black 31 | Sample: "Black of the bull's eye facing down a matador it does not see.", 32 | }, 33 | { 34 | RGB: "#FFFFFF", //white 35 | Sample: "White of the background rendered against the sun.", 36 | }, 37 | { 38 | RGB: "#808080", //grey 39 | Sample: "So grey the Spanish Civil War returned to present day.", 40 | }, 41 | }, 42 | }, 43 | }, 44 | } 45 | 46 | vermeer := Artist{ 47 | Name: "Johannes Vermeer", 48 | Description: "His luminous paintings are celebrated for their exquisite portrayal of light, form, and serene dignity.", 49 | Paintings: []Painting{ 50 | { 51 | Title: "Girl With A Pearl Earring", 52 | Description: "A young woman wearing an exotic dress and turban by dutch master's standards looking side long at the viewer with a large pearlescent earning.", 53 | Colors: []Color{ 54 | { 55 | RGB: "#ffff00", //yellow 56 | Sample: "The coat and turban have yellow accents with a golden glow.", 57 | }, 58 | { 59 | RGB: "#0000ff", //blue 60 | Sample: "Hat's blue and stunning as the model's distain.", 61 | }, 62 | { 63 | RGB: "#cc7722", 64 | Sample: "Skin tones so ochre they burst with sun burn.", 65 | }, 66 | }, 67 | }, 68 | { 69 | Title: "Girl Reading a Letter at an Open Window", 70 | Description: "This captivating painting depicts a young woman standing by an open window, absorbed in reading a letter.", 71 | Colors: []Color{ 72 | { 73 | RGB: "#00ff00", //green 74 | Sample: "If only the green curtain could speak.", 75 | }, 76 | { 77 | RGB: "#ff0000", //red 78 | Sample: "The window drapes are the cheeryest thing in the room.", 79 | }, 80 | }, 81 | }, 82 | { 83 | Title: "Milkmaid", 84 | Description: "The scene exudes domestic tranquility and everyday beauty", 85 | Colors: []Color{ 86 | { 87 | RGB: "#ffff00", 88 | Sample: "Yellow was a popular color for the Dutch.", 89 | }, 90 | { 91 | RGB: "#fdfff5", 92 | Sample: "There is a milk the color of her bonet", 93 | }, 94 | }, 95 | }, 96 | }, 97 | } 98 | 99 | return []Artist{pp, vermeer} 100 | } 101 | -------------------------------------------------------------------------------- /examples/deeper/demo.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:34742dd68660b2f0d7e4c2a6a6cd0ebd40071285cfafecc63f5f539f144195f0 3 | size 1016829 4 | -------------------------------------------------------------------------------- /examples/deeper/demo.tape: -------------------------------------------------------------------------------- 1 | Output demo.gif 2 | Require echo 3 | Set FontSize 36 4 | Set Width 1800 5 | Set Height 900 6 | Set PlaybackSpeed 0.5 7 | 8 | Set Shell bash 9 | Sleep 0.5s 10 | Type "./bin/deeper" 11 | Enter 12 | Sleep 1.5s 13 | Down 14 | Sleep 1s 15 | Enter 16 | Sleep 1.5s 17 | Down 2 18 | Sleep 1s 19 | Enter 20 | Sleep 1s 21 | Down 22 | Sleep 1s 23 | Up 24 | Sleep 1s 25 | Enter 26 | Sleep 2s 27 | Escape 28 | Sleep 1.5s 29 | Up 30 | Sleep 1s 31 | Escape 32 | Up 2 33 | Sleep 1s 34 | Enter 35 | Down 2 36 | Sleep 1s 37 | Enter 38 | Sleep 2s 39 | Enter 40 | Sleep 4s 41 | -------------------------------------------------------------------------------- /examples/deeper/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/kevm/bubbleo/examples/deeper/artistpaintings" 11 | "github.com/kevm/bubbleo/examples/deeper/data" 12 | "github.com/kevm/bubbleo/menu" 13 | "github.com/kevm/bubbleo/navstack" 14 | "github.com/kevm/bubbleo/shell" 15 | ) 16 | 17 | var docStyle = lipgloss.NewStyle() 18 | 19 | type model struct { 20 | SelectedArtist string 21 | SelectedPainting string 22 | SelectedColor string 23 | 24 | menu menu.Model 25 | } 26 | 27 | func (m model) Init() tea.Cmd { 28 | return nil 29 | } 30 | 31 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 32 | switch msg := msg.(type) { 33 | case artistpaintings.ArtistPaintingColorSelected: 34 | m.SelectedArtist = msg.Name 35 | m.SelectedPainting = msg.Painting 36 | m.SelectedColor = msg.Color 37 | return m, tea.Quit 38 | case tea.KeyMsg: 39 | if msg.String() == "ctrl+c" { 40 | return m, tea.Quit 41 | } 42 | case tea.WindowSizeMsg: 43 | m.menu.SetSize(msg) 44 | return m, nil 45 | } 46 | 47 | updatedmenu, cmd := m.menu.Update(msg) 48 | m.menu = updatedmenu.(menu.Model) 49 | return m, cmd 50 | } 51 | 52 | func (m model) View() string { 53 | menu := m.menu.View() 54 | return docStyle.Render(menu) 55 | } 56 | 57 | func main() { 58 | 59 | artists := data.GetArtists() 60 | choices := make([]menu.Choice, len(artists)) 61 | for i, a := range artists { 62 | choices[i] = menu.Choice{ 63 | Title: a.Name, 64 | Description: a.Description, 65 | Model: artistpaintings.New(a), 66 | } 67 | } 68 | 69 | title := "Choose an Artist:" 70 | m := model{ 71 | menu: menu.New(title, choices, nil), 72 | } 73 | 74 | s := shell.New() 75 | navItem := navstack.NavigationItem{Model: m, Title: "Artists"} 76 | s.Navstack.Push(navItem) 77 | p := tea.NewProgram(s, tea.WithAltScreen()) 78 | 79 | finalshell, err := p.Run() 80 | if err != nil { 81 | fmt.Println("Error running program:", err) 82 | os.Exit(1) 83 | } 84 | 85 | // the resulting model is a navstack. With the top model being the one that quit. 86 | topNavItem := finalshell.(shell.Model).Navstack.Top() 87 | if topNavItem == nil { 88 | log.Printf("Nothing selected") 89 | os.Exit(1) 90 | } 91 | 92 | selected := topNavItem.Model.(model) 93 | 94 | result := fmt.Sprintf("You selected the color %s from the painting %s by the artist %s ", selected.SelectedColor, selected.SelectedPainting, selected.SelectedArtist) 95 | log.Println(docStyle.Copy().Foreground(lipgloss.Color(selected.SelectedColor)).Render(result)) 96 | } 97 | -------------------------------------------------------------------------------- /examples/deeper/paintingcolors/events.go: -------------------------------------------------------------------------------- 1 | package paintingcolors 2 | 3 | type PaintingColorSelected struct { 4 | Painting string 5 | Color string 6 | } 7 | -------------------------------------------------------------------------------- /examples/deeper/paintingcolors/model.go: -------------------------------------------------------------------------------- 1 | package paintingcolors 2 | 3 | import ( 4 | "fmt" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/kevm/bubbleo/examples/deeper/color" 9 | "github.com/kevm/bubbleo/examples/deeper/data" 10 | "github.com/kevm/bubbleo/menu" 11 | "github.com/kevm/bubbleo/navstack" 12 | "github.com/kevm/bubbleo/utils" 13 | ) 14 | 15 | type Model struct { 16 | Painting data.Painting 17 | 18 | menu menu.Model 19 | } 20 | 21 | func New(painting data.Painting) Model { 22 | 23 | choices := []menu.Choice{} 24 | for _, c := range painting.Colors { 25 | choice := menu.Choice{ 26 | Title: lipgloss.NewStyle().Foreground(lipgloss.Color(c.RGB)).Render(c.RGB), 27 | Description: c.Sample, 28 | Model: color.Model{ 29 | RGB: c.RGB, 30 | Sample: c.Sample, 31 | }, 32 | } 33 | choices = append(choices, choice) 34 | } 35 | 36 | title := fmt.Sprintf(" 🎨 Colors featured in %s", painting.Title) 37 | menu := menu.New(title, choices, nil) 38 | 39 | return Model{ 40 | Painting: painting, 41 | menu: menu, 42 | } 43 | } 44 | 45 | func (m Model) Init() tea.Cmd { 46 | return nil 47 | } 48 | 49 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 50 | 51 | switch msg := msg.(type) { 52 | case tea.WindowSizeMsg: 53 | m.menu.SetSize(msg) 54 | case color.ColorSelected: 55 | pop := utils.Cmdize(navstack.PopNavigation{}) 56 | result := PaintingColorSelected{ 57 | Painting: m.Painting.Title, 58 | Color: msg.RGB, 59 | } 60 | cmd := utils.Cmdize(result) 61 | return m, tea.Sequence(pop, cmd) 62 | } 63 | 64 | updatedmenu, cmd := m.menu.Update(msg) 65 | m.menu = updatedmenu.(menu.Model) 66 | return m, cmd 67 | } 68 | 69 | func (m Model) View() string { 70 | return m.menu.View() 71 | } 72 | -------------------------------------------------------------------------------- /examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kevm/bubbleo/examples 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v0.25.0 7 | github.com/charmbracelet/lipgloss v0.9.1 8 | github.com/kevm/bubbleo v0.0.0-20240222171125-3f793cc03950 9 | ) 10 | 11 | require ( 12 | github.com/atotto/clipboard v0.1.4 // indirect 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 14 | github.com/charmbracelet/bubbles v0.18.0 // indirect 15 | github.com/containerd/console v1.0.4 // indirect 16 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 17 | github.com/mattn/go-isatty v0.0.20 // indirect 18 | github.com/mattn/go-localereader v0.0.1 // indirect 19 | github.com/mattn/go-runewidth v0.0.15 // indirect 20 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 21 | github.com/muesli/cancelreader v0.2.2 // indirect 22 | github.com/muesli/reflow v0.3.0 // indirect 23 | github.com/muesli/termenv v0.15.2 // indirect 24 | github.com/rivo/uniseg v0.4.7 // indirect 25 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect 26 | golang.org/x/sync v0.6.0 // indirect 27 | golang.org/x/sys v0.17.0 // indirect 28 | golang.org/x/term v0.17.0 // indirect 29 | golang.org/x/text v0.14.0 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /examples/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.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 8 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 9 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 10 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 11 | github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= 12 | github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 13 | github.com/kevm/bubbleo v0.0.0-20240222171125-3f793cc03950 h1:Yk7UJ5rVZiRECCR3gxql3HZLSvOOmvvt7buJ7DgxSK0= 14 | github.com/kevm/bubbleo v0.0.0-20240222171125-3f793cc03950/go.mod h1:5O+ivBYuSP5/EzlyRVtrNSxHTG9PCwGFzNIz3OTIUzM= 15 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 16 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 17 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 18 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 19 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 20 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 21 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 22 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 23 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 24 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 25 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 26 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 27 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 28 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 29 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 30 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 31 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 32 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 33 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 34 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 35 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 36 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 37 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 38 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= 39 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 40 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 41 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 42 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 45 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 46 | golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= 47 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 48 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 49 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 50 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | # Simple Menu 2 | 3 | This example demonstrates a very basic use of menu where choices are rendered and can be dismissed. A menu of color choices is presented. Each choice renders a color component demonstrating the color. Selecting a color updates the application state and exits. 4 | 5 | gif 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/simple/color/events.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | type ColorSelected struct { 4 | RGB string 5 | } 6 | -------------------------------------------------------------------------------- /examples/simple/color/model.go: -------------------------------------------------------------------------------- 1 | package color 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/kevm/bubbleo/navstack" 7 | "github.com/kevm/bubbleo/utils" 8 | ) 9 | 10 | type Model struct { 11 | RGB string 12 | Sample string 13 | } 14 | 15 | func (m Model) Init() tea.Cmd { 16 | return nil 17 | } 18 | 19 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 20 | switch msg := msg.(type) { 21 | case tea.KeyMsg: 22 | switch msg.String() { 23 | case "esc": 24 | pop := utils.Cmdize(navstack.PopNavigation{}) 25 | return m, pop 26 | case "enter": 27 | pop := utils.Cmdize(navstack.PopNavigation{}) 28 | selected := utils.Cmdize(ColorSelected{m.RGB}) 29 | return m, tea.Sequence(pop, selected) 30 | case "ctrl+c": 31 | return m, tea.Quit 32 | } 33 | } 34 | 35 | return m, nil 36 | } 37 | 38 | func (m Model) View() string { 39 | sample := lipgloss.NewStyle(). 40 | Foreground(lipgloss.Color(m.RGB)). 41 | Render(m.Sample) 42 | 43 | return "\n" + sample + "\n\n\n\n" + "enter: select, esc: back\n" 44 | } 45 | -------------------------------------------------------------------------------- /examples/simple/demo.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:56ce8ee003830bd76f20ee5b1b0b567e8ada0640c8b7c7e7cde40b8ee657dc98 3 | size 346309 4 | -------------------------------------------------------------------------------- /examples/simple/demo.tape: -------------------------------------------------------------------------------- 1 | Output demo.gif 2 | 3 | Require echo 4 | 5 | Set FontSize 36 6 | Set Width 1800 7 | Set Height 900 8 | Set PlaybackSpeed 0.5 9 | 10 | Set Shell bash 11 | Sleep 1s 12 | Type "./bin/simple" 13 | Enter 14 | Sleep 500ms 15 | Down 16 | Sleep 1.5s 17 | Down 18 | Sleep 1.5s 19 | Up 20 | Sleep 1.5s 21 | Enter 22 | Sleep 3s 23 | Escape 24 | Sleep 1s 25 | Down 26 | Sleep 1.5s 27 | Enter 28 | Sleep 2s 29 | Enter 30 | Sleep 5s 31 | 32 | -------------------------------------------------------------------------------- /examples/simple/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/kevm/bubbleo/examples/simple/color" 11 | "github.com/kevm/bubbleo/menu" 12 | "github.com/kevm/bubbleo/navstack" 13 | "github.com/kevm/bubbleo/shell" 14 | ) 15 | 16 | var docStyle = lipgloss.NewStyle() 17 | 18 | type model struct { 19 | SelectedColor string 20 | menu menu.Model 21 | } 22 | 23 | func (m model) Init() tea.Cmd { 24 | return nil 25 | } 26 | 27 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 28 | switch msg := msg.(type) { 29 | case color.ColorSelected: 30 | m.SelectedColor = msg.RGB 31 | return m, tea.Quit 32 | case tea.KeyMsg: 33 | switch msg.String() { 34 | case "ctrl+c": 35 | return m, tea.Quit 36 | } 37 | case tea.WindowSizeMsg: 38 | m.menu.SetSize(msg) 39 | return m, nil 40 | } 41 | 42 | updatedmenu, cmd := m.menu.Update(msg) 43 | m.menu = updatedmenu.(menu.Model) 44 | return m, cmd 45 | } 46 | 47 | func (m model) View() string { 48 | return docStyle.Render(m.menu.View()) 49 | } 50 | 51 | func main() { 52 | red := menu.Choice{ 53 | Title: "Red Envy", 54 | Description: "Raindrops on roses", 55 | Model: color.Model{RGB: "#FF0000", Sample: "❤️ Love makes the world go around ❤️"}, 56 | } 57 | 58 | green := menu.Choice{ 59 | Title: "Green Grass", 60 | Description: "Green grows the grass over thy neighbors septic tank", 61 | Model: color.Model{RGB: "#00FF00", Sample: "☘️ The luck you make for yourself ☘️"}, 62 | } 63 | 64 | blue := menu.Choice{ 65 | Title: "Blue Shoes", 66 | Description: "But did he cry?! No!", 67 | Model: color.Model{RGB: "#0000FF", Sample: "🧿 Never forget what it's like to feel young 🧿"}, 68 | } 69 | 70 | choices := []menu.Choice{red, green, blue} 71 | 72 | title := "Colorful Choices" 73 | // top, side := docStyle.GetFrameSize() 74 | // w := window.New(120, 25, top, side) 75 | // ns := navstack.New(&w) 76 | m := model{ 77 | menu: menu.New(title, choices, nil), 78 | } 79 | s := shell.New() 80 | s.Navstack.Push(navstack.NavigationItem{Model: m, Title: "🎨 Colors"}) 81 | p := tea.NewProgram(s, tea.WithAltScreen()) 82 | 83 | finalshell, err := p.Run() 84 | if err != nil { 85 | fmt.Println("Error running program:", err) 86 | os.Exit(1) 87 | } 88 | 89 | topNavItem := finalshell.(shell.Model).Navstack.Top() 90 | if topNavItem == nil { 91 | log.Printf("Nothing selected") 92 | os.Exit(1) 93 | } 94 | 95 | selected := topNavItem.Model.(model) 96 | log.Printf("You selected the color: %s", selected.SelectedColor) 97 | } 98 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kevm/bubbleo 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.18.0 7 | github.com/charmbracelet/bubbletea v0.25.0 8 | github.com/charmbracelet/lipgloss v0.9.1 9 | ) 10 | 11 | require ( 12 | github.com/atotto/clipboard v0.1.4 // indirect 13 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 14 | github.com/containerd/console v1.0.4 // indirect 15 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 16 | github.com/mattn/go-isatty v0.0.20 // indirect 17 | github.com/mattn/go-localereader v0.0.1 // indirect 18 | github.com/mattn/go-runewidth v0.0.15 // indirect 19 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 20 | github.com/muesli/cancelreader v0.2.2 // indirect 21 | github.com/muesli/reflow v0.3.0 // indirect 22 | github.com/muesli/termenv v0.15.2 // indirect 23 | github.com/rivo/uniseg v0.4.7 // indirect 24 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f // indirect 25 | golang.org/x/sync v0.6.0 // indirect 26 | golang.org/x/sys v0.17.0 // indirect 27 | golang.org/x/term v0.17.0 // indirect 28 | golang.org/x/text v0.14.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /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.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 8 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 9 | github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= 10 | github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= 11 | github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= 12 | github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 13 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 14 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 15 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 16 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 17 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 18 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 19 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 20 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 21 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 22 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 23 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 24 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 25 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 26 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 27 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 28 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 29 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 30 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 31 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 32 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 33 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 34 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 35 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 36 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= 37 | github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 38 | golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= 39 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 40 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 43 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 44 | golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= 45 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 46 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 47 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 48 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.22.0 2 | 3 | use ( 4 | . 5 | ./examples 6 | ) 7 | -------------------------------------------------------------------------------- /go.work.sum: -------------------------------------------------------------------------------- 1 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 2 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 3 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 4 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 5 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 6 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 7 | -------------------------------------------------------------------------------- /menu/keys.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/kevm/bubbleo/navstack" 7 | ) 8 | 9 | type KeyMap struct { 10 | Select key.Binding 11 | Back key.Binding 12 | Help key.Binding 13 | Quit key.Binding 14 | } 15 | 16 | var DefaultKeyMap = KeyMap{ 17 | Select: key.NewBinding( 18 | key.WithKeys("enter"), 19 | key.WithHelp("enter", "Select current choice"), 20 | ), 21 | Back: key.NewBinding( 22 | key.WithKeys("esc"), 23 | key.WithHelp("esc", "Back to previous view"), 24 | ), 25 | Help: key.NewBinding( 26 | key.WithKeys("?", "h"), 27 | key.WithHelp("? / h", "toggle help"), 28 | ), 29 | Quit: key.NewBinding( 30 | key.WithKeys("q", "ctrl+c"), 31 | key.WithHelp("q / ctrl+c", "quit"), 32 | ), 33 | } 34 | 35 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 36 | // of the key.Map interface. 37 | func (k KeyMap) ShortHelp() []key.Binding { 38 | return []key.Binding{k.Help, k.Quit} 39 | } 40 | 41 | // FullHelp returns keybindings for the expanded help view. It's part of the 42 | // key.Map interface. 43 | func (k KeyMap) FullHelp() [][]key.Binding { 44 | return [][]key.Binding{ 45 | {}, { 46 | k.Help, k.Quit, k.Back, k.Select, 47 | }, 48 | } 49 | } 50 | 51 | func (m Model) handleKeyMsg(keyMsg tea.KeyMsg, msg tea.Msg) (tea.Model, tea.Cmd) { 52 | 53 | if m.help.ShowAll && !key.Matches(keyMsg, DefaultKeyMap.Help) { 54 | m.help.ShowAll = false // toggle help view 55 | switch { //override escape to only close help 56 | case keyMsg.String() == tea.KeyEscape.String(): 57 | return m, nil 58 | } 59 | } 60 | 61 | switch { 62 | case key.Matches(keyMsg, DefaultKeyMap.Help): 63 | m.help.ShowAll = !m.help.ShowAll 64 | case key.Matches(keyMsg, DefaultKeyMap.Quit): 65 | return m, tea.Quit 66 | case key.Matches(keyMsg, DefaultKeyMap.Back): 67 | return m, navstack.PopNavigationCmd() 68 | case key.Matches(keyMsg, DefaultKeyMap.Select): 69 | choice, ok := m.list.SelectedItem().(choiceItem) 70 | if ok { 71 | return m.SelectChoice(choice.key) 72 | } 73 | default: 74 | l, cmd := m.list.Update(msg) 75 | m.list = l 76 | return m, cmd 77 | } 78 | 79 | return m, nil 80 | } 81 | -------------------------------------------------------------------------------- /menu/model.go: -------------------------------------------------------------------------------- 1 | // Package Menu takes a list of choices allowing the user to select a component 2 | // to push onto the navigation stack. Each choice has a title and a description and 3 | // a component model implementing [tea.Model]. 4 | // [tea.Model] https://github.com/charmbracelet/bubbletea/blob/a256e76ff5ff142d747ad833c7aa784113f8558c/tea.go#L39 5 | package menu 6 | 7 | import ( 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/key" 10 | "github.com/charmbracelet/bubbles/list" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | "github.com/kevm/bubbleo/navstack" 14 | "github.com/kevm/bubbleo/styles" 15 | "github.com/kevm/bubbleo/utils" 16 | ) 17 | 18 | type Choice struct { 19 | Title string 20 | Description string 21 | Model tea.Model 22 | } 23 | 24 | type choiceItem struct { 25 | title, desc string 26 | key Choice 27 | } 28 | 29 | func (i choiceItem) Title() string { return i.title } 30 | func (i choiceItem) Description() string { return i.desc } 31 | func (i choiceItem) FilterValue() string { return i.title + i.desc } 32 | 33 | // MenuStyles is a struct that holds the styles for the menu 34 | // This mostly a passthrough for bubble/list component styles. 35 | type MenuStyles struct { 36 | ListTitleStyle lipgloss.Style 37 | ListItemStyles list.DefaultItemStyles 38 | } 39 | 40 | type Model struct { 41 | Choices []Choice 42 | 43 | delegate list.DefaultDelegate 44 | list list.Model 45 | width int 46 | height int 47 | help.KeyMap 48 | keys KeyMap 49 | help help.Model 50 | } 51 | 52 | // New setups up a new menu model 53 | func New(title string, choices []Choice, selected *Choice) Model { 54 | 55 | styles := MenuStyles{ 56 | ListTitleStyle: styles.ListTitleStyle, 57 | ListItemStyles: list.NewDefaultItemStyles(), 58 | } 59 | 60 | delegation := list.NewDefaultDelegate() 61 | delegation.Styles = styles.ListItemStyles 62 | 63 | defaultWidth := 120 64 | defaultHeight := 20 65 | 66 | model := Model{ 67 | list: list.New([]list.Item{}, delegation, defaultWidth, defaultHeight), 68 | delegate: delegation, 69 | keys: DefaultKeyMap, 70 | help: help.New(), 71 | width: defaultWidth, 72 | height: defaultHeight, 73 | } 74 | 75 | model.list.Styles.Title = styles.ListTitleStyle 76 | model.list.Title = title 77 | model.list.SetShowPagination(true) 78 | model.list.SetShowTitle(true) 79 | model.list.SetFilteringEnabled(false) 80 | model.list.SetShowFilter(false) 81 | model.list.SetShowStatusBar(false) 82 | model.list.SetShowHelp(false) 83 | 84 | chooseKeyBinding := key.NewBinding( 85 | key.WithKeys("enter"), 86 | key.WithHelp("enter", "choose"), 87 | ) 88 | model.list.AdditionalFullHelpKeys = func() []key.Binding { 89 | return []key.Binding{chooseKeyBinding} 90 | } 91 | model.list.AdditionalShortHelpKeys = func() []key.Binding { 92 | return []key.Binding{chooseKeyBinding} 93 | } 94 | 95 | model.SetChoices(choices, selected) 96 | 97 | return model 98 | } 99 | 100 | func (m Model) Init() tea.Cmd { 101 | return nil 102 | } 103 | 104 | func (m *Model) SetChoices(choices []Choice, selected *Choice) { 105 | m.Choices = choices 106 | 107 | items := make([]list.Item, len(choices)) 108 | selectedIndex := -1 109 | for i, choice := range choices { 110 | if selected != nil && &choice == selected { 111 | selectedIndex = i 112 | } 113 | items[i] = choiceItem{title: choice.Title, desc: choice.Description, key: choice} 114 | } 115 | 116 | m.list.SetItems(items) 117 | if selected != nil { 118 | m.list.Select(selectedIndex) 119 | } 120 | } 121 | 122 | // SetStyles allows you to customize the styles used by the menu. This is mostly a passthrough 123 | // to the bubble/list component used by the menu. 124 | func (m Model) SetStyles(s MenuStyles) { 125 | m.list.Styles.Title = s.ListTitleStyle 126 | m.delegate.Styles = s.ListItemStyles 127 | } 128 | 129 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 130 | 131 | switch msg := msg.(type) { 132 | case tea.WindowSizeMsg: 133 | m.SetSize(msg) 134 | case tea.KeyMsg: 135 | return m.handleKeyMsg(msg, msg) 136 | } 137 | 138 | // No selection made yet so update the list 139 | var cmd tea.Cmd 140 | m.list, cmd = m.list.Update(msg) 141 | return m, cmd 142 | } 143 | 144 | // SelectChoice pushes the selected choice onto the navigation stack. If the choice is nil, nothing happens. 145 | func (m Model) SelectChoice(choice Choice) (Model, tea.Cmd) { 146 | item := navstack.NavigationItem{Title: choice.Title, Model: choice.Model} 147 | cmd := utils.Cmdize(navstack.PushNavigation{Item: item}) 148 | 149 | return m, cmd 150 | } 151 | 152 | // SetSize sets the size of the menu 153 | func (m *Model) SetSize(w tea.WindowSizeMsg) { 154 | m.width = w.Width 155 | m.height = w.Height 156 | m.list.SetSize(w.Width, w.Height) 157 | m.help.Width = w.Width 158 | } 159 | 160 | func (m *Model) SetShowTitle(display bool) { 161 | m.list.SetShowTitle(display) 162 | } 163 | 164 | // View renders the menu. When no choices are present, nothing is rendered. 165 | func (m Model) View() string { 166 | var help string 167 | if m.help.ShowAll { 168 | height := m.height - 5 169 | m.list.SetSize(m.width, height) 170 | help = styles.HelpStyle.Render(m.help.View(m.keys)) 171 | } 172 | 173 | // display menu if choices are present. 174 | if len(m.Choices) > 0 { 175 | return "\n" + m.list.View() + help 176 | } 177 | 178 | return "" 179 | } 180 | -------------------------------------------------------------------------------- /navstack/messages.go: -------------------------------------------------------------------------------- 1 | package navstack 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/kevm/bubbleo/utils" 6 | ) 7 | 8 | // ReloadCurrent is a message that can be sent to the menu model to reload the currently selected menu choice 9 | type ReloadCurrent struct{} 10 | 11 | // PopNavigation is a message that can be sent to the menu model to de-select the currently selected menu choice 12 | type PopNavigation struct{} 13 | 14 | type PushNavigation struct { 15 | Item NavigationItem 16 | } 17 | 18 | func PopNavigationCmd() tea.Cmd { 19 | return utils.Cmdize(PopNavigation{}) 20 | } 21 | 22 | func PushNavigationCmd(item NavigationItem) tea.Cmd { 23 | return utils.Cmdize(PushNavigation{Item: item}) 24 | } 25 | -------------------------------------------------------------------------------- /navstack/model.go: -------------------------------------------------------------------------------- 1 | // Package Navstack manages a stack of NavigationItems which can be pushed or popped from the stack. 2 | // The top most stack navigation item is used by [BubbleTea] to Update and renders it's View. 3 | // When pushing and popping items from the stack, the new view to be presented is sent a tea.WindowSizeMsg 4 | // to ensure it's view can be presented correctly. When the last item is popped from the stack the application will quit. 5 | // NavigationItem models which implement the Closable interface will have their Close method called when they are popped from the stack. 6 | // This is useful for cleaning up resources that may not be garbage collected when a view a no longer needed. 7 | // [BubbleTea]: https://github.com/charmbracelet/bubbletea 8 | package navstack 9 | 10 | import ( 11 | "errors" 12 | 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/kevm/bubbleo/window" 15 | ) 16 | 17 | // Closable is an interface for models that have resources that need to be cleaned up when 18 | // they are no longer needed. The navigation stack checks for this interface when popping items. 19 | type Closable interface { 20 | Close() error 21 | } 22 | 23 | type Model struct { 24 | stack []NavigationItem 25 | window *window.Model 26 | } 27 | 28 | // New creates a new navigation stack model. The window is used to 29 | // constrain the view within the container of the navigation stack. 30 | func New(w *window.Model) Model { 31 | model := Model{ 32 | stack: []NavigationItem{}, 33 | window: w, 34 | } 35 | 36 | return model 37 | } 38 | 39 | func (m Model) Init() tea.Cmd { 40 | top := m.Top() 41 | if top == nil { 42 | return nil 43 | } 44 | 45 | return top.Init() 46 | } 47 | 48 | // Push pushes a new navigation item onto the stack. 49 | // The new navigation item is given a tea.WindowSizeMsg to ensure it's view can be presented correctly. 50 | // The item's Init method is called and resulting command is processed by [BubbleTea]. 51 | // If top item's model implements the Closable interface the Close method is called. 52 | // This new item will be the top most item on the stack and thus will be rendered. 53 | func (m *Model) Push(item NavigationItem) tea.Cmd { 54 | 55 | top := m.Top() 56 | if top != nil { 57 | if c, ok := top.Model.(Closable); ok { 58 | c.Close() 59 | } 60 | } 61 | 62 | initCmd := item.Init() 63 | 64 | wmsg := m.window.GetWindowSizeMsg() 65 | nim, winCmd := item.Model.Update(wmsg) 66 | item.Model = nim 67 | 68 | m.stack = append(m.stack, item) 69 | return tea.Sequence(initCmd, winCmd) 70 | } 71 | 72 | // Pop removes the top most navigation item from the stack. 73 | // If the item implements the Closable interface the Close method is called. 74 | // The new top most item on the stack is given a tea.WindowSizeMsg to ensure it's view can be presented correctly. 75 | // If there are no more items on the stack the application will quit. 76 | func (m *Model) Pop() tea.Cmd { 77 | top := m.Top() 78 | if top == nil { 79 | return tea.Quit // should not happen 80 | } 81 | 82 | if c, ok := top.Model.(Closable); ok { 83 | c.Close() 84 | } 85 | 86 | m.stack = m.stack[:len(m.stack)-1] 87 | top = m.Top() 88 | if top == nil { 89 | return tea.Quit 90 | } 91 | 92 | initCmd := top.Init() 93 | nim, winCmd := top.Model.Update(m.window.GetWindowSizeMsg()) 94 | top.Model = nim 95 | 96 | return tea.Sequence(winCmd, initCmd) 97 | } 98 | 99 | // Clear pops all the items from the stack. 100 | func (m *Model) Clear() error { 101 | var errs []error 102 | for _, item := range m.stack { 103 | if c, ok := item.Model.(Closable); ok { 104 | err := c.Close() 105 | if err != nil { 106 | errs = append(errs, err) 107 | } 108 | } 109 | } 110 | 111 | m.stack = []NavigationItem{} 112 | return errors.Join(errs...) 113 | } 114 | 115 | // Top returns the top most navigation item on the stack. 116 | func (m Model) Top() *NavigationItem { 117 | if len(m.stack) == 0 { 118 | return nil 119 | } 120 | 121 | top := m.stack[len(m.stack)-1] 122 | return &top 123 | } 124 | 125 | // StackSummary returns a list of titles for each item on the stack. 126 | // This is currently used by the breadcrumb component to render the breadcrumb trail. 127 | func (m Model) StackSummary() []string { 128 | summary := []string{} 129 | for _, item := range m.stack { 130 | summary = append(summary, item.Title) 131 | } 132 | 133 | return summary 134 | } 135 | 136 | // Update processes messages for the top most navigation item on the stack. 137 | func (m *Model) Update(msg tea.Msg) tea.Cmd { 138 | top := m.Top() 139 | switch msg := msg.(type) { 140 | case tea.WindowSizeMsg: // update the window size based on offsets 141 | if top == nil { 142 | return nil 143 | } 144 | m.window.Height = msg.Height 145 | m.window.Width = msg.Width 146 | msg.Width = m.window.Width - m.window.SideOffset 147 | msg.Height = m.window.Height - m.window.TopOffset 148 | um, cmd := top.Update(msg) 149 | m.stack[len(m.stack)-1] = um.(NavigationItem) 150 | return cmd 151 | case ReloadCurrent: 152 | if top == nil { 153 | return nil 154 | } 155 | return top.Init() 156 | case PopNavigation: 157 | cmd := m.Pop() 158 | return cmd 159 | case PushNavigation: 160 | cmd := m.Push(msg.Item) 161 | return cmd 162 | default: 163 | if top == nil { 164 | return nil 165 | } 166 | um, cmd := top.Update(msg) 167 | m.stack[len(m.stack)-1] = um.(NavigationItem) 168 | return cmd 169 | } 170 | } 171 | 172 | // View renders the top most navigation item on the stack. 173 | func (m Model) View() string { 174 | 175 | top := m.Top() 176 | if top == nil { 177 | return "" 178 | } 179 | 180 | return top.View() 181 | } 182 | -------------------------------------------------------------------------------- /navstack/navitem.go: -------------------------------------------------------------------------------- 1 | package navstack 2 | 3 | import tea "github.com/charmbracelet/bubbletea" 4 | 5 | // NavigationItem is a component that represents an item in the navigation stack. 6 | // The top most item on the stack is rendered. 7 | type NavigationItem struct { 8 | Title string 9 | Model tea.Model 10 | } 11 | 12 | // Init is called when the item is pushed onto the stack. 13 | func (n NavigationItem) Init() tea.Cmd { 14 | return n.Model.Init() 15 | } 16 | 17 | // Update receives messages when the item is on top of the stack. 18 | func (n NavigationItem) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 19 | nm, cmd := n.Model.Update(msg) 20 | n.Model = nm 21 | return n, cmd 22 | } 23 | 24 | // View is calledn when the item is on top of the stack. 25 | func (n NavigationItem) View() string { 26 | return n.Model.View() 27 | } 28 | -------------------------------------------------------------------------------- /shell/model.go: -------------------------------------------------------------------------------- 1 | // Package Shell is a basic wrapper around the navstack and breadcrumb packages 2 | // It provides a basic navigation mechanism while showing breadcrumb view of where the user is 3 | // within the navigation stack. 4 | package shell 5 | 6 | import ( 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/kevm/bubbleo/breadcrumb" 10 | "github.com/kevm/bubbleo/navstack" 11 | "github.com/kevm/bubbleo/utils" 12 | "github.com/kevm/bubbleo/window" 13 | ) 14 | 15 | type Model struct { 16 | Navstack *navstack.Model 17 | Breadcrumb breadcrumb.Model 18 | 19 | window *window.Model 20 | } 21 | 22 | // New creates a new shell model 23 | func New() Model { 24 | w := window.New(120, 30, 0, 0) 25 | ns := navstack.New(&w) 26 | bc := breadcrumb.New(&ns) 27 | 28 | return Model{ 29 | Navstack: &ns, 30 | Breadcrumb: bc, 31 | 32 | window: &w, 33 | } 34 | } 35 | 36 | // Init determines the size of the widow used by the navigation stack. 37 | func (m Model) Init() tea.Cmd { 38 | 39 | w, h := m.Breadcrumb.Styles.Frame.GetFrameSize() 40 | m.window.SideOffset = w 41 | m.window.TopOffset = h 42 | 43 | return utils.Cmdize(m.window.GetWindowSizeMsg()) 44 | } 45 | 46 | // Update passes messages to the navigation stack. 47 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 48 | cmd := m.Navstack.Update(msg) 49 | return m, cmd 50 | } 51 | 52 | // View renders the breadcrumb and the navigation stack. 53 | func (m Model) View() string { 54 | m.Breadcrumb.Styles.Delimiter = " 🤳 " 55 | bc := m.Breadcrumb.View() 56 | nav := m.Navstack.View() 57 | return lipgloss.NewStyle().Render(bc, nav) 58 | } 59 | -------------------------------------------------------------------------------- /styles/styles.go: -------------------------------------------------------------------------------- 1 | // Package styles provides default styles for the bubbleo components 2 | package styles 3 | 4 | import ( 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | var ( 9 | ListTitleStyle = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("230")).Bold(true) 10 | BreadCrumbFrameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Margin(1) 11 | HelpStyle = lipgloss.NewStyle().Padding(1, 2) 12 | ) 13 | -------------------------------------------------------------------------------- /utils/utils.go: -------------------------------------------------------------------------------- 1 | // Package Utils is for bubblo utility functions 2 | package utils 3 | 4 | import tea "github.com/charmbracelet/bubbletea" 5 | 6 | // Cmdize is a utility function to convert a given value into a `tea.Cmd` 7 | func Cmdize[T any](t T) tea.Cmd { 8 | return func() tea.Msg { 9 | return t 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /window/model.go: -------------------------------------------------------------------------------- 1 | // Package Windows holds the dimensions of the container window. 2 | // The offsets communicate parts of the window that are already in use. 3 | // This component is used by shell to adjust child components based on the window size minus the offsets. 4 | package window 5 | 6 | import tea "github.com/charmbracelet/bubbletea" 7 | 8 | type Model struct { 9 | Width int 10 | Height int 11 | TopOffset int 12 | SideOffset int 13 | } 14 | 15 | // New creates a new window model the dimensions are usualy the starter default with 16 | // future tea.WindowSizeMsg messages updating the height and width dimensions. 17 | func New(width, height, topOffset int, sideOffset int) Model { 18 | return Model{ 19 | Width: width, 20 | Height: height, 21 | TopOffset: topOffset, 22 | SideOffset: sideOffset, 23 | } 24 | } 25 | 26 | func (m Model) Init() tea.Cmd { 27 | return nil 28 | } 29 | 30 | // Update updates the window model with the new window size. 31 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 32 | switch msg := msg.(type) { 33 | case tea.WindowSizeMsg: 34 | m.Width = msg.Width 35 | m.Height = msg.Height 36 | } 37 | return m, nil 38 | } 39 | 40 | // There is no default view. 41 | func (m Model) View() string { 42 | return "" 43 | } 44 | 45 | // GetWindowSizeMsg returns a tea.WindowSizeMsg with the current window size minus the offsets. 46 | func (m Model) GetWindowSizeMsg() tea.WindowSizeMsg { 47 | return tea.WindowSizeMsg{Width: m.Width - m.SideOffset, Height: m.Height - m.TopOffset} 48 | } 49 | --------------------------------------------------------------------------------