├── .gitattributes ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── coverage.yml │ ├── dependabot-sync.yml │ ├── examples.yml │ ├── lint-sync.yml │ ├── lint.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── README.md ├── Taskfile.yaml ├── commands.go ├── commands_test.go ├── examples ├── README.md ├── altscreen-toggle │ ├── README.md │ ├── altscreen-toggle.gif │ └── main.go ├── autocomplete │ └── main.go ├── cellbuffer │ └── main.go ├── chat │ ├── README.md │ ├── chat.gif │ └── main.go ├── composable-views │ ├── README.md │ ├── composable-views.gif │ └── main.go ├── credit-card-form │ ├── README.md │ ├── credit-card-form.gif │ └── main.go ├── debounce │ ├── README.md │ ├── debounce.gif │ └── main.go ├── exec │ ├── README.md │ ├── exec.gif │ └── main.go ├── file-picker │ └── main.go ├── focus-blur │ └── main.go ├── fullscreen │ ├── README.md │ ├── fullscreen.gif │ └── main.go ├── glamour │ ├── README.md │ ├── glamour.gif │ └── main.go ├── go.mod ├── go.sum ├── help │ ├── README.md │ ├── help.gif │ └── main.go ├── http │ ├── README.md │ ├── http.gif │ └── main.go ├── list-default │ ├── README.md │ ├── list-default.gif │ └── main.go ├── list-fancy │ ├── README.md │ ├── delegate.go │ ├── list-fancy.gif │ ├── main.go │ └── randomitems.go ├── list-simple │ ├── README.md │ ├── list-simple.gif │ └── main.go ├── mouse │ └── main.go ├── package-manager │ ├── README.md │ ├── main.go │ ├── package-manager.gif │ └── packages.go ├── pager │ ├── README.md │ ├── artichoke.md │ ├── main.go │ └── pager.gif ├── paginator │ ├── README.md │ ├── main.go │ └── paginator.gif ├── pipe │ ├── README.md │ ├── main.go │ └── pipe.gif ├── prevent-quit │ └── main.go ├── progress-animated │ ├── README.md │ ├── main.go │ └── progress-animated.gif ├── progress-download │ ├── README.md │ ├── main.go │ └── tui.go ├── progress-static │ ├── README.md │ ├── main.go │ └── progress-static.gif ├── realtime │ ├── README.md │ ├── main.go │ └── realtime.gif ├── result │ ├── README.md │ ├── main.go │ └── result.gif ├── send-msg │ ├── README.md │ ├── main.go │ └── send-msg.gif ├── sequence │ ├── README.md │ ├── main.go │ └── sequence.gif ├── set-window-title │ └── main.go ├── simple │ ├── README.md │ ├── main.go │ ├── main_test.go │ ├── simple.gif │ └── testdata │ │ └── TestApp.golden ├── spinner │ ├── README.md │ ├── main.go │ └── spinner.gif ├── spinners │ ├── README.md │ ├── main.go │ └── spinners.gif ├── split-editors │ ├── README.md │ ├── main.go │ └── split-editors.gif ├── stopwatch │ ├── README.md │ ├── main.go │ └── stopwatch.gif ├── suspend │ └── main.go ├── table-resize │ └── main.go ├── table │ ├── README.md │ ├── main.go │ └── table.gif ├── tabs │ ├── README.md │ ├── main.go │ └── tabs.gif ├── textarea │ ├── README.md │ ├── main.go │ └── textarea.gif ├── textinput │ ├── README.md │ ├── main.go │ └── textinput.gif ├── textinputs │ ├── README.md │ ├── main.go │ └── textinputs.gif ├── timer │ ├── README.md │ ├── main.go │ └── timer.gif ├── tui-daemon-combo │ ├── README.md │ ├── main.go │ └── tui-daemon-combo.gif ├── views │ ├── README.md │ ├── main.go │ └── views.gif └── window-size │ └── main.go ├── exec.go ├── exec_test.go ├── focus.go ├── go.mod ├── go.sum ├── inputreader_other.go ├── inputreader_windows.go ├── key.go ├── key_other.go ├── key_sequences.go ├── key_test.go ├── key_windows.go ├── logging.go ├── logging_test.go ├── mouse.go ├── mouse_test.go ├── nil_renderer.go ├── nil_renderer_test.go ├── options.go ├── options_test.go ├── renderer.go ├── screen.go ├── screen_test.go ├── signals_unix.go ├── signals_windows.go ├── standard_renderer.go ├── tea.go ├── tea_init.go ├── tea_test.go ├── tty.go ├── tty_unix.go ├── tty_windows.go └── tutorials ├── basics ├── README.md └── main.go ├── commands ├── README.md └── main.go ├── go.mod └── go.sum /.gitattributes: -------------------------------------------------------------------------------- 1 | *.golden -text 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @meowgorithm @aymanbagabas 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report! Please fill the form below. 9 | - type: textarea 10 | id: what-happened 11 | attributes: 12 | label: What happened? 13 | description: Also tell us, what did you expect to happen? 14 | validations: 15 | required: true 16 | - type: textarea 17 | id: reproducible 18 | attributes: 19 | label: How can we reproduce this? 20 | description: | 21 | Please share a code snippet, gist, or public repository that reproduces the issue. 22 | Make sure to make the reproducible as concise as possible, 23 | with only the minimum required code to reproduce the issue. 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: version 28 | attributes: 29 | label: Which version of bubbletea are you using? 30 | description: '' 31 | render: bash 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: terminaal 36 | attributes: 37 | label: Which terminals did you reproduce this with? 38 | description: | 39 | Other helpful information: 40 | was it over SSH? 41 | On tmux? 42 | Which version of said terminal? 43 | validations: 44 | required: true 45 | - type: checkboxes 46 | id: search 47 | attributes: 48 | label: Search 49 | options: 50 | - label: | 51 | I searched for other open and closed issues and pull requests before opening this, 52 | and didn't find anything that seems related. 53 | required: true 54 | - type: textarea 55 | id: ctx 56 | attributes: 57 | label: Additional context 58 | description: Anything else you would like to add 59 | validations: 60 | required: false 61 | 62 | -------------------------------------------------------------------------------- /.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 | **Setup** 14 | Please complete the following information along with version numbers, if applicable. 15 | - OS [e.g. Ubuntu, macOS] 16 | - Shell [e.g. zsh, fish] 17 | - Terminal Emulator [e.g. kitty, iterm] 18 | - Terminal Multiplexer [e.g. tmux] 19 | 20 | **To Reproduce** 21 | Steps to reproduce the behavior: 22 | 1. Go to '...' 23 | 2. Click on '....' 24 | 3. Scroll down to '....' 25 | 4. See error 26 | 27 | **Source Code** 28 | Please include source code if needed to reproduce the behavior. 29 | 30 | **Expected behavior** 31 | A clear and concise description of what you expected to happen. 32 | 33 | **Screenshots** 34 | Add screenshots to help explain your problem. 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Discord 4 | url: https://charm.sh/discord 5 | about: Chat on our Discord. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 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 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | day: "monday" 9 | time: "05:00" 10 | timezone: "America/New_York" 11 | labels: 12 | - "dependencies" 13 | commit-message: 14 | prefix: "chore" 15 | include: "scope" 16 | 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "weekly" 21 | day: "monday" 22 | time: "05:00" 23 | timezone: "America/New_York" 24 | labels: 25 | - "dependencies" 26 | commit-message: 27 | prefix: "chore" 28 | include: "scope" 29 | 30 | - package-ecosystem: "docker" 31 | directory: "/" 32 | schedule: 33 | interval: "weekly" 34 | day: "monday" 35 | time: "05:00" 36 | timezone: "America/New_York" 37 | labels: 38 | - "dependencies" 39 | commit-message: 40 | prefix: "chore" 41 | include: "scope" 42 | 43 | - package-ecosystem: "gomod" 44 | directory: "/examples" 45 | schedule: 46 | interval: "weekly" 47 | day: "monday" 48 | time: "05:00" 49 | timezone: "America/New_York" 50 | labels: 51 | - "dependencies" 52 | commit-message: 53 | prefix: "chore" 54 | include: "scope" 55 | 56 | - package-ecosystem: "gomod" 57 | directory: "/tutorials" 58 | schedule: 59 | interval: "weekly" 60 | day: "monday" 61 | time: "05:00" 62 | timezone: "America/New_York" 63 | labels: 64 | - "dependencies" 65 | commit-message: 66 | prefix: "chore" 67 | include: "scope" 68 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | build: 6 | uses: charmbracelet/meta/.github/workflows/build.yml@main 7 | 8 | build-go-mod: 9 | uses: charmbracelet/meta/.github/workflows/build.yml@main 10 | with: 11 | go-version: "" 12 | go-version-file: ./go.mod 13 | 14 | build-examples: 15 | uses: charmbracelet/meta/.github/workflows/build.yml@main 16 | with: 17 | go-version: "" 18 | go-version-file: ./examples/go.mod 19 | working-directory: ./examples 20 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | coverage: 6 | strategy: 7 | matrix: 8 | go-version: [^1] 9 | os: [ubuntu-latest] 10 | runs-on: ${{ matrix.os }} 11 | env: 12 | GO111MODULE: "on" 13 | steps: 14 | - name: Install Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Coverage 23 | run: | 24 | go test -race -covermode=atomic -coverprofile=coverage.txt ./... 25 | 26 | - uses: codecov/codecov-action@v5 27 | with: 28 | file: ./coverage.txt 29 | token: ${{ secrets.CODECOV_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-sync.yml: -------------------------------------------------------------------------------- 1 | name: dependabot-sync 2 | on: 3 | schedule: 4 | - cron: "0 0 * * 0" # every Sunday at midnight 5 | workflow_dispatch: # allows manual triggering 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | dependabot-sync: 13 | uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main 14 | with: 15 | repo_name: ${{ github.event.repository.name }} 16 | secrets: 17 | gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/examples.yml: -------------------------------------------------------------------------------- 1 | name: examples 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | paths: 8 | - '.github/workflows/examples.yml' 9 | - './examples/go.mod' 10 | - './examples/go.sum' 11 | - './tutorials/go.mod' 12 | - './tutorials/go.sum' 13 | - './go.mod' 14 | - './go.sum' 15 | workflow_dispatch: {} 16 | 17 | jobs: 18 | tidy: 19 | permissions: 20 | contents: write 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: actions/setup-go@v5 25 | with: 26 | go-version: '^1' 27 | cache: true 28 | - shell: bash 29 | run: | 30 | (cd ./examples && go mod tidy) 31 | (cd ./tutorials && go mod tidy) 32 | - uses: stefanzweifel/git-auto-commit-action@v5 33 | with: 34 | commit_message: "chore: go mod tidy tutorials and examples" 35 | branch: master 36 | commit_user_name: actions-user 37 | commit_user_email: actions@github.com 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/lint-sync.yml: -------------------------------------------------------------------------------- 1 | name: lint-sync 2 | on: 3 | schedule: 4 | # every Sunday at midnight 5 | - cron: "0 0 * * 0" 6 | workflow_dispatch: # allows manual triggering 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | lint: 14 | uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main 15 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | lint: 8 | uses: charmbracelet/meta/.github/workflows/lint.yml@main 9 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - v*.*.* 7 | 8 | concurrency: 9 | group: goreleaser 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | goreleaser: 14 | uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main 15 | secrets: 16 | docker_username: ${{ secrets.DOCKERHUB_USERNAME }} 17 | docker_token: ${{ secrets.DOCKERHUB_TOKEN }} 18 | gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} 19 | goreleaser_key: ${{ secrets.GORELEASER_KEY }} 20 | twitter_consumer_key: ${{ secrets.TWITTER_CONSUMER_KEY }} 21 | twitter_consumer_secret: ${{ secrets.TWITTER_CONSUMER_SECRET }} 22 | twitter_access_token: ${{ secrets.TWITTER_ACCESS_TOKEN }} 23 | twitter_access_token_secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} 24 | mastodon_client_id: ${{ secrets.MASTODON_CLIENT_ID }} 25 | mastodon_client_secret: ${{ secrets.MASTODON_CLIENT_SECRET }} 26 | mastodon_access_token: ${{ secrets.MASTODON_ACCESS_TOKEN }} 27 | discord_webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }} 28 | discord_webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} 29 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .envrc 3 | 4 | examples/fullscreen/fullscreen 5 | examples/help/help 6 | examples/http/http 7 | examples/list-default/list-default 8 | examples/list-fancy/list-fancy 9 | examples/list-simple/list-simple 10 | examples/mouse/mouse 11 | examples/pager/pager 12 | examples/progress-download/color_vortex.blend 13 | examples/progress-download/progress-download 14 | examples/simple/simple 15 | examples/spinner/spinner 16 | examples/textinput/textinput 17 | examples/textinputs/textinputs 18 | examples/views/views 19 | tutorials/basics/basics 20 | tutorials/commands/commands 21 | .idea 22 | coverage.txt 23 | dist/ 24 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | tests: false 4 | linters: 5 | enable: 6 | - bodyclose 7 | - exhaustive 8 | - goconst 9 | - godot 10 | - godox 11 | - gomoddirectives 12 | - goprintffuncname 13 | - gosec 14 | - misspell 15 | - nakedret 16 | - nestif 17 | - nilerr 18 | - noctx 19 | - nolintlint 20 | - prealloc 21 | - revive 22 | - rowserrcheck 23 | - sqlclosecheck 24 | - tparallel 25 | - unconvert 26 | - unparam 27 | - whitespace 28 | - wrapcheck 29 | exclusions: 30 | generated: lax 31 | presets: 32 | - common-false-positives 33 | issues: 34 | max-issues-per-linter: 0 35 | max-same-issues: 0 36 | formatters: 37 | enable: 38 | - gofumpt 39 | - goimports 40 | exclusions: 41 | generated: lax 42 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json 2 | version: 2 3 | includes: 4 | - from_url: 5 | url: charmbracelet/meta/main/goreleaser-lib.yaml 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2023 Charmbracelet, Inc 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 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev 2 | 3 | version: '3' 4 | 5 | tasks: 6 | lint: 7 | desc: Run lint 8 | cmds: 9 | - golangci-lint run 10 | 11 | test: 12 | desc: Run tests 13 | cmds: 14 | - go test ./... {{.CLI_ARGS}} 15 | -------------------------------------------------------------------------------- /commands_test.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestEvery(t *testing.T) { 10 | expected := "every ms" 11 | msg := Every(time.Millisecond, func(t time.Time) Msg { 12 | return expected 13 | })() 14 | if expected != msg { 15 | t.Fatalf("expected a msg %v but got %v", expected, msg) 16 | } 17 | } 18 | 19 | func TestTick(t *testing.T) { 20 | expected := "tick" 21 | msg := Tick(time.Millisecond, func(t time.Time) Msg { 22 | return expected 23 | })() 24 | if expected != msg { 25 | t.Fatalf("expected a msg %v but got %v", expected, msg) 26 | } 27 | } 28 | 29 | func TestSequentially(t *testing.T) { 30 | expectedErrMsg := fmt.Errorf("some err") 31 | expectedStrMsg := "some msg" 32 | 33 | nilReturnCmd := func() Msg { 34 | return nil 35 | } 36 | 37 | tests := []struct { 38 | name string 39 | cmds []Cmd 40 | expected Msg 41 | }{ 42 | { 43 | name: "all nil", 44 | cmds: []Cmd{nilReturnCmd, nilReturnCmd}, 45 | expected: nil, 46 | }, 47 | { 48 | name: "null cmds", 49 | cmds: []Cmd{nil, nil}, 50 | expected: nil, 51 | }, 52 | { 53 | name: "one error", 54 | cmds: []Cmd{ 55 | nilReturnCmd, 56 | func() Msg { 57 | return expectedErrMsg 58 | }, 59 | nilReturnCmd, 60 | }, 61 | expected: expectedErrMsg, 62 | }, 63 | { 64 | name: "some msg", 65 | cmds: []Cmd{ 66 | nilReturnCmd, 67 | func() Msg { 68 | return expectedStrMsg 69 | }, 70 | nilReturnCmd, 71 | }, 72 | expected: expectedStrMsg, 73 | }, 74 | } 75 | for _, test := range tests { 76 | t.Run(test.name, func(t *testing.T) { 77 | if msg := Sequentially(test.cmds...)(); msg != test.expected { 78 | t.Fatalf("expected a msg %v but got %v", test.expected, msg) 79 | } 80 | }) 81 | } 82 | } 83 | 84 | func TestBatch(t *testing.T) { 85 | t.Run("nil cmd", func(t *testing.T) { 86 | if b := Batch(nil); b != nil { 87 | t.Fatalf("expected nil, got %+v", b) 88 | } 89 | }) 90 | t.Run("empty cmd", func(t *testing.T) { 91 | if b := Batch(); b != nil { 92 | t.Fatalf("expected nil, got %+v", b) 93 | } 94 | }) 95 | t.Run("single cmd", func(t *testing.T) { 96 | b := Batch(Quit)() 97 | if _, ok := b.(QuitMsg); !ok { 98 | t.Fatalf("expected a QuitMsg, got %T", b) 99 | } 100 | }) 101 | t.Run("mixed nil cmds", func(t *testing.T) { 102 | b := Batch(nil, Quit, nil, Quit, nil, nil)() 103 | if l := len(b.(BatchMsg)); l != 2 { 104 | t.Fatalf("expected a []Cmd with len 2, got %d", l) 105 | } 106 | }) 107 | } 108 | -------------------------------------------------------------------------------- /examples/altscreen-toggle/README.md: -------------------------------------------------------------------------------- 1 | # Alt Screen Toggle 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/altscreen-toggle/altscreen-toggle.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/altscreen-toggle/altscreen-toggle.gif -------------------------------------------------------------------------------- /examples/altscreen-toggle/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | var ( 12 | keywordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Background(lipgloss.Color("235")) 13 | helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) 14 | ) 15 | 16 | type model struct { 17 | altscreen bool 18 | quitting bool 19 | suspending bool 20 | } 21 | 22 | func (m model) Init() tea.Cmd { 23 | return nil 24 | } 25 | 26 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 27 | switch msg := msg.(type) { 28 | case tea.ResumeMsg: 29 | m.suspending = false 30 | return m, nil 31 | case tea.KeyMsg: 32 | switch msg.String() { 33 | case "q", "ctrl+c", "esc": 34 | m.quitting = true 35 | return m, tea.Quit 36 | case "ctrl+z": 37 | m.suspending = true 38 | return m, tea.Suspend 39 | case " ": 40 | var cmd tea.Cmd 41 | if m.altscreen { 42 | cmd = tea.ExitAltScreen 43 | } else { 44 | cmd = tea.EnterAltScreen 45 | } 46 | m.altscreen = !m.altscreen 47 | return m, cmd 48 | } 49 | } 50 | return m, nil 51 | } 52 | 53 | func (m model) View() string { 54 | if m.suspending { 55 | return "" 56 | } 57 | 58 | if m.quitting { 59 | return "Bye!\n" 60 | } 61 | 62 | const ( 63 | altscreenMode = " altscreen mode " 64 | inlineMode = " inline mode " 65 | ) 66 | 67 | var mode string 68 | if m.altscreen { 69 | mode = altscreenMode 70 | } else { 71 | mode = inlineMode 72 | } 73 | 74 | return fmt.Sprintf("\n\n You're in %s\n\n\n", keywordStyle.Render(mode)) + 75 | helpStyle.Render(" space: switch modes • ctrl-z: suspend • q: exit\n") 76 | } 77 | 78 | func main() { 79 | if _, err := tea.NewProgram(model{}).Run(); err != nil { 80 | fmt.Println("Error running program:", err) 81 | os.Exit(1) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /examples/autocomplete/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/charmbracelet/bubbles/help" 11 | "github.com/charmbracelet/bubbles/key" 12 | "github.com/charmbracelet/bubbles/textinput" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | ) 16 | 17 | func main() { 18 | p := tea.NewProgram(initialModel()) 19 | if _, err := p.Run(); err != nil { 20 | log.Fatal(err) 21 | } 22 | } 23 | 24 | type gotReposSuccessMsg []repo 25 | type gotReposErrMsg error 26 | 27 | type repo struct { 28 | Name string `json:"name"` 29 | } 30 | 31 | const reposURL = "https://api.github.com/orgs/charmbracelet/repos" 32 | 33 | func getRepos() tea.Msg { 34 | req, err := http.NewRequest(http.MethodGet, reposURL, nil) 35 | if err != nil { 36 | return gotReposErrMsg(err) 37 | } 38 | 39 | req.Header.Add("Accept", "application/vnd.github+json") 40 | req.Header.Add("X-GitHub-Api-Version", "2022-11-28") 41 | 42 | resp, err := http.DefaultClient.Do(req) 43 | if err != nil { 44 | return gotReposErrMsg(err) 45 | } 46 | defer resp.Body.Close() // nolint: errcheck 47 | 48 | data, err := io.ReadAll(resp.Body) 49 | if err != nil { 50 | return gotReposErrMsg(err) 51 | } 52 | 53 | var repos []repo 54 | 55 | err = json.Unmarshal(data, &repos) 56 | if err != nil { 57 | return gotReposErrMsg(err) 58 | } 59 | 60 | return gotReposSuccessMsg(repos) 61 | } 62 | 63 | type model struct { 64 | textInput textinput.Model 65 | help help.Model 66 | keymap keymap 67 | } 68 | 69 | type keymap struct{} 70 | 71 | func (k keymap) ShortHelp() []key.Binding { 72 | return []key.Binding{ 73 | key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "complete")), 74 | key.NewBinding(key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "next")), 75 | key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "prev")), 76 | key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "quit")), 77 | } 78 | } 79 | func (k keymap) FullHelp() [][]key.Binding { 80 | return [][]key.Binding{k.ShortHelp()} 81 | } 82 | 83 | func initialModel() model { 84 | ti := textinput.New() 85 | ti.Placeholder = "repository" 86 | ti.Prompt = "charmbracelet/" 87 | ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) 88 | ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) 89 | ti.Focus() 90 | ti.CharLimit = 50 91 | ti.Width = 20 92 | ti.ShowSuggestions = true 93 | 94 | h := help.New() 95 | 96 | km := keymap{} 97 | 98 | return model{textInput: ti, help: h, keymap: km} 99 | } 100 | 101 | func (m model) Init() tea.Cmd { 102 | return tea.Batch(getRepos, textinput.Blink) 103 | } 104 | 105 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 106 | switch msg := msg.(type) { 107 | case tea.KeyMsg: 108 | switch msg.Type { 109 | case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc: 110 | return m, tea.Quit 111 | } 112 | case gotReposSuccessMsg: 113 | var suggestions []string 114 | for _, r := range msg { 115 | suggestions = append(suggestions, r.Name) 116 | } 117 | m.textInput.SetSuggestions(suggestions) 118 | } 119 | 120 | var cmd tea.Cmd 121 | m.textInput, cmd = m.textInput.Update(msg) 122 | return m, cmd 123 | } 124 | 125 | func (m model) View() string { 126 | return fmt.Sprintf( 127 | "Pick a Charm™ repo:\n\n %s\n\n%s\n\n", 128 | m.textInput.View(), 129 | m.help.View(m.keymap), 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /examples/cellbuffer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple example demonstrating how to draw and animate on a cellular grid. 4 | // Note that the cellbuffer implementation in this example does not support 5 | // double-width runes. 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/harmonica" 15 | ) 16 | 17 | const ( 18 | fps = 60 19 | frequency = 7.5 20 | damping = 0.15 21 | asterisk = "*" 22 | ) 23 | 24 | func drawEllipse(cb *cellbuffer, xc, yc, rx, ry float64) { 25 | var ( 26 | dx, dy, d1, d2 float64 27 | x float64 28 | y = ry 29 | ) 30 | 31 | d1 = ry*ry - rx*rx*ry + 0.25*rx*rx 32 | dx = 2 * ry * ry * x 33 | dy = 2 * rx * rx * y 34 | 35 | for dx < dy { 36 | cb.set(int(x+xc), int(y+yc)) 37 | cb.set(int(-x+xc), int(y+yc)) 38 | cb.set(int(x+xc), int(-y+yc)) 39 | cb.set(int(-x+xc), int(-y+yc)) 40 | if d1 < 0 { 41 | x++ 42 | dx = dx + (2 * ry * ry) 43 | d1 = d1 + dx + (ry * ry) 44 | } else { 45 | x++ 46 | y-- 47 | dx = dx + (2 * ry * ry) 48 | dy = dy - (2 * rx * rx) 49 | d1 = d1 + dx - dy + (ry * ry) 50 | } 51 | } 52 | 53 | d2 = ((ry * ry) * ((x + 0.5) * (x + 0.5))) + ((rx * rx) * ((y - 1) * (y - 1))) - (rx * rx * ry * ry) 54 | 55 | for y >= 0 { 56 | cb.set(int(x+xc), int(y+yc)) 57 | cb.set(int(-x+xc), int(y+yc)) 58 | cb.set(int(x+xc), int(-y+yc)) 59 | cb.set(int(-x+xc), int(-y+yc)) 60 | if d2 > 0 { 61 | y-- 62 | dy = dy - (2 * rx * rx) 63 | d2 = d2 + (rx * rx) - dy 64 | } else { 65 | y-- 66 | x++ 67 | dx = dx + (2 * ry * ry) 68 | dy = dy - (2 * rx * rx) 69 | d2 = d2 + dx - dy + (rx * rx) 70 | } 71 | } 72 | } 73 | 74 | type cellbuffer struct { 75 | cells []string 76 | stride int 77 | } 78 | 79 | func (c *cellbuffer) init(w, h int) { 80 | if w == 0 { 81 | return 82 | } 83 | c.stride = w 84 | c.cells = make([]string, w*h) 85 | c.wipe() 86 | } 87 | 88 | func (c cellbuffer) set(x, y int) { 89 | i := y*c.stride + x 90 | if i > len(c.cells)-1 || x < 0 || y < 0 || x >= c.width() || y >= c.height() { 91 | return 92 | } 93 | c.cells[i] = asterisk 94 | } 95 | 96 | func (c *cellbuffer) wipe() { 97 | for i := range c.cells { 98 | c.cells[i] = " " 99 | } 100 | } 101 | 102 | func (c cellbuffer) width() int { 103 | return c.stride 104 | } 105 | 106 | func (c cellbuffer) height() int { 107 | h := len(c.cells) / c.stride 108 | if len(c.cells)%c.stride != 0 { 109 | h++ 110 | } 111 | return h 112 | } 113 | 114 | func (c cellbuffer) ready() bool { 115 | return len(c.cells) > 0 116 | } 117 | 118 | func (c cellbuffer) String() string { 119 | var b strings.Builder 120 | for i := 0; i < len(c.cells); i++ { 121 | if i > 0 && i%c.stride == 0 && i < len(c.cells)-1 { 122 | b.WriteRune('\n') 123 | } 124 | b.WriteString(c.cells[i]) 125 | } 126 | return b.String() 127 | } 128 | 129 | type frameMsg struct{} 130 | 131 | func animate() tea.Cmd { 132 | return tea.Tick(time.Second/fps, func(_ time.Time) tea.Msg { 133 | return frameMsg{} 134 | }) 135 | } 136 | 137 | type model struct { 138 | cells cellbuffer 139 | spring harmonica.Spring 140 | targetX, targetY float64 141 | x, y float64 142 | xVelocity, yVelocity float64 143 | } 144 | 145 | func (m model) Init() tea.Cmd { 146 | return animate() 147 | } 148 | 149 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 150 | switch msg := msg.(type) { 151 | case tea.KeyMsg: 152 | return m, tea.Quit 153 | case tea.WindowSizeMsg: 154 | if !m.cells.ready() { 155 | m.targetX, m.targetY = float64(msg.Width)/2, float64(msg.Height)/2 156 | } 157 | m.cells.init(msg.Width, msg.Height) 158 | return m, nil 159 | case tea.MouseMsg: 160 | if !m.cells.ready() { 161 | return m, nil 162 | } 163 | m.targetX, m.targetY = float64(msg.X), float64(msg.Y) 164 | return m, nil 165 | 166 | case frameMsg: 167 | if !m.cells.ready() { 168 | return m, nil 169 | } 170 | 171 | m.cells.wipe() 172 | m.x, m.xVelocity = m.spring.Update(m.x, m.xVelocity, m.targetX) 173 | m.y, m.yVelocity = m.spring.Update(m.y, m.yVelocity, m.targetY) 174 | drawEllipse(&m.cells, m.x, m.y, 16, 8) 175 | return m, animate() 176 | default: 177 | return m, nil 178 | } 179 | } 180 | 181 | func (m model) View() string { 182 | return m.cells.String() 183 | } 184 | 185 | func main() { 186 | m := model{ 187 | spring: harmonica.NewSpring(harmonica.FPS(fps), frequency, damping), 188 | } 189 | 190 | p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) 191 | if _, err := p.Run(); err != nil { 192 | fmt.Println("Uh oh:", err) 193 | os.Exit(1) 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /examples/chat/README.md: -------------------------------------------------------------------------------- 1 | # Chat 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/chat/chat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/chat/chat.gif -------------------------------------------------------------------------------- /examples/chat/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program demonstrating the text area component from the Bubbles 4 | // component library. 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "strings" 10 | 11 | "github.com/charmbracelet/bubbles/textarea" 12 | "github.com/charmbracelet/bubbles/viewport" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | ) 16 | 17 | const gap = "\n\n" 18 | 19 | func main() { 20 | p := tea.NewProgram(initialModel()) 21 | 22 | if _, err := p.Run(); err != nil { 23 | log.Fatal(err) 24 | } 25 | } 26 | 27 | type ( 28 | errMsg error 29 | ) 30 | 31 | type model struct { 32 | viewport viewport.Model 33 | messages []string 34 | textarea textarea.Model 35 | senderStyle lipgloss.Style 36 | err error 37 | } 38 | 39 | func initialModel() model { 40 | ta := textarea.New() 41 | ta.Placeholder = "Send a message..." 42 | ta.Focus() 43 | 44 | ta.Prompt = "┃ " 45 | ta.CharLimit = 280 46 | 47 | ta.SetWidth(30) 48 | ta.SetHeight(3) 49 | 50 | // Remove cursor line styling 51 | ta.FocusedStyle.CursorLine = lipgloss.NewStyle() 52 | 53 | ta.ShowLineNumbers = false 54 | 55 | vp := viewport.New(30, 5) 56 | vp.SetContent(`Welcome to the chat room! 57 | Type a message and press Enter to send.`) 58 | 59 | ta.KeyMap.InsertNewline.SetEnabled(false) 60 | 61 | return model{ 62 | textarea: ta, 63 | messages: []string{}, 64 | viewport: vp, 65 | senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), 66 | err: nil, 67 | } 68 | } 69 | 70 | func (m model) Init() tea.Cmd { 71 | return textarea.Blink 72 | } 73 | 74 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 75 | var ( 76 | tiCmd tea.Cmd 77 | vpCmd tea.Cmd 78 | ) 79 | 80 | m.textarea, tiCmd = m.textarea.Update(msg) 81 | m.viewport, vpCmd = m.viewport.Update(msg) 82 | 83 | switch msg := msg.(type) { 84 | case tea.WindowSizeMsg: 85 | m.viewport.Width = msg.Width 86 | m.textarea.SetWidth(msg.Width) 87 | m.viewport.Height = msg.Height - m.textarea.Height() - lipgloss.Height(gap) 88 | 89 | if len(m.messages) > 0 { 90 | // Wrap content before setting it. 91 | m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width).Render(strings.Join(m.messages, "\n"))) 92 | } 93 | m.viewport.GotoBottom() 94 | case tea.KeyMsg: 95 | switch msg.Type { 96 | case tea.KeyCtrlC, tea.KeyEsc: 97 | fmt.Println(m.textarea.Value()) 98 | return m, tea.Quit 99 | case tea.KeyEnter: 100 | m.messages = append(m.messages, m.senderStyle.Render("You: ")+m.textarea.Value()) 101 | m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width).Render(strings.Join(m.messages, "\n"))) 102 | m.textarea.Reset() 103 | m.viewport.GotoBottom() 104 | } 105 | 106 | // We handle errors just like any other message 107 | case errMsg: 108 | m.err = msg 109 | return m, nil 110 | } 111 | 112 | return m, tea.Batch(tiCmd, vpCmd) 113 | } 114 | 115 | func (m model) View() string { 116 | return fmt.Sprintf( 117 | "%s%s%s", 118 | m.viewport.View(), 119 | gap, 120 | m.textarea.View(), 121 | ) 122 | } 123 | -------------------------------------------------------------------------------- /examples/composable-views/README.md: -------------------------------------------------------------------------------- 1 | # Composable Views 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/composable-views/composable-views.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/composable-views/composable-views.gif -------------------------------------------------------------------------------- /examples/composable-views/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/spinner" 9 | "github.com/charmbracelet/bubbles/timer" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | /* 15 | This example assumes an existing understanding of commands and messages. If you 16 | haven't already read our tutorials on the basics of Bubble Tea and working 17 | with commands, we recommend reading those first. 18 | 19 | Find them at: 20 | https://github.com/charmbracelet/bubbletea/tree/master/tutorials/commands 21 | https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics 22 | */ 23 | 24 | // sessionState is used to track which model is focused 25 | type sessionState uint 26 | 27 | const ( 28 | defaultTime = time.Minute 29 | timerView sessionState = iota 30 | spinnerView 31 | ) 32 | 33 | var ( 34 | // Available spinners 35 | spinners = []spinner.Spinner{ 36 | spinner.Line, 37 | spinner.Dot, 38 | spinner.MiniDot, 39 | spinner.Jump, 40 | spinner.Pulse, 41 | spinner.Points, 42 | spinner.Globe, 43 | spinner.Moon, 44 | spinner.Monkey, 45 | } 46 | modelStyle = lipgloss.NewStyle(). 47 | Width(15). 48 | Height(5). 49 | Align(lipgloss.Center, lipgloss.Center). 50 | BorderStyle(lipgloss.HiddenBorder()) 51 | focusedModelStyle = lipgloss.NewStyle(). 52 | Width(15). 53 | Height(5). 54 | Align(lipgloss.Center, lipgloss.Center). 55 | BorderStyle(lipgloss.NormalBorder()). 56 | BorderForeground(lipgloss.Color("69")) 57 | spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) 58 | helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) 59 | ) 60 | 61 | type mainModel struct { 62 | state sessionState 63 | timer timer.Model 64 | spinner spinner.Model 65 | index int 66 | } 67 | 68 | func newModel(timeout time.Duration) mainModel { 69 | m := mainModel{state: timerView} 70 | m.timer = timer.New(timeout) 71 | m.spinner = spinner.New() 72 | return m 73 | } 74 | 75 | func (m mainModel) Init() tea.Cmd { 76 | // start the timer and spinner on program start 77 | return tea.Batch(m.timer.Init(), m.spinner.Tick) 78 | } 79 | 80 | func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 81 | var cmd tea.Cmd 82 | var cmds []tea.Cmd 83 | switch msg := msg.(type) { 84 | case tea.KeyMsg: 85 | switch msg.String() { 86 | case "ctrl+c", "q": 87 | return m, tea.Quit 88 | case "tab": 89 | if m.state == timerView { 90 | m.state = spinnerView 91 | } else { 92 | m.state = timerView 93 | } 94 | case "n": 95 | if m.state == timerView { 96 | m.timer = timer.New(defaultTime) 97 | cmds = append(cmds, m.timer.Init()) 98 | } else { 99 | m.Next() 100 | m.resetSpinner() 101 | cmds = append(cmds, m.spinner.Tick) 102 | } 103 | } 104 | switch m.state { 105 | // update whichever model is focused 106 | case spinnerView: 107 | m.spinner, cmd = m.spinner.Update(msg) 108 | cmds = append(cmds, cmd) 109 | default: 110 | m.timer, cmd = m.timer.Update(msg) 111 | cmds = append(cmds, cmd) 112 | } 113 | case spinner.TickMsg: 114 | m.spinner, cmd = m.spinner.Update(msg) 115 | cmds = append(cmds, cmd) 116 | case timer.TickMsg: 117 | m.timer, cmd = m.timer.Update(msg) 118 | cmds = append(cmds, cmd) 119 | } 120 | return m, tea.Batch(cmds...) 121 | } 122 | 123 | func (m mainModel) View() string { 124 | var s string 125 | model := m.currentFocusedModel() 126 | if m.state == timerView { 127 | s += lipgloss.JoinHorizontal(lipgloss.Top, focusedModelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), modelStyle.Render(m.spinner.View())) 128 | } else { 129 | s += lipgloss.JoinHorizontal(lipgloss.Top, modelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), focusedModelStyle.Render(m.spinner.View())) 130 | } 131 | s += helpStyle.Render(fmt.Sprintf("\ntab: focus next • n: new %s • q: exit\n", model)) 132 | return s 133 | } 134 | 135 | func (m mainModel) currentFocusedModel() string { 136 | if m.state == timerView { 137 | return "timer" 138 | } 139 | return "spinner" 140 | } 141 | 142 | func (m *mainModel) Next() { 143 | if m.index == len(spinners)-1 { 144 | m.index = 0 145 | } else { 146 | m.index++ 147 | } 148 | } 149 | 150 | func (m *mainModel) resetSpinner() { 151 | m.spinner = spinner.New() 152 | m.spinner.Style = spinnerStyle 153 | m.spinner.Spinner = spinners[m.index] 154 | } 155 | 156 | func main() { 157 | p := tea.NewProgram(newModel(defaultTime)) 158 | 159 | if _, err := p.Run(); err != nil { 160 | log.Fatal(err) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /examples/credit-card-form/README.md: -------------------------------------------------------------------------------- 1 | # Credit Card Form 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/credit-card-form/credit-card-form.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/credit-card-form/credit-card-form.gif -------------------------------------------------------------------------------- /examples/credit-card-form/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/textinput" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | func main() { 15 | p := tea.NewProgram(initialModel()) 16 | 17 | if _, err := p.Run(); err != nil { 18 | log.Fatal(err) 19 | } 20 | } 21 | 22 | type ( 23 | errMsg error 24 | ) 25 | 26 | const ( 27 | ccn = iota 28 | exp 29 | cvv 30 | ) 31 | 32 | const ( 33 | hotPink = lipgloss.Color("#FF06B7") 34 | darkGray = lipgloss.Color("#767676") 35 | ) 36 | 37 | var ( 38 | inputStyle = lipgloss.NewStyle().Foreground(hotPink) 39 | continueStyle = lipgloss.NewStyle().Foreground(darkGray) 40 | ) 41 | 42 | type model struct { 43 | inputs []textinput.Model 44 | focused int 45 | err error 46 | } 47 | 48 | // Validator functions to ensure valid input 49 | func ccnValidator(s string) error { 50 | // Credit Card Number should a string less than 20 digits 51 | // It should include 16 integers and 3 spaces 52 | if len(s) > 16+3 { 53 | return fmt.Errorf("CCN is too long") 54 | } 55 | 56 | if len(s) == 0 || len(s)%5 != 0 && (s[len(s)-1] < '0' || s[len(s)-1] > '9') { 57 | return fmt.Errorf("CCN is invalid") 58 | } 59 | 60 | // The last digit should be a number unless it is a multiple of 4 in which 61 | // case it should be a space 62 | if len(s)%5 == 0 && s[len(s)-1] != ' ' { 63 | return fmt.Errorf("CCN must separate groups with spaces") 64 | } 65 | 66 | // The remaining digits should be integers 67 | c := strings.ReplaceAll(s, " ", "") 68 | _, err := strconv.ParseInt(c, 10, 64) 69 | 70 | return err 71 | } 72 | 73 | func expValidator(s string) error { 74 | // The 3 character should be a slash (/) 75 | // The rest should be numbers 76 | e := strings.ReplaceAll(s, "/", "") 77 | _, err := strconv.ParseInt(e, 10, 64) 78 | if err != nil { 79 | return fmt.Errorf("EXP is invalid") 80 | } 81 | 82 | // There should be only one slash and it should be in the 2nd index (3rd character) 83 | if len(s) >= 3 && (strings.Index(s, "/") != 2 || strings.LastIndex(s, "/") != 2) { 84 | return fmt.Errorf("EXP is invalid") 85 | } 86 | 87 | return nil 88 | } 89 | 90 | func cvvValidator(s string) error { 91 | // The CVV should be a number of 3 digits 92 | // Since the input will already ensure that the CVV is a string of length 3, 93 | // All we need to do is check that it is a number 94 | _, err := strconv.ParseInt(s, 10, 64) 95 | return err 96 | } 97 | 98 | func initialModel() model { 99 | var inputs []textinput.Model = make([]textinput.Model, 3) 100 | inputs[ccn] = textinput.New() 101 | inputs[ccn].Placeholder = "4505 **** **** 1234" 102 | inputs[ccn].Focus() 103 | inputs[ccn].CharLimit = 20 104 | inputs[ccn].Width = 30 105 | inputs[ccn].Prompt = "" 106 | inputs[ccn].Validate = ccnValidator 107 | 108 | inputs[exp] = textinput.New() 109 | inputs[exp].Placeholder = "MM/YY " 110 | inputs[exp].CharLimit = 5 111 | inputs[exp].Width = 5 112 | inputs[exp].Prompt = "" 113 | inputs[exp].Validate = expValidator 114 | 115 | inputs[cvv] = textinput.New() 116 | inputs[cvv].Placeholder = "XXX" 117 | inputs[cvv].CharLimit = 3 118 | inputs[cvv].Width = 5 119 | inputs[cvv].Prompt = "" 120 | inputs[cvv].Validate = cvvValidator 121 | 122 | return model{ 123 | inputs: inputs, 124 | focused: 0, 125 | err: nil, 126 | } 127 | } 128 | 129 | func (m model) Init() tea.Cmd { 130 | return textinput.Blink 131 | } 132 | 133 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 134 | var cmds []tea.Cmd = make([]tea.Cmd, len(m.inputs)) 135 | 136 | switch msg := msg.(type) { 137 | case tea.KeyMsg: 138 | switch msg.Type { 139 | case tea.KeyEnter: 140 | if m.focused == len(m.inputs)-1 { 141 | return m, tea.Quit 142 | } 143 | m.nextInput() 144 | case tea.KeyCtrlC, tea.KeyEsc: 145 | return m, tea.Quit 146 | case tea.KeyShiftTab, tea.KeyCtrlP: 147 | m.prevInput() 148 | case tea.KeyTab, tea.KeyCtrlN: 149 | m.nextInput() 150 | } 151 | for i := range m.inputs { 152 | m.inputs[i].Blur() 153 | } 154 | m.inputs[m.focused].Focus() 155 | 156 | // We handle errors just like any other message 157 | case errMsg: 158 | m.err = msg 159 | return m, nil 160 | } 161 | 162 | for i := range m.inputs { 163 | m.inputs[i], cmds[i] = m.inputs[i].Update(msg) 164 | } 165 | return m, tea.Batch(cmds...) 166 | } 167 | 168 | func (m model) View() string { 169 | return fmt.Sprintf( 170 | ` Total: $21.50: 171 | 172 | %s 173 | %s 174 | 175 | %s %s 176 | %s %s 177 | 178 | %s 179 | `, 180 | inputStyle.Width(30).Render("Card Number"), 181 | m.inputs[ccn].View(), 182 | inputStyle.Width(6).Render("EXP"), 183 | inputStyle.Width(6).Render("CVV"), 184 | m.inputs[exp].View(), 185 | m.inputs[cvv].View(), 186 | continueStyle.Render("Continue ->"), 187 | ) + "\n" 188 | } 189 | 190 | // nextInput focuses the next input field 191 | func (m *model) nextInput() { 192 | m.focused = (m.focused + 1) % len(m.inputs) 193 | } 194 | 195 | // prevInput focuses the previous input field 196 | func (m *model) prevInput() { 197 | m.focused-- 198 | // Wrap around 199 | if m.focused < 0 { 200 | m.focused = len(m.inputs) - 1 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /examples/debounce/README.md: -------------------------------------------------------------------------------- 1 | # Debounce 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/debounce/debounce.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/debounce/debounce.gif -------------------------------------------------------------------------------- /examples/debounce/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // This example illustrates how to debounce commands. 4 | // 5 | // When the user presses a key we increment the "tag" value on the model and, 6 | // after a short delay, we include that tag value in the message produced 7 | // by the Tick command. 8 | // 9 | // In a subsequent Update, if the tag in the Msg matches current tag on the 10 | // model's state we know that the debouncing is complete and we can proceed as 11 | // normal. If not, we simply ignore the inbound message. 12 | 13 | import ( 14 | "fmt" 15 | "os" 16 | "time" 17 | 18 | tea "github.com/charmbracelet/bubbletea" 19 | ) 20 | 21 | const debounceDuration = time.Second 22 | 23 | type exitMsg int 24 | 25 | type model struct { 26 | tag int 27 | } 28 | 29 | func (m model) Init() tea.Cmd { 30 | return nil 31 | } 32 | 33 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 34 | switch msg := msg.(type) { 35 | case tea.KeyMsg: 36 | // Increment the tag on the model... 37 | m.tag++ 38 | return m, tea.Tick(debounceDuration, func(_ time.Time) tea.Msg { 39 | // ...and include a copy of that tag value in the message. 40 | return exitMsg(m.tag) 41 | }) 42 | case exitMsg: 43 | // If the tag in the message doesn't match the tag on the model then we 44 | // know that this message was not the last one sent and another is on 45 | // the way. If that's the case we know, we can ignore this message. 46 | // Otherwise, the debounce timeout has passed and this message is a 47 | // valid debounced one. 48 | if int(msg) == m.tag { 49 | return m, tea.Quit 50 | } 51 | } 52 | 53 | return m, nil 54 | } 55 | 56 | func (m model) View() string { 57 | return fmt.Sprintf("Key presses: %d", m.tag) + 58 | "\nTo exit press any key, then wait for one second without pressing anything." 59 | } 60 | 61 | func main() { 62 | if _, err := tea.NewProgram(model{}).Run(); err != nil { 63 | fmt.Println("uh oh:", err) 64 | os.Exit(1) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/exec/README.md: -------------------------------------------------------------------------------- 1 | # Exec 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/exec/exec.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/exec/exec.gif -------------------------------------------------------------------------------- /examples/exec/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | type editorFinishedMsg struct{ err error } 12 | 13 | func openEditor() tea.Cmd { 14 | editor := os.Getenv("EDITOR") 15 | if editor == "" { 16 | editor = "vim" 17 | } 18 | c := exec.Command(editor) //nolint:gosec 19 | return tea.ExecProcess(c, func(err error) tea.Msg { 20 | return editorFinishedMsg{err} 21 | }) 22 | } 23 | 24 | type model struct { 25 | altscreenActive bool 26 | err error 27 | } 28 | 29 | func (m model) Init() tea.Cmd { 30 | return nil 31 | } 32 | 33 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 34 | switch msg := msg.(type) { 35 | case tea.KeyMsg: 36 | switch msg.String() { 37 | case "a": 38 | m.altscreenActive = !m.altscreenActive 39 | cmd := tea.EnterAltScreen 40 | if !m.altscreenActive { 41 | cmd = tea.ExitAltScreen 42 | } 43 | return m, cmd 44 | case "e": 45 | return m, openEditor() 46 | case "ctrl+c", "q": 47 | return m, tea.Quit 48 | } 49 | case editorFinishedMsg: 50 | if msg.err != nil { 51 | m.err = msg.err 52 | return m, tea.Quit 53 | } 54 | } 55 | return m, nil 56 | } 57 | 58 | func (m model) View() string { 59 | if m.err != nil { 60 | return "Error: " + m.err.Error() + "\n" 61 | } 62 | return "Press 'e' to open your EDITOR.\nPress 'a' to toggle the altscreen\nPress 'q' to quit.\n" 63 | } 64 | 65 | func main() { 66 | m := model{} 67 | if _, err := tea.NewProgram(m).Run(); err != nil { 68 | fmt.Println("Error running program:", err) 69 | os.Exit(1) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /examples/file-picker/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/charmbracelet/bubbles/filepicker" 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | type model struct { 15 | filepicker filepicker.Model 16 | selectedFile string 17 | quitting bool 18 | err error 19 | } 20 | 21 | type clearErrorMsg struct{} 22 | 23 | func clearErrorAfter(t time.Duration) tea.Cmd { 24 | return tea.Tick(t, func(_ time.Time) tea.Msg { 25 | return clearErrorMsg{} 26 | }) 27 | } 28 | 29 | func (m model) Init() tea.Cmd { 30 | return m.filepicker.Init() 31 | } 32 | 33 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 34 | switch msg := msg.(type) { 35 | case tea.KeyMsg: 36 | switch msg.String() { 37 | case "ctrl+c", "q": 38 | m.quitting = true 39 | return m, tea.Quit 40 | } 41 | case clearErrorMsg: 42 | m.err = nil 43 | } 44 | 45 | var cmd tea.Cmd 46 | m.filepicker, cmd = m.filepicker.Update(msg) 47 | 48 | // Did the user select a file? 49 | if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { 50 | // Get the path of the selected file. 51 | m.selectedFile = path 52 | } 53 | 54 | // Did the user select a disabled file? 55 | // This is only necessary to display an error to the user. 56 | if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect { 57 | // Let's clear the selectedFile and display an error. 58 | m.err = errors.New(path + " is not valid.") 59 | m.selectedFile = "" 60 | return m, tea.Batch(cmd, clearErrorAfter(2*time.Second)) 61 | } 62 | 63 | return m, cmd 64 | } 65 | 66 | func (m model) View() string { 67 | if m.quitting { 68 | return "" 69 | } 70 | var s strings.Builder 71 | s.WriteString("\n ") 72 | if m.err != nil { 73 | s.WriteString(m.filepicker.Styles.DisabledFile.Render(m.err.Error())) 74 | } else if m.selectedFile == "" { 75 | s.WriteString("Pick a file:") 76 | } else { 77 | s.WriteString("Selected file: " + m.filepicker.Styles.Selected.Render(m.selectedFile)) 78 | } 79 | s.WriteString("\n\n" + m.filepicker.View() + "\n") 80 | return s.String() 81 | } 82 | 83 | func main() { 84 | fp := filepicker.New() 85 | fp.AllowedTypes = []string{".mod", ".sum", ".go", ".txt", ".md"} 86 | fp.CurrentDirectory, _ = os.UserHomeDir() 87 | 88 | m := model{ 89 | filepicker: fp, 90 | } 91 | tm, _ := tea.NewProgram(&m).Run() 92 | mm := tm.(model) 93 | fmt.Println("\n You selected: " + m.filepicker.Styles.Selected.Render(mm.selectedFile) + "\n") 94 | } 95 | -------------------------------------------------------------------------------- /examples/focus-blur/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program that handled losing and acquiring focus. 4 | 5 | import ( 6 | "log" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | func main() { 12 | p := tea.NewProgram(model{ 13 | // assume we start focused... 14 | focused: true, 15 | reporting: true, 16 | }, tea.WithReportFocus()) 17 | if _, err := p.Run(); err != nil { 18 | log.Fatal(err) 19 | } 20 | } 21 | 22 | type model struct { 23 | focused bool 24 | reporting bool 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 tea.FocusMsg: 34 | m.focused = true 35 | case tea.BlurMsg: 36 | m.focused = false 37 | case tea.KeyMsg: 38 | switch msg.String() { 39 | case "t": 40 | m.reporting = !m.reporting 41 | case "ctrl+c", "q": 42 | return m, tea.Quit 43 | } 44 | } 45 | 46 | return m, nil 47 | } 48 | 49 | func (m model) View() string { 50 | s := "Hi. Focus report is currently " 51 | if m.reporting { 52 | s += "enabled" 53 | } else { 54 | s += "disabled" 55 | } 56 | s += ".\n\n" 57 | 58 | if m.reporting { 59 | if m.focused { 60 | s += "This program is currently focused!" 61 | } else { 62 | s += "This program is currently blurred!" 63 | } 64 | } 65 | return s + "\n\nTo quit sooner press ctrl-c, or t to toggle focus reporting...\n" 66 | } 67 | -------------------------------------------------------------------------------- /examples/fullscreen/README.md: -------------------------------------------------------------------------------- 1 | # Full Screen 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/fullscreen/fullscreen.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/fullscreen/fullscreen.gif -------------------------------------------------------------------------------- /examples/fullscreen/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program that opens the alternate screen buffer then counts down 4 | // from 5 and then exits. 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "time" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | type model int 15 | 16 | type tickMsg time.Time 17 | 18 | func main() { 19 | p := tea.NewProgram(model(5), tea.WithAltScreen()) 20 | if _, err := p.Run(); err != nil { 21 | log.Fatal(err) 22 | } 23 | } 24 | 25 | func (m model) Init() tea.Cmd { 26 | return tick() 27 | } 28 | 29 | func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) { 30 | switch msg := message.(type) { 31 | case tea.KeyMsg: 32 | switch msg.String() { 33 | case "q", "esc", "ctrl+c": 34 | return m, tea.Quit 35 | } 36 | 37 | case tickMsg: 38 | m-- 39 | if m <= 0 { 40 | return m, tea.Quit 41 | } 42 | return m, tick() 43 | } 44 | 45 | return m, nil 46 | } 47 | 48 | func (m model) View() string { 49 | return fmt.Sprintf("\n\n Hi. This program will exit in %d seconds...", m) 50 | } 51 | 52 | func tick() tea.Cmd { 53 | return tea.Tick(time.Second, func(t time.Time) tea.Msg { 54 | return tickMsg(t) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /examples/glamour/README.md: -------------------------------------------------------------------------------- 1 | # Glamour 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/glamour/glamour.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/glamour/glamour.gif -------------------------------------------------------------------------------- /examples/glamour/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/bubbles/viewport" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/glamour" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | const content = ` 14 | # Today’s Menu 15 | 16 | ## Appetizers 17 | 18 | | Name | Price | Notes | 19 | | --- | --- | --- | 20 | | Tsukemono | $2 | Just an appetizer | 21 | | Tomato Soup | $4 | Made with San Marzano tomatoes | 22 | | Okonomiyaki | $4 | Takes a few minutes to make | 23 | | Curry | $3 | We can add squash if you’d like | 24 | 25 | ## Seasonal Dishes 26 | 27 | | Name | Price | Notes | 28 | | --- | --- | --- | 29 | | Steamed bitter melon | $2 | Not so bitter | 30 | | Takoyaki | $3 | Fun to eat | 31 | | Winter squash | $3 | Today it's pumpkin | 32 | 33 | ## Desserts 34 | 35 | | Name | Price | Notes | 36 | | --- | --- | --- | 37 | | Dorayaki | $4 | Looks good on rabbits | 38 | | Banana Split | $5 | A classic | 39 | | Cream Puff | $3 | Pretty creamy! | 40 | 41 | All our dishes are made in-house by Karen, our chef. Most of our ingredients 42 | are from our garden or the fish market down the street. 43 | 44 | Some famous people that have eaten here lately: 45 | 46 | * [x] René Redzepi 47 | * [x] David Chang 48 | * [ ] Jiro Ono (maybe some day) 49 | 50 | Bon appétit! 51 | ` 52 | 53 | var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render 54 | 55 | type example struct { 56 | viewport viewport.Model 57 | } 58 | 59 | func newExample() (*example, error) { 60 | const width = 78 61 | 62 | vp := viewport.New(width, 20) 63 | vp.Style = lipgloss.NewStyle(). 64 | BorderStyle(lipgloss.RoundedBorder()). 65 | BorderForeground(lipgloss.Color("62")). 66 | PaddingRight(2) 67 | 68 | // We need to adjust the width of the glamour render from our main width 69 | // to account for a few things: 70 | // 71 | // * The viewport border width 72 | // * The viewport padding 73 | // * The viewport margins 74 | // * The gutter glamour applies to the left side of the content 75 | // 76 | const glamourGutter = 2 77 | glamourRenderWidth := width - vp.Style.GetHorizontalFrameSize() - glamourGutter 78 | 79 | renderer, err := glamour.NewTermRenderer( 80 | glamour.WithAutoStyle(), 81 | glamour.WithWordWrap(glamourRenderWidth), 82 | ) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | str, err := renderer.Render(content) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | vp.SetContent(str) 93 | 94 | return &example{ 95 | viewport: vp, 96 | }, nil 97 | } 98 | 99 | func (e example) Init() tea.Cmd { 100 | return nil 101 | } 102 | 103 | func (e example) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 104 | switch msg := msg.(type) { 105 | case tea.KeyMsg: 106 | switch msg.String() { 107 | case "q", "ctrl+c", "esc": 108 | return e, tea.Quit 109 | default: 110 | var cmd tea.Cmd 111 | e.viewport, cmd = e.viewport.Update(msg) 112 | return e, cmd 113 | } 114 | default: 115 | return e, nil 116 | } 117 | } 118 | 119 | func (e example) View() string { 120 | return e.viewport.View() + e.helpView() 121 | } 122 | 123 | func (e example) helpView() string { 124 | return helpStyle("\n ↑/↓: Navigate • q: Quit\n") 125 | } 126 | 127 | func main() { 128 | model, err := newExample() 129 | if err != nil { 130 | fmt.Println("Could not initialize Bubble Tea model:", err) 131 | os.Exit(1) 132 | } 133 | 134 | if _, err := tea.NewProgram(model).Run(); err != nil { 135 | fmt.Println("Bummer, there's been an error:", err) 136 | os.Exit(1) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /examples/go.mod: -------------------------------------------------------------------------------- 1 | module examples 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.21.0 9 | github.com/charmbracelet/bubbletea v1.3.4 10 | github.com/charmbracelet/glamour v0.10.0 11 | github.com/charmbracelet/harmonica v0.2.0 12 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 13 | github.com/charmbracelet/x/exp/teatest v0.0.0-20240521184646-23081fb03b28 14 | github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 15 | github.com/lucasb-eyer/go-colorful v1.2.0 16 | github.com/mattn/go-isatty v0.0.20 17 | ) 18 | 19 | require ( 20 | github.com/alecthomas/chroma/v2 v2.14.0 // indirect 21 | github.com/atotto/clipboard v0.1.4 // indirect 22 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 23 | github.com/aymanbagabas/go-udiff v0.2.0 // indirect 24 | github.com/aymerick/douceur v0.2.0 // indirect 25 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 26 | github.com/charmbracelet/x/ansi v0.9.2 // indirect 27 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 28 | github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 // indirect 29 | github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect 30 | github.com/charmbracelet/x/term v0.2.1 // indirect 31 | github.com/dlclark/regexp2 v1.11.0 // indirect 32 | github.com/dustin/go-humanize v1.0.1 // indirect 33 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 34 | github.com/gorilla/css v1.0.1 // indirect 35 | github.com/mattn/go-localereader v0.0.1 // indirect 36 | github.com/mattn/go-runewidth v0.0.16 // indirect 37 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 38 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 39 | github.com/muesli/cancelreader v0.2.2 // indirect 40 | github.com/muesli/reflow v0.3.0 // indirect 41 | github.com/muesli/termenv v0.16.0 // indirect 42 | github.com/rivo/uniseg v0.4.7 // indirect 43 | github.com/sahilm/fuzzy v0.1.1 // indirect 44 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 45 | github.com/yuin/goldmark v1.7.8 // indirect 46 | github.com/yuin/goldmark-emoji v1.0.5 // indirect 47 | golang.org/x/net v0.38.0 // indirect 48 | golang.org/x/sync v0.14.0 // indirect 49 | golang.org/x/sys v0.33.0 // indirect 50 | golang.org/x/term v0.31.0 // indirect 51 | golang.org/x/text v0.24.0 // indirect 52 | ) 53 | 54 | replace github.com/charmbracelet/bubbletea => ../ 55 | -------------------------------------------------------------------------------- /examples/help/README.md: -------------------------------------------------------------------------------- 1 | # Help 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/help/help.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/help/help.gif -------------------------------------------------------------------------------- /examples/help/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/key" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | // keyMap defines a set of keybindings. To work for help it must satisfy 15 | // key.Map. It could also very easily be a map[string]key.Binding. 16 | type keyMap struct { 17 | Up key.Binding 18 | Down key.Binding 19 | Left key.Binding 20 | Right key.Binding 21 | Help key.Binding 22 | Quit key.Binding 23 | } 24 | 25 | // ShortHelp returns keybindings to be shown in the mini help view. It's part 26 | // of the key.Map interface. 27 | func (k keyMap) ShortHelp() []key.Binding { 28 | return []key.Binding{k.Help, k.Quit} 29 | } 30 | 31 | // FullHelp returns keybindings for the expanded help view. It's part of the 32 | // key.Map interface. 33 | func (k keyMap) FullHelp() [][]key.Binding { 34 | return [][]key.Binding{ 35 | {k.Up, k.Down, k.Left, k.Right}, // first column 36 | {k.Help, k.Quit}, // second column 37 | } 38 | } 39 | 40 | var keys = keyMap{ 41 | Up: key.NewBinding( 42 | key.WithKeys("up", "k"), 43 | key.WithHelp("↑/k", "move up"), 44 | ), 45 | Down: key.NewBinding( 46 | key.WithKeys("down", "j"), 47 | key.WithHelp("↓/j", "move down"), 48 | ), 49 | Left: key.NewBinding( 50 | key.WithKeys("left", "h"), 51 | key.WithHelp("←/h", "move left"), 52 | ), 53 | Right: key.NewBinding( 54 | key.WithKeys("right", "l"), 55 | key.WithHelp("→/l", "move right"), 56 | ), 57 | Help: key.NewBinding( 58 | key.WithKeys("?"), 59 | key.WithHelp("?", "toggle help"), 60 | ), 61 | Quit: key.NewBinding( 62 | key.WithKeys("q", "esc", "ctrl+c"), 63 | key.WithHelp("q", "quit"), 64 | ), 65 | } 66 | 67 | type model struct { 68 | keys keyMap 69 | help help.Model 70 | inputStyle lipgloss.Style 71 | lastKey string 72 | quitting bool 73 | } 74 | 75 | func newModel() model { 76 | return model{ 77 | keys: keys, 78 | help: help.New(), 79 | inputStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF75B7")), 80 | } 81 | } 82 | 83 | func (m model) Init() tea.Cmd { 84 | return nil 85 | } 86 | 87 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 88 | switch msg := msg.(type) { 89 | case tea.WindowSizeMsg: 90 | // If we set a width on the help menu it can gracefully truncate 91 | // its view as needed. 92 | m.help.Width = msg.Width 93 | 94 | case tea.KeyMsg: 95 | switch { 96 | case key.Matches(msg, m.keys.Up): 97 | m.lastKey = "↑" 98 | case key.Matches(msg, m.keys.Down): 99 | m.lastKey = "↓" 100 | case key.Matches(msg, m.keys.Left): 101 | m.lastKey = "←" 102 | case key.Matches(msg, m.keys.Right): 103 | m.lastKey = "→" 104 | case key.Matches(msg, m.keys.Help): 105 | m.help.ShowAll = !m.help.ShowAll 106 | case key.Matches(msg, m.keys.Quit): 107 | m.quitting = true 108 | return m, tea.Quit 109 | } 110 | } 111 | 112 | return m, nil 113 | } 114 | 115 | func (m model) View() string { 116 | if m.quitting { 117 | return "Bye!\n" 118 | } 119 | 120 | var status string 121 | if m.lastKey == "" { 122 | status = "Waiting for input..." 123 | } else { 124 | status = "You chose: " + m.inputStyle.Render(m.lastKey) 125 | } 126 | 127 | helpView := m.help.View(m.keys) 128 | height := 8 - strings.Count(status, "\n") - strings.Count(helpView, "\n") 129 | 130 | return "\n" + status + strings.Repeat("\n", height) + helpView 131 | } 132 | 133 | func main() { 134 | if os.Getenv("HELP_DEBUG") != "" { 135 | f, err := tea.LogToFile("debug.log", "help") 136 | if err != nil { 137 | fmt.Println("Couldn't open a file for logging:", err) 138 | os.Exit(1) 139 | } 140 | defer f.Close() // nolint:errcheck 141 | } 142 | 143 | if _, err := tea.NewProgram(newModel()).Run(); err != nil { 144 | fmt.Printf("Could not start program :(\n%v\n", err) 145 | os.Exit(1) 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /examples/http/README.md: -------------------------------------------------------------------------------- 1 | # HTTP 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/http/http.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/http/http.gif -------------------------------------------------------------------------------- /examples/http/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program that makes a GET request and prints the response status. 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "time" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | const url = "https://charm.sh/" 15 | 16 | type model struct { 17 | status int 18 | err error 19 | } 20 | 21 | type statusMsg int 22 | 23 | type errMsg struct{ error } 24 | 25 | func (e errMsg) Error() string { return e.error.Error() } 26 | 27 | func main() { 28 | p := tea.NewProgram(model{}) 29 | if _, err := p.Run(); err != nil { 30 | log.Fatal(err) 31 | } 32 | } 33 | 34 | func (m model) Init() tea.Cmd { 35 | return checkServer 36 | } 37 | 38 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 39 | switch msg := msg.(type) { 40 | case tea.KeyMsg: 41 | switch msg.String() { 42 | case "q", "ctrl+c", "esc": 43 | return m, tea.Quit 44 | default: 45 | return m, nil 46 | } 47 | 48 | case statusMsg: 49 | m.status = int(msg) 50 | return m, tea.Quit 51 | 52 | case errMsg: 53 | m.err = msg 54 | return m, nil 55 | 56 | default: 57 | return m, nil 58 | } 59 | } 60 | 61 | func (m model) View() string { 62 | s := fmt.Sprintf("Checking %s...", url) 63 | if m.err != nil { 64 | s += fmt.Sprintf("something went wrong: %s", m.err) 65 | } else if m.status != 0 { 66 | s += fmt.Sprintf("%d %s", m.status, http.StatusText(m.status)) 67 | } 68 | return s + "\n" 69 | } 70 | 71 | func checkServer() tea.Msg { 72 | c := &http.Client{ 73 | Timeout: 10 * time.Second, 74 | } 75 | res, err := c.Get(url) 76 | if err != nil { 77 | return errMsg{err} 78 | } 79 | defer res.Body.Close() // nolint:errcheck 80 | 81 | return statusMsg(res.StatusCode) 82 | } 83 | -------------------------------------------------------------------------------- /examples/list-default/README.md: -------------------------------------------------------------------------------- 1 | # Default List 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/list-default/list-default.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/list-default/list-default.gif -------------------------------------------------------------------------------- /examples/list-default/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/bubbles/list" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | var docStyle = lipgloss.NewStyle().Margin(1, 2) 13 | 14 | type item struct { 15 | title, desc string 16 | } 17 | 18 | func (i item) Title() string { return i.title } 19 | func (i item) Description() string { return i.desc } 20 | func (i item) FilterValue() string { return i.title } 21 | 22 | type model struct { 23 | list list.Model 24 | } 25 | 26 | func (m model) Init() tea.Cmd { 27 | return nil 28 | } 29 | 30 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 31 | switch msg := msg.(type) { 32 | case tea.KeyMsg: 33 | if msg.String() == "ctrl+c" { 34 | return m, tea.Quit 35 | } 36 | case tea.WindowSizeMsg: 37 | h, v := docStyle.GetFrameSize() 38 | m.list.SetSize(msg.Width-h, msg.Height-v) 39 | } 40 | 41 | var cmd tea.Cmd 42 | m.list, cmd = m.list.Update(msg) 43 | return m, cmd 44 | } 45 | 46 | func (m model) View() string { 47 | return docStyle.Render(m.list.View()) 48 | } 49 | 50 | func main() { 51 | items := []list.Item{ 52 | item{title: "Raspberry Pi’s", desc: "I have ’em all over my house"}, 53 | item{title: "Nutella", desc: "It's good on toast"}, 54 | item{title: "Bitter melon", desc: "It cools you down"}, 55 | item{title: "Nice socks", desc: "And by that I mean socks without holes"}, 56 | item{title: "Eight hours of sleep", desc: "I had this once"}, 57 | item{title: "Cats", desc: "Usually"}, 58 | item{title: "Plantasia, the album", desc: "My plants love it too"}, 59 | item{title: "Pour over coffee", desc: "It takes forever to make though"}, 60 | item{title: "VR", desc: "Virtual reality...what is there to say?"}, 61 | item{title: "Noguchi Lamps", desc: "Such pleasing organic forms"}, 62 | item{title: "Linux", desc: "Pretty much the best OS"}, 63 | item{title: "Business school", desc: "Just kidding"}, 64 | item{title: "Pottery", desc: "Wet clay is a great feeling"}, 65 | item{title: "Shampoo", desc: "Nothing like clean hair"}, 66 | item{title: "Table tennis", desc: "It’s surprisingly exhausting"}, 67 | item{title: "Milk crates", desc: "Great for packing in your extra stuff"}, 68 | item{title: "Afternoon tea", desc: "Especially the tea sandwich part"}, 69 | item{title: "Stickers", desc: "The thicker the vinyl the better"}, 70 | item{title: "20° Weather", desc: "Celsius, not Fahrenheit"}, 71 | item{title: "Warm light", desc: "Like around 2700 Kelvin"}, 72 | item{title: "The vernal equinox", desc: "The autumnal equinox is pretty good too"}, 73 | item{title: "Gaffer’s tape", desc: "Basically sticky fabric"}, 74 | item{title: "Terrycloth", desc: "In other words, towel fabric"}, 75 | } 76 | 77 | m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 0)} 78 | m.list.Title = "My Fave Things" 79 | 80 | p := tea.NewProgram(m, tea.WithAltScreen()) 81 | 82 | if _, err := p.Run(); err != nil { 83 | fmt.Println("Error running program:", err) 84 | os.Exit(1) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /examples/list-fancy/README.md: -------------------------------------------------------------------------------- 1 | # Fancy List 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/list-fancy/delegate.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | "github.com/charmbracelet/bubbles/list" 6 | tea "github.com/charmbracelet/bubbletea" 7 | ) 8 | 9 | func newItemDelegate(keys *delegateKeyMap) list.DefaultDelegate { 10 | d := list.NewDefaultDelegate() 11 | 12 | d.UpdateFunc = func(msg tea.Msg, m *list.Model) tea.Cmd { 13 | var title string 14 | 15 | if i, ok := m.SelectedItem().(item); ok { 16 | title = i.Title() 17 | } else { 18 | return nil 19 | } 20 | 21 | switch msg := msg.(type) { 22 | case tea.KeyMsg: 23 | switch { 24 | case key.Matches(msg, keys.choose): 25 | return m.NewStatusMessage(statusMessageStyle("You chose " + title)) 26 | 27 | case key.Matches(msg, keys.remove): 28 | index := m.Index() 29 | m.RemoveItem(index) 30 | if len(m.Items()) == 0 { 31 | keys.remove.SetEnabled(false) 32 | } 33 | return m.NewStatusMessage(statusMessageStyle("Deleted " + title)) 34 | } 35 | } 36 | 37 | return nil 38 | } 39 | 40 | help := []key.Binding{keys.choose, keys.remove} 41 | 42 | d.ShortHelpFunc = func() []key.Binding { 43 | return help 44 | } 45 | 46 | d.FullHelpFunc = func() [][]key.Binding { 47 | return [][]key.Binding{help} 48 | } 49 | 50 | return d 51 | } 52 | 53 | type delegateKeyMap struct { 54 | choose key.Binding 55 | remove key.Binding 56 | } 57 | 58 | // Additional short help entries. This satisfies the help.KeyMap interface and 59 | // is entirely optional. 60 | func (d delegateKeyMap) ShortHelp() []key.Binding { 61 | return []key.Binding{ 62 | d.choose, 63 | d.remove, 64 | } 65 | } 66 | 67 | // Additional full help entries. This satisfies the help.KeyMap interface and 68 | // is entirely optional. 69 | func (d delegateKeyMap) FullHelp() [][]key.Binding { 70 | return [][]key.Binding{ 71 | { 72 | d.choose, 73 | d.remove, 74 | }, 75 | } 76 | } 77 | 78 | func newDelegateKeyMap() *delegateKeyMap { 79 | return &delegateKeyMap{ 80 | choose: key.NewBinding( 81 | key.WithKeys("enter"), 82 | key.WithHelp("enter", "choose"), 83 | ), 84 | remove: key.NewBinding( 85 | key.WithKeys("x", "backspace"), 86 | key.WithHelp("x", "delete"), 87 | ), 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /examples/list-fancy/list-fancy.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/list-fancy/list-fancy.gif -------------------------------------------------------------------------------- /examples/list-fancy/randomitems.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/rand" 5 | "sync" 6 | ) 7 | 8 | type randomItemGenerator struct { 9 | titles []string 10 | descs []string 11 | titleIndex int 12 | descIndex int 13 | mtx *sync.Mutex 14 | shuffle *sync.Once 15 | } 16 | 17 | func (r *randomItemGenerator) reset() { 18 | r.mtx = &sync.Mutex{} 19 | r.shuffle = &sync.Once{} 20 | 21 | r.titles = []string{ 22 | "Artichoke", 23 | "Baking Flour", 24 | "Bananas", 25 | "Barley", 26 | "Bean Sprouts", 27 | "Bitter Melon", 28 | "Black Cod", 29 | "Blood Orange", 30 | "Brown Sugar", 31 | "Cashew Apple", 32 | "Cashews", 33 | "Cat Food", 34 | "Coconut Milk", 35 | "Cucumber", 36 | "Curry Paste", 37 | "Currywurst", 38 | "Dill", 39 | "Dragonfruit", 40 | "Dried Shrimp", 41 | "Eggs", 42 | "Fish Cake", 43 | "Furikake", 44 | "Garlic", 45 | "Gherkin", 46 | "Ginger", 47 | "Granulated Sugar", 48 | "Grapefruit", 49 | "Green Onion", 50 | "Hazelnuts", 51 | "Heavy whipping cream", 52 | "Honey Dew", 53 | "Horseradish", 54 | "Jicama", 55 | "Kohlrabi", 56 | "Leeks", 57 | "Lentils", 58 | "Licorice Root", 59 | "Meyer Lemons", 60 | "Milk", 61 | "Molasses", 62 | "Muesli", 63 | "Nectarine", 64 | "Niagamo Root", 65 | "Nopal", 66 | "Nutella", 67 | "Oat Milk", 68 | "Oatmeal", 69 | "Olives", 70 | "Papaya", 71 | "Party Gherkin", 72 | "Peppers", 73 | "Persian Lemons", 74 | "Pickle", 75 | "Pineapple", 76 | "Plantains", 77 | "Pocky", 78 | "Powdered Sugar", 79 | "Quince", 80 | "Radish", 81 | "Ramps", 82 | "Star Anise", 83 | "Sweet Potato", 84 | "Tamarind", 85 | "Unsalted Butter", 86 | "Watermelon", 87 | "Weißwurst", 88 | "Yams", 89 | "Yeast", 90 | "Yuzu", 91 | "Snow Peas", 92 | } 93 | 94 | r.descs = []string{ 95 | "A little weird", 96 | "Bold flavor", 97 | "Can’t get enough", 98 | "Delectable", 99 | "Expensive", 100 | "Expired", 101 | "Exquisite", 102 | "Fresh", 103 | "Gimme", 104 | "In season", 105 | "Kind of spicy", 106 | "Looks fresh", 107 | "Looks good to me", 108 | "Maybe not", 109 | "My favorite", 110 | "Oh my", 111 | "On sale", 112 | "Organic", 113 | "Questionable", 114 | "Really fresh", 115 | "Refreshing", 116 | "Salty", 117 | "Scrumptious", 118 | "Delectable", 119 | "Slightly sweet", 120 | "Smells great", 121 | "Tasty", 122 | "Too ripe", 123 | "At last", 124 | "What?", 125 | "Wow", 126 | "Yum", 127 | "Maybe", 128 | "Sure, why not?", 129 | } 130 | 131 | r.shuffle.Do(func() { 132 | shuf := func(x []string) { 133 | rand.Shuffle(len(x), func(i, j int) { x[i], x[j] = x[j], x[i] }) 134 | } 135 | shuf(r.titles) 136 | shuf(r.descs) 137 | }) 138 | } 139 | 140 | func (r *randomItemGenerator) next() item { 141 | if r.mtx == nil { 142 | r.reset() 143 | } 144 | 145 | r.mtx.Lock() 146 | defer r.mtx.Unlock() 147 | 148 | i := item{ 149 | title: r.titles[r.titleIndex], 150 | description: r.descs[r.descIndex], 151 | } 152 | 153 | r.titleIndex++ 154 | if r.titleIndex >= len(r.titles) { 155 | r.titleIndex = 0 156 | } 157 | 158 | r.descIndex++ 159 | if r.descIndex >= len(r.descs) { 160 | r.descIndex = 0 161 | } 162 | 163 | return i 164 | } 165 | -------------------------------------------------------------------------------- /examples/list-simple/README.md: -------------------------------------------------------------------------------- 1 | # Simple List 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/list-simple/list-simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/list-simple/list-simple.gif -------------------------------------------------------------------------------- /examples/list-simple/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | 9 | "github.com/charmbracelet/bubbles/list" 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/lipgloss" 12 | ) 13 | 14 | const listHeight = 14 15 | 16 | var ( 17 | titleStyle = lipgloss.NewStyle().MarginLeft(2) 18 | itemStyle = lipgloss.NewStyle().PaddingLeft(4) 19 | selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) 20 | paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) 21 | helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) 22 | quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4) 23 | ) 24 | 25 | type item string 26 | 27 | func (i item) FilterValue() string { return "" } 28 | 29 | type itemDelegate struct{} 30 | 31 | func (d itemDelegate) Height() int { return 1 } 32 | func (d itemDelegate) Spacing() int { return 0 } 33 | func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } 34 | func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { 35 | i, ok := listItem.(item) 36 | if !ok { 37 | return 38 | } 39 | 40 | str := fmt.Sprintf("%d. %s", index+1, i) 41 | 42 | fn := itemStyle.Render 43 | if index == m.Index() { 44 | fn = func(s ...string) string { 45 | return selectedItemStyle.Render("> " + strings.Join(s, " ")) 46 | } 47 | } 48 | 49 | fmt.Fprint(w, fn(str)) 50 | } 51 | 52 | type model struct { 53 | list list.Model 54 | choice string 55 | quitting bool 56 | } 57 | 58 | func (m model) Init() tea.Cmd { 59 | return nil 60 | } 61 | 62 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 63 | switch msg := msg.(type) { 64 | case tea.WindowSizeMsg: 65 | m.list.SetWidth(msg.Width) 66 | return m, nil 67 | 68 | case tea.KeyMsg: 69 | switch keypress := msg.String(); keypress { 70 | case "q", "ctrl+c": 71 | m.quitting = true 72 | return m, tea.Quit 73 | 74 | case "enter": 75 | i, ok := m.list.SelectedItem().(item) 76 | if ok { 77 | m.choice = string(i) 78 | } 79 | return m, tea.Quit 80 | } 81 | } 82 | 83 | var cmd tea.Cmd 84 | m.list, cmd = m.list.Update(msg) 85 | return m, cmd 86 | } 87 | 88 | func (m model) View() string { 89 | if m.choice != "" { 90 | return quitTextStyle.Render(fmt.Sprintf("%s? Sounds good to me.", m.choice)) 91 | } 92 | if m.quitting { 93 | return quitTextStyle.Render("Not hungry? That’s cool.") 94 | } 95 | return "\n" + m.list.View() 96 | } 97 | 98 | func main() { 99 | items := []list.Item{ 100 | item("Ramen"), 101 | item("Tomato Soup"), 102 | item("Hamburgers"), 103 | item("Cheeseburgers"), 104 | item("Currywurst"), 105 | item("Okonomiyaki"), 106 | item("Pasta"), 107 | item("Fillet Mignon"), 108 | item("Caviar"), 109 | item("Just Wine"), 110 | } 111 | 112 | const defaultWidth = 20 113 | 114 | l := list.New(items, itemDelegate{}, defaultWidth, listHeight) 115 | l.Title = "What do you want for dinner?" 116 | l.SetShowStatusBar(false) 117 | l.SetFilteringEnabled(false) 118 | l.Styles.Title = titleStyle 119 | l.Styles.PaginationStyle = paginationStyle 120 | l.Styles.HelpStyle = helpStyle 121 | 122 | m := model{list: l} 123 | 124 | if _, err := tea.NewProgram(m).Run(); err != nil { 125 | fmt.Println("Error running program:", err) 126 | os.Exit(1) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /examples/mouse/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program that opens the alternate screen buffer and displays mouse 4 | // coordinates and events. 5 | 6 | import ( 7 | "log" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | func main() { 13 | p := tea.NewProgram(model{}, tea.WithMouseAllMotion()) 14 | if _, err := p.Run(); err != nil { 15 | log.Fatal(err) 16 | } 17 | } 18 | 19 | type model struct { 20 | mouseEvent tea.MouseEvent 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 tea.KeyMsg: 30 | if s := msg.String(); s == "ctrl+c" || s == "q" || s == "esc" { 31 | return m, tea.Quit 32 | } 33 | 34 | case tea.MouseMsg: 35 | return m, tea.Printf("(X: %d, Y: %d) %s", msg.X, msg.Y, tea.MouseEvent(msg)) 36 | } 37 | 38 | return m, nil 39 | } 40 | 41 | func (m model) View() string { 42 | s := "Do mouse stuff. When you're done press q to quit.\n" 43 | 44 | return s 45 | } 46 | -------------------------------------------------------------------------------- /examples/package-manager/README.md: -------------------------------------------------------------------------------- 1 | # Package Manager 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/package-manager/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/charmbracelet/bubbles/progress" 11 | "github.com/charmbracelet/bubbles/spinner" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | ) 15 | 16 | type model struct { 17 | packages []string 18 | index int 19 | width int 20 | height int 21 | spinner spinner.Model 22 | progress progress.Model 23 | done bool 24 | } 25 | 26 | var ( 27 | currentPkgNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) 28 | doneStyle = lipgloss.NewStyle().Margin(1, 2) 29 | checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") 30 | ) 31 | 32 | func newModel() model { 33 | p := progress.New( 34 | progress.WithDefaultGradient(), 35 | progress.WithWidth(40), 36 | progress.WithoutPercentage(), 37 | ) 38 | s := spinner.New() 39 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) 40 | return model{ 41 | packages: getPackages(), 42 | spinner: s, 43 | progress: p, 44 | } 45 | } 46 | 47 | func (m model) Init() tea.Cmd { 48 | return tea.Batch(downloadAndInstall(m.packages[m.index]), m.spinner.Tick) 49 | } 50 | 51 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 52 | switch msg := msg.(type) { 53 | case tea.WindowSizeMsg: 54 | m.width, m.height = msg.Width, msg.Height 55 | case tea.KeyMsg: 56 | switch msg.String() { 57 | case "ctrl+c", "esc", "q": 58 | return m, tea.Quit 59 | } 60 | case installedPkgMsg: 61 | pkg := m.packages[m.index] 62 | if m.index >= len(m.packages)-1 { 63 | // Everything's been installed. We're done! 64 | m.done = true 65 | return m, tea.Sequence( 66 | tea.Printf("%s %s", checkMark, pkg), // print the last success message 67 | tea.Quit, // exit the program 68 | ) 69 | } 70 | 71 | // Update progress bar 72 | m.index++ 73 | progressCmd := m.progress.SetPercent(float64(m.index) / float64(len(m.packages))) 74 | 75 | return m, tea.Batch( 76 | progressCmd, 77 | tea.Printf("%s %s", checkMark, pkg), // print success message above our program 78 | downloadAndInstall(m.packages[m.index]), // download the next package 79 | ) 80 | case spinner.TickMsg: 81 | var cmd tea.Cmd 82 | m.spinner, cmd = m.spinner.Update(msg) 83 | return m, cmd 84 | case progress.FrameMsg: 85 | newModel, cmd := m.progress.Update(msg) 86 | if newModel, ok := newModel.(progress.Model); ok { 87 | m.progress = newModel 88 | } 89 | return m, cmd 90 | } 91 | return m, nil 92 | } 93 | 94 | func (m model) View() string { 95 | n := len(m.packages) 96 | w := lipgloss.Width(fmt.Sprintf("%d", n)) 97 | 98 | if m.done { 99 | return doneStyle.Render(fmt.Sprintf("Done! Installed %d packages.\n", n)) 100 | } 101 | 102 | pkgCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, n) 103 | 104 | spin := m.spinner.View() + " " 105 | prog := m.progress.View() 106 | cellsAvail := max(0, m.width-lipgloss.Width(spin+prog+pkgCount)) 107 | 108 | pkgName := currentPkgNameStyle.Render(m.packages[m.index]) 109 | info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + pkgName) 110 | 111 | cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+pkgCount)) 112 | gap := strings.Repeat(" ", cellsRemaining) 113 | 114 | return spin + info + gap + prog + pkgCount 115 | } 116 | 117 | type installedPkgMsg string 118 | 119 | func downloadAndInstall(pkg string) tea.Cmd { 120 | // This is where you'd do i/o stuff to download and install packages. In 121 | // our case we're just pausing for a moment to simulate the process. 122 | d := time.Millisecond * time.Duration(rand.Intn(500)) //nolint:gosec 123 | return tea.Tick(d, func(t time.Time) tea.Msg { 124 | return installedPkgMsg(pkg) 125 | }) 126 | } 127 | 128 | func max(a, b int) int { 129 | if a > b { 130 | return a 131 | } 132 | return b 133 | } 134 | 135 | func main() { 136 | if _, err := tea.NewProgram(newModel()).Run(); err != nil { 137 | fmt.Println("Error running program:", err) 138 | os.Exit(1) 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /examples/package-manager/package-manager.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/package-manager/package-manager.gif -------------------------------------------------------------------------------- /examples/package-manager/packages.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | ) 7 | 8 | var packages = []string{ 9 | "vegeutils", 10 | "libgardening", 11 | "currykit", 12 | "spicerack", 13 | "fullenglish", 14 | "eggy", 15 | "bad-kitty", 16 | "chai", 17 | "hojicha", 18 | "libtacos", 19 | "babys-monads", 20 | "libpurring", 21 | "currywurst-devel", 22 | "xmodmeow", 23 | "licorice-utils", 24 | "cashew-apple", 25 | "rock-lobster", 26 | "standmixer", 27 | "coffee-CUPS", 28 | "libesszet", 29 | "zeichenorientierte-benutzerschnittstellen", 30 | "schnurrkit", 31 | "old-socks-devel", 32 | "jalapeño", 33 | "molasses-utils", 34 | "xkohlrabi", 35 | "party-gherkin", 36 | "snow-peas", 37 | "libyuzu", 38 | } 39 | 40 | func getPackages() []string { 41 | pkgs := packages 42 | copy(pkgs, packages) 43 | 44 | rand.Shuffle(len(pkgs), func(i, j int) { 45 | pkgs[i], pkgs[j] = pkgs[j], pkgs[i] 46 | }) 47 | 48 | for k := range pkgs { 49 | pkgs[k] += fmt.Sprintf("-%d.%d.%d", rand.Intn(10), rand.Intn(10), rand.Intn(10)) //nolint:gosec 50 | } 51 | return pkgs 52 | } 53 | -------------------------------------------------------------------------------- /examples/pager/README.md: -------------------------------------------------------------------------------- 1 | # Pager 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/pager/artichoke.md: -------------------------------------------------------------------------------- 1 | Glow 2 | ==== 3 | 4 | A casual introduction. 你好世界! 5 | 6 | ## Let’s talk about artichokes 7 | 8 | The _artichoke_ is mentioned as a garden plant in the 8th century BC by Homer 9 | **and** Hesiod. The naturally occurring variant of the artichoke, the cardoon, 10 | which is native to the Mediterranean area, also has records of use as a food 11 | among the ancient Greeks and Romans. Pliny the Elder mentioned growing of 12 | _carduus_ in Carthage and Cordoba. 13 | 14 | > He holds him with a skinny hand, 15 | > ‘There was a ship,’ quoth he. 16 | > ‘Hold off! unhand me, grey-beard loon!’ 17 | > An artichoke, dropt he. 18 | 19 | --Samuel Taylor Coleridge, [The Rime of the Ancient Mariner][rime] 20 | 21 | [rime]: https://poetryfoundation.org/poems/43997/ 22 | 23 | ## Other foods worth mentioning 24 | 25 | 1. Carrots 26 | 1. Celery 27 | 1. Tacos 28 | * Soft 29 | * Hard 30 | 1. Cucumber 31 | 32 | ## Things to eat today 33 | 34 | * [x] Carrots 35 | * [x] Ramen 36 | * [ ] Currywurst 37 | 38 | ### Power levels of the aforementioned foods 39 | 40 | | Name | Power | Comment | 41 | | --- | --- | --- | 42 | | Carrots | 9001 | It’s over 9000?! | 43 | | Ramen | 9002 | Also over 9000?! | 44 | | Currywurst | 10000 | What?! | 45 | 46 | ## Currying Artichokes 47 | 48 | Here’s a bit of code in [Haskell](https://haskell.org), because we are fancy. 49 | Remember that to compile Haskell you’ll need `ghc`. 50 | 51 | ```haskell 52 | module Main where 53 | 54 | import Data.Function ( (&) ) 55 | import Data.List ( intercalculate ) 56 | 57 | hello :: String -> String 58 | hello s = 59 | "Hello, " ++ s ++ "." 60 | 61 | main :: IO () 62 | main = 63 | map hello [ "artichoke", "alcachofa" ] & intercalculate "\n" & putStrLn 64 | ``` 65 | 66 | *** 67 | 68 | _Alcachofa_, if you were wondering, is artichoke in Spanish. 69 | -------------------------------------------------------------------------------- /examples/pager/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // An example program demonstrating the pager component from the Bubbles 4 | // component library. 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | "github.com/charmbracelet/bubbles/viewport" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | ) 15 | 16 | var ( 17 | titleStyle = func() lipgloss.Style { 18 | b := lipgloss.RoundedBorder() 19 | b.Right = "├" 20 | return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) 21 | }() 22 | 23 | infoStyle = func() lipgloss.Style { 24 | b := lipgloss.RoundedBorder() 25 | b.Left = "┤" 26 | return titleStyle.BorderStyle(b) 27 | }() 28 | ) 29 | 30 | type model struct { 31 | content string 32 | ready bool 33 | viewport viewport.Model 34 | } 35 | 36 | func (m model) Init() tea.Cmd { 37 | return nil 38 | } 39 | 40 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 41 | var ( 42 | cmd tea.Cmd 43 | cmds []tea.Cmd 44 | ) 45 | 46 | switch msg := msg.(type) { 47 | case tea.KeyMsg: 48 | if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" { 49 | return m, tea.Quit 50 | } 51 | 52 | case tea.WindowSizeMsg: 53 | headerHeight := lipgloss.Height(m.headerView()) 54 | footerHeight := lipgloss.Height(m.footerView()) 55 | verticalMarginHeight := headerHeight + footerHeight 56 | 57 | if !m.ready { 58 | // Since this program is using the full size of the viewport we 59 | // need to wait until we've received the window dimensions before 60 | // we can initialize the viewport. The initial dimensions come in 61 | // quickly, though asynchronously, which is why we wait for them 62 | // here. 63 | m.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight) 64 | m.viewport.YPosition = headerHeight 65 | m.viewport.SetContent(m.content) 66 | m.ready = true 67 | } else { 68 | m.viewport.Width = msg.Width 69 | m.viewport.Height = msg.Height - verticalMarginHeight 70 | } 71 | } 72 | 73 | // Handle keyboard and mouse events in the viewport 74 | m.viewport, cmd = m.viewport.Update(msg) 75 | cmds = append(cmds, cmd) 76 | 77 | return m, tea.Batch(cmds...) 78 | } 79 | 80 | func (m model) View() string { 81 | if !m.ready { 82 | return "\n Initializing..." 83 | } 84 | return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView()) 85 | } 86 | 87 | func (m model) headerView() string { 88 | title := titleStyle.Render("Mr. Pager") 89 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title))) 90 | return lipgloss.JoinHorizontal(lipgloss.Center, title, line) 91 | } 92 | 93 | func (m model) footerView() string { 94 | info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) 95 | line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) 96 | return lipgloss.JoinHorizontal(lipgloss.Center, line, info) 97 | } 98 | 99 | func max(a, b int) int { 100 | if a > b { 101 | return a 102 | } 103 | return b 104 | } 105 | 106 | func main() { 107 | // Load some text for our viewport 108 | content, err := os.ReadFile("artichoke.md") 109 | if err != nil { 110 | fmt.Println("could not load file:", err) 111 | os.Exit(1) 112 | } 113 | 114 | p := tea.NewProgram( 115 | model{content: string(content)}, 116 | tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer" 117 | tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel 118 | ) 119 | 120 | if _, err := p.Run(); err != nil { 121 | fmt.Println("could not run program:", err) 122 | os.Exit(1) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /examples/pager/pager.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/pager/pager.gif -------------------------------------------------------------------------------- /examples/paginator/README.md: -------------------------------------------------------------------------------- 1 | # Paginator 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/paginator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program demonstrating the paginator component from the Bubbles 4 | // component library. 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "strings" 10 | 11 | "github.com/charmbracelet/bubbles/paginator" 12 | "github.com/charmbracelet/lipgloss" 13 | 14 | tea "github.com/charmbracelet/bubbletea" 15 | ) 16 | 17 | func newModel() model { 18 | var items []string 19 | for i := 1; i < 101; i++ { 20 | text := fmt.Sprintf("Item %d", i) 21 | items = append(items, text) 22 | } 23 | 24 | p := paginator.New() 25 | p.Type = paginator.Dots 26 | p.PerPage = 10 27 | p.ActiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"}).Render("•") 28 | p.InactiveDot = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "238"}).Render("•") 29 | p.SetTotalPages(len(items)) 30 | 31 | return model{ 32 | paginator: p, 33 | items: items, 34 | } 35 | } 36 | 37 | type model struct { 38 | items []string 39 | paginator paginator.Model 40 | } 41 | 42 | func (m model) Init() tea.Cmd { 43 | return nil 44 | } 45 | 46 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 47 | var cmd tea.Cmd 48 | switch msg := msg.(type) { 49 | case tea.KeyMsg: 50 | switch msg.String() { 51 | case "q", "esc", "ctrl+c": 52 | return m, tea.Quit 53 | } 54 | } 55 | m.paginator, cmd = m.paginator.Update(msg) 56 | return m, cmd 57 | } 58 | 59 | func (m model) View() string { 60 | var b strings.Builder 61 | b.WriteString("\n Paginator Example\n\n") 62 | start, end := m.paginator.GetSliceBounds(len(m.items)) 63 | for _, item := range m.items[start:end] { 64 | b.WriteString(" • " + item + "\n\n") 65 | } 66 | b.WriteString(" " + m.paginator.View()) 67 | b.WriteString("\n\n h/l ←/→ page • q: quit\n") 68 | return b.String() 69 | } 70 | 71 | func main() { 72 | p := tea.NewProgram(newModel()) 73 | if _, err := p.Run(); err != nil { 74 | log.Fatal(err) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /examples/paginator/paginator.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/paginator/paginator.gif -------------------------------------------------------------------------------- /examples/pipe/README.md: -------------------------------------------------------------------------------- 1 | # Pipe 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/pipe/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // An example illustrating how to pipe in data to a Bubble Tea application. 4 | // More so, this serves as proof that Bubble Tea will automatically listen for 5 | // keystrokes when input is not a TTY, such as when data is piped or redirected 6 | // in. 7 | 8 | import ( 9 | "bufio" 10 | "fmt" 11 | "io" 12 | "os" 13 | "strings" 14 | 15 | "github.com/charmbracelet/bubbles/textinput" 16 | tea "github.com/charmbracelet/bubbletea" 17 | "github.com/charmbracelet/lipgloss" 18 | ) 19 | 20 | func main() { 21 | stat, err := os.Stdin.Stat() 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 { 27 | fmt.Println("Try piping in some text.") 28 | os.Exit(1) 29 | } 30 | 31 | reader := bufio.NewReader(os.Stdin) 32 | var b strings.Builder 33 | 34 | for { 35 | r, _, err := reader.ReadRune() 36 | if err != nil && err == io.EOF { 37 | break 38 | } 39 | _, err = b.WriteRune(r) 40 | if err != nil { 41 | fmt.Println("Error getting input:", err) 42 | os.Exit(1) 43 | } 44 | } 45 | 46 | model := newModel(strings.TrimSpace(b.String())) 47 | 48 | if _, err := tea.NewProgram(model).Run(); err != nil { 49 | fmt.Println("Couldn't start program:", err) 50 | os.Exit(1) 51 | } 52 | } 53 | 54 | type model struct { 55 | userInput textinput.Model 56 | } 57 | 58 | func newModel(initialValue string) (m model) { 59 | i := textinput.New() 60 | i.Prompt = "" 61 | i.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) 62 | i.Width = 48 63 | i.SetValue(initialValue) 64 | i.CursorEnd() 65 | i.Focus() 66 | 67 | m.userInput = i 68 | return 69 | } 70 | 71 | func (m model) Init() tea.Cmd { 72 | return textinput.Blink 73 | } 74 | 75 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 76 | if key, ok := msg.(tea.KeyMsg); ok { 77 | switch key.Type { 78 | case tea.KeyCtrlC, tea.KeyEscape, tea.KeyEnter: 79 | return m, tea.Quit 80 | } 81 | } 82 | 83 | var cmd tea.Cmd 84 | m.userInput, cmd = m.userInput.Update(msg) 85 | return m, cmd 86 | } 87 | 88 | func (m model) View() string { 89 | return fmt.Sprintf( 90 | "\nYou piped in: %s\n\nPress ^C to exit", 91 | m.userInput.View(), 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /examples/pipe/pipe.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/pipe/pipe.gif -------------------------------------------------------------------------------- /examples/prevent-quit/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A program demonstrating how to use the WithFilter option to intercept events. 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | 9 | "github.com/charmbracelet/bubbles/help" 10 | "github.com/charmbracelet/bubbles/key" 11 | "github.com/charmbracelet/bubbles/textarea" 12 | tea "github.com/charmbracelet/bubbletea" 13 | "github.com/charmbracelet/lipgloss" 14 | ) 15 | 16 | var ( 17 | choiceStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(lipgloss.Color("241")) 18 | saveTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) 19 | quitViewStyle = lipgloss.NewStyle().Padding(1).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("170")) 20 | ) 21 | 22 | func main() { 23 | p := tea.NewProgram(initialModel(), tea.WithFilter(filter)) 24 | 25 | if _, err := p.Run(); err != nil { 26 | log.Fatal(err) 27 | } 28 | } 29 | 30 | func filter(teaModel tea.Model, msg tea.Msg) tea.Msg { 31 | if _, ok := msg.(tea.QuitMsg); !ok { 32 | return msg 33 | } 34 | 35 | m := teaModel.(model) 36 | if m.hasChanges { 37 | return nil 38 | } 39 | 40 | return msg 41 | } 42 | 43 | type model struct { 44 | textarea textarea.Model 45 | help help.Model 46 | keymap keymap 47 | saveText string 48 | hasChanges bool 49 | quitting bool 50 | } 51 | 52 | type keymap struct { 53 | save key.Binding 54 | quit key.Binding 55 | } 56 | 57 | func initialModel() model { 58 | ti := textarea.New() 59 | ti.Placeholder = "Only the best words" 60 | ti.Focus() 61 | 62 | return model{ 63 | textarea: ti, 64 | help: help.New(), 65 | keymap: keymap{ 66 | save: key.NewBinding( 67 | key.WithKeys("ctrl+s"), 68 | key.WithHelp("ctrl+s", "save"), 69 | ), 70 | quit: key.NewBinding( 71 | key.WithKeys("esc", "ctrl+c"), 72 | key.WithHelp("esc", "quit"), 73 | ), 74 | }, 75 | } 76 | } 77 | 78 | func (m model) Init() tea.Cmd { 79 | return textarea.Blink 80 | } 81 | 82 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 83 | if m.quitting { 84 | return m.updatePromptView(msg) 85 | } 86 | 87 | return m.updateTextView(msg) 88 | } 89 | 90 | func (m model) updateTextView(msg tea.Msg) (tea.Model, tea.Cmd) { 91 | var cmds []tea.Cmd 92 | var cmd tea.Cmd 93 | 94 | switch msg := msg.(type) { 95 | case tea.KeyMsg: 96 | m.saveText = "" 97 | switch { 98 | case key.Matches(msg, m.keymap.save): 99 | m.saveText = "Changes saved!" 100 | m.hasChanges = false 101 | case key.Matches(msg, m.keymap.quit): 102 | m.quitting = true 103 | return m, tea.Quit 104 | case msg.Type == tea.KeyRunes: 105 | m.saveText = "" 106 | m.hasChanges = true 107 | fallthrough 108 | default: 109 | if !m.textarea.Focused() { 110 | cmd = m.textarea.Focus() 111 | cmds = append(cmds, cmd) 112 | } 113 | } 114 | } 115 | m.textarea, cmd = m.textarea.Update(msg) 116 | cmds = append(cmds, cmd) 117 | return m, tea.Batch(cmds...) 118 | } 119 | 120 | func (m model) updatePromptView(msg tea.Msg) (tea.Model, tea.Cmd) { 121 | switch msg := msg.(type) { 122 | case tea.KeyMsg: 123 | // For simplicity's sake, we'll treat any key besides "y" as "no" 124 | if key.Matches(msg, m.keymap.quit) || msg.String() == "y" { 125 | m.hasChanges = false 126 | return m, tea.Quit 127 | } 128 | m.quitting = false 129 | } 130 | 131 | return m, nil 132 | } 133 | 134 | func (m model) View() string { 135 | if m.quitting { 136 | if m.hasChanges { 137 | text := lipgloss.JoinHorizontal(lipgloss.Top, "You have unsaved changes. Quit without saving?", choiceStyle.Render("[yn]")) 138 | return quitViewStyle.Render(text) 139 | } 140 | return "Very important, thank you\n" 141 | } 142 | 143 | helpView := m.help.ShortHelpView([]key.Binding{ 144 | m.keymap.save, 145 | m.keymap.quit, 146 | }) 147 | 148 | return fmt.Sprintf( 149 | "\nType some important things.\n\n%s\n\n %s\n %s", 150 | m.textarea.View(), 151 | saveTextStyle.Render(m.saveText), 152 | helpView, 153 | ) + "\n\n" 154 | } 155 | -------------------------------------------------------------------------------- /examples/progress-animated/README.md: -------------------------------------------------------------------------------- 1 | # Animated Progress 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/progress-animated/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple example that shows how to render an animated progress bar. In this 4 | // example we bump the progress by 25% every two seconds, animating our 5 | // progress bar to its new target state. 6 | // 7 | // It's also possible to render a progress bar in a more static fashion without 8 | // transitions. For details on that approach see the progress-static example. 9 | 10 | import ( 11 | "fmt" 12 | "os" 13 | "strings" 14 | "time" 15 | 16 | "github.com/charmbracelet/bubbles/progress" 17 | tea "github.com/charmbracelet/bubbletea" 18 | "github.com/charmbracelet/lipgloss" 19 | ) 20 | 21 | const ( 22 | padding = 2 23 | maxWidth = 80 24 | ) 25 | 26 | var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render 27 | 28 | func main() { 29 | m := model{ 30 | progress: progress.New(progress.WithDefaultGradient()), 31 | } 32 | 33 | if _, err := tea.NewProgram(m).Run(); err != nil { 34 | fmt.Println("Oh no!", err) 35 | os.Exit(1) 36 | } 37 | } 38 | 39 | type tickMsg time.Time 40 | 41 | type model struct { 42 | progress progress.Model 43 | } 44 | 45 | func (m model) Init() tea.Cmd { 46 | return tickCmd() 47 | } 48 | 49 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 50 | switch msg := msg.(type) { 51 | case tea.KeyMsg: 52 | return m, tea.Quit 53 | 54 | case tea.WindowSizeMsg: 55 | m.progress.Width = msg.Width - padding*2 - 4 56 | if m.progress.Width > maxWidth { 57 | m.progress.Width = maxWidth 58 | } 59 | return m, nil 60 | 61 | case tickMsg: 62 | if m.progress.Percent() == 1.0 { 63 | return m, tea.Quit 64 | } 65 | 66 | // Note that you can also use progress.Model.SetPercent to set the 67 | // percentage value explicitly, too. 68 | cmd := m.progress.IncrPercent(0.25) 69 | return m, tea.Batch(tickCmd(), cmd) 70 | 71 | // FrameMsg is sent when the progress bar wants to animate itself 72 | case progress.FrameMsg: 73 | progressModel, cmd := m.progress.Update(msg) 74 | m.progress = progressModel.(progress.Model) 75 | return m, cmd 76 | 77 | default: 78 | return m, nil 79 | } 80 | } 81 | 82 | func (m model) View() string { 83 | pad := strings.Repeat(" ", padding) 84 | return "\n" + 85 | pad + m.progress.View() + "\n\n" + 86 | pad + helpStyle("Press any key to quit") 87 | } 88 | 89 | func tickCmd() tea.Cmd { 90 | return tea.Tick(time.Second*1, func(t time.Time) tea.Msg { 91 | return tickMsg(t) 92 | }) 93 | } 94 | -------------------------------------------------------------------------------- /examples/progress-animated/progress-animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/progress-animated/progress-animated.gif -------------------------------------------------------------------------------- /examples/progress-download/README.md: -------------------------------------------------------------------------------- 1 | # Download Progress 2 | 3 | This example demonstrates how to download a file from a URL and show its 4 | progress with a [Progress Bubble][progress]. 5 | 6 | In this case we're getting download progress with an [`io.TeeReader`][tee] and 7 | sending progress `Msg`s to the `Program` with `Program.Send()`. 8 | 9 | ## How to Run 10 | 11 | Build the application with `go build .`, then run with a `--url` argument 12 | specifying the URL of the file to download. For example: 13 | 14 | ``` 15 | ./progress-download --url="https://download.blender.org/demo/color_vortex.blend" 16 | ``` 17 | 18 | Note that in this example a TUI will not be shown for URLs that do not respond 19 | with a ContentLength header. 20 | 21 | * * * 22 | 23 | This example originally came from [this discussion][discussion]. 24 | 25 | * * * 26 | 27 | The Charm logo 28 | 29 | Charm热爱开源 • Charm loves open source 30 | 31 | 32 | [progress]: https://github.com/charmbracelet/bubbles/ 33 | [tee]: https://pkg.go.dev/io#TeeReader 34 | [discussion]: https://github.com/charmbracelet/bubbles/discussions/127 35 | -------------------------------------------------------------------------------- /examples/progress-download/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/charmbracelet/bubbles/progress" 13 | tea "github.com/charmbracelet/bubbletea" 14 | ) 15 | 16 | var p *tea.Program 17 | 18 | type progressWriter struct { 19 | total int 20 | downloaded int 21 | file *os.File 22 | reader io.Reader 23 | onProgress func(float64) 24 | } 25 | 26 | func (pw *progressWriter) Start() { 27 | // TeeReader calls pw.Write() each time a new response is received 28 | _, err := io.Copy(pw.file, io.TeeReader(pw.reader, pw)) 29 | if err != nil { 30 | p.Send(progressErrMsg{err}) 31 | } 32 | } 33 | 34 | func (pw *progressWriter) Write(p []byte) (int, error) { 35 | pw.downloaded += len(p) 36 | if pw.total > 0 && pw.onProgress != nil { 37 | pw.onProgress(float64(pw.downloaded) / float64(pw.total)) 38 | } 39 | return len(p), nil 40 | } 41 | 42 | func getResponse(url string) (*http.Response, error) { 43 | resp, err := http.Get(url) // nolint:gosec 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | if resp.StatusCode != http.StatusOK { 48 | return nil, fmt.Errorf("receiving status of %d for url: %s", resp.StatusCode, url) 49 | } 50 | return resp, nil 51 | } 52 | 53 | func main() { 54 | url := flag.String("url", "", "url for the file to download") 55 | flag.Parse() 56 | 57 | if *url == "" { 58 | flag.Usage() 59 | os.Exit(1) 60 | } 61 | 62 | resp, err := getResponse(*url) 63 | if err != nil { 64 | fmt.Println("could not get response", err) 65 | os.Exit(1) 66 | } 67 | defer resp.Body.Close() // nolint:errcheck 68 | 69 | // Don't add TUI if the header doesn't include content size 70 | // it's impossible see progress without total 71 | if resp.ContentLength <= 0 { 72 | fmt.Println("can't parse content length, aborting download") 73 | os.Exit(1) 74 | } 75 | 76 | filename := filepath.Base(*url) 77 | file, err := os.Create(filename) 78 | if err != nil { 79 | fmt.Println("could not create file:", err) 80 | os.Exit(1) 81 | } 82 | defer file.Close() // nolint:errcheck 83 | 84 | pw := &progressWriter{ 85 | total: int(resp.ContentLength), 86 | file: file, 87 | reader: resp.Body, 88 | onProgress: func(ratio float64) { 89 | p.Send(progressMsg(ratio)) 90 | }, 91 | } 92 | 93 | m := model{ 94 | pw: pw, 95 | progress: progress.New(progress.WithDefaultGradient()), 96 | } 97 | // Start Bubble Tea 98 | p = tea.NewProgram(m) 99 | 100 | // Start the download 101 | go pw.Start() 102 | 103 | if _, err := p.Run(); err != nil { 104 | fmt.Println("error running program:", err) 105 | os.Exit(1) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /examples/progress-download/tui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/charmbracelet/bubbles/progress" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render 13 | 14 | const ( 15 | padding = 2 16 | maxWidth = 80 17 | ) 18 | 19 | type progressMsg float64 20 | 21 | type progressErrMsg struct{ err error } 22 | 23 | func finalPause() tea.Cmd { 24 | return tea.Tick(time.Millisecond*750, func(_ time.Time) tea.Msg { 25 | return nil 26 | }) 27 | } 28 | 29 | type model struct { 30 | pw *progressWriter 31 | progress progress.Model 32 | err error 33 | } 34 | 35 | func (m model) Init() tea.Cmd { 36 | return nil 37 | } 38 | 39 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 40 | switch msg := msg.(type) { 41 | case tea.KeyMsg: 42 | return m, tea.Quit 43 | 44 | case tea.WindowSizeMsg: 45 | m.progress.Width = msg.Width - padding*2 - 4 46 | if m.progress.Width > maxWidth { 47 | m.progress.Width = maxWidth 48 | } 49 | return m, nil 50 | 51 | case progressErrMsg: 52 | m.err = msg.err 53 | return m, tea.Quit 54 | 55 | case progressMsg: 56 | var cmds []tea.Cmd 57 | 58 | if msg >= 1.0 { 59 | cmds = append(cmds, tea.Sequence(finalPause(), tea.Quit)) 60 | } 61 | 62 | cmds = append(cmds, m.progress.SetPercent(float64(msg))) 63 | return m, tea.Batch(cmds...) 64 | 65 | // FrameMsg is sent when the progress bar wants to animate itself 66 | case progress.FrameMsg: 67 | progressModel, cmd := m.progress.Update(msg) 68 | m.progress = progressModel.(progress.Model) 69 | return m, cmd 70 | 71 | default: 72 | return m, nil 73 | } 74 | } 75 | 76 | func (m model) View() string { 77 | if m.err != nil { 78 | return "Error downloading: " + m.err.Error() + "\n" 79 | } 80 | 81 | pad := strings.Repeat(" ", padding) 82 | return "\n" + 83 | pad + m.progress.View() + "\n\n" + 84 | pad + helpStyle("Press any key to quit") 85 | } 86 | -------------------------------------------------------------------------------- /examples/progress-static/README.md: -------------------------------------------------------------------------------- 1 | # Static Progress 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/progress-static/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple example that shows how to render a progress bar in a "pure" 4 | // fashion. In this example we bump the progress by 25% every second, 5 | // maintaining the progress state on our top level model using the progress bar 6 | // model's ViewAs method only for rendering. 7 | // 8 | // The signature for ViewAs is: 9 | // 10 | // func (m Model) ViewAs(percent float64) string 11 | // 12 | // So it takes a float between 0 and 1, and renders the progress bar 13 | // accordingly. When using the progress bar in this "pure" fashion and there's 14 | // no need to call an Update method. 15 | // 16 | // The progress bar is also able to animate itself, however. For details see 17 | // the progress-animated example. 18 | 19 | import ( 20 | "fmt" 21 | "os" 22 | "strings" 23 | "time" 24 | 25 | "github.com/charmbracelet/bubbles/progress" 26 | tea "github.com/charmbracelet/bubbletea" 27 | "github.com/charmbracelet/lipgloss" 28 | ) 29 | 30 | const ( 31 | padding = 2 32 | maxWidth = 80 33 | ) 34 | 35 | var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render 36 | 37 | func main() { 38 | prog := progress.New(progress.WithScaledGradient("#FF7CCB", "#FDFF8C")) 39 | 40 | if _, err := tea.NewProgram(model{progress: prog}).Run(); err != nil { 41 | fmt.Println("Oh no!", err) 42 | os.Exit(1) 43 | } 44 | } 45 | 46 | type tickMsg time.Time 47 | 48 | type model struct { 49 | percent float64 50 | progress progress.Model 51 | } 52 | 53 | func (m model) Init() tea.Cmd { 54 | return tickCmd() 55 | } 56 | 57 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 58 | switch msg := msg.(type) { 59 | case tea.KeyMsg: 60 | return m, tea.Quit 61 | 62 | case tea.WindowSizeMsg: 63 | m.progress.Width = msg.Width - padding*2 - 4 64 | if m.progress.Width > maxWidth { 65 | m.progress.Width = maxWidth 66 | } 67 | return m, nil 68 | 69 | case tickMsg: 70 | m.percent += 0.25 71 | if m.percent > 1.0 { 72 | m.percent = 1.0 73 | return m, tea.Quit 74 | } 75 | return m, tickCmd() 76 | 77 | default: 78 | return m, nil 79 | } 80 | } 81 | 82 | func (m model) View() string { 83 | pad := strings.Repeat(" ", padding) 84 | return "\n" + 85 | pad + m.progress.ViewAs(m.percent) + "\n\n" + 86 | pad + helpStyle("Press any key to quit") 87 | } 88 | 89 | func tickCmd() tea.Cmd { 90 | return tea.Tick(time.Second, func(t time.Time) tea.Msg { 91 | return tickMsg(t) 92 | }) 93 | } 94 | -------------------------------------------------------------------------------- /examples/progress-static/progress-static.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/progress-static/progress-static.gif -------------------------------------------------------------------------------- /examples/realtime/README.md: -------------------------------------------------------------------------------- 1 | # Real Time 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/realtime/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple example that shows how to send activity to Bubble Tea in real-time 4 | // through a channel. 5 | 6 | import ( 7 | "fmt" 8 | "math/rand" 9 | "os" 10 | "time" 11 | 12 | "github.com/charmbracelet/bubbles/spinner" 13 | tea "github.com/charmbracelet/bubbletea" 14 | ) 15 | 16 | // A message used to indicate that activity has occurred. In the real world (for 17 | // example, chat) this would contain actual data. 18 | type responseMsg struct{} 19 | 20 | // Simulate a process that sends events at an irregular interval in real time. 21 | // In this case, we'll send events on the channel at a random interval between 22 | // 100 to 1000 milliseconds. As a command, Bubble Tea will run this 23 | // asynchronously. 24 | func listenForActivity(sub chan struct{}) tea.Cmd { 25 | return func() tea.Msg { 26 | for { 27 | time.Sleep(time.Millisecond * time.Duration(rand.Int63n(900)+100)) // nolint:gosec 28 | sub <- struct{}{} 29 | } 30 | } 31 | } 32 | 33 | // A command that waits for the activity on a channel. 34 | func waitForActivity(sub chan struct{}) tea.Cmd { 35 | return func() tea.Msg { 36 | return responseMsg(<-sub) 37 | } 38 | } 39 | 40 | type model struct { 41 | sub chan struct{} // where we'll receive activity notifications 42 | responses int // how many responses we've received 43 | spinner spinner.Model 44 | quitting bool 45 | } 46 | 47 | func (m model) Init() tea.Cmd { 48 | return tea.Batch( 49 | m.spinner.Tick, 50 | listenForActivity(m.sub), // generate activity 51 | waitForActivity(m.sub), // wait for activity 52 | ) 53 | } 54 | 55 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 56 | switch msg.(type) { 57 | case tea.KeyMsg: 58 | m.quitting = true 59 | return m, tea.Quit 60 | case responseMsg: 61 | m.responses++ // record external activity 62 | return m, waitForActivity(m.sub) // wait for next event 63 | case spinner.TickMsg: 64 | var cmd tea.Cmd 65 | m.spinner, cmd = m.spinner.Update(msg) 66 | return m, cmd 67 | default: 68 | return m, nil 69 | } 70 | } 71 | 72 | func (m model) View() string { 73 | s := fmt.Sprintf("\n %s Events received: %d\n\n Press any key to exit\n", m.spinner.View(), m.responses) 74 | if m.quitting { 75 | s += "\n" 76 | } 77 | return s 78 | } 79 | 80 | func main() { 81 | p := tea.NewProgram(model{ 82 | sub: make(chan struct{}), 83 | spinner: spinner.New(), 84 | }) 85 | 86 | if _, err := p.Run(); err != nil { 87 | fmt.Println("could not start program:", err) 88 | os.Exit(1) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /examples/realtime/realtime.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/realtime/realtime.gif -------------------------------------------------------------------------------- /examples/result/README.md: -------------------------------------------------------------------------------- 1 | # Result 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/result/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple example that shows how to retrieve a value from a Bubble Tea 4 | // program after the Bubble Tea has exited. 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | var choices = []string{"Taro", "Coffee", "Lychee"} 15 | 16 | type model struct { 17 | cursor int 18 | choice string 19 | } 20 | 21 | func (m model) Init() tea.Cmd { 22 | return nil 23 | } 24 | 25 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 26 | switch msg := msg.(type) { 27 | case tea.KeyMsg: 28 | switch msg.String() { 29 | case "ctrl+c", "q", "esc": 30 | return m, tea.Quit 31 | 32 | case "enter": 33 | // Send the choice on the channel and exit. 34 | m.choice = choices[m.cursor] 35 | return m, tea.Quit 36 | 37 | case "down", "j": 38 | m.cursor++ 39 | if m.cursor >= len(choices) { 40 | m.cursor = 0 41 | } 42 | 43 | case "up", "k": 44 | m.cursor-- 45 | if m.cursor < 0 { 46 | m.cursor = len(choices) - 1 47 | } 48 | } 49 | } 50 | 51 | return m, nil 52 | } 53 | 54 | func (m model) View() string { 55 | s := strings.Builder{} 56 | s.WriteString("What kind of Bubble Tea would you like to order?\n\n") 57 | 58 | for i := 0; i < len(choices); i++ { 59 | if m.cursor == i { 60 | s.WriteString("(•) ") 61 | } else { 62 | s.WriteString("( ) ") 63 | } 64 | s.WriteString(choices[i]) 65 | s.WriteString("\n") 66 | } 67 | s.WriteString("\n(press q to quit)\n") 68 | 69 | return s.String() 70 | } 71 | 72 | func main() { 73 | p := tea.NewProgram(model{}) 74 | 75 | // Run returns the model as a tea.Model. 76 | m, err := p.Run() 77 | if err != nil { 78 | fmt.Println("Oh no:", err) 79 | os.Exit(1) 80 | } 81 | 82 | // Assert the final tea.Model to our local model and print the choice. 83 | if m, ok := m.(model); ok && m.choice != "" { 84 | fmt.Printf("\n---\nYou chose %s!\n", m.choice) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /examples/result/result.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/result/result.gif -------------------------------------------------------------------------------- /examples/send-msg/README.md: -------------------------------------------------------------------------------- 1 | # Send Msg 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/send-msg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple example that shows how to send messages to a Bubble Tea program 4 | // from outside the program using Program.Send(Msg). 5 | 6 | import ( 7 | "fmt" 8 | "math/rand" 9 | "os" 10 | "strings" 11 | "time" 12 | 13 | "github.com/charmbracelet/bubbles/spinner" 14 | tea "github.com/charmbracelet/bubbletea" 15 | "github.com/charmbracelet/lipgloss" 16 | ) 17 | 18 | var ( 19 | spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) 20 | helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Margin(1, 0) 21 | dotStyle = helpStyle.UnsetMargins() 22 | durationStyle = dotStyle 23 | appStyle = lipgloss.NewStyle().Margin(1, 2, 0, 2) 24 | ) 25 | 26 | type resultMsg struct { 27 | duration time.Duration 28 | food string 29 | } 30 | 31 | func (r resultMsg) String() string { 32 | if r.duration == 0 { 33 | return dotStyle.Render(strings.Repeat(".", 30)) 34 | } 35 | return fmt.Sprintf("🍔 Ate %s %s", r.food, 36 | durationStyle.Render(r.duration.String())) 37 | } 38 | 39 | type model struct { 40 | spinner spinner.Model 41 | results []resultMsg 42 | quitting bool 43 | } 44 | 45 | func newModel() model { 46 | const numLastResults = 5 47 | s := spinner.New() 48 | s.Style = spinnerStyle 49 | return model{ 50 | spinner: s, 51 | results: make([]resultMsg, numLastResults), 52 | } 53 | } 54 | 55 | func (m model) Init() tea.Cmd { 56 | return m.spinner.Tick 57 | } 58 | 59 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 60 | switch msg := msg.(type) { 61 | case tea.KeyMsg: 62 | m.quitting = true 63 | return m, tea.Quit 64 | case resultMsg: 65 | m.results = append(m.results[1:], msg) 66 | return m, nil 67 | case spinner.TickMsg: 68 | var cmd tea.Cmd 69 | m.spinner, cmd = m.spinner.Update(msg) 70 | return m, cmd 71 | default: 72 | return m, nil 73 | } 74 | } 75 | 76 | func (m model) View() string { 77 | var s string 78 | 79 | if m.quitting { 80 | s += "That’s all for today!" 81 | } else { 82 | s += m.spinner.View() + " Eating food..." 83 | } 84 | 85 | s += "\n\n" 86 | 87 | for _, res := range m.results { 88 | s += res.String() + "\n" 89 | } 90 | 91 | if !m.quitting { 92 | s += helpStyle.Render("Press any key to exit") 93 | } 94 | 95 | if m.quitting { 96 | s += "\n" 97 | } 98 | 99 | return appStyle.Render(s) 100 | } 101 | 102 | func main() { 103 | p := tea.NewProgram(newModel()) 104 | 105 | // Simulate activity 106 | go func() { 107 | for { 108 | pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond // nolint:gosec 109 | time.Sleep(pause) 110 | 111 | // Send the Bubble Tea program a message from outside the 112 | // tea.Program. This will block until it is ready to receive 113 | // messages. 114 | p.Send(resultMsg{food: randomFood(), duration: pause}) 115 | } 116 | }() 117 | 118 | if _, err := p.Run(); err != nil { 119 | fmt.Println("Error running program:", err) 120 | os.Exit(1) 121 | } 122 | } 123 | 124 | func randomFood() string { 125 | food := []string{ 126 | "an apple", "a pear", "a gherkin", "a party gherkin", 127 | "a kohlrabi", "some spaghetti", "tacos", "a currywurst", "some curry", 128 | "a sandwich", "some peanut butter", "some cashews", "some ramen", 129 | } 130 | return food[rand.Intn(len(food))] // nolint:gosec 131 | } 132 | -------------------------------------------------------------------------------- /examples/send-msg/send-msg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/send-msg/send-msg.gif -------------------------------------------------------------------------------- /examples/sequence/README.md: -------------------------------------------------------------------------------- 1 | # Sequence 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/sequence/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple example illustrating how to run a series of commands in order. 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | type model struct{} 13 | 14 | func (m model) Init() tea.Cmd { 15 | return tea.Sequence( 16 | tea.Batch( 17 | tea.Println("A"), 18 | tea.Println("B"), 19 | tea.Println("C"), 20 | ), 21 | tea.Println("Z"), 22 | tea.Quit, 23 | ) 24 | } 25 | 26 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 27 | switch msg.(type) { 28 | case tea.KeyMsg: 29 | return m, tea.Quit 30 | } 31 | return m, nil 32 | } 33 | 34 | func (m model) View() string { 35 | return "" 36 | } 37 | 38 | func main() { 39 | if _, err := tea.NewProgram(model{}).Run(); err != nil { 40 | fmt.Println("Uh oh:", err) 41 | os.Exit(1) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /examples/sequence/sequence.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/sequence/sequence.gif -------------------------------------------------------------------------------- /examples/set-window-title/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple example illustrating how to set a window title. 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | type model struct{} 13 | 14 | func (m model) Init() tea.Cmd { 15 | return tea.SetWindowTitle("Bubble Tea Example") 16 | } 17 | 18 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 19 | switch msg.(type) { 20 | case tea.KeyMsg: 21 | return m, tea.Quit 22 | } 23 | return m, nil 24 | } 25 | 26 | func (m model) View() string { 27 | return "\nPress any key to quit." 28 | } 29 | 30 | func main() { 31 | if _, err := tea.NewProgram(model{}).Run(); err != nil { 32 | fmt.Println("Uh oh:", err) 33 | os.Exit(1) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | # Simple 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/simple/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program that counts down from 5 and then exits. 4 | 5 | import ( 6 | "fmt" 7 | "log" 8 | "os" 9 | "time" 10 | 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | func main() { 15 | // Log to a file. Useful in debugging since you can't really log to stdout. 16 | // Not required. 17 | logfilePath := os.Getenv("BUBBLETEA_LOG") 18 | if logfilePath != "" { 19 | if _, err := tea.LogToFile(logfilePath, "simple"); err != nil { 20 | log.Fatal(err) 21 | } 22 | } 23 | 24 | // Initialize our program 25 | p := tea.NewProgram(model(5)) 26 | if _, err := p.Run(); err != nil { 27 | log.Fatal(err) 28 | } 29 | } 30 | 31 | // A model can be more or less any type of data. It holds all the data for a 32 | // program, so often it's a struct. For this simple example, however, all 33 | // we'll need is a simple integer. 34 | type model int 35 | 36 | // Init optionally returns an initial command we should run. In this case we 37 | // want to start the timer. 38 | func (m model) Init() tea.Cmd { 39 | return tick 40 | } 41 | 42 | // Update is called when messages are received. The idea is that you inspect the 43 | // message and send back an updated model accordingly. You can also return 44 | // a command, which is a function that performs I/O and returns a message. 45 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 46 | switch msg := msg.(type) { 47 | case tea.KeyMsg: 48 | switch msg.String() { 49 | case "ctrl+c", "q": 50 | return m, tea.Quit 51 | case "ctrl+z": 52 | return m, tea.Suspend 53 | } 54 | 55 | case tickMsg: 56 | m-- 57 | if m <= 0 { 58 | return m, tea.Quit 59 | } 60 | return m, tick 61 | } 62 | return m, nil 63 | } 64 | 65 | // View returns a string based on data in the model. That string which will be 66 | // rendered to the terminal. 67 | func (m model) View() string { 68 | return fmt.Sprintf("Hi. This program will exit in %d seconds.\n\nTo quit sooner press ctrl-c, or press ctrl-z to suspend...\n", m) 69 | } 70 | 71 | // Messages are events that we respond to in our Update function. This 72 | // particular one indicates that the timer has ticked. 73 | type tickMsg time.Time 74 | 75 | func tick() tea.Msg { 76 | time.Sleep(time.Second) 77 | return tickMsg{} 78 | } 79 | -------------------------------------------------------------------------------- /examples/simple/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "regexp" 7 | "testing" 8 | "time" 9 | 10 | tea "github.com/charmbracelet/bubbletea" 11 | "github.com/charmbracelet/x/exp/teatest" 12 | ) 13 | 14 | func TestApp(t *testing.T) { 15 | m := model(10) 16 | tm := teatest.NewTestModel( 17 | t, m, 18 | teatest.WithInitialTermSize(70, 30), 19 | ) 20 | t.Cleanup(func() { 21 | if err := tm.Quit(); err != nil { 22 | t.Fatal(err) 23 | } 24 | }) 25 | 26 | time.Sleep(time.Second + time.Millisecond*200) 27 | tm.Type("I'm typing things, but it'll be ignored by my program") 28 | tm.Send("ignored msg") 29 | tm.Send(tea.KeyMsg{ 30 | Type: tea.KeyEnter, 31 | }) 32 | 33 | if err := tm.Quit(); err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | out := readBts(t, tm.FinalOutput(t)) 38 | if !regexp.MustCompile(`This program will exit in \d+ seconds`).Match(out) { 39 | t.Fatalf("output does not match the given regular expression: %s", string(out)) 40 | } 41 | teatest.RequireEqualOutput(t, out) 42 | 43 | if tm.FinalModel(t).(model) != 9 { 44 | t.Errorf("expected model to be 10, was %d", m) 45 | } 46 | } 47 | 48 | func TestAppInteractive(t *testing.T) { 49 | m := model(10) 50 | tm := teatest.NewTestModel( 51 | t, m, 52 | teatest.WithInitialTermSize(70, 30), 53 | ) 54 | 55 | time.Sleep(time.Second + time.Millisecond*200) 56 | tm.Send("ignored msg") 57 | 58 | if bts := readBts(t, tm.Output()); !bytes.Contains(bts, []byte("This program will exit in 9 seconds")) { 59 | t.Fatalf("output does not match: expected %q", string(bts)) 60 | } 61 | 62 | teatest.WaitFor(t, tm.Output(), func(out []byte) bool { 63 | return bytes.Contains(out, []byte("This program will exit in 7 seconds")) 64 | }, teatest.WithDuration(5*time.Second)) 65 | 66 | tm.Send(tea.KeyMsg{ 67 | Type: tea.KeyEnter, 68 | }) 69 | 70 | if err := tm.Quit(); err != nil { 71 | t.Fatal(err) 72 | } 73 | 74 | if tm.FinalModel(t).(model) != 7 { 75 | t.Errorf("expected model to be 7, was %d", m) 76 | } 77 | } 78 | 79 | func readBts(tb testing.TB, r io.Reader) []byte { 80 | tb.Helper() 81 | bts, err := io.ReadAll(r) 82 | if err != nil { 83 | tb.Fatal(err) 84 | } 85 | return bts 86 | } 87 | -------------------------------------------------------------------------------- /examples/simple/simple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/simple/simple.gif -------------------------------------------------------------------------------- /examples/simple/testdata/TestApp.golden: -------------------------------------------------------------------------------- 1 | [?25l[?2004h Hi. This program will exit in 10 seconds. 2 |  3 | To quit sooner press ctrl-c, or press ctrl-z to suspend... 4 | Hi. This program will exit in 9 seconds. 5 | 6 | 7 |  [?2004l[?25h[?1002l[?1003l[?1006l -------------------------------------------------------------------------------- /examples/spinner/README.md: -------------------------------------------------------------------------------- 1 | # Spinner 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/spinner/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program demonstrating the spinner component from the Bubbles 4 | // component library. 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/charmbracelet/bubbles/spinner" 11 | tea "github.com/charmbracelet/bubbletea" 12 | "github.com/charmbracelet/lipgloss" 13 | ) 14 | 15 | type errMsg error 16 | 17 | type model struct { 18 | spinner spinner.Model 19 | quitting bool 20 | err error 21 | } 22 | 23 | func initialModel() model { 24 | s := spinner.New() 25 | s.Spinner = spinner.Dot 26 | s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 27 | return model{spinner: s} 28 | } 29 | 30 | func (m model) Init() tea.Cmd { 31 | return m.spinner.Tick 32 | } 33 | 34 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 35 | switch msg := msg.(type) { 36 | case tea.KeyMsg: 37 | switch msg.String() { 38 | case "q", "esc", "ctrl+c": 39 | m.quitting = true 40 | return m, tea.Quit 41 | default: 42 | return m, nil 43 | } 44 | 45 | case errMsg: 46 | m.err = msg 47 | return m, nil 48 | 49 | default: 50 | var cmd tea.Cmd 51 | m.spinner, cmd = m.spinner.Update(msg) 52 | return m, cmd 53 | } 54 | } 55 | 56 | func (m model) View() string { 57 | if m.err != nil { 58 | return m.err.Error() 59 | } 60 | str := fmt.Sprintf("\n\n %s Loading forever...press q to quit\n\n", m.spinner.View()) 61 | if m.quitting { 62 | return str + "\n" 63 | } 64 | return str 65 | } 66 | 67 | func main() { 68 | p := tea.NewProgram(initialModel()) 69 | if _, err := p.Run(); err != nil { 70 | fmt.Println(err) 71 | os.Exit(1) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /examples/spinner/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/spinner/spinner.gif -------------------------------------------------------------------------------- /examples/spinners/README.md: -------------------------------------------------------------------------------- 1 | # Spinners 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/spinners/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/bubbles/spinner" 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | var ( 13 | // Available spinners 14 | spinners = []spinner.Spinner{ 15 | spinner.Line, 16 | spinner.Dot, 17 | spinner.MiniDot, 18 | spinner.Jump, 19 | spinner.Pulse, 20 | spinner.Points, 21 | spinner.Globe, 22 | spinner.Moon, 23 | spinner.Monkey, 24 | } 25 | 26 | textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Render 27 | spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) 28 | helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render 29 | ) 30 | 31 | func main() { 32 | m := model{} 33 | m.resetSpinner() 34 | 35 | if _, err := tea.NewProgram(m).Run(); err != nil { 36 | fmt.Println("could not run program:", err) 37 | os.Exit(1) 38 | } 39 | } 40 | 41 | type model struct { 42 | index int 43 | spinner spinner.Model 44 | } 45 | 46 | func (m model) Init() tea.Cmd { 47 | return m.spinner.Tick 48 | } 49 | 50 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 51 | switch msg := msg.(type) { 52 | case tea.KeyMsg: 53 | switch msg.String() { 54 | case "ctrl+c", "q", "esc": 55 | return m, tea.Quit 56 | case "h", "left": 57 | m.index-- 58 | if m.index < 0 { 59 | m.index = len(spinners) - 1 60 | } 61 | m.resetSpinner() 62 | return m, m.spinner.Tick 63 | case "l", "right": 64 | m.index++ 65 | if m.index >= len(spinners) { 66 | m.index = 0 67 | } 68 | m.resetSpinner() 69 | return m, m.spinner.Tick 70 | default: 71 | return m, nil 72 | } 73 | case spinner.TickMsg: 74 | var cmd tea.Cmd 75 | m.spinner, cmd = m.spinner.Update(msg) 76 | return m, cmd 77 | default: 78 | return m, nil 79 | } 80 | } 81 | 82 | func (m *model) resetSpinner() { 83 | m.spinner = spinner.New() 84 | m.spinner.Style = spinnerStyle 85 | m.spinner.Spinner = spinners[m.index] 86 | } 87 | 88 | func (m model) View() (s string) { 89 | var gap string 90 | switch m.index { 91 | case 1: 92 | gap = "" 93 | default: 94 | gap = " " 95 | } 96 | 97 | s += fmt.Sprintf("\n %s%s%s\n\n", m.spinner.View(), gap, textStyle("Spinning...")) 98 | s += helpStyle("h/l, ←/→: change spinner • q: exit\n") 99 | return 100 | } 101 | -------------------------------------------------------------------------------- /examples/spinners/spinners.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/spinners/spinners.gif -------------------------------------------------------------------------------- /examples/split-editors/README.md: -------------------------------------------------------------------------------- 1 | # Split Editors 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/split-editors/split-editors.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/split-editors/split-editors.gif -------------------------------------------------------------------------------- /examples/stopwatch/README.md: -------------------------------------------------------------------------------- 1 | # Stopwatch 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/stopwatch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/key" 10 | "github.com/charmbracelet/bubbles/stopwatch" 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | type model struct { 15 | stopwatch stopwatch.Model 16 | keymap keymap 17 | help help.Model 18 | quitting bool 19 | } 20 | 21 | type keymap struct { 22 | start key.Binding 23 | stop key.Binding 24 | reset key.Binding 25 | quit key.Binding 26 | } 27 | 28 | func (m model) Init() tea.Cmd { 29 | return m.stopwatch.Init() 30 | } 31 | 32 | func (m model) View() string { 33 | // Note: you could further customize the time output by getting the 34 | // duration from m.stopwatch.Elapsed(), which returns a time.Duration, and 35 | // skip m.stopwatch.View() altogether. 36 | s := m.stopwatch.View() + "\n" 37 | if !m.quitting { 38 | s = "Elapsed: " + s 39 | s += m.helpView() 40 | } 41 | return s 42 | } 43 | 44 | func (m model) helpView() string { 45 | return "\n" + m.help.ShortHelpView([]key.Binding{ 46 | m.keymap.start, 47 | m.keymap.stop, 48 | m.keymap.reset, 49 | m.keymap.quit, 50 | }) 51 | } 52 | 53 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 54 | switch msg := msg.(type) { 55 | case tea.KeyMsg: 56 | switch { 57 | case key.Matches(msg, m.keymap.quit): 58 | m.quitting = true 59 | return m, tea.Quit 60 | case key.Matches(msg, m.keymap.reset): 61 | return m, m.stopwatch.Reset() 62 | case key.Matches(msg, m.keymap.start, m.keymap.stop): 63 | m.keymap.stop.SetEnabled(!m.stopwatch.Running()) 64 | m.keymap.start.SetEnabled(m.stopwatch.Running()) 65 | return m, m.stopwatch.Toggle() 66 | } 67 | } 68 | var cmd tea.Cmd 69 | m.stopwatch, cmd = m.stopwatch.Update(msg) 70 | return m, cmd 71 | } 72 | 73 | func main() { 74 | m := model{ 75 | stopwatch: stopwatch.NewWithInterval(time.Millisecond), 76 | keymap: keymap{ 77 | start: key.NewBinding( 78 | key.WithKeys("s"), 79 | key.WithHelp("s", "start"), 80 | ), 81 | stop: key.NewBinding( 82 | key.WithKeys("s"), 83 | key.WithHelp("s", "stop"), 84 | ), 85 | reset: key.NewBinding( 86 | key.WithKeys("r"), 87 | key.WithHelp("r", "reset"), 88 | ), 89 | quit: key.NewBinding( 90 | key.WithKeys("ctrl+c", "q"), 91 | key.WithHelp("q", "quit"), 92 | ), 93 | }, 94 | help: help.New(), 95 | } 96 | 97 | m.keymap.start.SetEnabled(false) 98 | 99 | if _, err := tea.NewProgram(m).Run(); err != nil { 100 | fmt.Println("Oh no, it didn't work:", err) 101 | os.Exit(1) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /examples/stopwatch/stopwatch.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/stopwatch/stopwatch.gif -------------------------------------------------------------------------------- /examples/suspend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | type model struct { 12 | quitting bool 13 | suspending bool 14 | } 15 | 16 | func (m model) Init() tea.Cmd { 17 | return nil 18 | } 19 | 20 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 21 | switch msg := msg.(type) { 22 | case tea.ResumeMsg: 23 | m.suspending = false 24 | return m, nil 25 | case tea.KeyMsg: 26 | switch msg.String() { 27 | case "q", "esc": 28 | m.quitting = true 29 | return m, tea.Quit 30 | case "ctrl+c": 31 | m.quitting = true 32 | return m, tea.Interrupt 33 | case "ctrl+z": 34 | m.suspending = true 35 | return m, tea.Suspend 36 | } 37 | } 38 | return m, nil 39 | } 40 | 41 | func (m model) View() string { 42 | if m.suspending || m.quitting { 43 | return "" 44 | } 45 | 46 | return "\nPress ctrl-z to suspend, ctrl+c to interrupt, q, or esc to exit\n" 47 | } 48 | 49 | func main() { 50 | if _, err := tea.NewProgram(model{}).Run(); err != nil { 51 | fmt.Println("Error running program:", err) 52 | if errors.Is(err, tea.ErrInterrupted) { 53 | os.Exit(130) 54 | } 55 | os.Exit(1) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /examples/table-resize/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/charmbracelet/lipgloss/table" 10 | ) 11 | 12 | type model struct { 13 | table *table.Table 14 | } 15 | 16 | func (m model) Init() tea.Cmd { return nil } 17 | 18 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 19 | var cmd tea.Cmd 20 | switch msg := msg.(type) { 21 | case tea.WindowSizeMsg: 22 | m.table = m.table.Width(msg.Width) 23 | m.table = m.table.Height(msg.Height) 24 | case tea.KeyMsg: 25 | switch msg.String() { 26 | case "q", "ctrl+c": 27 | return m, tea.Quit 28 | case "enter": 29 | } 30 | } 31 | return m, cmd 32 | } 33 | 34 | func (m model) View() string { 35 | return "\n" + m.table.String() + "\n" 36 | } 37 | 38 | func main() { 39 | re := lipgloss.NewRenderer(os.Stdout) 40 | baseStyle := re.NewStyle().Padding(0, 1) 41 | headerStyle := baseStyle.Foreground(lipgloss.Color("252")).Bold(true) 42 | selectedStyle := baseStyle.Foreground(lipgloss.Color("#01BE85")).Background(lipgloss.Color("#00432F")) 43 | typeColors := map[string]lipgloss.Color{ 44 | "Bug": lipgloss.Color("#D7FF87"), 45 | "Electric": lipgloss.Color("#FDFF90"), 46 | "Fire": lipgloss.Color("#FF7698"), 47 | "Flying": lipgloss.Color("#FF87D7"), 48 | "Grass": lipgloss.Color("#75FBAB"), 49 | "Ground": lipgloss.Color("#FF875F"), 50 | "Normal": lipgloss.Color("#929292"), 51 | "Poison": lipgloss.Color("#7D5AFC"), 52 | "Water": lipgloss.Color("#00E2C7"), 53 | } 54 | dimTypeColors := map[string]lipgloss.Color{ 55 | "Bug": lipgloss.Color("#97AD64"), 56 | "Electric": lipgloss.Color("#FCFF5F"), 57 | "Fire": lipgloss.Color("#BA5F75"), 58 | "Flying": lipgloss.Color("#C97AB2"), 59 | "Grass": lipgloss.Color("#59B980"), 60 | "Ground": lipgloss.Color("#C77252"), 61 | "Normal": lipgloss.Color("#727272"), 62 | "Poison": lipgloss.Color("#634BD0"), 63 | "Water": lipgloss.Color("#439F8E"), 64 | } 65 | headers := []string{"#", "NAME", "TYPE 1", "TYPE 2", "JAPANESE", "OFFICIAL ROM."} 66 | rows := [][]string{ 67 | {"1", "Bulbasaur", "Grass", "Poison", "フシギダネ", "Bulbasaur"}, 68 | {"2", "Ivysaur", "Grass", "Poison", "フシギソウ", "Ivysaur"}, 69 | {"3", "Venusaur", "Grass", "Poison", "フシギバナ", "Venusaur"}, 70 | {"4", "Charmander", "Fire", "", "ヒトカゲ", "Hitokage"}, 71 | {"5", "Charmeleon", "Fire", "", "リザード", "Lizardo"}, 72 | {"6", "Charizard", "Fire", "Flying", "リザードン", "Lizardon"}, 73 | {"7", "Squirtle", "Water", "", "ゼニガメ", "Zenigame"}, 74 | {"8", "Wartortle", "Water", "", "カメール", "Kameil"}, 75 | {"9", "Blastoise", "Water", "", "カメックス", "Kamex"}, 76 | {"10", "Caterpie", "Bug", "", "キャタピー", "Caterpie"}, 77 | {"11", "Metapod", "Bug", "", "トランセル", "Trancell"}, 78 | {"12", "Butterfree", "Bug", "Flying", "バタフリー", "Butterfree"}, 79 | {"13", "Weedle", "Bug", "Poison", "ビードル", "Beedle"}, 80 | {"14", "Kakuna", "Bug", "Poison", "コクーン", "Cocoon"}, 81 | {"15", "Beedrill", "Bug", "Poison", "スピアー", "Spear"}, 82 | {"16", "Pidgey", "Normal", "Flying", "ポッポ", "Poppo"}, 83 | {"17", "Pidgeotto", "Normal", "Flying", "ピジョン", "Pigeon"}, 84 | {"18", "Pidgeot", "Normal", "Flying", "ピジョット", "Pigeot"}, 85 | {"19", "Rattata", "Normal", "", "コラッタ", "Koratta"}, 86 | {"20", "Raticate", "Normal", "", "ラッタ", "Ratta"}, 87 | {"21", "Spearow", "Normal", "Flying", "オニスズメ", "Onisuzume"}, 88 | {"22", "Fearow", "Normal", "Flying", "オニドリル", "Onidrill"}, 89 | {"23", "Ekans", "Poison", "", "アーボ", "Arbo"}, 90 | {"24", "Arbok", "Poison", "", "アーボック", "Arbok"}, 91 | {"25", "Pikachu", "Electric", "", "ピカチュウ", "Pikachu"}, 92 | {"26", "Raichu", "Electric", "", "ライチュウ", "Raichu"}, 93 | {"27", "Sandshrew", "Ground", "", "サンド", "Sand"}, 94 | {"28", "Sandslash", "Ground", "", "サンドパン", "Sandpan"}, 95 | } 96 | 97 | t := table.New(). 98 | Headers(headers...). 99 | Rows(rows...). 100 | Border(lipgloss.NormalBorder()). 101 | BorderStyle(re.NewStyle().Foreground(lipgloss.Color("238"))). 102 | StyleFunc(func(row, col int) lipgloss.Style { 103 | if row == 0 { 104 | return headerStyle 105 | } 106 | 107 | rowIndex := row - 1 108 | if rowIndex < 0 || rowIndex >= len(rows) { 109 | return baseStyle 110 | } 111 | 112 | if rows[rowIndex][1] == "Pikachu" { 113 | return selectedStyle 114 | } 115 | 116 | even := row%2 == 0 117 | 118 | switch col { 119 | case 2, 3: // Type 1 + 2 120 | c := typeColors 121 | if even { 122 | c = dimTypeColors 123 | } 124 | 125 | if col >= len(rows[rowIndex]) { 126 | return baseStyle 127 | } 128 | 129 | color, ok := c[rows[rowIndex][col]] 130 | if !ok { 131 | return baseStyle 132 | } 133 | return baseStyle.Foreground(color) 134 | } 135 | 136 | if even { 137 | return baseStyle.Foreground(lipgloss.Color("245")) 138 | } 139 | return baseStyle.Foreground(lipgloss.Color("252")) 140 | }). 141 | Border(lipgloss.ThickBorder()) 142 | 143 | m := model{t} 144 | if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { 145 | fmt.Println("Error running program:", err) 146 | os.Exit(1) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /examples/table/README.md: -------------------------------------------------------------------------------- 1 | # Table 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/table/table.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/table/table.gif -------------------------------------------------------------------------------- /examples/tabs/README.md: -------------------------------------------------------------------------------- 1 | # Tabs 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/tabs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | "github.com/charmbracelet/lipgloss" 10 | ) 11 | 12 | type model struct { 13 | Tabs []string 14 | TabContent []string 15 | activeTab int 16 | } 17 | 18 | func (m model) Init() tea.Cmd { 19 | return nil 20 | } 21 | 22 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 23 | switch msg := msg.(type) { 24 | case tea.KeyMsg: 25 | switch keypress := msg.String(); keypress { 26 | case "ctrl+c", "q": 27 | return m, tea.Quit 28 | case "right", "l", "n", "tab": 29 | m.activeTab = min(m.activeTab+1, len(m.Tabs)-1) 30 | return m, nil 31 | case "left", "h", "p", "shift+tab": 32 | m.activeTab = max(m.activeTab-1, 0) 33 | return m, nil 34 | } 35 | } 36 | 37 | return m, nil 38 | } 39 | 40 | func tabBorderWithBottom(left, middle, right string) lipgloss.Border { 41 | border := lipgloss.RoundedBorder() 42 | border.BottomLeft = left 43 | border.Bottom = middle 44 | border.BottomRight = right 45 | return border 46 | } 47 | 48 | var ( 49 | inactiveTabBorder = tabBorderWithBottom("┴", "─", "┴") 50 | activeTabBorder = tabBorderWithBottom("┘", " ", "└") 51 | docStyle = lipgloss.NewStyle().Padding(1, 2, 1, 2) 52 | highlightColor = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} 53 | inactiveTabStyle = lipgloss.NewStyle().Border(inactiveTabBorder, true).BorderForeground(highlightColor).Padding(0, 1) 54 | activeTabStyle = inactiveTabStyle.Border(activeTabBorder, true) 55 | windowStyle = lipgloss.NewStyle().BorderForeground(highlightColor).Padding(2, 0).Align(lipgloss.Center).Border(lipgloss.NormalBorder()).UnsetBorderTop() 56 | ) 57 | 58 | func (m model) View() string { 59 | doc := strings.Builder{} 60 | 61 | var renderedTabs []string 62 | 63 | for i, t := range m.Tabs { 64 | var style lipgloss.Style 65 | isFirst, isLast, isActive := i == 0, i == len(m.Tabs)-1, i == m.activeTab 66 | if isActive { 67 | style = activeTabStyle 68 | } else { 69 | style = inactiveTabStyle 70 | } 71 | border, _, _, _, _ := style.GetBorder() 72 | if isFirst && isActive { 73 | border.BottomLeft = "│" 74 | } else if isFirst && !isActive { 75 | border.BottomLeft = "├" 76 | } else if isLast && isActive { 77 | border.BottomRight = "│" 78 | } else if isLast && !isActive { 79 | border.BottomRight = "┤" 80 | } 81 | style = style.Border(border) 82 | renderedTabs = append(renderedTabs, style.Render(t)) 83 | } 84 | 85 | row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) 86 | doc.WriteString(row) 87 | doc.WriteString("\n") 88 | doc.WriteString(windowStyle.Width((lipgloss.Width(row) - windowStyle.GetHorizontalFrameSize())).Render(m.TabContent[m.activeTab])) 89 | return docStyle.Render(doc.String()) 90 | } 91 | 92 | func main() { 93 | tabs := []string{"Lip Gloss", "Blush", "Eye Shadow", "Mascara", "Foundation"} 94 | tabContent := []string{"Lip Gloss Tab", "Blush Tab", "Eye Shadow Tab", "Mascara Tab", "Foundation Tab"} 95 | m := model{Tabs: tabs, TabContent: tabContent} 96 | if _, err := tea.NewProgram(m).Run(); err != nil { 97 | fmt.Println("Error running program:", err) 98 | os.Exit(1) 99 | } 100 | } 101 | 102 | func max(a, b int) int { 103 | if a > b { 104 | return a 105 | } 106 | return b 107 | } 108 | 109 | func min(a, b int) int { 110 | if a < b { 111 | return a 112 | } 113 | return b 114 | } 115 | -------------------------------------------------------------------------------- /examples/tabs/tabs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/tabs/tabs.gif -------------------------------------------------------------------------------- /examples/textarea/README.md: -------------------------------------------------------------------------------- 1 | # Text Area 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/textarea/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program demonstrating the textarea component from the Bubbles 4 | // component library. 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | 10 | "github.com/charmbracelet/bubbles/textarea" 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | func main() { 15 | p := tea.NewProgram(initialModel()) 16 | 17 | if _, err := p.Run(); err != nil { 18 | log.Fatal(err) 19 | } 20 | } 21 | 22 | type errMsg error 23 | 24 | type model struct { 25 | textarea textarea.Model 26 | err error 27 | } 28 | 29 | func initialModel() model { 30 | ti := textarea.New() 31 | ti.Placeholder = "Once upon a time..." 32 | ti.Focus() 33 | 34 | return model{ 35 | textarea: ti, 36 | err: nil, 37 | } 38 | } 39 | 40 | func (m model) Init() tea.Cmd { 41 | return textarea.Blink 42 | } 43 | 44 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 45 | var cmds []tea.Cmd 46 | var cmd tea.Cmd 47 | 48 | switch msg := msg.(type) { 49 | case tea.KeyMsg: 50 | switch msg.Type { 51 | case tea.KeyEsc: 52 | if m.textarea.Focused() { 53 | m.textarea.Blur() 54 | } 55 | case tea.KeyCtrlC: 56 | return m, tea.Quit 57 | default: 58 | if !m.textarea.Focused() { 59 | cmd = m.textarea.Focus() 60 | cmds = append(cmds, cmd) 61 | } 62 | } 63 | 64 | // We handle errors just like any other message 65 | case errMsg: 66 | m.err = msg 67 | return m, nil 68 | } 69 | 70 | m.textarea, cmd = m.textarea.Update(msg) 71 | cmds = append(cmds, cmd) 72 | return m, tea.Batch(cmds...) 73 | } 74 | 75 | func (m model) View() string { 76 | return fmt.Sprintf( 77 | "Tell me a story.\n\n%s\n\n%s", 78 | m.textarea.View(), 79 | "(ctrl+c to quit)", 80 | ) + "\n\n" 81 | } 82 | -------------------------------------------------------------------------------- /examples/textarea/textarea.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/textarea/textarea.gif -------------------------------------------------------------------------------- /examples/textinput/README.md: -------------------------------------------------------------------------------- 1 | # Text Input 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/textinput/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program demonstrating the text input component from the Bubbles 4 | // component library. 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | 10 | "github.com/charmbracelet/bubbles/textinput" 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | func main() { 15 | p := tea.NewProgram(initialModel()) 16 | if _, err := p.Run(); err != nil { 17 | log.Fatal(err) 18 | } 19 | } 20 | 21 | type ( 22 | errMsg error 23 | ) 24 | 25 | type model struct { 26 | textInput textinput.Model 27 | err error 28 | } 29 | 30 | func initialModel() model { 31 | ti := textinput.New() 32 | ti.Placeholder = "Pikachu" 33 | ti.Focus() 34 | ti.CharLimit = 156 35 | ti.Width = 20 36 | 37 | return model{ 38 | textInput: ti, 39 | err: nil, 40 | } 41 | } 42 | 43 | func (m model) Init() tea.Cmd { 44 | return textinput.Blink 45 | } 46 | 47 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 48 | var cmd tea.Cmd 49 | 50 | switch msg := msg.(type) { 51 | case tea.KeyMsg: 52 | switch msg.Type { 53 | case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc: 54 | return m, tea.Quit 55 | } 56 | 57 | // We handle errors just like any other message 58 | case errMsg: 59 | m.err = msg 60 | return m, nil 61 | } 62 | 63 | m.textInput, cmd = m.textInput.Update(msg) 64 | return m, cmd 65 | } 66 | 67 | func (m model) View() string { 68 | return fmt.Sprintf( 69 | "What’s your favorite Pokémon?\n\n%s\n\n%s", 70 | m.textInput.View(), 71 | "(esc to quit)", 72 | ) + "\n" 73 | } 74 | -------------------------------------------------------------------------------- /examples/textinput/textinput.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/textinput/textinput.gif -------------------------------------------------------------------------------- /examples/textinputs/README.md: -------------------------------------------------------------------------------- 1 | # Text Inputs 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/textinputs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple example demonstrating the use of multiple text input components 4 | // from the Bubbles component library. 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "strings" 10 | 11 | "github.com/charmbracelet/bubbles/cursor" 12 | "github.com/charmbracelet/bubbles/textinput" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | ) 16 | 17 | var ( 18 | focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) 19 | blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) 20 | cursorStyle = focusedStyle 21 | noStyle = lipgloss.NewStyle() 22 | helpStyle = blurredStyle 23 | cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) 24 | 25 | focusedButton = focusedStyle.Render("[ Submit ]") 26 | blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) 27 | ) 28 | 29 | type model struct { 30 | focusIndex int 31 | inputs []textinput.Model 32 | cursorMode cursor.Mode 33 | } 34 | 35 | func initialModel() model { 36 | m := model{ 37 | inputs: make([]textinput.Model, 3), 38 | } 39 | 40 | var t textinput.Model 41 | for i := range m.inputs { 42 | t = textinput.New() 43 | t.Cursor.Style = cursorStyle 44 | t.CharLimit = 32 45 | 46 | switch i { 47 | case 0: 48 | t.Placeholder = "Nickname" 49 | t.Focus() 50 | t.PromptStyle = focusedStyle 51 | t.TextStyle = focusedStyle 52 | case 1: 53 | t.Placeholder = "Email" 54 | t.CharLimit = 64 55 | case 2: 56 | t.Placeholder = "Password" 57 | t.EchoMode = textinput.EchoPassword 58 | t.EchoCharacter = '•' 59 | } 60 | 61 | m.inputs[i] = t 62 | } 63 | 64 | return m 65 | } 66 | 67 | func (m model) Init() tea.Cmd { 68 | return textinput.Blink 69 | } 70 | 71 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 72 | switch msg := msg.(type) { 73 | case tea.KeyMsg: 74 | switch msg.String() { 75 | case "ctrl+c", "esc": 76 | return m, tea.Quit 77 | 78 | // Change cursor mode 79 | case "ctrl+r": 80 | m.cursorMode++ 81 | if m.cursorMode > cursor.CursorHide { 82 | m.cursorMode = cursor.CursorBlink 83 | } 84 | cmds := make([]tea.Cmd, len(m.inputs)) 85 | for i := range m.inputs { 86 | cmds[i] = m.inputs[i].Cursor.SetMode(m.cursorMode) 87 | } 88 | return m, tea.Batch(cmds...) 89 | 90 | // Set focus to next input 91 | case "tab", "shift+tab", "enter", "up", "down": 92 | s := msg.String() 93 | 94 | // Did the user press enter while the submit button was focused? 95 | // If so, exit. 96 | if s == "enter" && m.focusIndex == len(m.inputs) { 97 | return m, tea.Quit 98 | } 99 | 100 | // Cycle indexes 101 | if s == "up" || s == "shift+tab" { 102 | m.focusIndex-- 103 | } else { 104 | m.focusIndex++ 105 | } 106 | 107 | if m.focusIndex > len(m.inputs) { 108 | m.focusIndex = 0 109 | } else if m.focusIndex < 0 { 110 | m.focusIndex = len(m.inputs) 111 | } 112 | 113 | cmds := make([]tea.Cmd, len(m.inputs)) 114 | for i := 0; i <= len(m.inputs)-1; i++ { 115 | if i == m.focusIndex { 116 | // Set focused state 117 | cmds[i] = m.inputs[i].Focus() 118 | m.inputs[i].PromptStyle = focusedStyle 119 | m.inputs[i].TextStyle = focusedStyle 120 | continue 121 | } 122 | // Remove focused state 123 | m.inputs[i].Blur() 124 | m.inputs[i].PromptStyle = noStyle 125 | m.inputs[i].TextStyle = noStyle 126 | } 127 | 128 | return m, tea.Batch(cmds...) 129 | } 130 | } 131 | 132 | // Handle character input and blinking 133 | cmd := m.updateInputs(msg) 134 | 135 | return m, cmd 136 | } 137 | 138 | func (m *model) updateInputs(msg tea.Msg) tea.Cmd { 139 | cmds := make([]tea.Cmd, len(m.inputs)) 140 | 141 | // Only text inputs with Focus() set will respond, so it's safe to simply 142 | // update all of them here without any further logic. 143 | for i := range m.inputs { 144 | m.inputs[i], cmds[i] = m.inputs[i].Update(msg) 145 | } 146 | 147 | return tea.Batch(cmds...) 148 | } 149 | 150 | func (m model) View() string { 151 | var b strings.Builder 152 | 153 | for i := range m.inputs { 154 | b.WriteString(m.inputs[i].View()) 155 | if i < len(m.inputs)-1 { 156 | b.WriteRune('\n') 157 | } 158 | } 159 | 160 | button := &blurredButton 161 | if m.focusIndex == len(m.inputs) { 162 | button = &focusedButton 163 | } 164 | fmt.Fprintf(&b, "\n\n%s\n\n", *button) 165 | 166 | b.WriteString(helpStyle.Render("cursor mode is ")) 167 | b.WriteString(cursorModeHelpStyle.Render(m.cursorMode.String())) 168 | b.WriteString(helpStyle.Render(" (ctrl+r to change style)")) 169 | 170 | return b.String() 171 | } 172 | 173 | func main() { 174 | if _, err := tea.NewProgram(initialModel()).Run(); err != nil { 175 | fmt.Printf("could not start program: %s\n", err) 176 | os.Exit(1) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /examples/textinputs/textinputs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/textinputs/textinputs.gif -------------------------------------------------------------------------------- /examples/timer/README.md: -------------------------------------------------------------------------------- 1 | # Timer 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/timer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/charmbracelet/bubbles/help" 9 | "github.com/charmbracelet/bubbles/key" 10 | "github.com/charmbracelet/bubbles/timer" 11 | tea "github.com/charmbracelet/bubbletea" 12 | ) 13 | 14 | const timeout = time.Second * 5 15 | 16 | type model struct { 17 | timer timer.Model 18 | keymap keymap 19 | help help.Model 20 | quitting bool 21 | } 22 | 23 | type keymap struct { 24 | start key.Binding 25 | stop key.Binding 26 | reset key.Binding 27 | quit key.Binding 28 | } 29 | 30 | func (m model) Init() tea.Cmd { 31 | return m.timer.Init() 32 | } 33 | 34 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 35 | switch msg := msg.(type) { 36 | case timer.TickMsg: 37 | var cmd tea.Cmd 38 | m.timer, cmd = m.timer.Update(msg) 39 | return m, cmd 40 | 41 | case timer.StartStopMsg: 42 | var cmd tea.Cmd 43 | m.timer, cmd = m.timer.Update(msg) 44 | m.keymap.stop.SetEnabled(m.timer.Running()) 45 | m.keymap.start.SetEnabled(!m.timer.Running()) 46 | return m, cmd 47 | 48 | case timer.TimeoutMsg: 49 | m.quitting = true 50 | return m, tea.Quit 51 | 52 | case tea.KeyMsg: 53 | switch { 54 | case key.Matches(msg, m.keymap.quit): 55 | m.quitting = true 56 | return m, tea.Quit 57 | case key.Matches(msg, m.keymap.reset): 58 | m.timer.Timeout = timeout 59 | case key.Matches(msg, m.keymap.start, m.keymap.stop): 60 | return m, m.timer.Toggle() 61 | } 62 | } 63 | 64 | return m, nil 65 | } 66 | 67 | func (m model) helpView() string { 68 | return "\n" + m.help.ShortHelpView([]key.Binding{ 69 | m.keymap.start, 70 | m.keymap.stop, 71 | m.keymap.reset, 72 | m.keymap.quit, 73 | }) 74 | } 75 | 76 | func (m model) View() string { 77 | // For a more detailed timer view you could read m.timer.Timeout to get 78 | // the remaining time as a time.Duration and skip calling m.timer.View() 79 | // entirely. 80 | s := m.timer.View() 81 | 82 | if m.timer.Timedout() { 83 | s = "All done!" 84 | } 85 | s += "\n" 86 | if !m.quitting { 87 | s = "Exiting in " + s 88 | s += m.helpView() 89 | } 90 | return s 91 | } 92 | 93 | func main() { 94 | m := model{ 95 | timer: timer.NewWithInterval(timeout, time.Millisecond), 96 | keymap: keymap{ 97 | start: key.NewBinding( 98 | key.WithKeys("s"), 99 | key.WithHelp("s", "start"), 100 | ), 101 | stop: key.NewBinding( 102 | key.WithKeys("s"), 103 | key.WithHelp("s", "stop"), 104 | ), 105 | reset: key.NewBinding( 106 | key.WithKeys("r"), 107 | key.WithHelp("r", "reset"), 108 | ), 109 | quit: key.NewBinding( 110 | key.WithKeys("q", "ctrl+c"), 111 | key.WithHelp("q", "quit"), 112 | ), 113 | }, 114 | help: help.New(), 115 | } 116 | m.keymap.start.SetEnabled(false) 117 | 118 | if _, err := tea.NewProgram(m).Run(); err != nil { 119 | fmt.Println("Uh oh, we encountered an error:", err) 120 | os.Exit(1) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /examples/timer/timer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/timer/timer.gif -------------------------------------------------------------------------------- /examples/tui-daemon-combo/README.md: -------------------------------------------------------------------------------- 1 | # TUI Daemon 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/tui-daemon-combo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io" 7 | "log" 8 | "math/rand" 9 | "os" 10 | "time" 11 | 12 | "github.com/charmbracelet/bubbles/spinner" 13 | tea "github.com/charmbracelet/bubbletea" 14 | "github.com/charmbracelet/lipgloss" 15 | "github.com/mattn/go-isatty" 16 | ) 17 | 18 | var ( 19 | helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render 20 | mainStyle = lipgloss.NewStyle().MarginLeft(1) 21 | ) 22 | 23 | func main() { 24 | var ( 25 | daemonMode bool 26 | showHelp bool 27 | opts []tea.ProgramOption 28 | ) 29 | 30 | flag.BoolVar(&daemonMode, "d", false, "run as a daemon") 31 | flag.BoolVar(&showHelp, "h", false, "show help") 32 | flag.Parse() 33 | 34 | if showHelp { 35 | flag.Usage() 36 | os.Exit(0) 37 | } 38 | 39 | if daemonMode || !isatty.IsTerminal(os.Stdout.Fd()) { 40 | // If we're in daemon mode don't render the TUI 41 | opts = []tea.ProgramOption{tea.WithoutRenderer()} 42 | } else { 43 | // If we're in TUI mode, discard log output 44 | log.SetOutput(io.Discard) 45 | } 46 | 47 | p := tea.NewProgram(newModel(), opts...) 48 | if _, err := p.Run(); err != nil { 49 | fmt.Println("Error starting Bubble Tea program:", err) 50 | os.Exit(1) 51 | } 52 | } 53 | 54 | type result struct { 55 | duration time.Duration 56 | emoji string 57 | } 58 | 59 | type model struct { 60 | spinner spinner.Model 61 | results []result 62 | quitting bool 63 | } 64 | 65 | func newModel() model { 66 | const showLastResults = 5 67 | 68 | sp := spinner.New() 69 | sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("206")) 70 | 71 | return model{ 72 | spinner: sp, 73 | results: make([]result, showLastResults), 74 | } 75 | } 76 | 77 | func (m model) Init() tea.Cmd { 78 | log.Println("Starting work...") 79 | return tea.Batch( 80 | m.spinner.Tick, 81 | runPretendProcess, 82 | ) 83 | } 84 | 85 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 86 | switch msg := msg.(type) { 87 | case tea.KeyMsg: 88 | m.quitting = true 89 | return m, tea.Quit 90 | case spinner.TickMsg: 91 | var cmd tea.Cmd 92 | m.spinner, cmd = m.spinner.Update(msg) 93 | return m, cmd 94 | case processFinishedMsg: 95 | d := time.Duration(msg) 96 | res := result{emoji: randomEmoji(), duration: d} 97 | log.Printf("%s Job finished in %s", res.emoji, res.duration) 98 | m.results = append(m.results[1:], res) 99 | return m, runPretendProcess 100 | default: 101 | return m, nil 102 | } 103 | } 104 | 105 | func (m model) View() string { 106 | s := "\n" + 107 | m.spinner.View() + " Doing some work...\n\n" 108 | 109 | for _, res := range m.results { 110 | if res.duration == 0 { 111 | s += "........................\n" 112 | } else { 113 | s += fmt.Sprintf("%s Job finished in %s\n", res.emoji, res.duration) 114 | } 115 | } 116 | 117 | s += helpStyle("\nPress any key to exit\n") 118 | 119 | if m.quitting { 120 | s += "\n" 121 | } 122 | 123 | return mainStyle.Render(s) 124 | } 125 | 126 | // processFinishedMsg is sent when a pretend process completes. 127 | type processFinishedMsg time.Duration 128 | 129 | // pretendProcess simulates a long-running process. 130 | func runPretendProcess() tea.Msg { 131 | pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond // nolint:gosec 132 | time.Sleep(pause) 133 | return processFinishedMsg(pause) 134 | } 135 | 136 | func randomEmoji() string { 137 | emojis := []rune("🍦🧋🍡🤠👾😭🦊🐯🦆🥨🎏🍔🍒🍥🎮📦🦁🐶🐸🍕🥐🧲🚒🥇🏆🌽") 138 | return string(emojis[rand.Intn(len(emojis))]) // nolint:gosec 139 | } 140 | -------------------------------------------------------------------------------- /examples/tui-daemon-combo/tui-daemon-combo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/tui-daemon-combo/tui-daemon-combo.gif -------------------------------------------------------------------------------- /examples/views/README.md: -------------------------------------------------------------------------------- 1 | # Views 2 | 3 | 4 | -------------------------------------------------------------------------------- /examples/views/views.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charmbracelet/bubbletea/78ed49b060c81477836604621a079edf7b683ff5/examples/views/views.gif -------------------------------------------------------------------------------- /examples/window-size/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // A simple program that queries and displays the window-size. 4 | 5 | import ( 6 | "log" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | func main() { 12 | p := tea.NewProgram(model{}) 13 | if _, err := p.Run(); err != nil { 14 | log.Fatal(err) 15 | } 16 | } 17 | 18 | type model struct{} 19 | 20 | func (m model) Init() tea.Cmd { 21 | return nil 22 | } 23 | 24 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 25 | switch msg := msg.(type) { 26 | case tea.KeyMsg: 27 | if s := msg.String(); s == "ctrl+c" || s == "q" || s == "esc" { 28 | return m, tea.Quit 29 | } 30 | 31 | return m, tea.WindowSize() 32 | 33 | case tea.WindowSizeMsg: 34 | return m, tea.Printf("%dx%d", msg.Width, msg.Height) 35 | } 36 | 37 | return m, nil 38 | } 39 | 40 | func (m model) View() string { 41 | s := "When you're done press q to quit. Press any other key to query the window-size.\n" 42 | 43 | return s 44 | } 45 | -------------------------------------------------------------------------------- /exec.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | // execMsg is used internally to run an ExecCommand sent with Exec. 10 | type execMsg struct { 11 | cmd ExecCommand 12 | fn ExecCallback 13 | } 14 | 15 | // Exec is used to perform arbitrary I/O in a blocking fashion, effectively 16 | // pausing the Program while execution is running and resuming it when 17 | // execution has completed. 18 | // 19 | // Most of the time you'll want to use ExecProcess, which runs an exec.Cmd. 20 | // 21 | // For non-interactive i/o you should use a Cmd (that is, a tea.Cmd). 22 | func Exec(c ExecCommand, fn ExecCallback) Cmd { 23 | return func() Msg { 24 | return execMsg{cmd: c, fn: fn} 25 | } 26 | } 27 | 28 | // ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively 29 | // pausing the Program while the command is running. After the *exec.Cmd exists 30 | // the Program resumes. It's useful for spawning other interactive applications 31 | // such as editors and shells from within a Program. 32 | // 33 | // To produce the command, pass an *exec.Cmd and a function which returns 34 | // a message containing the error which may have occurred when running the 35 | // ExecCommand. 36 | // 37 | // type VimFinishedMsg struct { err error } 38 | // 39 | // c := exec.Command("vim", "file.txt") 40 | // 41 | // cmd := ExecProcess(c, func(err error) Msg { 42 | // return VimFinishedMsg{err: err} 43 | // }) 44 | // 45 | // Or, if you don't care about errors, you could simply: 46 | // 47 | // cmd := ExecProcess(exec.Command("vim", "file.txt"), nil) 48 | // 49 | // For non-interactive i/o you should use a Cmd (that is, a tea.Cmd). 50 | func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd { 51 | return Exec(wrapExecCommand(c), fn) 52 | } 53 | 54 | // ExecCallback is used when executing an *exec.Command to return a message 55 | // with an error, which may or may not be nil. 56 | type ExecCallback func(error) Msg 57 | 58 | // ExecCommand can be implemented to execute things in a blocking fashion in 59 | // the current terminal. 60 | type ExecCommand interface { 61 | Run() error 62 | SetStdin(io.Reader) 63 | SetStdout(io.Writer) 64 | SetStderr(io.Writer) 65 | } 66 | 67 | // wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand 68 | // interface so it can be used with Exec. 69 | func wrapExecCommand(c *exec.Cmd) ExecCommand { 70 | return &osExecCommand{Cmd: c} 71 | } 72 | 73 | // osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand 74 | // interface. 75 | type osExecCommand struct{ *exec.Cmd } 76 | 77 | // SetStdin sets stdin on underlying exec.Cmd to the given io.Reader. 78 | func (c *osExecCommand) SetStdin(r io.Reader) { 79 | // If unset, have the command use the same input as the terminal. 80 | if c.Stdin == nil { 81 | c.Stdin = r 82 | } 83 | } 84 | 85 | // SetStdout sets stdout on underlying exec.Cmd to the given io.Writer. 86 | func (c *osExecCommand) SetStdout(w io.Writer) { 87 | // If unset, have the command use the same output as the terminal. 88 | if c.Stdout == nil { 89 | c.Stdout = w 90 | } 91 | } 92 | 93 | // SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer. 94 | func (c *osExecCommand) SetStderr(w io.Writer) { 95 | // If unset, use stderr for the command's stderr 96 | if c.Stderr == nil { 97 | c.Stderr = w 98 | } 99 | } 100 | 101 | // exec runs an ExecCommand and delivers the results to the program as a Msg. 102 | func (p *Program) exec(c ExecCommand, fn ExecCallback) { 103 | if err := p.ReleaseTerminal(); err != nil { 104 | // If we can't release input, abort. 105 | if fn != nil { 106 | go p.Send(fn(err)) 107 | } 108 | return 109 | } 110 | 111 | c.SetStdin(p.input) 112 | c.SetStdout(p.output) 113 | c.SetStderr(os.Stderr) 114 | 115 | // Execute system command. 116 | if err := c.Run(); err != nil { 117 | p.renderer.resetLinesRendered() 118 | _ = p.RestoreTerminal() // also try to restore the terminal. 119 | if fn != nil { 120 | go p.Send(fn(err)) 121 | } 122 | return 123 | } 124 | 125 | // Maintain the existing output from the command 126 | p.renderer.resetLinesRendered() 127 | 128 | // Have the program re-capture input. 129 | err := p.RestoreTerminal() 130 | if fn != nil { 131 | go p.Send(fn(err)) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /exec_test.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "bytes" 5 | "os/exec" 6 | "runtime" 7 | "testing" 8 | ) 9 | 10 | type execFinishedMsg struct{ err error } 11 | 12 | type testExecModel struct { 13 | cmd string 14 | err error 15 | } 16 | 17 | func (m testExecModel) Init() Cmd { 18 | c := exec.Command(m.cmd) //nolint:gosec 19 | return ExecProcess(c, func(err error) Msg { 20 | return execFinishedMsg{err} 21 | }) 22 | } 23 | 24 | func (m *testExecModel) Update(msg Msg) (Model, Cmd) { 25 | switch msg := msg.(type) { 26 | case execFinishedMsg: 27 | if msg.err != nil { 28 | m.err = msg.err 29 | } 30 | return m, Quit 31 | } 32 | 33 | return m, nil 34 | } 35 | 36 | func (m *testExecModel) View() string { 37 | return "\n" 38 | } 39 | 40 | type spyRenderer struct { 41 | renderer 42 | calledReset bool 43 | } 44 | 45 | func (r *spyRenderer) resetLinesRendered() { 46 | r.calledReset = true 47 | r.renderer.resetLinesRendered() 48 | } 49 | 50 | func TestTeaExec(t *testing.T) { 51 | type test struct { 52 | name string 53 | cmd string 54 | expectErr bool 55 | } 56 | tests := []test{ 57 | { 58 | name: "invalid command", 59 | cmd: "invalid", 60 | expectErr: true, 61 | }, 62 | } 63 | 64 | if runtime.GOOS != "windows" { 65 | tests = append(tests, []test{ 66 | { 67 | name: "true", 68 | cmd: "true", 69 | expectErr: false, 70 | }, 71 | { 72 | name: "false", 73 | cmd: "false", 74 | expectErr: true, 75 | }, 76 | }...) 77 | } 78 | 79 | for _, test := range tests { 80 | t.Run(test.name, func(t *testing.T) { 81 | var buf bytes.Buffer 82 | var in bytes.Buffer 83 | 84 | m := &testExecModel{cmd: test.cmd} 85 | p := NewProgram(m, WithInput(&in), WithOutput(&buf)) 86 | if _, err := p.Run(); err != nil { 87 | t.Error(err) 88 | } 89 | p.renderer = &spyRenderer{renderer: p.renderer} 90 | 91 | if m.err != nil && !test.expectErr { 92 | t.Errorf("expected no error, got %v", m.err) 93 | 94 | if !p.renderer.(*spyRenderer).calledReset { 95 | t.Error("expected renderer to be reset") 96 | } 97 | } 98 | if m.err == nil && test.expectErr { 99 | t.Error("expected error, got nil") 100 | } 101 | }) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /focus.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | // FocusMsg represents a terminal focus message. 4 | // This occurs when the terminal gains focus. 5 | type FocusMsg struct{} 6 | 7 | // BlurMsg represents a terminal blur message. 8 | // This occurs when the terminal loses focus. 9 | type BlurMsg struct{} 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/charmbracelet/bubbletea 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.23.7 6 | 7 | require ( 8 | github.com/charmbracelet/lipgloss v1.1.0 9 | github.com/charmbracelet/x/ansi v0.9.2 10 | github.com/charmbracelet/x/term v0.2.1 11 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f 12 | github.com/mattn/go-localereader v0.0.1 13 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 14 | github.com/muesli/cancelreader v0.2.2 15 | golang.org/x/sync v0.14.0 16 | golang.org/x/sys v0.33.0 17 | ) 18 | 19 | require ( 20 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 21 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 22 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // 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-runewidth v0.0.16 // indirect 26 | github.com/muesli/termenv v0.16.0 // indirect 27 | github.com/rivo/uniseg v0.4.7 // indirect 28 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 29 | golang.org/x/text v0.3.8 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 4 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 5 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 6 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 7 | github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY= 8 | github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 9 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 10 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 11 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 12 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 13 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 14 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 22 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 23 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 24 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 25 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 26 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 27 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 28 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 29 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 30 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 31 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 32 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 33 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 34 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 35 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 36 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 37 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 38 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 41 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 42 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 43 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 44 | -------------------------------------------------------------------------------- /inputreader_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package tea 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | 10 | "github.com/muesli/cancelreader" 11 | ) 12 | 13 | func newInputReader(r io.Reader, _ bool) (cancelreader.CancelReader, error) { 14 | cr, err := cancelreader.NewReader(r) 15 | if err != nil { 16 | return nil, fmt.Errorf("bubbletea: error creating cancel reader: %w", err) 17 | } 18 | return cr, nil 19 | } 20 | -------------------------------------------------------------------------------- /inputreader_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package tea 5 | 6 | import ( 7 | "fmt" 8 | "io" 9 | "os" 10 | "sync" 11 | 12 | "github.com/charmbracelet/x/term" 13 | "github.com/erikgeiser/coninput" 14 | "github.com/muesli/cancelreader" 15 | "golang.org/x/sys/windows" 16 | ) 17 | 18 | type conInputReader struct { 19 | cancelMixin 20 | 21 | conin windows.Handle 22 | 23 | originalMode uint32 24 | } 25 | 26 | var _ cancelreader.CancelReader = &conInputReader{} 27 | 28 | func newInputReader(r io.Reader, enableMouse bool) (cancelreader.CancelReader, error) { 29 | fallback := func(io.Reader) (cancelreader.CancelReader, error) { 30 | return cancelreader.NewReader(r) 31 | } 32 | if f, ok := r.(term.File); !ok || f.Fd() != os.Stdin.Fd() { 33 | return fallback(r) 34 | } 35 | 36 | conin, err := coninput.NewStdinHandle() 37 | if err != nil { 38 | return fallback(r) 39 | } 40 | 41 | modes := []uint32{ 42 | windows.ENABLE_WINDOW_INPUT, 43 | windows.ENABLE_EXTENDED_FLAGS, 44 | } 45 | 46 | // Since we have options to enable mouse events, [WithMouseCellMotion], 47 | // [WithMouseAllMotion], and [EnableMouseCellMotion], 48 | // [EnableMouseAllMotion], and [DisableMouse], we need to check if the user 49 | // has enabled mouse events and add the appropriate mode accordingly. 50 | // Otherwise, mouse events will be enabled all the time. 51 | if enableMouse { 52 | modes = append(modes, windows.ENABLE_MOUSE_INPUT) 53 | } 54 | 55 | originalMode, err := prepareConsole(conin, modes...) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to prepare console input: %w", err) 58 | } 59 | 60 | return &conInputReader{ 61 | conin: conin, 62 | originalMode: originalMode, 63 | }, nil 64 | } 65 | 66 | // Cancel implements cancelreader.CancelReader. 67 | func (r *conInputReader) Cancel() bool { 68 | r.setCanceled() 69 | 70 | return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil 71 | } 72 | 73 | // Close implements cancelreader.CancelReader. 74 | func (r *conInputReader) Close() error { 75 | if r.originalMode != 0 { 76 | err := windows.SetConsoleMode(r.conin, r.originalMode) 77 | if err != nil { 78 | return fmt.Errorf("reset console mode: %w", err) 79 | } 80 | } 81 | 82 | return nil 83 | } 84 | 85 | // Read implements cancelreader.CancelReader. 86 | func (r *conInputReader) Read(_ []byte) (n int, err error) { 87 | if r.isCanceled() { 88 | err = cancelreader.ErrCanceled 89 | } 90 | return 91 | } 92 | 93 | func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) { 94 | err = windows.GetConsoleMode(input, &originalMode) 95 | if err != nil { 96 | return 0, fmt.Errorf("get console mode: %w", err) 97 | } 98 | 99 | newMode := coninput.AddInputModes(0, modes...) 100 | 101 | err = windows.SetConsoleMode(input, newMode) 102 | if err != nil { 103 | return 0, fmt.Errorf("set console mode: %w", err) 104 | } 105 | 106 | return originalMode, nil 107 | } 108 | 109 | // cancelMixin represents a goroutine-safe cancelation status. 110 | type cancelMixin struct { 111 | unsafeCanceled bool 112 | lock sync.Mutex 113 | } 114 | 115 | func (c *cancelMixin) setCanceled() { 116 | c.lock.Lock() 117 | defer c.lock.Unlock() 118 | 119 | c.unsafeCanceled = true 120 | } 121 | 122 | func (c *cancelMixin) isCanceled() bool { 123 | c.lock.Lock() 124 | defer c.lock.Unlock() 125 | 126 | return c.unsafeCanceled 127 | } 128 | -------------------------------------------------------------------------------- /key_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package tea 5 | 6 | import ( 7 | "context" 8 | "io" 9 | ) 10 | 11 | func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { 12 | return readAnsiInputs(ctx, msgs, input) 13 | } 14 | -------------------------------------------------------------------------------- /key_sequences.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "bytes" 5 | "sort" 6 | "unicode/utf8" 7 | ) 8 | 9 | // extSequences is used by the map-based algorithm below. It contains 10 | // the sequences plus their alternatives with an escape character 11 | // prefixed, plus the control chars, plus the space. 12 | // It does not contain the NUL character, which is handled specially 13 | // by detectOneMsg. 14 | var extSequences = func() map[string]Key { 15 | s := map[string]Key{} 16 | for seq, key := range sequences { 17 | key := key 18 | s[seq] = key 19 | if !key.Alt { 20 | key.Alt = true 21 | s["\x1b"+seq] = key 22 | } 23 | } 24 | for i := keyNUL + 1; i <= keyDEL; i++ { 25 | if i == keyESC { 26 | continue 27 | } 28 | s[string([]byte{byte(i)})] = Key{Type: i} 29 | s[string([]byte{'\x1b', byte(i)})] = Key{Type: i, Alt: true} 30 | if i == keyUS { 31 | i = keyDEL - 1 32 | } 33 | } 34 | s[" "] = Key{Type: KeySpace, Runes: spaceRunes} 35 | s["\x1b "] = Key{Type: KeySpace, Alt: true, Runes: spaceRunes} 36 | s["\x1b\x1b"] = Key{Type: KeyEscape, Alt: true} 37 | return s 38 | }() 39 | 40 | // seqLengths is the sizes of valid sequences, starting with the 41 | // largest size. 42 | var seqLengths = func() []int { 43 | sizes := map[int]struct{}{} 44 | for seq := range extSequences { 45 | sizes[len(seq)] = struct{}{} 46 | } 47 | lsizes := make([]int, 0, len(sizes)) 48 | for sz := range sizes { 49 | lsizes = append(lsizes, sz) 50 | } 51 | sort.Slice(lsizes, func(i, j int) bool { return lsizes[i] > lsizes[j] }) 52 | return lsizes 53 | }() 54 | 55 | // detectSequence uses a longest prefix match over the input 56 | // sequence and a hash map. 57 | func detectSequence(input []byte) (hasSeq bool, width int, msg Msg) { 58 | seqs := extSequences 59 | for _, sz := range seqLengths { 60 | if sz > len(input) { 61 | continue 62 | } 63 | prefix := input[:sz] 64 | key, ok := seqs[string(prefix)] 65 | if ok { 66 | return true, sz, KeyMsg(key) 67 | } 68 | } 69 | // Is this an unknown CSI sequence? 70 | if loc := unknownCSIRe.FindIndex(input); loc != nil { 71 | return true, loc[1], unknownCSISequenceMsg(input[:loc[1]]) 72 | } 73 | 74 | return false, 0, nil 75 | } 76 | 77 | // detectBracketedPaste detects an input pasted while bracketed 78 | // paste mode was enabled. 79 | // 80 | // Note: this function is a no-op if bracketed paste was not enabled 81 | // on the terminal, since in that case we'd never see this 82 | // particular escape sequence. 83 | func detectBracketedPaste(input []byte) (hasBp bool, width int, msg Msg) { 84 | // Detect the start sequence. 85 | const bpStart = "\x1b[200~" 86 | if len(input) < len(bpStart) || string(input[:len(bpStart)]) != bpStart { 87 | return false, 0, nil 88 | } 89 | 90 | // Skip over the start sequence. 91 | input = input[len(bpStart):] 92 | 93 | // If we saw the start sequence, then we must have an end sequence 94 | // as well. Find it. 95 | const bpEnd = "\x1b[201~" 96 | idx := bytes.Index(input, []byte(bpEnd)) 97 | inputLen := len(bpStart) + idx + len(bpEnd) 98 | if idx == -1 { 99 | // We have encountered the end of the input buffer without seeing 100 | // the marker for the end of the bracketed paste. 101 | // Tell the outer loop we have done a short read and we want more. 102 | return true, 0, nil 103 | } 104 | 105 | // The paste is everything in-between. 106 | paste := input[:idx] 107 | 108 | // All there is in-between is runes, not to be interpreted further. 109 | k := Key{Type: KeyRunes, Paste: true} 110 | for len(paste) > 0 { 111 | r, w := utf8.DecodeRune(paste) 112 | if r != utf8.RuneError { 113 | k.Runes = append(k.Runes, r) 114 | } 115 | paste = paste[w:] 116 | } 117 | 118 | return true, inputLen, KeyMsg(k) 119 | } 120 | 121 | // detectReportFocus detects a focus report sequence. 122 | func detectReportFocus(input []byte) (hasRF bool, width int, msg Msg) { 123 | switch { 124 | case bytes.Equal(input, []byte("\x1b[I")): 125 | return true, 3, FocusMsg{} //nolint:mnd 126 | case bytes.Equal(input, []byte("\x1b[O")): 127 | return true, 3, BlurMsg{} //nolint:mnd 128 | } 129 | return false, 0, nil 130 | } 131 | -------------------------------------------------------------------------------- /logging.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "unicode" 9 | ) 10 | 11 | // LogToFile sets up default logging to log to a file. This is helpful as we 12 | // can't print to the terminal since our TUI is occupying it. If the file 13 | // doesn't exist it will be created. 14 | // 15 | // Don't forget to close the file when you're done with it. 16 | // 17 | // f, err := LogToFile("debug.log", "debug") 18 | // if err != nil { 19 | // fmt.Println("fatal:", err) 20 | // os.Exit(1) 21 | // } 22 | // defer f.Close() 23 | func LogToFile(path string, prefix string) (*os.File, error) { 24 | return LogToFileWith(path, prefix, log.Default()) 25 | } 26 | 27 | // LogOptionsSetter is an interface implemented by stdlib's log and charm's log 28 | // libraries. 29 | type LogOptionsSetter interface { 30 | SetOutput(io.Writer) 31 | SetPrefix(string) 32 | } 33 | 34 | // LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter. 35 | func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) { 36 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd 37 | if err != nil { 38 | return nil, fmt.Errorf("error opening file for logging: %w", err) 39 | } 40 | log.SetOutput(f) 41 | 42 | // Add a space after the prefix if a prefix is being specified and it 43 | // doesn't already have a trailing space. 44 | if len(prefix) > 0 { 45 | finalChar := prefix[len(prefix)-1] 46 | if !unicode.IsSpace(rune(finalChar)) { 47 | prefix += " " 48 | } 49 | } 50 | log.SetPrefix(prefix) 51 | 52 | return f, nil 53 | } 54 | -------------------------------------------------------------------------------- /logging_test.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestLogToFile(t *testing.T) { 11 | path := filepath.Join(t.TempDir(), "log.txt") 12 | prefix := "logprefix" 13 | f, err := LogToFile(path, prefix) 14 | if err != nil { 15 | t.Error(err) 16 | } 17 | log.SetFlags(log.Lmsgprefix) 18 | log.Println("some test log") 19 | if err := f.Close(); err != nil { 20 | t.Error(err) 21 | } 22 | out, err := os.ReadFile(path) 23 | if err != nil { 24 | t.Error(err) 25 | } 26 | if string(out) != prefix+" some test log\n" { 27 | t.Fatalf("wrong log msg: %q", string(out)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /nil_renderer.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | type nilRenderer struct{} 4 | 5 | func (n nilRenderer) start() {} 6 | func (n nilRenderer) stop() {} 7 | func (n nilRenderer) kill() {} 8 | func (n nilRenderer) write(_ string) {} 9 | func (n nilRenderer) repaint() {} 10 | func (n nilRenderer) clearScreen() {} 11 | func (n nilRenderer) altScreen() bool { return false } 12 | func (n nilRenderer) enterAltScreen() {} 13 | func (n nilRenderer) exitAltScreen() {} 14 | func (n nilRenderer) showCursor() {} 15 | func (n nilRenderer) hideCursor() {} 16 | func (n nilRenderer) enableMouseCellMotion() {} 17 | func (n nilRenderer) disableMouseCellMotion() {} 18 | func (n nilRenderer) enableMouseAllMotion() {} 19 | func (n nilRenderer) disableMouseAllMotion() {} 20 | func (n nilRenderer) enableBracketedPaste() {} 21 | func (n nilRenderer) disableBracketedPaste() {} 22 | func (n nilRenderer) enableMouseSGRMode() {} 23 | func (n nilRenderer) disableMouseSGRMode() {} 24 | func (n nilRenderer) bracketedPasteActive() bool { return false } 25 | func (n nilRenderer) setWindowTitle(_ string) {} 26 | func (n nilRenderer) reportFocus() bool { return false } 27 | func (n nilRenderer) enableReportFocus() {} 28 | func (n nilRenderer) disableReportFocus() {} 29 | func (n nilRenderer) resetLinesRendered() {} 30 | -------------------------------------------------------------------------------- /nil_renderer_test.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import "testing" 4 | 5 | func TestNilRenderer(t *testing.T) { 6 | r := nilRenderer{} 7 | r.start() 8 | r.stop() 9 | r.kill() 10 | r.write("a") 11 | r.repaint() 12 | r.enterAltScreen() 13 | if r.altScreen() { 14 | t.Errorf("altScreen should always return false") 15 | } 16 | r.exitAltScreen() 17 | r.clearScreen() 18 | r.showCursor() 19 | r.hideCursor() 20 | r.enableMouseCellMotion() 21 | r.disableMouseCellMotion() 22 | r.enableMouseAllMotion() 23 | r.disableMouseAllMotion() 24 | } 25 | -------------------------------------------------------------------------------- /options_test.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "os" 7 | "sync/atomic" 8 | "testing" 9 | ) 10 | 11 | func TestOptions(t *testing.T) { 12 | t.Run("output", func(t *testing.T) { 13 | var b bytes.Buffer 14 | p := NewProgram(nil, WithOutput(&b)) 15 | if f, ok := p.output.(*os.File); ok { 16 | t.Errorf("expected output to custom, got %v", f.Fd()) 17 | } 18 | }) 19 | 20 | t.Run("custom input", func(t *testing.T) { 21 | var b bytes.Buffer 22 | p := NewProgram(nil, WithInput(&b)) 23 | if p.input != &b { 24 | t.Errorf("expected input to custom, got %v", p.input) 25 | } 26 | if p.inputType != customInput { 27 | t.Errorf("expected startup options to have custom input set, got %v", p.input) 28 | } 29 | }) 30 | 31 | t.Run("renderer", func(t *testing.T) { 32 | p := NewProgram(nil, WithoutRenderer()) 33 | switch p.renderer.(type) { 34 | case *nilRenderer: 35 | return 36 | default: 37 | t.Errorf("expected renderer to be a nilRenderer, got %v", p.renderer) 38 | } 39 | }) 40 | 41 | t.Run("without signals", func(t *testing.T) { 42 | p := NewProgram(nil, WithoutSignals()) 43 | if atomic.LoadUint32(&p.ignoreSignals) == 0 { 44 | t.Errorf("ignore signals should have been set") 45 | } 46 | }) 47 | 48 | t.Run("filter", func(t *testing.T) { 49 | p := NewProgram(nil, WithFilter(func(_ Model, msg Msg) Msg { return msg })) 50 | if p.filter == nil { 51 | t.Errorf("expected filter to be set") 52 | } 53 | }) 54 | 55 | t.Run("external context", func(t *testing.T) { 56 | extCtx, extCancel := context.WithCancel(context.Background()) 57 | defer extCancel() 58 | 59 | p := NewProgram(nil, WithContext(extCtx)) 60 | if p.externalCtx != extCtx || p.externalCtx == context.Background() { 61 | t.Errorf("expected passed in external context, got default (nil)") 62 | } 63 | }) 64 | 65 | t.Run("input options", func(t *testing.T) { 66 | exercise := func(t *testing.T, opt ProgramOption, expect inputType) { 67 | p := NewProgram(nil, opt) 68 | if p.inputType != expect { 69 | t.Errorf("expected input type %s, got %s", expect, p.inputType) 70 | } 71 | } 72 | 73 | t.Run("tty input", func(t *testing.T) { 74 | exercise(t, WithInputTTY(), ttyInput) 75 | }) 76 | 77 | t.Run("custom input", func(t *testing.T) { 78 | var b bytes.Buffer 79 | exercise(t, WithInput(&b), customInput) 80 | }) 81 | }) 82 | 83 | t.Run("startup options", func(t *testing.T) { 84 | exercise := func(t *testing.T, opt ProgramOption, expect startupOptions) { 85 | p := NewProgram(nil, opt) 86 | if !p.startupOptions.has(expect) { 87 | t.Errorf("expected startup options have %v, got %v", expect, p.startupOptions) 88 | } 89 | } 90 | 91 | t.Run("alt screen", func(t *testing.T) { 92 | exercise(t, WithAltScreen(), withAltScreen) 93 | }) 94 | 95 | t.Run("bracketed paste disabled", func(t *testing.T) { 96 | exercise(t, WithoutBracketedPaste(), withoutBracketedPaste) 97 | }) 98 | 99 | t.Run("ansi compression", func(t *testing.T) { 100 | exercise(t, WithANSICompressor(), withANSICompressor) 101 | }) 102 | 103 | t.Run("without catch panics", func(t *testing.T) { 104 | exercise(t, WithoutCatchPanics(), withoutCatchPanics) 105 | }) 106 | 107 | t.Run("without signal handler", func(t *testing.T) { 108 | exercise(t, WithoutSignalHandler(), withoutSignalHandler) 109 | }) 110 | 111 | t.Run("mouse cell motion", func(t *testing.T) { 112 | p := NewProgram(nil, WithMouseAllMotion(), WithMouseCellMotion()) 113 | if !p.startupOptions.has(withMouseCellMotion) { 114 | t.Errorf("expected startup options have %v, got %v", withMouseCellMotion, p.startupOptions) 115 | } 116 | if p.startupOptions.has(withMouseAllMotion) { 117 | t.Errorf("expected startup options not have %v, got %v", withMouseAllMotion, p.startupOptions) 118 | } 119 | }) 120 | 121 | t.Run("mouse all motion", func(t *testing.T) { 122 | p := NewProgram(nil, WithMouseCellMotion(), WithMouseAllMotion()) 123 | if !p.startupOptions.has(withMouseAllMotion) { 124 | t.Errorf("expected startup options have %v, got %v", withMouseAllMotion, p.startupOptions) 125 | } 126 | if p.startupOptions.has(withMouseCellMotion) { 127 | t.Errorf("expected startup options not have %v, got %v", withMouseCellMotion, p.startupOptions) 128 | } 129 | }) 130 | }) 131 | 132 | t.Run("multiple", func(t *testing.T) { 133 | p := NewProgram(nil, WithMouseAllMotion(), WithoutBracketedPaste(), WithAltScreen(), WithInputTTY()) 134 | for _, opt := range []startupOptions{withMouseAllMotion, withoutBracketedPaste, withAltScreen} { 135 | if !p.startupOptions.has(opt) { 136 | t.Errorf("expected startup options have %v, got %v", opt, p.startupOptions) 137 | } 138 | if p.inputType != ttyInput { 139 | t.Errorf("expected input to be %v, got %v", opt, p.startupOptions) 140 | } 141 | } 142 | }) 143 | } 144 | -------------------------------------------------------------------------------- /renderer.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | // renderer is the interface for Bubble Tea renderers. 4 | type renderer interface { 5 | // Start the renderer. 6 | start() 7 | 8 | // Stop the renderer, but render the final frame in the buffer, if any. 9 | stop() 10 | 11 | // Stop the renderer without doing any final rendering. 12 | kill() 13 | 14 | // Write a frame to the renderer. The renderer can write this data to 15 | // output at its discretion. 16 | write(string) 17 | 18 | // Request a full re-render. Note that this will not trigger a render 19 | // immediately. Rather, this method causes the next render to be a full 20 | // repaint. Because of this, it's safe to call this method multiple times 21 | // in succession. 22 | repaint() 23 | 24 | // Clears the terminal. 25 | clearScreen() 26 | 27 | // Whether or not the alternate screen buffer is enabled. 28 | altScreen() bool 29 | // Enable the alternate screen buffer. 30 | enterAltScreen() 31 | // Disable the alternate screen buffer. 32 | exitAltScreen() 33 | 34 | // Show the cursor. 35 | showCursor() 36 | // Hide the cursor. 37 | hideCursor() 38 | 39 | // enableMouseCellMotion enables mouse click, release, wheel and motion 40 | // events if a mouse button is pressed (i.e., drag events). 41 | enableMouseCellMotion() 42 | 43 | // disableMouseCellMotion disables Mouse Cell Motion tracking. 44 | disableMouseCellMotion() 45 | 46 | // enableMouseAllMotion enables mouse click, release, wheel and motion 47 | // events, regardless of whether a mouse button is pressed. Many modern 48 | // terminals support this, but not all. 49 | enableMouseAllMotion() 50 | 51 | // disableMouseAllMotion disables All Motion mouse tracking. 52 | disableMouseAllMotion() 53 | 54 | // enableMouseSGRMode enables mouse extended mode (SGR). 55 | enableMouseSGRMode() 56 | 57 | // disableMouseSGRMode disables mouse extended mode (SGR). 58 | disableMouseSGRMode() 59 | 60 | // enableBracketedPaste enables bracketed paste, where characters 61 | // inside the input are not interpreted when pasted as a whole. 62 | enableBracketedPaste() 63 | 64 | // disableBracketedPaste disables bracketed paste. 65 | disableBracketedPaste() 66 | 67 | // bracketedPasteActive reports whether bracketed paste mode is 68 | // currently enabled. 69 | bracketedPasteActive() bool 70 | 71 | // setWindowTitle sets the terminal window title. 72 | setWindowTitle(string) 73 | 74 | // reportFocus returns whether reporting focus events is enabled. 75 | reportFocus() bool 76 | 77 | // enableReportFocus reports focus events to the program. 78 | enableReportFocus() 79 | 80 | // disableReportFocus stops reporting focus events to the program. 81 | disableReportFocus() 82 | 83 | // resetLinesRendered ensures exec output remains on screen on exit 84 | resetLinesRendered() 85 | } 86 | 87 | // repaintMsg forces a full repaint. 88 | type repaintMsg struct{} 89 | -------------------------------------------------------------------------------- /screen_test.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | ) 7 | 8 | func TestClearMsg(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | cmds sequenceMsg 12 | expected string 13 | }{ 14 | { 15 | name: "clear_screen", 16 | cmds: []Cmd{ClearScreen}, 17 | expected: "\x1b[?25l\x1b[?2004h\x1b[2J\x1b[H\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", 18 | }, 19 | { 20 | name: "altscreen", 21 | cmds: []Cmd{EnterAltScreen, ExitAltScreen}, 22 | expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l\x1b[?1049l\x1b[?25l\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", 23 | }, 24 | { 25 | name: "altscreen_autoexit", 26 | cmds: []Cmd{EnterAltScreen}, 27 | expected: "\x1b[?25l\x1b[?2004h\x1b[?1049h\x1b[2J\x1b[H\x1b[?25l\x1b[H\rsuccess\x1b[K\r\n\x1b[K\x1b[2;H\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1049l\x1b[?25h", 28 | }, 29 | { 30 | name: "mouse_cellmotion", 31 | cmds: []Cmd{EnableMouseCellMotion}, 32 | expected: "\x1b[?25l\x1b[?2004h\x1b[?1002h\x1b[?1006h\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", 33 | }, 34 | { 35 | name: "mouse_allmotion", 36 | cmds: []Cmd{EnableMouseAllMotion}, 37 | expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", 38 | }, 39 | { 40 | name: "mouse_disable", 41 | cmds: []Cmd{EnableMouseAllMotion, DisableMouse}, 42 | expected: "\x1b[?25l\x1b[?2004h\x1b[?1003h\x1b[?1006h\x1b[?1002l\x1b[?1003l\x1b[?1006l\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", 43 | }, 44 | { 45 | name: "cursor_hide", 46 | cmds: []Cmd{HideCursor}, 47 | expected: "\x1b[?25l\x1b[?2004h\x1b[?25l\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", 48 | }, 49 | { 50 | name: "cursor_hideshow", 51 | cmds: []Cmd{HideCursor, ShowCursor}, 52 | expected: "\x1b[?25l\x1b[?2004h\x1b[?25l\x1b[?25h\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", 53 | }, 54 | { 55 | name: "bp_stop_start", 56 | cmds: []Cmd{DisableBracketedPaste, EnableBracketedPaste}, 57 | expected: "\x1b[?25l\x1b[?2004h\x1b[?2004l\x1b[?2004h\rsuccess\x1b[K\r\n\x1b[K\x1b[80D\x1b[2K\r\x1b[?2004l\x1b[?25h\x1b[?1002l\x1b[?1003l\x1b[?1006l", 58 | }, 59 | } 60 | 61 | for _, test := range tests { 62 | t.Run(test.name, func(t *testing.T) { 63 | var buf bytes.Buffer 64 | var in bytes.Buffer 65 | 66 | m := &testModel{} 67 | p := NewProgram(m, WithInput(&in), WithOutput(&buf)) 68 | 69 | test.cmds = append([]Cmd{func() Msg { return WindowSizeMsg{80, 24} }}, test.cmds...) 70 | test.cmds = append(test.cmds, Quit) 71 | go p.Send(test.cmds) 72 | 73 | if _, err := p.Run(); err != nil { 74 | t.Fatal(err) 75 | } 76 | 77 | if buf.String() != test.expected { 78 | t.Errorf("expected embedded sequence:\n%q\ngot:\n%q", test.expected, buf.String()) 79 | } 80 | }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /signals_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos 2 | // +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos 3 | 4 | package tea 5 | 6 | import ( 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | ) 11 | 12 | // listenForResize sends messages (or errors) when the terminal resizes. 13 | // Argument output should be the file descriptor for the terminal; usually 14 | // os.Stdout. 15 | func (p *Program) listenForResize(done chan struct{}) { 16 | sig := make(chan os.Signal, 1) 17 | signal.Notify(sig, syscall.SIGWINCH) 18 | 19 | defer func() { 20 | signal.Stop(sig) 21 | close(done) 22 | }() 23 | 24 | for { 25 | select { 26 | case <-p.ctx.Done(): 27 | return 28 | case <-sig: 29 | } 30 | 31 | p.checkResize() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /signals_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package tea 5 | 6 | // listenForResize is not available on windows because windows does not 7 | // implement syscall.SIGWINCH. 8 | func (p *Program) listenForResize(done chan struct{}) { 9 | close(done) 10 | } 11 | -------------------------------------------------------------------------------- /tea_init.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | func init() { 8 | // XXX: This is a workaround to make assure that Lip Gloss and Termenv 9 | // query the terminal before any Bubble Tea Program runs and acquires the 10 | // terminal. Without this, Programs that use Lip Gloss/Termenv might hang 11 | // while waiting for a a [termenv.OSCTimeout] while querying the terminal 12 | // for its background/foreground colors. 13 | // 14 | // This happens because Bubble Tea acquires the terminal before termenv 15 | // reads any responses. 16 | // 17 | // Note that this will only affect programs running on the default IO i.e. 18 | // [os.Stdout] and [os.Stdin]. 19 | // 20 | // This workaround will be removed in v2. 21 | _ = lipgloss.HasDarkBackground() 22 | } 23 | -------------------------------------------------------------------------------- /tty.go: -------------------------------------------------------------------------------- 1 | package tea 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "time" 8 | 9 | "github.com/charmbracelet/x/term" 10 | "github.com/muesli/cancelreader" 11 | ) 12 | 13 | func (p *Program) suspend() { 14 | if err := p.ReleaseTerminal(); err != nil { 15 | // If we can't release input, abort. 16 | return 17 | } 18 | 19 | suspendProcess() 20 | 21 | _ = p.RestoreTerminal() 22 | go p.Send(ResumeMsg{}) 23 | } 24 | 25 | func (p *Program) initTerminal() error { 26 | if _, ok := p.renderer.(*nilRenderer); ok { 27 | // No need to initialize the terminal if we're not rendering 28 | return nil 29 | } 30 | 31 | if err := p.initInput(); err != nil { 32 | return err 33 | } 34 | 35 | p.renderer.hideCursor() 36 | return nil 37 | } 38 | 39 | // restoreTerminalState restores the terminal to the state prior to running the 40 | // Bubble Tea program. 41 | func (p *Program) restoreTerminalState() error { 42 | if p.renderer != nil { 43 | p.renderer.disableBracketedPaste() 44 | p.renderer.showCursor() 45 | p.disableMouse() 46 | 47 | if p.renderer.reportFocus() { 48 | p.renderer.disableReportFocus() 49 | } 50 | 51 | if p.renderer.altScreen() { 52 | p.renderer.exitAltScreen() 53 | 54 | // give the terminal a moment to catch up 55 | time.Sleep(time.Millisecond * 10) //nolint:mnd 56 | } 57 | } 58 | 59 | return p.restoreInput() 60 | } 61 | 62 | // restoreInput restores the tty input to its original state. 63 | func (p *Program) restoreInput() error { 64 | if p.ttyInput != nil && p.previousTtyInputState != nil { 65 | if err := term.Restore(p.ttyInput.Fd(), p.previousTtyInputState); err != nil { 66 | return fmt.Errorf("error restoring console: %w", err) 67 | } 68 | } 69 | if p.ttyOutput != nil && p.previousOutputState != nil { 70 | if err := term.Restore(p.ttyOutput.Fd(), p.previousOutputState); err != nil { 71 | return fmt.Errorf("error restoring console: %w", err) 72 | } 73 | } 74 | return nil 75 | } 76 | 77 | // initCancelReader (re)commences reading inputs. 78 | func (p *Program) initCancelReader(cancel bool) error { 79 | if cancel && p.cancelReader != nil { 80 | p.cancelReader.Cancel() 81 | p.waitForReadLoop() 82 | } 83 | 84 | var err error 85 | p.cancelReader, err = newInputReader(p.input, p.mouseMode) 86 | if err != nil { 87 | return fmt.Errorf("error creating cancelreader: %w", err) 88 | } 89 | 90 | p.readLoopDone = make(chan struct{}) 91 | go p.readLoop() 92 | 93 | return nil 94 | } 95 | 96 | func (p *Program) readLoop() { 97 | defer close(p.readLoopDone) 98 | 99 | err := readInputs(p.ctx, p.msgs, p.cancelReader) 100 | if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) { 101 | select { 102 | case <-p.ctx.Done(): 103 | case p.errs <- err: 104 | } 105 | } 106 | } 107 | 108 | // waitForReadLoop waits for the cancelReader to finish its read loop. 109 | func (p *Program) waitForReadLoop() { 110 | select { 111 | case <-p.readLoopDone: 112 | case <-time.After(500 * time.Millisecond): //nolint:mnd 113 | // The read loop hangs, which means the input 114 | // cancelReader's cancel function has returned true even 115 | // though it was not able to cancel the read. 116 | } 117 | } 118 | 119 | // checkResize detects the current size of the output and informs the program 120 | // via a WindowSizeMsg. 121 | func (p *Program) checkResize() { 122 | if p.ttyOutput == nil { 123 | // can't query window size 124 | return 125 | } 126 | 127 | w, h, err := term.GetSize(p.ttyOutput.Fd()) 128 | if err != nil { 129 | select { 130 | case <-p.ctx.Done(): 131 | case p.errs <- err: 132 | } 133 | 134 | return 135 | } 136 | 137 | p.Send(WindowSizeMsg{ 138 | Width: w, 139 | Height: h, 140 | }) 141 | } 142 | -------------------------------------------------------------------------------- /tty_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos 2 | // +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos 3 | 4 | package tea 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "os/signal" 10 | "syscall" 11 | 12 | "github.com/charmbracelet/x/term" 13 | ) 14 | 15 | func (p *Program) initInput() (err error) { 16 | // Check if input is a terminal 17 | if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { 18 | p.ttyInput = f 19 | p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) 20 | if err != nil { 21 | return fmt.Errorf("error entering raw mode: %w", err) 22 | } 23 | } 24 | 25 | if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) { 26 | p.ttyOutput = f 27 | } 28 | 29 | return nil 30 | } 31 | 32 | func openInputTTY() (*os.File, error) { 33 | f, err := os.Open("/dev/tty") 34 | if err != nil { 35 | return nil, fmt.Errorf("could not open a new TTY: %w", err) 36 | } 37 | return f, nil 38 | } 39 | 40 | const suspendSupported = true 41 | 42 | // Send SIGTSTP to the entire process group. 43 | func suspendProcess() { 44 | c := make(chan os.Signal, 1) 45 | signal.Notify(c, syscall.SIGCONT) 46 | _ = syscall.Kill(0, syscall.SIGTSTP) 47 | // blocks until a CONT happens... 48 | <-c 49 | } 50 | -------------------------------------------------------------------------------- /tty_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package tea 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | 10 | "github.com/charmbracelet/x/term" 11 | "golang.org/x/sys/windows" 12 | ) 13 | 14 | func (p *Program) initInput() (err error) { 15 | // Save stdin state and enable VT input 16 | // We also need to enable VT 17 | // input here. 18 | if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { 19 | p.ttyInput = f 20 | p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) 21 | if err != nil { 22 | return fmt.Errorf("error making raw: %w", err) 23 | } 24 | 25 | // Enable VT input 26 | var mode uint32 27 | if err := windows.GetConsoleMode(windows.Handle(p.ttyInput.Fd()), &mode); err != nil { 28 | return fmt.Errorf("error getting console mode: %w", err) 29 | } 30 | 31 | if err := windows.SetConsoleMode(windows.Handle(p.ttyInput.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT); err != nil { 32 | return fmt.Errorf("error setting console mode: %w", err) 33 | } 34 | } 35 | 36 | // Save output screen buffer state and enable VT processing. 37 | if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) { 38 | p.ttyOutput = f 39 | p.previousOutputState, err = term.GetState(f.Fd()) 40 | if err != nil { 41 | return fmt.Errorf("error getting state: %w", err) 42 | } 43 | 44 | var mode uint32 45 | if err := windows.GetConsoleMode(windows.Handle(p.ttyOutput.Fd()), &mode); err != nil { 46 | return fmt.Errorf("error getting console mode: %w", err) 47 | } 48 | 49 | if err := windows.SetConsoleMode(windows.Handle(p.ttyOutput.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING); err != nil { 50 | return fmt.Errorf("error setting console mode: %w", err) 51 | } 52 | } 53 | 54 | return nil 55 | } 56 | 57 | // Open the Windows equivalent of a TTY. 58 | func openInputTTY() (*os.File, error) { 59 | f, err := os.OpenFile("CONIN$", os.O_RDWR, 0o644) 60 | if err != nil { 61 | return nil, fmt.Errorf("error opening file: %w", err) 62 | } 63 | return f, nil 64 | } 65 | 66 | const suspendSupported = false 67 | 68 | func suspendProcess() {} 69 | -------------------------------------------------------------------------------- /tutorials/basics/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | ) 9 | 10 | type model struct { 11 | cursor int 12 | choices []string 13 | selected map[int]struct{} 14 | } 15 | 16 | func initialModel() model { 17 | return model{ 18 | choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, 19 | 20 | // A map which indicates which choices are selected. We're using 21 | // the map like a mathematical set. The keys refer to the indexes 22 | // of the `choices` slice, above. 23 | selected: make(map[int]struct{}), 24 | } 25 | } 26 | 27 | func (m model) Init() tea.Cmd { 28 | return tea.SetWindowTitle("Grocery List") 29 | } 30 | 31 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 32 | switch msg := msg.(type) { 33 | case tea.KeyMsg: 34 | switch msg.String() { 35 | case "ctrl+c", "q": 36 | return m, tea.Quit 37 | case "up", "k": 38 | if m.cursor > 0 { 39 | m.cursor-- 40 | } 41 | case "down", "j": 42 | if m.cursor < len(m.choices)-1 { 43 | m.cursor++ 44 | } 45 | case "enter", " ": 46 | _, ok := m.selected[m.cursor] 47 | if ok { 48 | delete(m.selected, m.cursor) 49 | } else { 50 | m.selected[m.cursor] = struct{}{} 51 | } 52 | } 53 | } 54 | 55 | return m, nil 56 | } 57 | 58 | func (m model) View() string { 59 | s := "What should we buy at the market?\n\n" 60 | 61 | for i, choice := range m.choices { 62 | cursor := " " 63 | if m.cursor == i { 64 | cursor = ">" 65 | } 66 | 67 | checked := " " 68 | if _, ok := m.selected[i]; ok { 69 | checked = "x" 70 | } 71 | 72 | s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) 73 | } 74 | 75 | s += "\nPress q to quit.\n" 76 | 77 | return s 78 | } 79 | 80 | func main() { 81 | p := tea.NewProgram(initialModel()) 82 | if _, err := p.Run(); err != nil { 83 | fmt.Printf("Alas, there's been an error: %v", err) 84 | os.Exit(1) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tutorials/commands/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "time" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | ) 11 | 12 | const url = "https://charm.sh/" 13 | 14 | type model struct { 15 | status int 16 | err error 17 | } 18 | 19 | func checkServer() tea.Msg { 20 | c := &http.Client{Timeout: 10 * time.Second} 21 | res, err := c.Get(url) 22 | if err != nil { 23 | return errMsg{err} 24 | } 25 | defer res.Body.Close() // nolint:errcheck 26 | 27 | return statusMsg(res.StatusCode) 28 | } 29 | 30 | type statusMsg int 31 | 32 | type errMsg struct{ err error } 33 | 34 | // For messages that contain errors it's often handy to also implement the 35 | // error interface on the message. 36 | func (e errMsg) Error() string { return e.err.Error() } 37 | 38 | func (m model) Init() tea.Cmd { 39 | return checkServer 40 | } 41 | 42 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 43 | switch msg := msg.(type) { 44 | case statusMsg: 45 | m.status = int(msg) 46 | return m, tea.Quit 47 | 48 | case errMsg: 49 | m.err = msg 50 | return m, tea.Quit 51 | 52 | case tea.KeyMsg: 53 | if msg.Type == tea.KeyCtrlC { 54 | return m, tea.Quit 55 | } 56 | } 57 | 58 | return m, nil 59 | } 60 | 61 | func (m model) View() string { 62 | if m.err != nil { 63 | return fmt.Sprintf("\nWe had some trouble: %v\n\n", m.err) 64 | } 65 | 66 | s := fmt.Sprintf("Checking %s ... ", url) 67 | if m.status > 0 { 68 | s += fmt.Sprintf("%d %s!", m.status, http.StatusText(m.status)) 69 | } 70 | return "\n" + s + "\n\n" 71 | } 72 | 73 | func main() { 74 | if _, err := tea.NewProgram(model{}).Run(); err != nil { 75 | fmt.Printf("Uh oh, there was an error: %v\n", err) 76 | os.Exit(1) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tutorials/go.mod: -------------------------------------------------------------------------------- 1 | module tutorial 2 | 3 | go 1.18 4 | 5 | require github.com/charmbracelet/bubbletea v0.25.0 6 | 7 | require ( 8 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 9 | github.com/charmbracelet/lipgloss v0.13.1 // indirect 10 | github.com/charmbracelet/x/ansi v0.4.0 // indirect 11 | github.com/charmbracelet/x/term v0.2.0 // indirect 12 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 13 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 14 | github.com/mattn/go-isatty v0.0.20 // indirect 15 | github.com/mattn/go-localereader v0.0.1 // indirect 16 | github.com/mattn/go-runewidth v0.0.15 // indirect 17 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 18 | github.com/muesli/cancelreader v0.2.2 // indirect 19 | github.com/muesli/termenv v0.15.2 // indirect 20 | github.com/rivo/uniseg v0.4.7 // indirect 21 | golang.org/x/sync v0.8.0 // indirect 22 | golang.org/x/sys v0.26.0 // indirect 23 | golang.org/x/text v0.13.0 // indirect 24 | ) 25 | 26 | replace github.com/charmbracelet/bubbletea => ../ 27 | -------------------------------------------------------------------------------- /tutorials/go.sum: -------------------------------------------------------------------------------- 1 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 | github.com/charmbracelet/lipgloss v0.13.1 h1:Oik/oqDTMVA01GetT4JdEC033dNzWoQHdWnHnQmXE2A= 4 | github.com/charmbracelet/lipgloss v0.13.1/go.mod h1:zaYVJ2xKSKEnTEEbX6uAHabh2d975RJ+0yfkFpRBz5U= 5 | github.com/charmbracelet/x/ansi v0.4.0 h1:NqwHA4B23VwsDn4H3VcNX1W1tOmgnvY1NDx5tOXdnOU= 6 | github.com/charmbracelet/x/ansi v0.4.0/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= 7 | github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= 8 | github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= 9 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 10 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 11 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 12 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 13 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 14 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 15 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 16 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 17 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 18 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 19 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 20 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 21 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 22 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 23 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 24 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 25 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 26 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 27 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 28 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 29 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 30 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= 33 | golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 34 | golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= 35 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 36 | --------------------------------------------------------------------------------