├── .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 |
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.[K
2 | [K
3 | To quit sooner press ctrl-c, or press ctrl-z to suspend...[K
4 | [K[70D[3AHi. This program will exit in 9 seconds.[K
5 |
6 |
7 | [70D[2K
[?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 |
--------------------------------------------------------------------------------