Repository: charmbracelet/bubbletea Branch: main Commit: f8926a7aa8a5 Files: 191 Total size: 406.2 KB Directory structure: gitextract_mfu3e63q/ ├── .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 ├── UPGRADE_GUIDE_V2.md ├── clipboard.go ├── color.go ├── commands.go ├── commands_test.go ├── cursed_renderer.go ├── cursor.go ├── environ.go ├── examples/ │ ├── README.md │ ├── altscreen-toggle/ │ │ ├── README.md │ │ └── main.go │ ├── autocomplete/ │ │ └── main.go │ ├── canvas/ │ │ └── main.go │ ├── capability/ │ │ └── main.go │ ├── cellbuffer/ │ │ └── main.go │ ├── chat/ │ │ ├── README.md │ │ └── main.go │ ├── clickable/ │ │ ├── main.go │ │ └── words.go │ ├── colorprofile/ │ │ └── main.go │ ├── composable-views/ │ │ ├── README.md │ │ └── main.go │ ├── cursor-style/ │ │ └── main.go │ ├── debounce/ │ │ ├── README.md │ │ └── main.go │ ├── doom-fire/ │ │ └── main.go │ ├── exec/ │ │ ├── README.md │ │ └── main.go │ ├── eyes/ │ │ └── main.go │ ├── file-picker/ │ │ └── main.go │ ├── focus-blur/ │ │ └── main.go │ ├── fullscreen/ │ │ ├── README.md │ │ └── main.go │ ├── glamour/ │ │ ├── README.md │ │ └── main.go │ ├── go.mod │ ├── go.sum │ ├── help/ │ │ ├── README.md │ │ └── main.go │ ├── http/ │ │ ├── README.md │ │ └── main.go │ ├── isbn-form/ │ │ ├── isbn-form.tape │ │ └── main.go │ ├── keyboard-enhancements/ │ │ └── main.go │ ├── list-default/ │ │ ├── README.md │ │ └── main.go │ ├── list-fancy/ │ │ ├── README.md │ │ ├── delegate.go │ │ ├── main.go │ │ └── randomitems.go │ ├── list-simple/ │ │ ├── README.md │ │ └── main.go │ ├── mouse/ │ │ └── main.go │ ├── package-manager/ │ │ ├── README.md │ │ ├── main.go │ │ └── packages.go │ ├── pager/ │ │ ├── README.md │ │ ├── artichoke.md │ │ └── main.go │ ├── paginator/ │ │ ├── README.md │ │ └── main.go │ ├── pipe/ │ │ ├── README.md │ │ └── main.go │ ├── prevent-quit/ │ │ └── main.go │ ├── print-key/ │ │ └── main.go │ ├── progress-animated/ │ │ ├── README.md │ │ └── main.go │ ├── progress-bar/ │ │ └── main.go │ ├── progress-download/ │ │ ├── README.md │ │ ├── main.go │ │ └── tui.go │ ├── progress-static/ │ │ ├── README.md │ │ └── main.go │ ├── query-term/ │ │ └── main.go │ ├── realtime/ │ │ ├── README.md │ │ └── main.go │ ├── result/ │ │ ├── README.md │ │ └── main.go │ ├── send-msg/ │ │ ├── README.md │ │ └── main.go │ ├── sequence/ │ │ ├── README.md │ │ └── main.go │ ├── set-terminal-color/ │ │ └── main.go │ ├── set-window-title/ │ │ └── main.go │ ├── simple/ │ │ ├── README.md │ │ ├── main.go │ │ ├── main_test.go │ │ └── testdata/ │ │ └── TestApp.golden │ ├── space/ │ │ └── main.go │ ├── spinner/ │ │ ├── README.md │ │ └── main.go │ ├── spinners/ │ │ ├── README.md │ │ └── main.go │ ├── splash/ │ │ └── main.go │ ├── split-editors/ │ │ ├── README.md │ │ └── main.go │ ├── stopwatch/ │ │ ├── README.md │ │ └── main.go │ ├── suspend/ │ │ └── main.go │ ├── table/ │ │ ├── README.md │ │ └── main.go │ ├── table-resize/ │ │ └── main.go │ ├── tabs/ │ │ ├── README.md │ │ └── main.go │ ├── textarea/ │ │ ├── README.md │ │ └── main.go │ ├── textinput/ │ │ ├── README.md │ │ └── main.go │ ├── textinputs/ │ │ ├── README.md │ │ └── main.go │ ├── timer/ │ │ ├── README.md │ │ └── main.go │ ├── tui-daemon-combo/ │ │ ├── README.md │ │ └── main.go │ ├── vanish/ │ │ └── main.go │ ├── views/ │ │ ├── README.md │ │ └── main.go │ └── window-size/ │ └── main.go ├── exec.go ├── exec_test.go ├── focus.go ├── go.mod ├── go.sum ├── input.go ├── key.go ├── keyboard.go ├── logging.go ├── logging_test.go ├── mod.go ├── mouse.go ├── nil_renderer.go ├── options.go ├── options_test.go ├── paste.go ├── profile.go ├── raw.go ├── renderer.go ├── screen.go ├── screen_test.go ├── signals_unix.go ├── signals_windows.go ├── tea.go ├── tea_test.go ├── termcap.go ├── termios_bsd.go ├── termios_other.go ├── termios_unix.go ├── termios_windows.go ├── testdata/ │ ├── TestClearMsg/ │ │ ├── bg_fg_cur_color.golden │ │ ├── clear_screen.golden │ │ └── read_set_clipboard.golden │ └── TestViewModel/ │ ├── altscreen.golden │ ├── altscreen_autoexit.golden │ ├── bg_set_color.golden │ ├── bp_stop_start.golden │ ├── cursor_hide.golden │ ├── cursor_hideshow.golden │ ├── kitty_stop_startreleases.golden │ ├── mouse_allmotion.golden │ ├── mouse_cellmotion.golden │ └── mouse_disable.golden ├── tty.go ├── tty_unix.go ├── tty_windows.go ├── tutorials/ │ ├── basics/ │ │ ├── README.md │ │ └── main.go │ ├── commands/ │ │ ├── README.md │ │ └── main.go │ ├── go.mod │ └── go.sum └── xterm.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ *.golden -text ================================================ FILE: .github/CODEOWNERS ================================================ * @meowgorithm @aymanbagabas ================================================ FILE: .github/ISSUE_TEMPLATE/bug.yml ================================================ name: Bug Report description: File a bug report labels: [bug] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! Please fill the form below. - type: textarea id: what-happened attributes: label: What happened? description: Also tell us, what did you expect to happen? validations: required: true - type: textarea id: reproducible attributes: label: How can we reproduce this? description: | Please share a code snippet, gist, or public repository that reproduces the issue. Make sure to make the reproducible as concise as possible, with only the minimum required code to reproduce the issue. validations: required: true - type: textarea id: version attributes: label: Which version of bubbletea are you using? description: '' render: bash validations: required: true - type: textarea id: terminaal attributes: label: Which terminals did you reproduce this with? description: | Other helpful information: was it over SSH? On tmux? Which version of said terminal? validations: required: true - type: checkboxes id: search attributes: label: Search options: - label: | I searched for other open and closed issues and pull requests before opening this, and didn't find anything that seems related. required: true - type: textarea id: ctx attributes: label: Additional context description: Anything else you would like to add validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **Setup** Please complete the following information along with version numbers, if applicable. - OS [e.g. Ubuntu, macOS] - Shell [e.g. zsh, fish] - Terminal Emulator [e.g. kitty, iterm] - Terminal Multiplexer [e.g. tmux] **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Source Code** Please include source code if needed to reproduce the behavior. **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** Add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: Discord url: https://charm.sh/discord about: Chat on our Discord. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" ignore: - dependency-name: github.com/charmbracelet/bubbletea/v2 versions: - v2.0.0-beta1 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" - package-ecosystem: "docker" directory: "/" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" - package-ecosystem: "gomod" directory: "/examples" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" - package-ecosystem: "gomod" directory: "/tutorials" schedule: interval: "weekly" day: "monday" time: "05:00" timezone: "America/New_York" labels: - "dependencies" commit-message: prefix: "chore" include: "scope" groups: all: patterns: - "*" ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: [push, pull_request] jobs: build: uses: charmbracelet/meta/.github/workflows/build.yml@main build-go-mod: uses: charmbracelet/meta/.github/workflows/build.yml@main with: go-version: "" go-version-file: ./go.mod build-examples: uses: charmbracelet/meta/.github/workflows/build.yml@main with: go-version: "" go-version-file: ./examples/go.mod working-directory: ./examples ================================================ FILE: .github/workflows/coverage.yml ================================================ name: coverage on: [push, pull_request] jobs: coverage: strategy: matrix: go-version: [^1] os: [ubuntu-latest] runs-on: ${{ matrix.os }} env: GO111MODULE: "on" steps: - name: Install Go uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v6 - name: Coverage run: | go test -race -covermode=atomic -coverprofile=coverage.txt ./... - uses: codecov/codecov-action@v5 with: file: ./coverage.txt token: ${{ secrets.CODECOV_TOKEN }} ================================================ FILE: .github/workflows/dependabot-sync.yml ================================================ name: dependabot-sync on: schedule: - cron: "0 0 * * 0" # every Sunday at midnight workflow_dispatch: # allows manual triggering permissions: contents: write pull-requests: write jobs: dependabot-sync: uses: charmbracelet/meta/.github/workflows/dependabot-sync.yml@main with: repo_name: ${{ github.event.repository.name }} secrets: gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} ================================================ FILE: .github/workflows/examples.yml ================================================ name: examples on: push: branches: - 'master' paths: - '.github/workflows/examples.yml' - './examples/go.mod' - './examples/go.sum' - './tutorials/go.mod' - './tutorials/go.sum' - './go.mod' - './go.sum' workflow_dispatch: {} jobs: tidy: permissions: contents: write runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-go@v6 with: go-version: '^1' cache: true - shell: bash run: | (cd ./examples && go mod tidy) (cd ./tutorials && go mod tidy) - uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "chore: go mod tidy tutorials and examples" branch: master commit_user_name: actions-user commit_user_email: actions@github.com ================================================ FILE: .github/workflows/lint-sync.yml ================================================ name: lint-sync on: schedule: # every Sunday at midnight - cron: "0 0 * * 0" workflow_dispatch: # allows manual triggering permissions: contents: write pull-requests: write jobs: lint: uses: charmbracelet/meta/.github/workflows/lint-sync.yml@main ================================================ FILE: .github/workflows/lint.yml ================================================ name: lint on: push: pull_request: jobs: lint: uses: charmbracelet/meta/.github/workflows/lint.yml@main with: golangci_path: .golangci.yml golangci_version: v2.9 timeout: 10m ================================================ FILE: .github/workflows/release.yml ================================================ name: goreleaser on: push: tags: - v*.*.* concurrency: group: goreleaser cancel-in-progress: true jobs: goreleaser: uses: charmbracelet/meta/.github/workflows/goreleaser.yml@main secrets: docker_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_token: ${{ secrets.DOCKERHUB_TOKEN }} gh_pat: ${{ secrets.PERSONAL_ACCESS_TOKEN }} goreleaser_key: ${{ secrets.GORELEASER_KEY }} twitter_consumer_key: ${{ secrets.TWITTER_CONSUMER_KEY }} twitter_consumer_secret: ${{ secrets.TWITTER_CONSUMER_SECRET }} twitter_access_token: ${{ secrets.TWITTER_ACCESS_TOKEN }} twitter_access_token_secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} mastodon_client_id: ${{ secrets.MASTODON_CLIENT_ID }} mastodon_client_secret: ${{ secrets.MASTODON_CLIENT_SECRET }} mastodon_access_token: ${{ secrets.MASTODON_ACCESS_TOKEN }} discord_webhook_id: ${{ secrets.DISCORD_WEBHOOK_ID }} discord_webhook_token: ${{ secrets.DISCORD_WEBHOOK_TOKEN }} # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json ================================================ FILE: .gitignore ================================================ .DS_Store .envrc examples/fullscreen/fullscreen examples/help/help examples/http/http examples/list-default/list-default examples/list-fancy/list-fancy examples/list-simple/list-simple examples/mouse/mouse examples/pager/pager examples/progress-download/color_vortex.blend examples/progress-download/progress-download examples/simple/simple examples/spinner/spinner examples/textinput/textinput examples/textinputs/textinputs examples/views/views tutorials/basics/basics tutorials/commands/commands .idea coverage.txt dist/ ================================================ FILE: .golangci.yml ================================================ version: "2" run: tests: false linters: enable: - bodyclose - exhaustive - goconst - godot - gomoddirectives - goprintffuncname - gosec - misspell - nakedret - nestif - nilerr - noctx - nolintlint - prealloc - revive - rowserrcheck - sqlclosecheck - tparallel - unconvert - unparam - whitespace - wrapcheck exclusions: rules: - text: '(slog|log)\.\w+' linters: - noctx generated: lax presets: - common-false-positives issues: max-issues-per-linter: 0 max-same-issues: 0 formatters: enable: - gofumpt - goimports exclusions: generated: lax ================================================ FILE: .goreleaser.yml ================================================ # yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json version: 2 includes: - from_url: url: charmbracelet/meta/main/goreleaser-lib.yaml ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020-2026 Charmbracelet, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Bubble Tea


Latest Release GoDoc Build Status

The fun, functional and stateful way to build terminal apps. A Go framework based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and complex terminal applications, either inline, full-window, or a mix of both.

Bubble Tea Example

Bubble Tea is in use in production and includes a number of features and performance optimizations we’ve added along the way. Among those is a high-performance cell-based renderer, built-in color downsampling, declarative views, high-fidelity keyboard and mouse handling, native clipboard support, and more. To get started, see the tutorial below, the [examples][examples], the [docs][docs], and some common [resources](#libraries-we-use-with-bubble-tea). > [!TIP] > > Upgrading from v1? Check out the [upgrade guide](./UPGRADE_GUIDE_V2.md), or > point your LLM at it and let it go to town. ## By the way Be sure to check out [Bubbles][bubbles], a library of common UI components for Bubble Tea.

Bubbles Badge   Text Input Example from Bubbles

--- ## Tutorial Bubble Tea is based on the functional design paradigms of [The Elm Architecture][elm], which happens to work nicely with Go. It's a delightful way to build applications. This tutorial assumes you have a working knowledge of Go. By the way, the non-annotated source code for this program is available [on GitHub][tut-source]. [elm]: https://guide.elm-lang.org/architecture/ [tut-source]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/basics ### Enough! Let's get to it. For this tutorial, we're making a shopping list. To start we'll define our package and import some libraries. Our only external import will be the Bubble Tea library, which we'll call `tea` for short. ```go package main // These imports will be used later in the tutorial. If you save the file // now, Go might complain they are unused, but that's fine. // You may also need to run `go mod tidy` to download bubbletea and its // dependencies. import ( "fmt" "os" tea "charm.land/bubbletea/v2" ) ``` Bubble Tea programs are comprised of a **model** that describes the application state and three simple methods on that model: - **Init**, a function that returns an initial command for the application to run. - **Update**, a function that handles incoming events and updates the model accordingly. - **View**, a function that renders the UI based on the data in the model. ### The Model So let's start by defining our model which will store our application's state. It can be any type, but a `struct` usually makes the most sense. ```go type model struct { choices []string // items on the to-do list cursor int // which to-do list item our cursor is pointing at selected map[int]struct{} // which to-do items are selected } ``` ## Initialization Next, we’ll define our application’s initial state. `Init` can return a `Cmd` that could perform some initial I/O. For now, we don’t need to do any I/O, so for the command, we’ll just return `nil`, which translates to “no command.” ```go func initialModel() model { return model{ // Our to-do list is a grocery list choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, // A map which indicates which choices are selected. We're using // the map like a mathematical set. The keys refer to the indexes // of the `choices` slice, above. selected: make(map[int]struct{}), } } ``` After that, we’ll define our application’s initial state in the `Init` method. `Init` can return a `Cmd` that could perform some initial I/O. For now, we don't need to do any I/O, so for the command, we'll just return `nil`, which translates to "no command." ```go func (m model) Init() tea.Cmd { // Just return `nil`, which means "no I/O right now, please." return nil } ``` ### The Update Method Next up is the update method. The update function is called when “things happen.” Its job is to look at what has happened and return an updated model in response. It can also return a `Cmd` to make more things happen, but for now don't worry about that part. In our case, when a user presses the down arrow, `Update`’s job is to notice that the down arrow was pressed and move the cursor accordingly (or not). The “something happened” comes in the form of a `Msg`, which can be any type. Messages are the result of some I/O that took place, such as a keypress, timer tick, or a response from a server. We usually figure out which type of `Msg` we received with a type switch, but you could also use a type assertion. For now, we'll just deal with `tea.KeyPressMsg` messages, which are automatically sent to the update function when keys are pressed. ```go func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { // Is it a key press? case tea.KeyPressMsg: // Cool, what was the actual key pressed? switch msg.String() { // These keys should exit the program. case "ctrl+c", "q": return m, tea.Quit // The "up" and "k" keys move the cursor up case "up", "k": if m.cursor > 0 { m.cursor-- } // The "down" and "j" keys move the cursor down case "down", "j": if m.cursor < len(m.choices)-1 { m.cursor++ } // The "enter" key and the space bar toggle the selected state // for the item that the cursor is pointing at. case "enter", "space": _, ok := m.selected[m.cursor] if ok { delete(m.selected, m.cursor) } else { m.selected[m.cursor] = struct{}{} } } } // Return the updated model to the Bubble Tea runtime for processing. // Note that we're not returning a command. return m, nil } ``` You may have noticed that ctrl+c and q above return a `tea.Quit` command with the model. That’s a special command which instructs the Bubble Tea runtime to quit, exiting the program. ### The View Method At last, it’s time to render our UI. Of all the methods, the view is the simplest. We look at the model in its current state and use it to build a `tea.View`. The view declares our UI content and, optionally, terminal features like alt screen mode, mouse tracking, cursor position, and more. Because the view describes the entire UI of your application, you don’t have to worry about redrawing logic and stuff like that. Bubble Tea takes care of it for you. ```go func (m model) View() tea.View { // The header s := "What should we buy at the market?\n\n" // Iterate over our choices for i, choice := range m.choices { // Is the cursor pointing at this choice? cursor := " " // no cursor if m.cursor == i { cursor = ">" // cursor! } // Is this choice selected? checked := " " // not selected if _, ok := m.selected[i]; ok { checked = "x" // selected! } // Render the row s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) } // The footer s += "\nPress q to quit.\n" // Send the UI for rendering return tea.NewView(s) } ``` ### All Together Now The last step is to simply run our program. We pass our initial model to `tea.NewProgram` and let it rip: ```go func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) } } ``` ## What’s Next? This tutorial covers the basics of building an interactive terminal UI, but in the real world you'll also need to perform I/O. To learn about that have a look at the [Command Tutorial][cmd]. It's pretty simple. There are also several [Bubble Tea examples][examples] available and, of course, there are [Go Docs][docs]. [cmd]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/commands/ [examples]: https://github.com/charmbracelet/bubbletea/tree/main/examples [docs]: https://pkg.go.dev/charm.land/bubbletea/v2?tab=doc ## Debugging ### Debugging with Delve Since Bubble Tea apps assume control of stdin and stdout, you’ll need to run delve in headless mode and then connect to it: ```bash # Start the debugger $ dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 . API server listening at: 127.0.0.1:43000 # Connect to it from another terminal $ dlv connect 127.0.0.1:43000 ``` If you do not explicitly supply the `--listen` flag, the port used will vary per run, so passing this in makes the debugger easier to use from a script or your IDE of choice. Additionally, we pass in `--api-version=2` because delve defaults to version 1 for backwards compatibility reasons. However, delve recommends using version 2 for all new development and some clients may no longer work with version 1. For more information, see the [Delve documentation](https://github.com/go-delve/delve/tree/master/Documentation/api). ### Logging Stuff You can’t really log to stdout with Bubble Tea because your TUI is busy occupying that! You can, however, log to a file by including something like the following prior to starting your Bubble Tea program: ```go if len(os.Getenv("DEBUG")) > 0 { f, err := tea.LogToFile("debug.log", "debug") if err != nil { fmt.Println("fatal:", err) os.Exit(1) } defer f.Close() } ``` To see what’s being logged in real time, run `tail -f debug.log` while you run your program in another window. ## Libraries we use with Bubble Tea - [Bubbles][bubbles]: Common Bubble Tea components such as text inputs, viewports, spinners and so on - [Lip Gloss][lipgloss]: Style, format and layout tools for terminal applications - [Harmonica][harmonica]: A spring animation library for smooth, natural motion - [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components - [ntcharts][ntcharts]: A terminal charting library built for Bubble Tea and [Lip Gloss][lipgloss] [bubbles]: https://github.com/charmbracelet/bubbles [lipgloss]: https://github.com/charmbracelet/lipgloss [harmonica]: https://github.com/charmbracelet/harmonica [bubblezone]: https://github.com/lrstanley/bubblezone [ntcharts]: https://github.com/NimbleMarkets/ntcharts ## Bubble Tea in the Wild There are over [18,000 applications](https://github.com/charmbracelet/bubbletea/network/dependents) built with Bubble Tea! Here are a handful of ’em. ### Staff favourites - [chezmoi](https://github.com/twpayne/chezmoi): securely manage your dotfiles across multiple machines - [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in the terminal - [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues - [Tetrigo](https://github.com/Broderick-Westrope/tetrigo): Tetris in the terminal - [Signls](https://github.com/emprcl/signls): a generative midi sequencer designed for composition and live performance - [Superfile](https://github.com/yorukot/superfile): a super file manager ### In Industry - Microsoft Azure – [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform - Daytona – [Daytona](https://github.com/daytonaio/daytona): an AI infrastructure platform - Cockroach Labs – [CockroachDB](https://github.com/cockroachdb/cockroach): a cloud-native, high-availability distributed SQL database - Truffle Security Co. – [Trufflehog](https://github.com/trufflesecurity/trufflehog): find leaked credentials - NVIDIA – [container-canary](https://github.com/NVIDIA/container-canary): a container validator - AWS – [eks-node-viewer](https://github.com/awslabs/eks-node-viewer): a tool for visualizing dynamic node usage within an EKS cluster - MinIO – [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client - Ubuntu – [Authd](https://github.com/ubuntu/authd): an authentication daemon for cloud-based identity providers ### Charm stuff - [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser, and online markdown stash - [Huh?](https://github.com/charmbracelet/huh): an interactive prompt and form toolkit - [Mods](https://github.com/charmbracelet/mods): AI on the CLI, built for pipelines - [Wishlist](https://github.com/charmbracelet/wishlist): an SSH directory (and bastion!) ### There’s so much more where that came from For more applications built with Bubble Tea see [Charm & Friends][community]. Is there something cool you made with Bubble Tea you want to share? [PRs][community] are welcome! ## Contributing See [contributing][contribute]. [contribute]: https://github.com/charmbracelet/bubbletea/contribute ## Feedback We’d love to hear your thoughts on this project. Feel free to drop us a note! - [Twitter](https://twitter.com/charmcli) - [The Fediverse](https://mastodon.social/@charmcli) - [Discord](https://charm.sh/chat) ## Acknowledgments Bubble Tea is based on the paradigms of [The Elm Architecture][elm] by Evan Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk. It’s inspired by the many great [_Zeichenorientierte Benutzerschnittstellen_][zb] of days past. [elm]: https://guide.elm-lang.org/architecture/ [gotea]: https://github.com/tj/go-tea [zb]: https://de.wikipedia.org/wiki/Zeichenorientierte_Benutzerschnittstelle [community]: https://github.com/charm-and-friends/charm-in-the-wild ## License [MIT](https://github.com/charmbracelet/bubbletea/raw/main/LICENSE) --- Part of [Charm](https://charm.sh). The Charm logo Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة ================================================ FILE: Taskfile.yaml ================================================ # https://taskfile.dev version: '3' tasks: lint: desc: Run lint cmds: - golangci-lint run test: desc: Run tests cmds: - go test ./... {{.CLI_ARGS}} ================================================ FILE: UPGRADE_GUIDE_V2.md ================================================ # Bubble Tea v2 Upgrade Guide This guide covers everything you need to change when upgrading from Bubble Tea v1 to v2. For a tour of all the exciting new features, check out the [What's New](https://github.com/charmbracelet/bubbletea/releases/tag/v2.0.0) doc. > [!NOTE] > We don't take API changes lightly and strive to make the upgrade process as simple as possible. If something feels way off, let us know. ## Migration Checklist Here's the short version — a checklist you can follow top to bottom. Each item links to the relevant section below. - [ ] [Update import paths](#import-paths) - [ ] [Change `View() string` to `View() tea.View`](#view-returns-a-teaview-now) - [ ] [Replace `tea.KeyMsg` with `tea.KeyPressMsg`](#key-messages) - [ ] [Update key fields: `msg.Type` / `msg.Runes` / `msg.Alt`](#key-messages) - [ ] [Replace `case " ":` with `case "space":`](#key-messages) - [ ] [Update mouse message usage](#mouse-messages) - [ ] [Rename mouse button constants](#mouse-messages) - [ ] [Remove old program options → use View fields](#removed-program-options) - [ ] [Remove imperative commands → use View fields](#removed-commands) - [ ] [Remove old program methods](#removed-program-methods) - [ ] [Rename `tea.WindowSize()` → `tea.RequestWindowSize`](#renamed-apis) - [ ] [Replace `tea.Sequentially(...)` → `tea.Sequence(...)`](#renamed-apis) ## Import Paths The module path changed to a vanity domain. Lip Gloss moved too. ```go // Before import tea "github.com/charmbracelet/bubbletea" import "github.com/charmbracelet/lipgloss" // After import tea "charm.land/bubbletea/v2" import "charm.land/lipgloss/v2" ``` ## The Big Idea: Declarative Views The single biggest change in v2 is the shift from **imperative commands** to **declarative View fields**. In v1, you'd use program options like `tea.WithAltScreen()` and commands like `tea.EnterAltScreen` to toggle terminal features on and off. In v2, you just set fields on the `tea.View` struct in your `View()` method and Bubble Tea handles the rest. This means: no more startup option flags, no more toggle commands, no more fighting over state. Just declare what you want and Bubble Tea will make it so. ```go // v1: imperative — scattered across NewProgram, Init, and Update p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion()) // v2: declarative — everything lives in View() func (m model) View() tea.View { v := tea.NewView("Hello!") v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion return v } ``` Keep this in mind as you go through the rest of the guide — most of the "removed" things simply moved into View fields. ## View Returns a `tea.View` Now The `View()` method no longer returns a `string`. It returns a `tea.View` struct. ```go // Before: func (m model) View() string { return "Hello, world!" } // After: func (m model) View() tea.View { return tea.NewView("Hello, world!") } ``` You can also use the longer form if you need to set additional fields: ```go func (m model) View() tea.View { var v tea.View v.SetContent("Hello, world!") v.AltScreen = true return v } ``` The `tea.View` struct has fields for everything that used to be controlled by options and commands: | View Field | What It Does | |---|---| | `Content` | The rendered string (set via `SetContent()` or `NewView()`) | | `AltScreen` | Enter/exit the alternate screen buffer | | `MouseMode` | `MouseModeNone`, `MouseModeCellMotion`, or `MouseModeAllMotion` | | `ReportFocus` | Enable focus/blur event reporting | | `DisableBracketedPasteMode` | Disable bracketed paste | | `WindowTitle` | Set the terminal window title | | `Cursor` | Control cursor position, shape, color, and blink | | `ForegroundColor` | Set the terminal foreground color | | `BackgroundColor` | Set the terminal background color | | `ProgressBar` | Show a native terminal progress bar | | `KeyboardEnhancements` | Request keyboard enhancement features | | `OnMouse` | Intercept mouse messages based on view content | ## Key Messages Key messages got a major overhaul. Here's the quick rundown: ### `tea.KeyMsg` is now an interface In v1, `tea.KeyMsg` was a struct you'd match on for key presses. In v2, it's an **interface** that covers both key presses and releases. For most code, you want `tea.KeyPressMsg`: ```go // Before: case tea.KeyMsg: switch msg.String() { case "q": return m, tea.Quit } // After: case tea.KeyPressMsg: switch msg.String() { case "q": return m, tea.Quit } ``` If you want to handle both presses and releases, use `tea.KeyMsg` and type-switch inside: ```go case tea.KeyMsg: switch key := msg.(type) { case tea.KeyPressMsg: // key press case tea.KeyReleaseMsg: // key release } ``` ### Key fields changed | v1 | v2 | Notes | |---|---|---| | `msg.Type` | `msg.Code` | A `rune` — can be `tea.KeyEnter`, `'a'`, etc. | | `msg.Runes` | `msg.Text` | Now a `string`, not `[]rune` | | `msg.Alt` | `msg.Mod` | `msg.Mod.Contains(tea.ModAlt)` for alt, etc. | | `tea.KeyRune` | — | Check `len(msg.Text) > 0` instead | | `tea.KeyCtrlC` | — | Use `msg.String() == "ctrl+c"` or check `msg.Code` + `msg.Mod` | ### Space bar changed Space bar now returns `"space"` instead of `" "` when using `msg.String()`: ```go // Before: case " ": // After: case "space": ``` `key.Code` is still `' '` and `key.Text` is still `" "`, but `String()` returns `"space"`. ### Ctrl+key matching ```go // Before: case tea.KeyCtrlC: // ctrl+c // After (option A — string matching): case tea.KeyPressMsg: switch msg.String() { case "ctrl+c": // ctrl+c } // After (option B — field matching): case tea.KeyPressMsg: if msg.Code == 'c' && msg.Mod == tea.ModCtrl { // ctrl+c } ``` ### New Key fields These are new in v2 and don't have v1 equivalents: - **`key.ShiftedCode`** — the shifted key code (e.g., `'B'` when pressing shift+b) - **`key.BaseCode`** — the key on a US PC-101 layout (handy for international keyboards) - **`key.IsRepeat`** — whether the key is auto-repeating (Kitty protocol / Windows Console only) - **`key.Keystroke()`** — like `String()` but always includes modifier info ## Paste Messages Paste events no longer come in as `tea.KeyMsg` with a `Paste` flag. They're now their own message types: ```go // Before: case tea.KeyMsg: if msg.Paste { m.text += string(msg.Runes) } // After: case tea.PasteMsg: m.text += msg.Content case tea.PasteStartMsg: // paste started case tea.PasteEndMsg: // paste ended ``` ## Mouse Messages ### `tea.MouseMsg` is now an interface In v1, `tea.MouseMsg` was a struct with `X`, `Y`, `Button`, etc. In v2, it's an **interface**. You get the coordinates by calling `msg.Mouse()`: ```go // Before: case tea.MouseMsg: x, y := msg.X, msg.Y // After: case tea.MouseMsg: mouse := msg.Mouse() x, y := mouse.X, mouse.Y ``` ### Mouse events are split by type Instead of checking `msg.Action`, match on specific message types: ```go // Before: case tea.MouseMsg: if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { // left click } // After: case tea.MouseClickMsg: if msg.Button == tea.MouseLeft { // left click } case tea.MouseReleaseMsg: // release case tea.MouseWheelMsg: // scroll case tea.MouseMotionMsg: // movement ``` ### Button constants renamed | v1 | v2 | |---|---| | `tea.MouseButtonLeft` | `tea.MouseLeft` | | `tea.MouseButtonRight` | `tea.MouseRight` | | `tea.MouseButtonMiddle` | `tea.MouseMiddle` | | `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` | | `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` | | `tea.MouseButtonWheelLeft` | `tea.MouseWheelLeft` | | `tea.MouseButtonWheelRight` | `tea.MouseWheelRight` | ### `tea.MouseEvent` → `tea.Mouse` The `MouseEvent` struct is gone. The new `Mouse` struct has `X`, `Y`, `Button`, and `Mod` fields. ### Mouse mode is now a View field ```go // Before: p := tea.NewProgram(model{}, tea.WithMouseCellMotion()) // After: func (m model) View() tea.View { v := tea.NewView("...") v.MouseMode = tea.MouseModeCellMotion return v } ``` ## Removed Program Options These options no longer exist. They all moved to View fields. | Removed Option | Do This Instead | |---|---| | `tea.WithAltScreen()` | `view.AltScreen = true` | | `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` | | `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` | | `tea.WithReportFocus()` | `view.ReportFocus = true` | | `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` | | `tea.WithInputTTY()` | Just remove it — v2 always opens the TTY for input automatically | | `tea.WithANSICompressor()` | Just remove it — the new renderer handles optimization automatically | ## Removed Commands These commands no longer exist. Set the corresponding View field instead. | Removed Command | Do This Instead | |---|---| | `tea.EnterAltScreen` | `view.AltScreen = true` | | `tea.ExitAltScreen` | `view.AltScreen = false` | | `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` | | `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` | | `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` | | `tea.HideCursor` | `view.Cursor = nil` | | `tea.ShowCursor` | `view.Cursor = &tea.Cursor{...}` or `tea.NewCursor(x, y)` | | `tea.EnableBracketedPaste` | `view.DisableBracketedPasteMode = false` | | `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = true` | | `tea.EnableReportFocus` | `view.ReportFocus = true` | | `tea.DisableReportFocus` | `view.ReportFocus = false` | | `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` | ## Removed Program Methods These methods on `*Program` are gone. | Removed Method | Do This Instead | |---|---| | `p.Start()` | `p.Run()` | | `p.StartReturningModel()` | `p.Run()` | | `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` | | `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` | | `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` | | `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | | `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` | | `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | | `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` | ## Renamed APIs | v1 | v2 | Notes | |---|---|---| | `tea.Sequentially(...)` | `tea.Sequence(...)` | `Sequentially` was already deprecated in v1 | | `tea.WindowSize()` | `tea.RequestWindowSize` | Now returns `Msg` directly, not a `Cmd` | ## New Program Options These are new in v2: | Option | What It Does | |---|---| | `tea.WithColorProfile(p)` | Force a specific color profile (great for testing) | | `tea.WithWindowSize(w, h)` | Set initial terminal size (great for testing) | ## Complete Before & After Here's a minimal but complete program showing the most common migration patterns side by side. **v1:** ```go package main import ( "fmt" "os" tea "github.com/charmbracelet/bubbletea" ) type model struct { count int } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case " ": m.count++ } case tea.MouseMsg: if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft { m.count++ } } return m, nil } func (m model) View() string { return fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count) } func main() { p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ``` **v2:** ```go package main import ( "fmt" "os" tea "charm.land/bubbletea/v2" ) type model struct { count int } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "space": m.count++ } case tea.MouseClickMsg: if msg.Button == tea.MouseLeft { m.count++ } } return m, nil } func (m model) View() tea.View { v := tea.NewView(fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count)) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion return v } func main() { p := tea.NewProgram(model{}) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ``` Notice how the `NewProgram` call got simpler? All the terminal feature flags moved into `View()` where they belong. ## Quick Reference A flat old → new lookup table. Handy for search-and-replace and LLM-assisted migration. ### Import Paths | v1 | v2 | |---|---| | `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` | | `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` | ### Model Interface | v1 | v2 | |---|---| | `View() string` | `View() tea.View` | ### Key Events | v1 | v2 | |---|---| | `tea.KeyMsg` (struct) | `tea.KeyPressMsg` for presses, `tea.KeyMsg` (interface) for both | | `msg.Type` | `msg.Code` | | `msg.Runes` | `msg.Text` (string, not `[]rune`) | | `msg.Alt` | `msg.Mod.Contains(tea.ModAlt)` | | `tea.KeyRune` | check `len(msg.Text) > 0` | | `tea.KeyCtrlC` | `msg.Code == 'c' && msg.Mod == tea.ModCtrl` or `msg.String() == "ctrl+c"` | | `case " ":` (space) | `case "space":` | ### Mouse Events | v1 | v2 | |---|---| | `tea.MouseMsg` (struct) | `tea.MouseMsg` (interface) — call `.Mouse()` for the data | | `tea.MouseEvent` | `tea.Mouse` | | `tea.MouseButtonLeft` | `tea.MouseLeft` | | `tea.MouseButtonRight` | `tea.MouseRight` | | `tea.MouseButtonMiddle` | `tea.MouseMiddle` | | `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` | | `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` | | `msg.X`, `msg.Y` (direct) | `msg.Mouse().X`, `msg.Mouse().Y` | ### Options → View Fields | v1 Option | v2 View Field | |---|---| | `tea.WithAltScreen()` | `view.AltScreen = true` | | `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` | | `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` | | `tea.WithReportFocus()` | `view.ReportFocus = true` | | `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` | ### Commands → View Fields | v1 Command | v2 View Field | |---|---| | `tea.EnterAltScreen` / `tea.ExitAltScreen` | `view.AltScreen = true/false` | | `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` | | `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` | | `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` | | `tea.HideCursor` / `tea.ShowCursor` | `view.Cursor = nil` / `view.Cursor = &tea.Cursor{...}` | | `tea.EnableBracketedPaste` / `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = false/true` | | `tea.EnableReportFocus` / `tea.DisableReportFocus` | `view.ReportFocus = true/false` | | `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` | ### Removed Options (No Replacement Needed) | v1 Option | What Happened | |---|---| | `tea.WithInputTTY()` | v2 always opens the TTY for input automatically | | `tea.WithANSICompressor()` | The new renderer handles optimization automatically | ### Removed Program Methods | v1 Method | v2 Replacement | |---|---| | `p.Start()` | `p.Run()` | | `p.StartReturningModel()` | `p.Run()` | | `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` | | `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` | | `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` | | `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | | `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` | | `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` | | `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` | ### Other Renames | v1 | v2 | |---|---| | `tea.Sequentially(...)` | `tea.Sequence(...)` | | `tea.WindowSize()` | `tea.RequestWindowSize` (now returns `Msg`, not `Cmd`) | ### New Program Options | Option | Description | |---|---| | `tea.WithColorProfile(p)` | Force a specific color profile | | `tea.WithWindowSize(w, h)` | Set initial window size (great for testing) | ## Feedback Have thoughts on the v2 upgrade? We'd _love_ to hear about it. Let us know on… - [Discord](https://charm.land/chat) - [Matrix](https://charm.land/matrix) - [Email](mailto:vt100@charm.land) --- Part of [Charm](https://charm.land). The Charm logo Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة ================================================ FILE: clipboard.go ================================================ package tea // ClipboardMsg is a clipboard read message event. This message is emitted when // a terminal receives an OSC52 clipboard read message event. type ClipboardMsg struct { Content string Selection byte } // Clipboard returns the clipboard selection type. This will be one of the // following values: // // - c: System clipboard. // - p: Primary clipboard (X11/Wayland only). func (e ClipboardMsg) Clipboard() byte { return e.Selection } // String returns the string representation of the clipboard message. func (e ClipboardMsg) String() string { return e.Content } // setClipboardMsg is an internal message used to set the system clipboard // using OSC52. type setClipboardMsg string // SetClipboard produces a command that sets the system clipboard using OSC52. // Note that OSC52 is not supported in all terminals. func SetClipboard(s string) Cmd { return func() Msg { return setClipboardMsg(s) } } // readClipboardMsg is an internal message used to read the system clipboard // using OSC52. type readClipboardMsg struct{} // ReadClipboard produces a command that reads the system clipboard using OSC52. // Note that OSC52 is not supported in all terminals. func ReadClipboard() Msg { return readClipboardMsg{} } // setPrimaryClipboardMsg is an internal message used to set the primary // clipboard using OSC52. type setPrimaryClipboardMsg string // SetPrimaryClipboard produces a command that sets the primary clipboard using // OSC52. Primary clipboard selection is a feature present in X11 and Wayland // only. // Note that OSC52 is not supported in all terminals. func SetPrimaryClipboard(s string) Cmd { return func() Msg { return setPrimaryClipboardMsg(s) } } // readPrimaryClipboardMsg is an internal message used to read the primary // clipboard using OSC52. type readPrimaryClipboardMsg struct{} // ReadPrimaryClipboard produces a command that reads the primary clipboard // using OSC52. Primary clipboard selection is a feature present in X11 and // Wayland only. // Note that OSC52 is not supported in all terminals. func ReadPrimaryClipboard() Msg { return readPrimaryClipboardMsg{} } ================================================ FILE: color.go ================================================ package tea import ( "image/color" uv "github.com/charmbracelet/ultraviolet" ) // backgroundColorMsg is a message that requests the terminal background color. type backgroundColorMsg struct{} // RequestBackgroundColor is a command that requests the terminal background color. func RequestBackgroundColor() Msg { return backgroundColorMsg{} } // foregroundColorMsg is a message that requests the terminal foreground color. type foregroundColorMsg struct{} // RequestForegroundColor is a command that requests the terminal foreground color. func RequestForegroundColor() Msg { return foregroundColorMsg{} } // cursorColorMsg is a message that requests the terminal cursor color. type cursorColorMsg struct{} // RequestCursorColor is a command that requests the terminal cursor color. func RequestCursorColor() Msg { return cursorColorMsg{} } // ForegroundColorMsg represents a foreground color message. This message is // emitted when the program requests the terminal foreground color with the // [RequestForegroundColor] Cmd. type ForegroundColorMsg struct{ color.Color } // String returns the hex representation of the color. func (e ForegroundColorMsg) String() string { return uv.ForegroundColorEvent(e).String() } // IsDark returns whether the color is dark. func (e ForegroundColorMsg) IsDark() bool { return uv.ForegroundColorEvent(e).IsDark() } // BackgroundColorMsg represents a background color message. This message is // emitted when the program requests the terminal background color with the // [RequestBackgroundColor] Cmd. // // This is commonly used in [Update.Init] to get the terminal background color // for style definitions. For that you'll want to call // [BackgroundColorMsg.IsDark] to determine if the color is dark or light. For // example: // // func (m Model) Init() (Model, Cmd) { // return m, RequestBackgroundColor() // } // // func (m Model) Update(msg Msg) (Model, Cmd) { // switch msg := msg.(type) { // case BackgroundColorMsg: // m.styles = newStyles(msg.IsDark()) // } // } type BackgroundColorMsg struct{ color.Color } // String returns the hex representation of the color. func (e BackgroundColorMsg) String() string { return uv.BackgroundColorEvent(e).String() } // IsDark returns whether the color is dark. func (e BackgroundColorMsg) IsDark() bool { return uv.BackgroundColorEvent(e).IsDark() } // CursorColorMsg represents a cursor color change message. This message is // emitted when the program requests the terminal cursor color. type CursorColorMsg struct{ color.Color } // String returns the hex representation of the color. func (e CursorColorMsg) String() string { return uv.CursorColorEvent(e).String() } // IsDark returns whether the color is dark. func (e CursorColorMsg) IsDark() bool { return uv.CursorColorEvent(e).IsDark() } ================================================ FILE: commands.go ================================================ package tea import ( "time" ) // Batch performs a bunch of commands concurrently with no ordering guarantees // about the results. Use a Batch to return several commands. // // Example: // // func (m model) Init() (Model, Cmd) { // return m, tea.Batch(someCommand, someOtherCommand) // } func Batch(cmds ...Cmd) Cmd { return compactCmds[BatchMsg](cmds) } // BatchMsg is a message used to perform a bunch of commands concurrently with // no ordering guarantees. You can send a BatchMsg with Batch. type BatchMsg []Cmd // Sequence runs the given commands one at a time, in order. Contrast this with // Batch, which runs commands concurrently. func Sequence(cmds ...Cmd) Cmd { return compactCmds[sequenceMsg](cmds) } // sequenceMsg is used internally to run the given commands in order. type sequenceMsg []Cmd // compactCmds ignores any nil commands in cmds, and returns the most direct // command possible. That is, considering the non-nil commands, if there are // none it returns nil, if there is exactly one it returns that command // directly, else it returns the non-nil commands as type T. func compactCmds[T ~[]Cmd](cmds []Cmd) Cmd { var validCmds []Cmd for _, c := range cmds { if c == nil { continue } validCmds = append(validCmds, c) } switch len(validCmds) { case 0: return nil case 1: return validCmds[0] default: return func() Msg { return T(validCmds) } } } // Every is a command that ticks in sync with the system clock. So, if you // wanted to tick with the system clock every second, minute or hour you // could use this. It's also handy for having different things tick in sync. // // Because we're ticking with the system clock the tick will likely not run for // the entire specified duration. For example, if we're ticking for one minute // and the clock is at 12:34:20 then the next tick will happen at 12:35:00, 40 // seconds later. // // To produce the command, pass a duration and a function which returns // a message containing the time at which the tick occurred. // // type TickMsg time.Time // // cmd := Every(time.Second, func(t time.Time) Msg { // return TickMsg(t) // }) // // Beginners' note: Every sends a single message and won't automatically // dispatch messages at an interval. To do that, you'll want to return another // Every command after receiving your tick message. For example: // // type TickMsg time.Time // // // Send a message every second. // func tickEvery() Cmd { // return Every(time.Second, func(t time.Time) Msg { // return TickMsg(t) // }) // } // // func (m model) Init() (Model, Cmd) { // // Start ticking. // return m, tickEvery() // } // // func (m model) Update(msg Msg) (Model, Cmd) { // switch msg.(type) { // case TickMsg: // // Return your Every command again to loop. // return m, tickEvery() // } // return m, nil // } // // Every is analogous to Tick in the Elm Architecture. func Every(duration time.Duration, fn func(time.Time) Msg) Cmd { n := time.Now() d := n.Truncate(duration).Add(duration).Sub(n) t := time.NewTimer(d) return func() Msg { ts := <-t.C t.Stop() for len(t.C) > 0 { <-t.C } return fn(ts) } } // Tick produces a command at an interval independent of the system clock at // the given duration. That is, the timer begins precisely when invoked, // and runs for its entire duration. // // To produce the command, pass a duration and a function which returns // a message containing the time at which the tick occurred. // // type TickMsg time.Time // // cmd := Tick(time.Second, func(t time.Time) Msg { // return TickMsg(t) // }) // // Beginners' note: Tick sends a single message and won't automatically // dispatch messages at an interval. To do that, you'll want to return another // Tick command after receiving your tick message. For example: // // type TickMsg time.Time // // func doTick() Cmd { // return Tick(time.Second, func(t time.Time) Msg { // return TickMsg(t) // }) // } // // func (m model) Init() (Model, Cmd) { // // Start ticking. // return m, doTick() // } // // func (m model) Update(msg Msg) (Model, Cmd) { // switch msg.(type) { // case TickMsg: // // Return your Tick command again to loop. // return m, doTick() // } // return m, nil // } func Tick(d time.Duration, fn func(time.Time) Msg) Cmd { t := time.NewTimer(d) return func() Msg { ts := <-t.C t.Stop() for len(t.C) > 0 { <-t.C } return fn(ts) } } type windowSizeMsg struct{} // RequestWindowSize is a command that queries the terminal for its current // size. It delivers the results to Update via a [WindowSizeMsg]. Keep in mind // that WindowSizeMsgs will automatically be delivered to Update when the // [Program] starts and when the window dimensions change so in many cases you // will not need to explicitly invoke this command. func RequestWindowSize() Msg { return windowSizeMsg{} } ================================================ FILE: commands_test.go ================================================ package tea import ( "testing" "time" ) func TestEvery(t *testing.T) { expected := "every ms" msg := Every(time.Millisecond, func(t time.Time) Msg { return expected })() if expected != msg { t.Fatalf("expected a msg %v but got %v", expected, msg) } } func TestTick(t *testing.T) { expected := "tick" msg := Tick(time.Millisecond, func(t time.Time) Msg { return expected })() if expected != msg { t.Fatalf("expected a msg %v but got %v", expected, msg) } } func TestBatch(t *testing.T) { testMultipleCommands[BatchMsg](t, Batch) } func TestSequence(t *testing.T) { testMultipleCommands[sequenceMsg](t, Sequence) } func testMultipleCommands[T ~[]Cmd](t *testing.T, createFn func(cmd ...Cmd) Cmd) { t.Run("nil cmd", func(t *testing.T) { if b := createFn(nil); b != nil { t.Fatalf("expected nil, got %+v", b) } }) t.Run("empty cmd", func(t *testing.T) { if b := createFn(); b != nil { t.Fatalf("expected nil, got %+v", b) } }) t.Run("single cmd", func(t *testing.T) { b := createFn(Quit)() if _, ok := b.(QuitMsg); !ok { t.Fatalf("expected a QuitMsg, got %T", b) } }) t.Run("mixed nil cmds", func(t *testing.T) { b := createFn(nil, Quit, nil, Quit, nil, nil)() if l := len(b.(T)); l != 2 { t.Fatalf("expected a []Cmd with len 2, got %d", l) } }) } ================================================ FILE: cursed_renderer.go ================================================ package tea import ( "bytes" "fmt" "image/color" "io" "runtime" "strings" "sync" "github.com/charmbracelet/colorprofile" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/lucasb-eyer/go-colorful" ) type cursedRenderer struct { w io.Writer buf bytes.Buffer // updates buffer to be flushed to [w] scr *uv.TerminalRenderer cellbuf uv.ScreenBuffer lastView *View env []string term string // the terminal type $TERM width, height int mu sync.Mutex profile colorprofile.Profile logger uv.Logger view View hardTabs bool // whether to use hard tabs to optimize cursor movements backspace bool // whether to use backspace to optimize cursor movements mapnl bool syncdUpdates bool // whether to use synchronized output mode for updates starting bool // indicates whether the renderer is starting after being stopped } var _ renderer = &cursedRenderer{} func newCursedRenderer(w io.Writer, env []string, width, height int) (s *cursedRenderer) { s = new(cursedRenderer) s.w = w s.env = env s.term = uv.Environ(env).Getenv("TERM") s.width, s.height = width, height // This needs to happen before [cursedRenderer.reset]. s.cellbuf = uv.NewScreenBuffer(s.width, s.height) reset(s) return } // setLogger sets the logger for the renderer. func (s *cursedRenderer) setLogger(logger uv.Logger) { s.mu.Lock() s.logger = logger s.mu.Unlock() } // setOptimizations sets the cursor movement optimizations. func (s *cursedRenderer) setOptimizations(hardTabs, backspace, mapnl bool) { s.mu.Lock() s.hardTabs = hardTabs s.backspace = backspace s.mapnl = mapnl s.scr.SetTabStops(s.width) s.scr.SetBackspace(s.backspace) s.scr.SetMapNewline(s.mapnl) s.mu.Unlock() } // start implements renderer. func (s *cursedRenderer) start() { s.mu.Lock() defer s.mu.Unlock() // Mark that we're starting. This is used to restore some state when // starting the renderer again after it was stopped. s.starting = true if s.lastView == nil { return } if s.lastView.AltScreen { enableAltScreen(s, true, true) } enableTextCursor(s, s.lastView.Cursor != nil) if s.lastView.Cursor != nil { if s.lastView.Cursor.Color != nil { col, ok := colorful.MakeColor(s.lastView.Cursor.Color) if ok { _, _ = s.scr.WriteString(ansi.SetCursorColor(col.Hex())) } } curStyle := encodeCursorStyle(s.lastView.Cursor.Shape, s.lastView.Cursor.Blink) if curStyle != 0 && curStyle != 1 { _, _ = s.scr.WriteString(ansi.SetCursorStyle(curStyle)) } } if s.lastView.ForegroundColor != nil { col, ok := colorful.MakeColor(s.lastView.ForegroundColor) if ok { _, _ = s.scr.WriteString(ansi.SetForegroundColor(col.Hex())) } } if s.lastView.BackgroundColor != nil { col, ok := colorful.MakeColor(s.lastView.BackgroundColor) if ok { _, _ = s.scr.WriteString(ansi.SetBackgroundColor(col.Hex())) } } if !s.lastView.DisableBracketedPasteMode { _, _ = s.scr.WriteString(ansi.SetModeBracketedPaste) } if s.lastView.ReportFocus { _, _ = s.scr.WriteString(ansi.SetModeFocusEvent) } switch s.lastView.MouseMode { case MouseModeNone: case MouseModeCellMotion: _, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr) case MouseModeAllMotion: _, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr) } if s.lastView.WindowTitle != "" { _, _ = s.scr.WriteString(ansi.SetWindowTitle(s.lastView.WindowTitle)) } if s.lastView.ProgressBar != nil { setProgressBar(s, s.lastView.ProgressBar) } // Enable modifyOtherKeys and Kitty keyboard protocol. // Both can coexist; terminals ignore what they don't support. _, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2) kittyFlags := ansi.KittyDisambiguateEscapeCodes if s.lastView.KeyboardEnhancements.ReportEventTypes { kittyFlags |= ansi.KittyReportEventTypes } _, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1)) } // close implements renderer. func (s *cursedRenderer) close() (err error) { s.mu.Lock() defer s.mu.Unlock() // Exit the altScreen and show cursor before closing. It's important that // we don't change the [cursedRenderer] altScreen and cursorHidden states // so that we can restore them when we start the renderer again. This is // used when the user suspends the program and then resumes it. if lv := s.lastView; lv != nil { //nolint:nestif // NOTE: The Kitty keyboard specs specify that the terminal should have // two registries for the main and alt screens. We disable keyboard // enhancements whenever we enter/exit alt screen mode in // [cursedRenderer.flush]. // Here, we reset the keyboard protocol of the last screen used // assuming the other screen is already reset when we switched screens. _, _ = s.buf.WriteString(ansi.ResetModifyOtherKeys) _, _ = s.buf.WriteString(ansi.KittyKeyboard(0, 1)) // Go to the bottom of the screen. // We need to go to the bottom of the screen regardless of whether // we're in alt screen mode or not to avoid leaving the cursor in the // middle in terminals that don't support alt screen mode. s.scr.MoveTo(0, s.cellbuf.Height()-1) _ = s.scr.Flush() // we need to flush to write the cursor movement if lv.AltScreen { enableAltScreen(s, false, true) } else { _, _ = s.scr.WriteString(ansi.EraseScreenBelow) } if lv.Cursor == nil { enableTextCursor(s, true) } if !lv.DisableBracketedPasteMode { _, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste) } if lv.ReportFocus { _, _ = s.scr.WriteString(ansi.ResetModeFocusEvent) } switch lv.MouseMode { case MouseModeNone: case MouseModeCellMotion, MouseModeAllMotion: _, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent + ansi.ResetModeMouseAnyEvent + ansi.ResetModeMouseExtSgr) } if lv.WindowTitle != "" { // Clear the window title if it was set. _, _ = s.scr.WriteString(ansi.SetWindowTitle("")) } if lc := lv.Cursor; lc != nil { curShape := encodeCursorStyle(lc.Shape, lc.Blink) if curShape != 0 && curShape != 1 { // Reset the cursor style to default if it was set to something other // blinking block. _, _ = s.scr.WriteString(ansi.SetCursorStyle(0)) } if lc.Color != nil { _, _ = s.scr.WriteString(ansi.ResetCursorColor) } } if lv.BackgroundColor != nil { _, _ = s.scr.WriteString(ansi.ResetBackgroundColor) } if lv.ForegroundColor != nil { _, _ = s.scr.WriteString(ansi.ResetForegroundColor) } if lv.ProgressBar != nil && lv.ProgressBar.State != ProgressBarNone { _, _ = s.scr.WriteString(ansi.ResetProgressBar) } } if s.cellbuf.Method == ansi.GraphemeWidth { // Make sure to turn off Unicode mode (2027) _, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore) } if err := s.scr.Flush(); err != nil { return fmt.Errorf("bubbletea: error closing screen writer: %w", err) } if s.buf.Len() > 0 { if s.logger != nil { s.logger.Printf("output: %q", s.buf.String()) } if _, err := io.Copy(s.w, &s.buf); err != nil { return fmt.Errorf("bubbletea: error writing to screen: %w", err) } s.buf.Reset() } x, y := s.scr.Position() // We want to clear the renderer state but not the cursor position. This is // because we might be putting the tea process in the background, run some // other process, and then return to the tea process. We want to keep the // cursor position so that we can continue where we left off. reset(s) s.scr.SetPosition(x, y) return nil } // writeString implements renderer. func (s *cursedRenderer) writeString(str string) (int, error) { s.mu.Lock() defer s.mu.Unlock() return s.scr.WriteString(str) //nolint:wrapcheck } // flush implements renderer. func (s *cursedRenderer) flush(closing bool) error { s.mu.Lock() defer s.mu.Unlock() view := s.view frameArea := uv.Rect(0, 0, s.width, s.height) if len(view.Content) == 0 { // If the component is nil, we should clear the screen buffer. frameArea.Max.Y = 0 } content := uv.NewStyledString(view.Content) if !view.AltScreen { // We need to resizes the screen based on the frame height and // terminal width. This is because the frame height can change based on // the content of the frame. For example, if the frame contains a list // of items, the height of the frame will be the number of items in the // list. This is different from the alt screen buffer, which has a // fixed height and width. frameHeight := content.Height() if frameHeight != frameArea.Dy() { frameArea.Max.Y = frameHeight } } if !s.starting && !closing && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() { // No changes, nothing to do. return nil } // We're no longer starting. s.starting = false if frameArea != s.cellbuf.Bounds() { s.scr.Erase() // Force a full redraw to avoid artifacts. // We need to reset the touched lines buffer to match the new height. s.cellbuf.Touched = nil // Resize the screen buffer to match the frame area. This is necessary // to ensure that the screen buffer is the same size as the frame area // and to avoid rendering issues when the frame area is smaller than // the screen buffer. s.cellbuf.Resize(frameArea.Dx(), frameArea.Dy()) } // Clear our screen buffer before copying the new frame into it to ensure // we erase any old content. s.cellbuf.Clear() content.Draw(s.cellbuf, s.cellbuf.Bounds()) // If the frame height is greater than the screen height, we drop the // lines from the top of the buffer. if frameHeight := frameArea.Dy(); frameHeight > s.height { s.cellbuf.Lines = s.cellbuf.Lines[frameHeight-s.height:] } // Alt screen mode. shouldUpdateAltScreen := (s.lastView == nil && view.AltScreen) || (s.lastView != nil && s.lastView.AltScreen != view.AltScreen) if shouldUpdateAltScreen { // We want to enter/exit altscreen mode but defer writing the actual // sequences until we flush the rest of the updates. This is because we // control the cursor visibility and we need to ensure that happens // after entering/exiting alt screen mode. Some terminals have // different cursor visibility states for main and alt screen modes and // this ensures we handle that correctly. enableAltScreen(s, view.AltScreen, false) } // bracketed paste mode. if s.lastView == nil || view.DisableBracketedPasteMode != s.lastView.DisableBracketedPasteMode { if !view.DisableBracketedPasteMode { _, _ = s.scr.WriteString(ansi.SetModeBracketedPaste) } else if s.lastView != nil { _, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste) } } // report focus events mode. if s.lastView == nil || s.lastView.ReportFocus != view.ReportFocus { if view.ReportFocus { _, _ = s.scr.WriteString(ansi.SetModeFocusEvent) } else if s.lastView != nil { _, _ = s.scr.WriteString(ansi.ResetModeFocusEvent) } } // mouse events mode. if s.lastView == nil || view.MouseMode != s.lastView.MouseMode { switch view.MouseMode { case MouseModeNone: if s.lastView != nil && s.lastView.MouseMode != MouseModeNone { _, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent + ansi.ResetModeMouseAnyEvent + ansi.ResetModeMouseExtSgr) } case MouseModeCellMotion: if s.lastView != nil && s.lastView.MouseMode == MouseModeAllMotion { _, _ = s.scr.WriteString(ansi.ResetModeMouseAnyEvent) } _, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr) case MouseModeAllMotion: if s.lastView != nil && s.lastView.MouseMode == MouseModeCellMotion { _, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent) } _, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr) } } // Set window title. if s.lastView == nil || view.WindowTitle != s.lastView.WindowTitle { if s.lastView != nil || view.WindowTitle != "" { _, _ = s.scr.WriteString(ansi.SetWindowTitle(view.WindowTitle)) } } // kitty keyboard protocol if s.lastView == nil || view.KeyboardEnhancements != s.lastView.KeyboardEnhancements || view.AltScreen != s.lastView.AltScreen { // NOTE: We need to reset the keyboard protocol when switching // between main and alt screen. This is because the specs specify // two different states for the main and alt screen. // Enable modifyOtherKeys and Kitty keyboard protocol. _, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2) kittyFlags := ansi.KittyDisambiguateEscapeCodes // always enable basic key disambiguation if view.KeyboardEnhancements.ReportEventTypes { kittyFlags |= ansi.KittyReportEventTypes } _, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1)) if !closing { // Request keyboard enhancements when they change _, _ = s.scr.WriteString(ansi.RequestKittyKeyboard) } } // Set terminal colors. var ( cc, lcc color.Color lfg, lbg color.Color ) if view.Cursor != nil { cc = view.Cursor.Color } if s.lastView != nil { if s.lastView.Cursor != nil { lcc = s.lastView.Cursor.Color } lfg = s.lastView.ForegroundColor lbg = s.lastView.BackgroundColor } for _, c := range []struct { newColor color.Color oldColor color.Color reset string setter func(string) string }{ {newColor: cc, oldColor: lcc, reset: ansi.ResetCursorColor, setter: ansi.SetCursorColor}, {newColor: view.ForegroundColor, oldColor: lfg, reset: ansi.ResetForegroundColor, setter: ansi.SetForegroundColor}, {newColor: view.BackgroundColor, oldColor: lbg, reset: ansi.ResetBackgroundColor, setter: ansi.SetBackgroundColor}, } { if c.newColor != c.oldColor { if c.newColor == nil { // Reset the color if it was set to nil. _, _ = s.scr.WriteString(c.reset) } else { // Set the color. col, ok := colorful.MakeColor(c.newColor) if ok { _, _ = s.scr.WriteString(c.setter(col.Hex())) } } } } // Set cursor shape and blink if set. var ccStyle, lcStyle int var lcur *Cursor ccur := view.Cursor if lv := s.lastView; lv != nil { lcur = lv.Cursor } if ccur != nil { ccStyle = encodeCursorStyle(ccur.Shape, ccur.Blink) } if lcur != nil { lcStyle = encodeCursorStyle(lcur.Shape, lcur.Blink) } if ccStyle != lcStyle { _, _ = s.scr.WriteString(ansi.SetCursorStyle(ccStyle)) } // Render progress bar if it's changed. if (s.lastView == nil && view.ProgressBar != nil && view.ProgressBar.State != ProgressBarNone) || (s.lastView != nil && (s.lastView.ProgressBar == nil) != (view.ProgressBar == nil)) || (s.lastView != nil && s.lastView.ProgressBar != nil && view.ProgressBar != nil && *s.lastView.ProgressBar != *view.ProgressBar) { // Render or clear the progress bar if it was added or removed. setProgressBar(s, view.ProgressBar) } // Render and queue changes to the screen buffer. s.scr.Render(s.cellbuf.RenderBuffer) if cur := view.Cursor; cur != nil { // MoveTo must come after [uv.TerminalRenderer.Render] because the // cursor position might get updated during rendering. s.scr.MoveTo(view.Cursor.X, view.Cursor.Y) } else if !view.AltScreen { // We don't want the cursor to be dangling at the end of the line in // inline mode because it can cause unwanted line wraps in some // terminals. So we move it to the beginning of the next line if // necessary. // This is only needed when the cursor is hidden because when it's // visible, we already set its position above. x, y := s.scr.Position() if x >= s.width-1 { s.scr.MoveTo(0, y) } } if err := s.scr.Flush(); err != nil { return fmt.Errorf("bubbletea: error flushing screen writer: %w", err) } // Check if we have any render updates to flush. hasUpdates := s.buf.Len() > 0 // Cursor visibility. didShowCursor := s.lastView != nil && s.lastView.Cursor != nil showCursor := view.Cursor != nil hideCursor := !showCursor shouldUpdateCursorVis := (s.lastView == nil || didShowCursor != showCursor) || shouldUpdateAltScreen // Build final output buffer with synchronized output or hide/show cursor // updates. But first, enter/exit alt screen mode if needed. // // Here, we have two scenarios: // 1. Synchronized output updates are supported. In this case, we want to // wrap all updates, unless it's just a cursor visibility change, in // synchronized output mode. This is because synchronized output mode // takes care of rendering the updates atomically. In the case of // just a cursor visibility change, we don't need to enter // synchronized output mode because it's just a single sequence to // flush out to the terminal. // // 2. We don't have synchronized output updates support. In this case, and // if the cursor is visible or should be visible, we wrap the updates // with hide/show cursor sequences to try and mitigate cursor // flickering. This is terminal dependent and may still result in // flickering in some terminals. It's the best effort we can do instead // of showing the cursor flying around the screen during updates. var buf bytes.Buffer if shouldUpdateAltScreen { // We always disable keyboard enhancements when switching screens // because the terminal is expected to have two different keyboard // registries for main and alt screens. _, _ = buf.WriteString(ansi.ResetModifyOtherKeys) _, _ = buf.WriteString(ansi.KittyKeyboard(0, 1)) if view.AltScreen { // Entering alt screen mode. buf.WriteString(ansi.SetModeAltScreenSaveCursor) } else { // Exiting alt screen mode. buf.WriteString(ansi.ResetModeAltScreenSaveCursor) } } if s.syncdUpdates { if hasUpdates { // We have synchronized output updates enabled. buf.WriteString(ansi.SetModeSynchronizedOutput) } if shouldUpdateCursorVis && hideCursor { // Do we need to update the cursor visibility to hidden? If so, do // it here before writing any updates to the buffer. _, _ = buf.WriteString(ansi.ResetModeTextCursorEnable) } } else if (shouldUpdateCursorVis && hideCursor) || (hasUpdates && showCursor && didShowCursor) { _, _ = buf.WriteString(ansi.ResetModeTextCursorEnable) } if hasUpdates { buf.Write(s.buf.Bytes()) } if s.syncdUpdates { if shouldUpdateCursorVis && showCursor { // Do we need to update the cursor visibility to visible? If so, do // it here after writing any updates to the buffer. _, _ = buf.WriteString(ansi.SetModeTextCursorEnable) } if hasUpdates { // Close synchronized output mode. buf.WriteString(ansi.ResetModeSynchronizedOutput) } } else if (shouldUpdateCursorVis && showCursor) || (hasUpdates && showCursor && didShowCursor) { _, _ = buf.WriteString(ansi.SetModeTextCursorEnable) } // Reset internal screen renderer buffer. s.buf.Reset() // If our updates flush buffer has content, write it to the output writer. if buf.Len() > 0 { if s.logger != nil { s.logger.Printf("output: %q", buf.String()) } if _, err := io.Copy(s.w, &buf); err != nil { return fmt.Errorf("bubbletea: error flushing update to the writer: %w", err) } } s.lastView = &view return nil } // render implements renderer. func (s *cursedRenderer) render(v View) { s.mu.Lock() defer s.mu.Unlock() s.view = v } // reset implements renderer. func (s *cursedRenderer) reset() { s.mu.Lock() reset(s) s.mu.Unlock() } func reset(s *cursedRenderer) { s.buf.Reset() scr := uv.NewTerminalRenderer(&s.buf, s.env) scr.SetColorProfile(s.profile) scr.SetRelativeCursor(true) // Always start in inline mode scr.SetFullscreen(false) // Always start in inline mode scr.SetTabStops(s.width) scr.SetBackspace(s.backspace) scr.SetMapNewline(s.mapnl) scr.SetScrollOptim(runtime.GOOS != "windows") // disable scroll optimization on Windows due to bugs in some terminals s.scr = scr } // setColorProfile implements renderer. func (s *cursedRenderer) setColorProfile(p colorprofile.Profile) { s.mu.Lock() s.profile = p s.scr.SetColorProfile(p) s.mu.Unlock() } // resize implements renderer. func (s *cursedRenderer) resize(w, h int) { s.mu.Lock() // We need to mark the screen for clear to force a redraw. However, we // only do so if we're using alt screen or the width has changed. // That's because redrawing is expensive and we can avoid it if the // width hasn't changed in inline mode. On the other hand, when using // alt screen mode, we always want to redraw because some terminals // would scroll the screen and our content would be lost. s.scr.Erase() s.width, s.height = w, h s.scr.Resize(s.width, s.height) s.mu.Unlock() } // clearScreen implements renderer. func (s *cursedRenderer) clearScreen() { s.mu.Lock() // Move the cursor to the top left corner of the screen and trigger a full // screen redraw. s.scr.MoveTo(0, 0) s.scr.Erase() s.mu.Unlock() } // enableAltScreen sets the alt screen mode. // Note that this writes to the buffer directly if write is true. func enableAltScreen(s *cursedRenderer, enable bool, write bool) { if enable { enterAltScreen(s, write) } else { exitAltScreen(s, write) } } func enterAltScreen(s *cursedRenderer, write bool) { s.scr.SaveCursor() if write { s.buf.WriteString(ansi.SetModeAltScreenSaveCursor) } s.scr.SetFullscreen(true) s.scr.SetRelativeCursor(false) s.scr.Erase() } func exitAltScreen(s *cursedRenderer, write bool) { s.scr.Erase() s.scr.SetRelativeCursor(true) s.scr.SetFullscreen(false) if write { s.buf.WriteString(ansi.ResetModeAltScreenSaveCursor) } s.scr.RestoreCursor() } // enableTextCursor sets the text cursor mode. func enableTextCursor(s *cursedRenderer, enable bool) { if enable { _, _ = s.scr.WriteString(ansi.SetModeTextCursorEnable) } else { _, _ = s.scr.WriteString(ansi.ResetModeTextCursorEnable) } } // setSyncdUpdates implements renderer. func (s *cursedRenderer) setSyncdUpdates(syncd bool) { s.mu.Lock() s.syncdUpdates = syncd s.mu.Unlock() } // setWidthMethod implements renderer. func (s *cursedRenderer) setWidthMethod(method ansi.Method) { s.mu.Lock() if method == ansi.GraphemeWidth { // Turn on Unicode mode (2027) for accurate grapheme width calculation. // This is needed for proper rendering of wide characters and emojis. _, _ = s.scr.WriteString(ansi.SetModeUnicodeCore) } else if s.cellbuf.Method == ansi.GraphemeWidth { // Turn off Unicode mode if we're switching away from grapheme width // calculation to avoid issues with some terminals that might still be // in Unicode mode and render characters incorrectly. _, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore) } s.cellbuf.Method = method s.mu.Unlock() } // insertAbove implements renderer. func (s *cursedRenderer) insertAbove(str string) error { s.mu.Lock() defer s.mu.Unlock() if len(str) == 0 { return nil } var sb strings.Builder w, h := s.cellbuf.Width(), s.cellbuf.Height() _, y := s.scr.Position() // We need to scroll the screen up by the number of lines in the queue. sb.WriteByte('\r') down := h - y - 1 if down > 0 { sb.WriteString(ansi.CursorDown(down)) } lines := strings.Split(str, "\n") offset := len(lines) for _, line := range lines { lineWidth := ansi.StringWidth(line) if w > 0 && lineWidth > w { offset += (lineWidth / w) } } // Scroll the screen up by the offset to make room for the new lines. sb.WriteString(strings.Repeat("\n", offset)) // XXX: Now go to the top of the screen, insert new lines, and write // the queued strings. It is important to use [Screen.moveCursor] // instead of [Screen.move] because we don't want to perform any checks // on the cursor position. up := offset + h - 1 sb.WriteString(ansi.CursorUp(up)) sb.WriteString(ansi.InsertLine(offset)) for _, line := range lines { sb.WriteString(line) sb.WriteString(ansi.EraseLineRight) sb.WriteString("\r\n") } s.scr.SetPosition(0, 0) if s.logger != nil { s.logger.Printf("insert above: %q", sb.String()) } _, err := io.WriteString(s.w, sb.String()) if err != nil { return fmt.Errorf("bubbletea: error writing insert above to the writer: %w", err) } return nil } // onMouse implements renderer. func (s *cursedRenderer) onMouse(m MouseMsg) Cmd { if s.lastView != nil && s.lastView.OnMouse != nil { return s.lastView.OnMouse(m) } return nil } func setProgressBar(s *cursedRenderer, pb *ProgressBar) { if pb == nil { _, _ = s.scr.WriteString(ansi.ResetProgressBar) return } var seq string switch pb.State { case ProgressBarNone: seq = ansi.ResetProgressBar case ProgressBarDefault: seq = ansi.SetProgressBar(pb.Value) case ProgressBarError: seq = ansi.SetErrorProgressBar(pb.Value) case ProgressBarIndeterminate: seq = ansi.SetIndeterminateProgressBar case ProgressBarWarning: seq = ansi.SetWarningProgressBar(pb.Value) } if seq != "" { _, _ = s.scr.WriteString(seq) } } func viewEquals(a, b *View) bool { if a == nil || b == nil { return false } if a.Content != b.Content || a.AltScreen != b.AltScreen || a.DisableBracketedPasteMode != b.DisableBracketedPasteMode || a.ReportFocus != b.ReportFocus || a.MouseMode != b.MouseMode || a.WindowTitle != b.WindowTitle || a.ForegroundColor != b.ForegroundColor || a.BackgroundColor != b.BackgroundColor || a.KeyboardEnhancements != b.KeyboardEnhancements { return false } if (a.Cursor == nil) != (b.Cursor == nil) { return false } if a.Cursor != nil && b.Cursor != nil { if a.Cursor.X != b.Cursor.X || a.Cursor.Y != b.Cursor.Y || a.Cursor.Shape != b.Cursor.Shape || a.Cursor.Blink != b.Cursor.Blink || a.Cursor.Color != b.Cursor.Color { return false } } if (a.ProgressBar == nil) != (b.ProgressBar == nil) { return false } if a.ProgressBar != nil && b.ProgressBar != nil { if *a.ProgressBar != *b.ProgressBar { return false } } return true } ================================================ FILE: cursor.go ================================================ package tea // Position represents a position in the terminal. type Position struct{ X, Y int } // CursorPositionMsg is a message that represents the terminal cursor position. type CursorPositionMsg struct { X, Y int } // CursorShape represents a terminal cursor shape. type CursorShape int // Cursor shapes. const ( CursorBlock CursorShape = iota CursorUnderline CursorBar ) // requestCursorPosMsg is a message that requests the cursor position. type requestCursorPosMsg struct{} // RequestCursorPosition is a command that requests the cursor position. // The cursor position will be sent as a [CursorPositionMsg] message. func RequestCursorPosition() Msg { return requestCursorPosMsg{} } ================================================ FILE: environ.go ================================================ package tea import uv "github.com/charmbracelet/ultraviolet" // EnvMsg is a message that represents the environment variables of the // program. This is useful for getting the environment variables of programs // running in a remote session like SSH. In that case, using [os.Getenv] would // return the server's environment variables, not the client's. // // This message is sent to the program when it starts. // // Example: // // switch msg := msg.(type) { // case EnvMsg: // // What terminal type is being used? // term := msg.Getenv("TERM") // } type EnvMsg uv.Environ // Getenv returns the value of the environment variable named by the key. If // the variable is not present in the environment, the value returned will be // the empty string. func (msg EnvMsg) Getenv(key string) (v string) { return uv.Environ(msg).Getenv(key) } // LookupEnv retrieves the value of the environment variable named by the key. // If the variable is present in the environment the value (which may be empty) // is returned and the boolean is true. Otherwise the returned value will be // empty and the boolean will be false. func (msg EnvMsg) LookupEnv(key string) (s string, v bool) { return uv.Environ(msg).LookupEnv(key) } ================================================ FILE: examples/README.md ================================================ # Examples ### Alt Screen Toggle The `altscreen-toggle` example shows how to transition between the alternative screen buffer and the normal screen buffer using Bubble Tea. ### Chat The `chat` examples shows a basic chat application with a multi-line `textarea` input. ### Composable Views The `composable-views` example shows how to compose two bubble models (spinner and timer) together in a single application and switch between them. ### ISBN Book Form The `isbn-form` example demonstrates how to build a multi-step form with `textinput` bubbles and validation on the inputs. ### Debounce The `debounce` example shows how to throttle key presses to avoid overloading your Bubble Tea application. ### Exec The `exec` example shows how to execute a running command during the execution of a Bubble Tea application such as launching an `EDITOR`. ### Full Screen The `fullscreen` example shows how to make a Bubble Tea application fullscreen. ### Glamour The `glamour` example shows how to use [Glamour](https://github.com/charmbracelet/glamour) inside a viewport bubble. ### Help The `help` example shows how to use the `help` bubble to display help to the user of your application. ### Http The `http` example shows how to make an `http` call within your Bubble Tea application. ### Default List The `list-default` example shows how to use the list bubble. ### Fancy List The `list-fancy` example shows how to use the list bubble with extra customizations. ### Simple List The `list-simple` example shows how to use the list and customize it to have a simpler, more compact, appearance. ### Mouse The `mouse` example shows how to receive mouse events in a Bubble Tea application. Code ### Package Manager The `package-manager` example shows how to build an interface for a package manager using the `tea.Println` feature. ### Pager The `pager` example shows how to build a simple pager application similar to `less`. ### Paginator The `paginator` example shows how to build a simple paginated list. ### Pipe The `pipe` example demonstrates using shell pipes to communicate with Bubble Tea applications. ### Animated Progress The `progress-animated` example shows how to build a progress bar with an animated progression. ### Download Progress The `progress-download` example demonstrates how to download a file while indicating download progress through Bubble Tea. Code ### Static Progress The `progress-static` example shows a progress bar with static incrementation of progress. ### Real Time The `realtime` example demonstrates the use of go channels to perform realtime communication with a Bubble Tea application. ### Result The `result` example shows a choice menu with the ability to select an option. ### Send Msg The `send-msg` example demonstrates the usage of custom `tea.Msg`s. ### Sequence The `sequence` example demonstrates the `tea.Sequence` command. ### Simple The `simple` example shows a very simple Bubble Tea application. ### Spinner The `spinner` example demonstrates a spinner bubble being used to indicate loading. ### Spinners The `spinner` example shows various spinner types that are available. ### Split Editors The `split-editors` example shows multiple `textarea`s being used in a single application and being able to switch focus between them. ### Stop Watch The `stopwatch` example shows a sample stop watch built with Bubble Tea. ### Table The `table` example demonstrates the table bubble being used to display tabular data. ### Tabs The `tabs` example demonstrates tabbed navigation styled with [Lip Gloss](https://github.com/charmbracelet/lipgloss). ### Text Area The `textarea` example demonstrates a simple Bubble Tea application using a `textarea` bubble. ### Text Input The `textinput` example demonstrates a simple Bubble Tea application using a `textinput` bubble. ### Multiple Text Inputs The `textinputs` example shows multiple `textinputs` and being able to switch focus between them as well as changing the cursor mode. ### Timer The `timer` example shows a simple timer built with Bubble Tea. ### TUI Daemon The `tui-daemon-combo` demonstrates building a text-user interface along with a daemon mode using Bubble Tea. ### Views The `views` example demonstrates how to build a Bubble Tea application with multiple views and switch between them. ================================================ FILE: examples/altscreen-toggle/README.md ================================================ # Alt Screen Toggle ================================================ FILE: examples/altscreen-toggle/main.go ================================================ package main import ( "fmt" "os" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var ( keywordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("204")).Background(lipgloss.Color("235")) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) ) type model struct { altscreen bool quitting bool suspending bool } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.ResumeMsg: m.suspending = false return m, nil case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c", "esc": m.quitting = true return m, tea.Quit case "ctrl+z": m.suspending = true return m, tea.Suspend case "space": var cmd tea.Cmd m.altscreen = !m.altscreen return m, cmd } } return m, nil } func (m model) View() tea.View { if m.suspending { v := tea.NewView("") v.AltScreen = m.altscreen return v } if m.quitting { v := tea.NewView("Bye!\n") v.AltScreen = m.altscreen return v } const ( altscreenMode = " altscreen mode " inlineMode = " inline mode " ) var mode string if m.altscreen { mode = altscreenMode } else { mode = inlineMode } v := tea.NewView(fmt.Sprintf("\n\n You're in %s\n\n\n", keywordStyle.Render(mode)) + helpStyle.Render(" space: switch modes • ctrl-z: suspend • q: exit\n")) v.AltScreen = m.altscreen return v } func main() { if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } ================================================ FILE: examples/autocomplete/main.go ================================================ package main import ( "encoding/json" "io" "log" "net/http" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { log.Fatal(err) } } type ( gotReposSuccessMsg []repo gotReposErrMsg error ) type repo struct { Name string `json:"name"` } const reposURL = "https://api.github.com/orgs/charmbracelet/repos" func getRepos() tea.Msg { req, err := http.NewRequest(http.MethodGet, reposURL, nil) if err != nil { return gotReposErrMsg(err) } req.Header.Add("Accept", "application/vnd.github+json") req.Header.Add("X-GitHub-Api-Version", "2022-11-28") resp, err := http.DefaultClient.Do(req) if err != nil { return gotReposErrMsg(err) } defer resp.Body.Close() // nolint: errcheck data, err := io.ReadAll(resp.Body) if err != nil { return gotReposErrMsg(err) } var repos []repo err = json.Unmarshal(data, &repos) if err != nil { return gotReposErrMsg(err) } return gotReposSuccessMsg(repos) } type model struct { textInput textinput.Model help help.Model keymap keymap } type keymap struct { complete, next, prev, quit key.Binding } func (k keymap) ShortHelp() []key.Binding { return []key.Binding{ k.complete, k.next, k.prev, k.quit, } } func (k keymap) FullHelp() [][]key.Binding { return [][]key.Binding{k.ShortHelp()} } func initialModel() model { ti := textinput.New() ti.Prompt = "charmbracelet/" s := ti.Styles() s.Focused.Prompt = lipgloss.NewStyle().Foreground(lipgloss.Color("63")).MarginLeft(2) s.Cursor.Color = lipgloss.Color("63") ti.SetStyles(s) ti.SetVirtualCursor(false) ti.Focus() ti.CharLimit = 50 ti.SetWidth(20) ti.ShowSuggestions = true km := keymap{ // XXX: we should be using the keybindings on the textinput model. complete: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "complete"), key.WithDisabled()), next: key.NewBinding(key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "next"), key.WithDisabled()), prev: key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "prev"), key.WithDisabled()), quit: key.NewBinding(key.WithKeys("enter", "ctrl+c", "esc"), key.WithHelp("esc", "quit")), } return model{ textInput: ti, keymap: km, help: help.New(), } } func (m model) Init() tea.Cmd { return tea.Batch(getRepos, textinput.Blink) } func (m model) Cursor() *tea.Cursor { c := m.textInput.Cursor() if c != nil { c.Y += lipgloss.Height(m.headerView()) } return c } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case gotReposSuccessMsg: var suggestions []string for _, r := range msg { suggestions = append(suggestions, r.Name) } m.textInput.SetSuggestions(suggestions) case tea.KeyPressMsg: switch { case key.Matches(msg, m.keymap.quit): return m, tea.Quit } } var cmd tea.Cmd m.textInput, cmd = m.textInput.Update(msg) // Determine whether to show completion keybindings. // // XXX: we should be using the keybindings on the textinput model. hasChoices := len(m.textInput.MatchedSuggestions()) > 1 m.keymap.complete.SetEnabled(hasChoices) m.keymap.next.SetEnabled(hasChoices) m.keymap.prev.SetEnabled(hasChoices) return m, cmd } func (m model) View() tea.View { if len(m.textInput.AvailableSuggestions()) < 1 { return tea.NewView("One sec, we're fetching completions...") } v := tea.NewView(lipgloss.JoinVertical( lipgloss.Left, m.headerView(), m.textInput.View(), m.footerView(), )) c := m.textInput.Cursor() if c != nil { c.Y += lipgloss.Height(m.headerView()) } v.Cursor = c return v } func (m model) headerView() string { return "Enter a Charm™ repo:\n" } func (m model) footerView() string { return "\n" + m.help.View(m.keymap) } ================================================ FILE: examples/canvas/main.go ================================================ package main import ( "fmt" "os" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/exp/charmtone" ) type model struct { width int flip bool quitting bool } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width return m, nil case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c", "esc": m.quitting = true return m, tea.Quit default: m.flip = !m.flip } } return m, nil } func (m model) View() tea.View { var view tea.View if m.quitting { return view } z := []int{0, 1} if m.flip { z = reverse(z) } footer := lipgloss.NewStyle(). Height(13). Foreground(charmtone.Oyster). AlignVertical(lipgloss.Bottom). Render("Press any key to swap the cards, or q to quit.") cardA := newCard("Hello").Z(z[0]) cardB := newCard("Goodbye").Z(z[1]) comp := lipgloss.NewCompositor( lipgloss.NewLayer(footer), cardA, cardB.X(10).Y(2), ) view.SetContent(comp.Render()) return view } func newCard(str string) *lipgloss.Layer { return lipgloss.NewLayer( lipgloss.NewStyle(). Width(20). Height(10). Border(lipgloss.RoundedBorder()). BorderForeground(charmtone.Charple). Align(lipgloss.Center, lipgloss.Center). Render(str), ) } // Reverse a slice, returning a new slice. func reverse[T any](s []T) []T { n := len(s) r := make([]T, n) for i, v := range s { r[n-1-i] = v } return r } func main() { if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Fprintln(os.Stderr, "Urgh:", err) os.Exit(1) } } ================================================ FILE: examples/capability/main.go ================================================ package main import ( "fmt" "os" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) type model struct { input textinput.Model width int } var _ tea.Model = model{} // Init implements tea.Model. func (m model) Init() tea.Cmd { return m.input.Focus() } // Update implements tea.Model. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit case "enter": input := m.input.Value() m.input.Reset() return m, tea.RequestCapability(input) } case tea.CapabilityMsg: return m, tea.Printf("Got capability: %s", msg) } m.input, cmd = m.input.Update(msg) return m, cmd } // View implements tea.Model. func (m model) View() tea.View { w := min(m.width, 60) instructions := lipgloss.NewStyle(). Width(w). Render("Query for terminal capabilities. You can enter things like 'TN', 'RGB', 'cols', and so on. This will not work in all terminals and multiplexers.") return tea.NewView("\n" + instructions + "\n\n" + m.input.View() + "\n\nPress enter to request capability, or ctrl+c to quit.") } func main() { m := model{} m.input = textinput.New() m.input.Placeholder = "Enter capability name to request" m.input.Focus() if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Fprintln(os.Stderr, "Uh oh:", err) os.Exit(1) } } func min(a, b int) int { if a < b { return a } return b } ================================================ FILE: examples/cellbuffer/main.go ================================================ package main // A simple example demonstrating how to draw and animate on a cellular grid. // Note that the cellbuffer implementation in this example does not support // double-width runes. import ( "fmt" "os" "strings" "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/harmonica" ) const ( fps = 60 frequency = 7.5 damping = 0.15 asterisk = "*" ) func drawEllipse(cb *cellbuffer, xc, yc, rx, ry float64) { var ( dx, dy, d1, d2 float64 x float64 y = ry ) d1 = ry*ry - rx*rx*ry + 0.25*rx*rx dx = 2 * ry * ry * x dy = 2 * rx * rx * y for dx < dy { cb.set(int(x+xc), int(y+yc)) cb.set(int(-x+xc), int(y+yc)) cb.set(int(x+xc), int(-y+yc)) cb.set(int(-x+xc), int(-y+yc)) if d1 < 0 { x++ dx = dx + (2 * ry * ry) d1 = d1 + dx + (ry * ry) } else { x++ y-- dx = dx + (2 * ry * ry) dy = dy - (2 * rx * rx) d1 = d1 + dx - dy + (ry * ry) } } d2 = ((ry * ry) * ((x + 0.5) * (x + 0.5))) + ((rx * rx) * ((y - 1) * (y - 1))) - (rx * rx * ry * ry) for y >= 0 { cb.set(int(x+xc), int(y+yc)) cb.set(int(-x+xc), int(y+yc)) cb.set(int(x+xc), int(-y+yc)) cb.set(int(-x+xc), int(-y+yc)) if d2 > 0 { y-- dy = dy - (2 * rx * rx) d2 = d2 + (rx * rx) - dy } else { y-- x++ dx = dx + (2 * ry * ry) dy = dy - (2 * rx * rx) d2 = d2 + dx - dy + (rx * rx) } } } type cellbuffer struct { cells []string stride int } func (c *cellbuffer) init(w, h int) { if w == 0 { return } c.stride = w c.cells = make([]string, w*h) c.wipe() } func (c cellbuffer) set(x, y int) { i := y*c.stride + x if i > len(c.cells)-1 || x < 0 || y < 0 || x >= c.width() || y >= c.height() { return } c.cells[i] = asterisk } func (c *cellbuffer) wipe() { for i := range c.cells { c.cells[i] = " " } } func (c cellbuffer) width() int { return c.stride } func (c cellbuffer) height() int { h := len(c.cells) / c.stride if len(c.cells)%c.stride != 0 { h++ } return h } func (c cellbuffer) ready() bool { return len(c.cells) > 0 } func (c cellbuffer) String() string { var b strings.Builder for i := range c.cells { if i > 0 && i%c.stride == 0 && i < len(c.cells)-1 { b.WriteRune('\n') } b.WriteString(c.cells[i]) } return b.String() } type frameMsg struct{} func animate() tea.Cmd { return tea.Tick(time.Second/fps, func(_ time.Time) tea.Msg { return frameMsg{} }) } type model struct { cells cellbuffer spring harmonica.Spring targetX, targetY float64 x, y float64 xVelocity, yVelocity float64 } func (m model) Init() tea.Cmd { return animate() } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit case tea.WindowSizeMsg: if !m.cells.ready() { m.targetX, m.targetY = float64(msg.Width)/2, float64(msg.Height)/2 } m.cells.init(msg.Width, msg.Height) return m, nil case tea.MouseMsg: switch msg.(type) { case tea.MouseClickMsg, tea.MouseMotionMsg: default: break } if !m.cells.ready() { return m, nil } mouse := msg.Mouse() m.targetX, m.targetY = float64(mouse.X), float64(mouse.Y) return m, nil case frameMsg: if !m.cells.ready() { return m, nil } m.cells.wipe() m.x, m.xVelocity = m.spring.Update(m.x, m.xVelocity, m.targetX) m.y, m.yVelocity = m.spring.Update(m.y, m.yVelocity, m.targetY) drawEllipse(&m.cells, m.x, m.y, 16, 8) return m, animate() default: return m, nil } } func (m model) View() tea.View { v := tea.NewView(m.cells.String()) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion return v } func main() { m := model{ spring: harmonica.NewSpring(harmonica.FPS(fps), frequency, damping), } p := tea.NewProgram(m) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, "Uh oh:", err) os.Exit(1) } } ================================================ FILE: examples/chat/README.md ================================================ # Chat ================================================ FILE: examples/chat/main.go ================================================ package main // A simple program demonstrating the text area component from the Bubbles // component library. import ( "fmt" "os" "strings" "charm.land/bubbles/v2/cursor" "charm.land/bubbles/v2/textarea" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Oof: %v\n", err) } } type model struct { viewport viewport.Model messages []string textarea textarea.Model senderStyle lipgloss.Style err error } func initialModel() model { ta := textarea.New() ta.Placeholder = "Send a message..." ta.SetVirtualCursor(false) ta.Focus() ta.Prompt = "┃ " ta.CharLimit = 280 ta.SetWidth(30) ta.SetHeight(3) // Remove cursor line styling s := ta.Styles() s.Focused.CursorLine = lipgloss.NewStyle() ta.SetStyles(s) ta.ShowLineNumbers = false vp := viewport.New(viewport.WithWidth(30), viewport.WithHeight(5)) vp.SetContent(`Welcome to the chat room! Type a message and press Enter to send.`) vp.KeyMap.Left.SetEnabled(false) vp.KeyMap.Right.SetEnabled(false) ta.KeyMap.InsertNewline.SetEnabled(false) return model{ textarea: ta, messages: []string{}, viewport: vp, senderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("5")), err: nil, } } func (m model) Init() tea.Cmd { return textarea.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.viewport.SetWidth(msg.Width) m.textarea.SetWidth(msg.Width) m.viewport.SetHeight(msg.Height - m.textarea.Height()) if len(m.messages) > 0 { // Wrap content before setting it. m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(strings.Join(m.messages, "\n"))) } m.viewport.GotoBottom() case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "esc": fmt.Println(m.textarea.Value()) return m, tea.Quit case "enter": m.messages = append(m.messages, m.senderStyle.Render("You: ")+m.textarea.Value()) m.viewport.SetContent(lipgloss.NewStyle().Width(m.viewport.Width()).Render(strings.Join(m.messages, "\n"))) m.textarea.Reset() m.viewport.GotoBottom() return m, nil default: // Send all other keypresses to the textarea. var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) return m, cmd } case cursor.BlinkMsg: // Textarea should also process cursor blinks. var cmd tea.Cmd m.textarea, cmd = m.textarea.Update(msg) return m, cmd } return m, nil } func (m model) View() tea.View { viewportView := m.viewport.View() v := tea.NewView(viewportView + "\n" + m.textarea.View()) c := m.textarea.Cursor() if c != nil { c.Y += lipgloss.Height(viewportView) } v.Cursor = c v.AltScreen = true return v } ================================================ FILE: examples/clickable/main.go ================================================ package main import ( "fmt" "os" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/segmentio/ksuid" ) // LayerHitMsg is a message that is sent to the program when a layer is hit by // a mouse event. This is used to determine which layer in a compostable view // was hit by the mouse event. The layer is identified by its ID, which is a // string that is unique to the layer. type LayerHitMsg struct { ID string Mouse tea.MouseMsg } const maxDialogs = 999 // Styles var ( bgTextStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("239")). Padding(1, 2) bgWhitespace = []lipgloss.WhitespaceOption{ lipgloss.WithWhitespaceChars("/"), lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))), } dialogWordStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#E7E1CC")) dialogStyle = dialogWordStyle. Width(36). Height(8). Padding(1, 3). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#874BFD")) hoveredDialogStyle = dialogStyle. BorderForeground(lipgloss.Color("#F25D94")) specialWordLightColor = lipgloss.Color("#43BF6D") specialWordDarkColor = lipgloss.Color("#73F59F") buttonStyle = lipgloss.NewStyle(). Padding(0, 3). Foreground(lipgloss.Color("#FFF7DB")). Background(lipgloss.Color("#6124DF")) hoveredButtonStyle = buttonStyle. Background(lipgloss.Color("#FF5F87")) ) // Model type model struct { specialWordStyle lipgloss.Style width, height int dialogs []dialog mouseDown bool pressID string dragID string dragOffsetX int dragOffsetY int } func (m model) Init() tea.Cmd { return tea.Batch( tea.RequestBackgroundColor, ) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height case tea.BackgroundColorMsg: if msg.IsDark() { m.specialWordStyle = m.specialWordStyle.Foreground(specialWordDarkColor) } else { m.specialWordStyle = m.specialWordStyle.Foreground(specialWordLightColor) } case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c", "esc": return m, tea.Quit } case LayerHitMsg: mouse := msg.Mouse.Mouse() switch msg.Mouse.(type) { case tea.MouseClickMsg: if mouse.Button != tea.MouseLeft { break } // Initial press if !m.mouseDown { m.mouseDown = true m.pressID = msg.ID // Did we press on a dialog box? for i, d := range m.dialogs { if d.id != msg.ID { continue } // Init drag m.dragID = msg.ID m.dragOffsetX = mouse.X - d.x m.dragOffsetY = mouse.Y - d.y if len(m.dialogs) < 2 { break } // Move the one we're going to drag to the end of the slice // so that it gets the highest z-index when we do // compositing later. There are, of course, lots of other // ways you could manage the z-index, too. m.dialogs = m.removeDialog(i) m.dialogs = append(m.dialogs, d) break } break } // MouseMotion events are send when the mouse has moved and a mouse // button is not pressed. case tea.MouseMotionMsg: // Dragging if m.mouseDown && m.dragID != "" { // Find the dialog box we're dragging for i := range m.dialogs { d := &m.dialogs[i] if d.id != m.dragID { continue } // Move the dialog box with the cursor if m.dragID == d.id { d.x = clamp(mouse.X-(m.dragOffsetX), 0, m.width-lipgloss.Width(d.windowView())) d.y = clamp(mouse.Y-(m.dragOffsetY), 0, m.height-lipgloss.Height(d.windowView())) } break } } // Are we hoving over a dialog box? for i := range m.dialogs { d := &m.dialogs[i] d.hovering = false d.hoveringButton = false if d.id == msg.ID { d.hovering = true continue } if d.buttonID == msg.ID { d.hovering = true d.hoveringButton = true continue } } case tea.MouseReleaseMsg: // Make sure we're releasing on something with an ID. A successful // click is a press and release. if m.pressID == "" { break } // Did we click a button? for i, d := range m.dialogs { if msg.ID == d.buttonID && m.pressID == d.buttonID { // "Close" the window m.dialogs = m.removeDialog(i) break } } // Clicking the background spawns a new dialog if msg.ID == "bg" && m.pressID == "bg" { if len(m.dialogs) < maxDialogs { m.dialogs = append(m.dialogs, m.newDialog(mouse.X, mouse.Y)) } } m.mouseDown = false m.dragID = "" m.pressID = "" } } return m, nil } func (m model) View() tea.View { var v tea.View var body string n := len(m.dialogs) if n > 0 { body += "Drag to move. " } if n == 0 && n < maxDialogs { body += "Click to spawn." } else if n >= 1 && n < maxDialogs { body += fmt.Sprintf("Click to spawn up to %d more.", maxDialogs-len(m.dialogs)) } body += "\n\nPress q to quit." bg := lipgloss.Place( m.width, m.height, lipgloss.Top, lipgloss.Left, bgTextStyle.Render(body), bgWhitespace..., ) root := lipgloss.NewLayer(bg).ID("bg") for i, d := range m.dialogs { root.AddLayers(d.view().Z(i + 1)) } comp := lipgloss.NewCompositor(root) v.MouseMode = tea.MouseModeAllMotion v.AltScreen = true v.OnMouse = func(msg tea.MouseMsg) tea.Cmd { return func() tea.Msg { mouse := msg.Mouse() x, y := mouse.X, mouse.Y if id := comp.Hit(x, y).ID(); id != "" { return LayerHitMsg{ ID: id, Mouse: msg, } } return nil } } v.SetContent(comp.Render()) return v } func (m *model) newDialog(x, y int) (d dialog) { d.specialWordStyle = &m.specialWordStyle dummyView := d.windowView() w := lipgloss.Width(dummyView) h := lipgloss.Height(dummyView) d.x = clamp(x-w/2, 0, m.width-w) d.y = clamp(y-h/2, 0, m.height-h) d.text = nextRandomWord() d.id = ksuid.New().String() d.buttonID = ksuid.New().String() return d } func (m model) removeDialog(index int) []dialog { d := m.dialogs if len(d) <= index { return m.dialogs } copy(d[index:], d[index+1:]) // shift d[len(d)-1] = dialog{} // nullify return d[:len(d)-1] // truncate } // Dialog Windows type dialog struct { specialWordStyle *lipgloss.Style id string buttonID string x, y int text string hovering bool hoveringButton bool } func (d dialog) buttonView() string { const label = "Run Away" if d.hoveringButton { return hoveredButtonStyle.Render(label) } return buttonStyle.Render(label) } func (d dialog) windowView() string { var style lipgloss.Style if d.hovering { style = hoveredDialogStyle } else { style = dialogStyle } s := d.specialWordStyle.Render(d.text) + dialogWordStyle.Render(" draws near. Command?") return style.Render(s) } func (d dialog) view() *lipgloss.Layer { const hGap, vGap = 3, 1 window := d.windowView() button := d.buttonView() buttonX := lipgloss.Width(window) - lipgloss.Width(button) - 1 - hGap buttonY := lipgloss.Height(window) - lipgloss.Height(button) - 1 - vGap buttonLayer := lipgloss.NewLayer(button). ID(d.buttonID). X(buttonX). Y(buttonY) return lipgloss.NewLayer(window). ID(d.id). X(d.x). Y(d.y). AddLayers(buttonLayer) } // Main func main() { ksuid.SetRand(ksuid.FastRander) path := os.Getenv("TEA_LOGFILE") if path != "" { f, err := tea.LogToFile(path, "layers") if err != nil { fmt.Println("could not open logfile:", err) os.Exit(1) } defer f.Close() } if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Error while running program:", err) os.Exit(1) } } func clamp(n, min, max int) int { if n < min { return min } if n > max { return max } return n } ================================================ FILE: examples/clickable/words.go ================================================ package main import ( "math/rand" "strings" "sync" ) const uncapitalized = " of a an and ’n’ " var ( adjectives = []string{ "a hot", "a cute", "a fresh", "a nice", "a lovely", "an eager", "a soft", "an expensive", "a new", "an old", "a happy", "a messy", "a good", "a bad", "a cheesy", "a friendly", "a free", "a cold", "a gorgeous", "a glamorous", "a handsome", "an exquisite", "a tantalizing", "a suspicious", "an american", "a wooden", "a golden", "a dirty", "a hairy", "a lukewarm", "a burning hot", "a shiny", "a rogue", "a green", "a late night", "a mass produced", "a handmade", "a wild", "a clean", "a rugged", "the #1", "the best", "the worst", "a famous", "an infamous", "a clever", "a microwaved", "a 3D printed", "your favorite", "your least favorite", "someone’s", "a precious", "a fake", "a genuine", "a bejeweled", "a good-smelling", } nouns = []string{ "pear", "banana", "bowl of ramen", "currywurst", "quince", "pie", "cake", "burrito", "sushi", "basket of fish ’n’ chips", "burger", "kohlrabi", "pineapple", "cantaloupe", "sausage roll", "yuzu", "grapefruit", "espresso shot", "sandwich", "bowl of chow mein", "lemon", "cup of coffee", "bottle of hot sauce", "can of beer", "glass of wine", "muffin", "bagel", "glass of champagne", "bottle of rosé", "pengu", "badger", "mango", "okonomiyaki", "meatball", "box of wine", "artichoke", "TUI", "linux distro", "dotfile", "weißwurst", "computer", } shuffle sync.Once nextWordMtx sync.Mutex ) func nextRandomWord() string { shuffle.Do(shuffleWords) nextWordMtx.Lock() defer nextWordMtx.Unlock() adjectives = cycle(adjectives) nouns = cycle(nouns) return capitalize(adjectives[0] + " " + nouns[0]) } func shuffleWords() { shuf := func(x []string) { rand.Shuffle(len(x), func(i, j int) { x[i], x[j] = x[j], x[i] }) } shuf(adjectives) shuf(nouns) } func capitalize(s string) string { words := strings.Fields(s) for i, w := range words { if i > 0 && strings.Contains(uncapitalized, " "+w+" ") { words[i] = w } else { words[i] = strings.Title(w) } } return strings.Join(words, " ") } func cycle(stack []string) []string { return append(stack[1:], stack[0]) } ================================================ FILE: examples/colorprofile/main.go ================================================ package main import ( "image/color" "log" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/ansi" "github.com/lucasb-eyer/go-colorful" ) var myFancyColor color.Color type model struct{} var _ tea.Model = model{} // Init implements tea.Model. func (m model) Init() tea.Cmd { return tea.Batch( tea.RequestCapability("RGB"), tea.RequestCapability("Tc"), ) } // Update implements tea.Model. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m, tea.Quit case tea.ColorProfileMsg: return m, tea.Println("Color profile manually set to ", msg) } return m, nil } // View implements tea.Model. func (m model) View() tea.View { return tea.NewView("This will produce the wrong colors on Apple Terminal :)\n\n" + ansi.Style{}.ForegroundColor(myFancyColor).Styled("Howdy!") + "\n\n" + "Press any key to exit.") } func main() { myFancyColor, _ = colorful.Hex("#6b50ff") p := tea.NewProgram(model{}, tea.WithColorProfile(colorprofile.TrueColor)) if _, err := p.Run(); err != nil { log.Fatal(err) } } ================================================ FILE: examples/composable-views/README.md ================================================ # Composable Views ================================================ FILE: examples/composable-views/main.go ================================================ package main import ( "fmt" "log" "strings" "time" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/timer" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) /* This example assumes an existing understanding of commands and messages. If you haven't already read our tutorials on the basics of Bubble Tea and working with commands, we recommend reading those first. Find them at: https://github.com/charmbracelet/bubbletea/tree/master/tutorials/commands https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics */ // sessionState is used to track which model is focused type sessionState uint const ( defaultTime = time.Minute timerView sessionState = iota spinnerView ) var ( // Available spinners spinners = []spinner.Spinner{ spinner.Line, spinner.Dot, spinner.MiniDot, spinner.Jump, spinner.Pulse, spinner.Points, spinner.Globe, spinner.Moon, spinner.Monkey, } modelStyle = lipgloss.NewStyle(). Width(15). Height(5). Align(lipgloss.Center, lipgloss.Center). BorderStyle(lipgloss.HiddenBorder()) focusedModelStyle = lipgloss.NewStyle(). Width(15). Height(5). Align(lipgloss.Center, lipgloss.Center). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("69")) spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) ) type mainModel struct { state sessionState timer timer.Model spinner spinner.Model index int } func newModel(timeout time.Duration) mainModel { m := mainModel{state: timerView} m.timer = timer.New(timeout) m.spinner = spinner.New() return m } func (m mainModel) Init() tea.Cmd { // start the timer and spinner on program start return tea.Batch(m.timer.Init(), m.spinner.Tick) } func (m mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "tab": if m.state == timerView { m.state = spinnerView } else { m.state = timerView } case "n": if m.state == timerView { m.timer = timer.New(defaultTime) cmds = append(cmds, m.timer.Init()) } else { m.Next() m.resetSpinner() cmds = append(cmds, m.spinner.Tick) } } switch m.state { // update whichever model is focused case spinnerView: m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) default: m.timer, cmd = m.timer.Update(msg) cmds = append(cmds, cmd) } case spinner.TickMsg: m.spinner, cmd = m.spinner.Update(msg) cmds = append(cmds, cmd) case timer.TickMsg: m.timer, cmd = m.timer.Update(msg) cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) } func (m mainModel) View() tea.View { var s strings.Builder model := m.currentFocusedModel() if m.state == timerView { s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, focusedModelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), modelStyle.Render(m.spinner.View()))) } else { s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, modelStyle.Render(fmt.Sprintf("%4s", m.timer.View())), focusedModelStyle.Render(m.spinner.View()))) } s.WriteString(helpStyle.Render(fmt.Sprintf("\ntab: focus next • n: new %s • q: exit\n", model))) return tea.NewView(s.String()) } func (m mainModel) currentFocusedModel() string { if m.state == timerView { return "timer" } return "spinner" } func (m *mainModel) Next() { if m.index == len(spinners)-1 { m.index = 0 } else { m.index++ } } func (m *mainModel) resetSpinner() { m.spinner = spinner.New() m.spinner.Style = spinnerStyle m.spinner.Spinner = spinners[m.index] } func main() { p := tea.NewProgram(newModel(defaultTime)) if _, err := p.Run(); err != nil { log.Fatal(err) } } ================================================ FILE: examples/cursor-style/main.go ================================================ package main import ( "fmt" "os" tea "charm.land/bubbletea/v2" ) type model struct { cursor tea.Cursor blink bool } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "h", "left": m.cursor.Shape-- if m.cursor.Shape < tea.CursorBlock { m.cursor.Shape = tea.CursorBar } case "l", "right": m.cursor.Shape++ if m.cursor.Shape > tea.CursorBar { m.cursor.Shape = tea.CursorBlock } } } m.blink = !m.blink return m, nil } func (m model) View() tea.View { v := tea.NewView("Press left/right to change the cursor style, q or ctrl+c to quit." + "\n\n" + " <- This is the cursor (a " + m.describeCursor() + ")") c := tea.NewCursor(0, 2) c.Shape = m.cursor.Shape c.Blink = m.blink v.Cursor = c return v } func (m model) describeCursor() string { var adj, noun string if m.blink { adj = "blinking" } else { adj = "steady" } switch m.cursor.Shape { case tea.CursorBlock: noun = "block" case tea.CursorUnderline: noun = "underline" case tea.CursorBar: noun = "bar" } return fmt.Sprintf("%s %s", adj, noun) } func main() { p := tea.NewProgram(model{blink: true}) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Error: %v", err) os.Exit(1) } } ================================================ FILE: examples/debounce/README.md ================================================ # Debounce ================================================ FILE: examples/debounce/main.go ================================================ package main // This example illustrates how to debounce commands. // // When the user presses a key we increment the "tag" value on the model and, // after a short delay, we include that tag value in the message produced // by the Tick command. // // In a subsequent Update, if the tag in the Msg matches current tag on the // model's state we know that the debouncing is complete and we can proceed as // normal. If not, we simply ignore the inbound message. import ( "fmt" "os" "time" tea "charm.land/bubbletea/v2" ) const debounceDuration = time.Second type exitMsg int type model struct { tag int } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: // Increment the tag on the model... m.tag++ return m, tea.Tick(debounceDuration, func(_ time.Time) tea.Msg { // ...and include a copy of that tag value in the message. return exitMsg(m.tag) }) case exitMsg: // If the tag in the message doesn't match the tag on the model then we // know that this message was not the last one sent and another is on // the way. If that's the case we know, we can ignore this message. // Otherwise, the debounce timeout has passed and this message is a // valid debounced one. if int(msg) == m.tag { return m, tea.Quit } } return m, nil } func (m model) View() tea.View { return tea.NewView(fmt.Sprintf("Key presses: %d", m.tag) + "\nTo exit press any key, then wait for one second without pressing anything.") } func main() { if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("uh oh:", err) os.Exit(1) } } ================================================ FILE: examples/doom-fire/main.go ================================================ package main import ( "fmt" "math/rand" "strings" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) // This Doom Fire implementation was ported from @const-void's Node version. // See https://github.com/const-void/DOOM-fire-node var whiteFg = lipgloss.NewStyle().Foreground(lipgloss.White) type model struct { screenBuf []int width int height int firePalette []int startTime time.Time } func (m model) Init() tea.Cmd { return tick } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if msg.String() == "q" || msg.String() == "ctrl+c" { return m, tea.Quit } case tickMsg: m.spreadFire() return m, tick case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height * 2 // Double height for half-block characters m.screenBuf = make([]int, m.width*m.height) // Initialize the bottom row with white (maximum intensity) for i := range m.width { m.screenBuf[(m.height-1)*m.width+i] = len(m.firePalette) - 1 } } return m, nil } func (m model) View() tea.View { if m.width == 0 { return tea.NewView("Initializing...") } var s strings.Builder for y := 0; y < m.height-2; y += 2 { for x := range m.width { pixelHi := m.screenBuf[y*m.width+x] pixelLo := m.screenBuf[(y+1)*m.width+x] hiColor := m.firePalette[pixelHi] loColor := m.firePalette[pixelLo] s.WriteString(lipgloss.NewStyle(). Foreground(lipgloss.ANSIColor(hiColor)). Background(lipgloss.ANSIColor(loColor)). Render("▀")) } if y < m.height-2 { s.WriteByte('\n') } } elapsed := time.Since(m.startTime) s.WriteString(whiteFg.Render("Press q or ctrl+c to quit. " + fmt.Sprintf("Elapsed: %s", elapsed.Round(time.Second)))) v := tea.NewView(s.String()) v.AltScreen = true return v } func (m *model) spreadFire() { for x := range m.width { for y := range m.height { m.spreadPixel(y*m.width + x) } } } func (m *model) spreadPixel(idx int) { if idx < m.width { return } pixel := m.screenBuf[idx] if pixel == 0 { m.screenBuf[idx-m.width] = 0 return } rnd := rand.Intn(3) dst := idx - rnd + 1 if dst-m.width >= 0 && dst-m.width < len(m.screenBuf) { decay := rnd & 1 newValue := max(pixel-decay, 0) m.screenBuf[dst-m.width] = newValue } } type tickMsg time.Time func tick() tea.Msg { time.Sleep(time.Millisecond * 50) return tickMsg(time.Now()) } func initialModel() model { // Same color palette as the original palette := []int{0, 233, 234, 52, 53, 88, 89, 94, 95, 96, 130, 131, 132, 133, 172, 214, 215, 220, 220, 221, 3, 226, 227, 230, 231, 7} return model{ firePalette: palette, startTime: time.Now(), } } func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { fmt.Printf("Error running program: %v", err) } } ================================================ FILE: examples/exec/README.md ================================================ # Exec ================================================ FILE: examples/exec/main.go ================================================ package main import ( "cmp" "fmt" "os" "os/exec" tea "charm.land/bubbletea/v2" ) type editorFinishedMsg struct{ err error } func openEditor() tea.Cmd { editor := cmp.Or(os.Getenv("EDITOR"), "vim") c := exec.Command(editor) //nolint:gosec return tea.ExecProcess(c, func(err error) tea.Msg { return editorFinishedMsg{err} }) } type model struct { altscreenActive bool err error } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "a": m.altscreenActive = !m.altscreenActive return m, nil case "e": return m, openEditor() case "ctrl+c", "q": return m, tea.Quit } case editorFinishedMsg: if msg.err != nil { m.err = msg.err return m, tea.Quit } } return m, nil } func (m model) View() tea.View { if m.err != nil { v := tea.NewView("Error: " + m.err.Error() + "\n") v.AltScreen = m.altscreenActive return v } v := tea.NewView("Press 'e' to open your EDITOR.\nPress 'a' to toggle the altscreen\nPress 'q' to quit.\n") v.AltScreen = m.altscreenActive return v } func main() { m := model{} if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } ================================================ FILE: examples/eyes/main.go ================================================ // roughly converted to Go from https://github.com/dmtrKovalenko/esp32-smooth-eye-blinking/blob/main/src/main.cpp package main import ( "fmt" "math" "math/rand" "strings" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) const ( // Eye dimensions (corresponding to original EYE_WIDTH and EYE_HEIGHT) eyeWidth = 15 eyeHeight = 12 // Increased height for taller eyes eyeSpacing = 40 // Blink animation timing (matching original constants) blinkFrames = 20 openTimeMin = 1000 openTimeMax = 4000 ) // Characters for drawing the eyes const ( eyeChar = "●" bgChar = " " ) type model struct { width int height int eyePositions [2]int eyeY int isBlinking bool blinkState int lastBlink time.Time openTime time.Duration } type tickMsg time.Time func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { fmt.Printf("Error running program: %v\n", err) } } func initialModel() model { m := model{ width: 80, height: 24, isBlinking: false, blinkState: 0, lastBlink: time.Now(), openTime: time.Duration(rand.Intn(openTimeMax-openTimeMin)+openTimeMin) * time.Millisecond, } m.updateEyePositions() return m } func (m *model) updateEyePositions() { startX := (m.width - eyeSpacing) / 2 m.eyeY = m.height / 2 m.eyePositions[0] = startX m.eyePositions[1] = startX + eyeSpacing } func (m model) Init() tea.Cmd { return tickCmd() } func tickCmd() tea.Cmd { return tea.Tick(50*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.updateEyePositions() case tickMsg: currentTime := time.Now() if !m.isBlinking && currentTime.Sub(m.lastBlink) >= m.openTime { m.isBlinking = true m.blinkState = 0 } if m.isBlinking { m.blinkState++ if m.blinkState >= blinkFrames { m.isBlinking = false m.lastBlink = currentTime m.openTime = time.Duration(rand.Intn(openTimeMax-openTimeMin)+openTimeMin) * time.Millisecond // 10% chance of double blink (matching original logic) if rand.Intn(10) == 0 { m.openTime = 300 * time.Millisecond } } } } return m, tickCmd() } func (m model) View() tea.View { var v tea.View v.AltScreen = true // Use alternate screen buffer // Create empty canvas canvas := make([][]string, m.height) for y := range canvas { canvas[y] = make([]string, m.width) for x := range canvas[y] { canvas[y][x] = bgChar } } // Calculate current eye height based on blink state currentHeight := eyeHeight if m.isBlinking { var blinkProgress float64 if m.blinkState < blinkFrames/2 { // Closing eyes (with easing function from original) blinkProgress = float64(m.blinkState) / float64(blinkFrames/2) blinkProgress = 1.0 - (blinkProgress * blinkProgress) } else { // Opening eyes (with easing function from original) blinkProgress = float64(m.blinkState-blinkFrames/2) / float64(blinkFrames/2) blinkProgress = blinkProgress * (2.0 - blinkProgress) } currentHeight = int(math.Max(1, float64(eyeHeight)*blinkProgress)) } // Draw both eyes for i := 0; i < 2; i++ { drawEllipse(canvas, m.eyePositions[i], m.eyeY, eyeWidth, currentHeight) } // Convert canvas to string var s strings.Builder for _, row := range canvas { for _, cell := range row { s.WriteString(cell) } s.WriteString("\n") } // Style output style := lipgloss.NewStyle(). Foreground(lipgloss.Color("#F0F0F0")) v.SetContent(style.Render(s.String())) return v } func drawEllipse(canvas [][]string, x0, y0, rx, ry int) { // Improved ellipse drawing algorithm with better angles for y := -ry; y <= ry; y++ { // Calculate the width at this y position for a smoother ellipse // Use a slightly modified formula to improve the angles width := int(float64(rx) * math.Sqrt(1.0-math.Pow(float64(y)/float64(ry), 2.0))) for x := -width; x <= width; x++ { // Calculate canvas position canvasX := x0 + x canvasY := y0 + y // Make sure we're within canvas bounds if canvasX >= 0 && canvasX < len(canvas[0]) && canvasY >= 0 && canvasY < len(canvas) { canvas[canvasY][canvasX] = eyeChar } } } } ================================================ FILE: examples/file-picker/main.go ================================================ package main import ( "errors" "fmt" "os" "strings" "time" "charm.land/bubbles/v2/filepicker" tea "charm.land/bubbletea/v2" ) type model struct { filepicker filepicker.Model selectedFile string quitting bool err error } type clearErrorMsg struct{} func clearErrorAfter(t time.Duration) tea.Cmd { return tea.Tick(t, func(_ time.Time) tea.Msg { return clearErrorMsg{} }) } func (m model) Init() tea.Cmd { return m.filepicker.Init() } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q": m.quitting = true return m, tea.Quit } case clearErrorMsg: m.err = nil } var cmd tea.Cmd m.filepicker, cmd = m.filepicker.Update(msg) // Did the user select a file? if didSelect, path := m.filepicker.DidSelectFile(msg); didSelect { // Get the path of the selected file. m.selectedFile = path } // Did the user select a disabled file? // This is only necessary to display an error to the user. if didSelect, path := m.filepicker.DidSelectDisabledFile(msg); didSelect { // Let's clear the selectedFile and display an error. m.err = errors.New(path + " is not valid.") m.selectedFile = "" return m, tea.Batch(cmd, clearErrorAfter(2*time.Second)) } return m, cmd } func (m model) View() tea.View { if m.quitting { return tea.NewView("") } var s strings.Builder s.WriteString("\n ") if m.err != nil { s.WriteString(m.filepicker.Styles.DisabledFile.Render(m.err.Error())) } else if m.selectedFile == "" { s.WriteString("Pick a file:") } else { s.WriteString("Selected file: " + m.filepicker.Styles.Selected.Render(m.selectedFile)) } s.WriteString("\n\n" + m.filepicker.View() + "\n") v := tea.NewView(s.String()) v.AltScreen = true return v } func main() { fp := filepicker.New() fp.AllowedTypes = []string{".mod", ".sum", ".go", ".txt", ".md"} fp.CurrentDirectory, _ = os.UserHomeDir() m := model{filepicker: fp} tm, _ := tea.NewProgram(m).Run() mm := tm.(model) fmt.Println("\n You selected: " + m.filepicker.Styles.Selected.Render(mm.selectedFile) + "\n") } ================================================ FILE: examples/focus-blur/main.go ================================================ package main // A simple program that handled losing and acquiring focus. import ( "log" tea "charm.land/bubbletea/v2" ) func main() { p := tea.NewProgram(model{ focused: true, reporting: true, }) if _, err := p.Run(); err != nil { log.Fatal(err) } } type model struct { focused bool reporting bool } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.FocusMsg: m.focused = true case tea.BlurMsg: m.focused = false case tea.KeyPressMsg: switch msg.String() { case "t": m.reporting = !m.reporting case "ctrl+c", "q": return m, tea.Quit } } return m, nil } func (m model) View() tea.View { s := "Hi. Focus report is currently " if m.reporting { s += "enabled" } else { s += "disabled" } s += ".\n\n" if m.reporting { if m.focused { s += "This program is currently focused!" } else { s += "This program is currently blurred!" } } v := tea.NewView(s + "\n\nTo quit sooner press ctrl-c, or t to toggle focus reporting...\n") v.ReportFocus = m.reporting return v } ================================================ FILE: examples/fullscreen/README.md ================================================ # Full Screen ================================================ FILE: examples/fullscreen/main.go ================================================ package main // A simple program that opens the alternate screen buffer then counts down // from 5 and then exits. import ( "fmt" "log" "time" tea "charm.land/bubbletea/v2" ) type model int type tickMsg time.Time func main() { p := tea.NewProgram(model(5)) if _, err := p.Run(); err != nil { log.Fatal(err) } } func (m model) Init() tea.Cmd { return tick() } func (m model) Update(message tea.Msg) (tea.Model, tea.Cmd) { switch msg := message.(type) { case tea.KeyPressMsg: switch msg.String() { case "q", "esc", "ctrl+c": return m, tea.Quit } case tickMsg: m-- if m <= 0 { return m, tea.Quit } return m, tick() } return m, nil } func (m model) View() tea.View { v := tea.NewView(fmt.Sprintf("\n\n Hi. This program will exit in %d seconds...", m)) v.AltScreen = true return v } func tick() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } ================================================ FILE: examples/glamour/README.md ================================================ # Glamour ================================================ FILE: examples/glamour/main.go ================================================ package main import ( "fmt" "os" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/glamour/v2" "github.com/charmbracelet/glamour/v2/styles" "charm.land/lipgloss/v2" ) const content = ` # Today’s Menu ## Appetizers | Name | Price | Notes | | --- | --- | --- | | Tsukemono | $2 | Just an appetizer | | Tomato Soup | $4 | Made with San Marzano tomatoes | | Okonomiyaki | $4 | Takes a few minutes to make | | Curry | $3 | We can add squash if you’d like | ## Seasonal Dishes | Name | Price | Notes | | --- | --- | --- | | Steamed bitter melon | $2 | Not so bitter | | Takoyaki | $3 | Fun to eat | | Winter squash | $3 | Today it's pumpkin | ## Desserts | Name | Price | Notes | | --- | --- | --- | | Dorayaki | $4 | Looks good on rabbits | | Banana Split | $5 | A classic | | Cream Puff | $3 | Pretty creamy! | All our dishes are made in-house by Karen, our chef. Most of our ingredients are from our garden or the fish market down the street. Some famous people that have eaten here lately: * [x] René Redzepi * [x] David Chang * [ ] Jiro Ono (maybe some day) Bon appétit! ` var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render type example struct { viewport viewport.Model } func newExample(isDark bool) (*example, error) { const ( width = 78 height = 20 ) vp := viewport.New() vp.SetWidth(width) vp.SetHeight(height) vp.Style = lipgloss.NewStyle(). BorderStyle(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("62")). PaddingRight(2) // We need to adjust the width of the glamour render from our main width // to account for a few things: // // * The viewport border width // * The viewport padding // * The viewport margins // * The gutter glamour applies to the left side of the content // const glamourGutter = 3 glamourRenderWidth := width - vp.Style.GetHorizontalFrameSize() - glamourGutter style := styles.DarkStyleConfig if !isDark { style = styles.LightStyleConfig } renderer, err := glamour.NewTermRenderer( glamour.WithStyles(style), glamour.WithWordWrap(glamourRenderWidth), ) if err != nil { return nil, err } str, err := renderer.Render(content) if err != nil { return nil, err } vp.SetContent(str) return &example{ viewport: vp, }, nil } func (e example) Init() tea.Cmd { return nil } func (e example) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c", "esc": return e, tea.Quit default: var cmd tea.Cmd e.viewport, cmd = e.viewport.Update(msg) return e, cmd } default: return e, nil } } func (e example) View() tea.View { return tea.NewView(e.viewport.View() + e.helpView()) } func (e example) helpView() string { return helpStyle("\n ↑/↓: Navigate • q: Quit\n") } func main() { hasDarkBg := lipgloss.HasDarkBackground(os.Stdin, os.Stdout) model, err := newExample(hasDarkBg) if err != nil { fmt.Println("Could not initialize Bubble Tea model:", err) os.Exit(1) } if _, err := tea.NewProgram(model).Run(); err != nil { fmt.Println("Bummer, there's been an error:", err) os.Exit(1) } } ================================================ FILE: examples/go.mod ================================================ module examples go 1.25.2 replace charm.land/bubbletea/v2 => ../ require ( charm.land/bubbles/v2 v2.0.0 charm.land/bubbletea/v2 v2.0.0 charm.land/lipgloss/v2 v2.0.2 github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930 github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/x/ansi v0.11.6 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250602192518-9e722df69bbb github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-isatty v0.0.20 github.com/segmentio/ksuid v1.0.4 ) require ( github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.24.0 // indirect ) ================================================ FILE: examples/go.sum ================================================ charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930 h1:+47Z2jVAWPSLGjPRbfZizW3OpcAYsu7EUk2DR+66FyM= github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930/go.mod h1:izs11tnkYaT3DTEH2E0V/lCb18VGZ7k9HLYEGuvgXGA= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250602192518-9e722df69bbb h1:oTM8tZxV7FY0ehvYjFuICouuhzE08UZYNqUIp/lDQdY= github.com/charmbracelet/x/exp/charmtone v0.0.0-20250602192518-9e722df69bbb/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776 h1:VRIbnDWRmAh5yBdz+J6yFMF5vso1It6vn+WmM/5l7MA= github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776/go.mod h1:9wvnDu3YOfxzWM9Cst40msBF1C2UdQgDv962oTxSuMs= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= ================================================ FILE: examples/help/README.md ================================================ # Help ================================================ FILE: examples/help/main.go ================================================ package main import ( "fmt" "os" "strings" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) // keyMap defines a set of keybindings. To work for help it must satisfy // key.Map. It could also very easily be a map[string]key.Binding. type keyMap struct { Up key.Binding Down key.Binding Left key.Binding Right key.Binding Help key.Binding Quit key.Binding } // ShortHelp returns keybindings to be shown in the mini help view. It's part // of the key.Map interface. func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{k.Help, k.Quit} } // FullHelp returns keybindings for the expanded help view. It's part of the // key.Map interface. func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Up, k.Down, k.Left, k.Right}, // first column {k.Help, k.Quit}, // second column } } var keys = keyMap{ Up: key.NewBinding( key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up"), ), Down: key.NewBinding( key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down"), ), Left: key.NewBinding( key.WithKeys("left", "h"), key.WithHelp("←/h", "move left"), ), Right: key.NewBinding( key.WithKeys("right", "l"), key.WithHelp("→/l", "move right"), ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "toggle help"), ), Quit: key.NewBinding( key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q", "quit"), ), } type model struct { keys keyMap help help.Model inputStyle lipgloss.Style lastKey string quitting bool } func newModel() model { return model{ keys: keys, help: help.New(), inputStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("#FF75B7")), } } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: // If we set a width on the help menu it can gracefully truncate // its view as needed. m.help.SetWidth(msg.Width) case tea.KeyPressMsg: switch { case key.Matches(msg, m.keys.Up): m.lastKey = "↑" case key.Matches(msg, m.keys.Down): m.lastKey = "↓" case key.Matches(msg, m.keys.Left): m.lastKey = "←" case key.Matches(msg, m.keys.Right): m.lastKey = "→" case key.Matches(msg, m.keys.Help): m.help.ShowAll = !m.help.ShowAll case key.Matches(msg, m.keys.Quit): m.quitting = true return m, tea.Quit } } return m, nil } func (m model) View() tea.View { if m.quitting { return tea.NewView("Bye!\n") } var status string if m.lastKey == "" { status = "Waiting for input..." } else { status = "You chose: " + m.inputStyle.Render(m.lastKey) } helpView := m.help.View(m.keys) height := 8 - strings.Count(status, "\n") - strings.Count(helpView, "\n") return tea.NewView(status + strings.Repeat("\n", height) + helpView) } func main() { if os.Getenv("HELP_DEBUG") != "" { f, err := tea.LogToFile("debug.log", "help") if err != nil { fmt.Println("Couldn't open a file for logging:", err) os.Exit(1) } defer f.Close() // nolint:errcheck } if _, err := tea.NewProgram(newModel()).Run(); err != nil { fmt.Printf("Could not start program :(\n%v\n", err) os.Exit(1) } } ================================================ FILE: examples/http/README.md ================================================ # HTTP ================================================ FILE: examples/http/main.go ================================================ package main // A simple program that makes a GET request and prints the response status. import ( "fmt" "log" "net/http" "time" tea "charm.land/bubbletea/v2" ) const url = "https://charm.sh/" type model struct { status int err error } type statusMsg int type errMsg struct{ error } func (e errMsg) Error() string { return e.error.Error() } func main() { p := tea.NewProgram(model{}) if _, err := p.Run(); err != nil { log.Fatal(err) } } func (m model) Init() tea.Cmd { return checkServer } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c", "esc": return m, tea.Quit default: return m, nil } case statusMsg: m.status = int(msg) return m, tea.Quit case errMsg: m.err = msg return m, nil default: return m, nil } } func (m model) View() tea.View { s := fmt.Sprintf("Checking %s...", url) if m.err != nil { s += fmt.Sprintf("something went wrong: %s", m.err) } else if m.status != 0 { s += fmt.Sprintf("%d %s", m.status, http.StatusText(m.status)) } return tea.NewView(s + "\n") } func checkServer() tea.Msg { c := &http.Client{ Timeout: 10 * time.Second, } res, err := c.Get(url) if err != nil { return errMsg{err} } defer res.Body.Close() // nolint:errcheck return statusMsg(res.StatusCode) } ================================================ FILE: examples/isbn-form/isbn-form.tape ================================================ Type "go run ./isbn-form" Enter Sleep 2s Type "9783548372570" Sleep 500ms Sleep 1s Down Sleep 500ms Type "my book title" Sleep 500ms Backspace 10 Type "title" Sleep 500ms Enter Sleep 3s Output isbn-form.gif ================================================ FILE: examples/isbn-form/main.go ================================================ package main import ( "fmt" "log" "strings" "unicode" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/exp/charmtone" ) func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { log.Fatal(err) } } type ( errMsg error ) var ( inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(charmtone.Tang.Hex())) continueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(charmtone.Anchovy.Hex())) validStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(charmtone.Guac.Hex())) errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(charmtone.Cherry.Hex())) ) type model struct { isbnInput textinput.Model titleInput textinput.Model focusedInput int err error } // canFindBook returns whether the find button is to be pressed func (m model) canFindBook() bool { correctIsbnGiven := m.isbnInput.Err == nil && len(m.isbnInput.Value()) != 0 correctTitleGiven := m.titleInput.Err == nil && len(m.titleInput.Value()) != 0 return correctIsbnGiven && correctTitleGiven } // Validator function to ensure valid input func isbn13Validator(s string) error { // A valid ISBN looks like this: // 978-3-548-37257-0 or // 9783548372570 without any spaces // Remove dashes s = strings.ReplaceAll(s, "-", "") if len(s) != 13 { return fmt.Errorf("ISBN is of wrong length") } for _, c := range s { if !unicode.IsDigit(c) { return fmt.Errorf("ISBN contains invalid characters") } } gs1Prefix := s[:3] switch gs1Prefix { case "978", "979": break default: return fmt.Errorf("ISBN has invalid GS1 prefix") } // The last digit, the check digit, // must make the checksum a multiple of 10. // All digits are added up after being multiplied // by either 1 or 3 alternately. // So 9x1 + 7x3 + 8x1 + ... + 0 must be a multiple of 10. sum := 0 for i, c := range s { // Convert rune to int n := int(c - '0') // Multiply the uneven indices by 3 if i%2 != 0 { n *= 3 } sum += n } if sum%10 != 0 { return fmt.Errorf("ISBN has invalid check digit") } return nil } var bannedTitleWords = []string{ "very", "bad", "words", "that", "should", "not", "appear", "in", "book", "titles", } func bookTitleValidator(s string) error { s = strings.TrimSpace(s) if len(s) == 0 { return fmt.Errorf("Book title is empty") } for _, bannedWord := range bannedTitleWords { if strings.Contains(s, bannedWord) { return fmt.Errorf("Book title contains banned word %q", bannedWord) } } return nil } func initialModel() model { isbnInput := textinput.New() isbnInput.Focus() isbnInput.Placeholder = "978-X-XXX-XXXXX-X" isbnInput.CharLimit = 17 isbnInput.SetWidth(30) isbnInput.Prompt = "" isbnInput.Validate = isbn13Validator titleInput := textinput.New() titleInput.Blur() titleInput.Placeholder = "Title" titleInput.CharLimit = 100 titleInput.SetWidth(100) titleInput.Prompt = "" titleInput.Validate = bookTitleValidator return model{ isbnInput: isbnInput, titleInput: titleInput, focusedInput: 0, err: nil, } } func (m model) Init() tea.Cmd { return textinput.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "up", "down": // Switch between text inputs switch m.focusedInput { case 0: m.focusedInput = 1 m.titleInput.Focus() m.isbnInput.Blur() case 1: m.focusedInput = 0 m.isbnInput.Focus() m.titleInput.Blur() } case "enter": // Enter is blocked until all inputs are ok if m.canFindBook() { return m, tea.Quit } case "ctrl+c", "esc": return m, tea.Quit } // We handle errors just like any other message case errMsg: m.err = msg return m, nil } var isbnCommand tea.Cmd m.isbnInput, isbnCommand = m.isbnInput.Update(msg) var titleCommand tea.Cmd m.titleInput, titleCommand = m.titleInput.Update(msg) return m, tea.Batch(isbnCommand, titleCommand) } func (m model) View() tea.View { var continueText string if m.canFindBook() { continueText = continueStyle.Render("Find ->") } var isbnErrorText string if m.isbnInput.Value() != "" { if m.isbnInput.Err != nil { isbnErrorText = errStyle.Render(m.isbnInput.Err.Error()) } else { isbnErrorText = validStyle.Render("Valid ISBN") } } var titleErrorText string if m.titleInput.Value() != "" { if m.titleInput.Err != nil { titleErrorText = errStyle.Render(m.titleInput.Err.Error()) } else { titleErrorText = validStyle.Render("Valid title") } } return tea.NewView(fmt.Sprintf( ` Search book: %s %s %s %s %s %s %s `, inputStyle.Width(30).Render("ISBN"), m.isbnInput.View(), isbnErrorText, inputStyle.Width(30).Render("Title"), m.titleInput.View(), titleErrorText, continueText, ) + "\n") } ================================================ FILE: examples/keyboard-enhancements/main.go ================================================ package main // This is a simple example illustrating how to enable enhanced keyboard // support. import ( "fmt" "os" "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) type styles struct { ui lipgloss.Style } type model struct { supportsDisambiguation bool supportsEventTypes bool styles styles } func (m model) Init() tea.Cmd { return tea.RequestBackgroundColor } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { // Bubble Tea will send a [tea.KeyboardEnhancementsMsg] on startup if the // terminal supports keyboard enhancements features. // // These features extend the capabilities of keyboard input beyond the basic legacy // support found in most terminals. This includes features like: // - Key disambiguation: Improved ability to distinguish between certain key presses // like "enter" and "shift+enter" or "tab" and "ctrl+i". // - Key event types: The ability to report different types of key events such as // key presses and key releases. // // This allows for more nuanced input handling in terminal applications. // You can ask Bubble Tea to request additional keyboard enhancements // features by setting fields on the [tea.View.KeyboardEnhancements] struct // in your [tea.View] method. case tea.KeyboardEnhancementsMsg: // Check which features were able to be enabled. m.supportsDisambiguation = true // This is always enabled when this msg is received. m.supportsEventTypes = msg.SupportsEventTypes() case tea.KeyPressMsg: switch msg.String() { case "ctrl+c": return m, tea.Quit default: return m, tea.Println(" press: " + msg.String()) } case tea.KeyReleaseMsg: return m, tea.Printf("release: %s", msg.String()) case tea.BackgroundColorMsg: // Initialize styles. m.updateStyles(msg.IsDark()) } return m, nil } func (m model) View() tea.View { var v tea.View var b strings.Builder fmt.Fprintf(&b, "Terminal supports key releases: %v\n", m.supportsEventTypes) fmt.Fprintf(&b, "Terminal supports key disambiguation: %v\n", m.supportsDisambiguation) fmt.Fprint(&b, "This demo logs key events. Press ctrl+c to quit.") v.SetContent(b.String() + "\n") // Attempt to enable reporting key event types (key presses and key // releases). By default, only key disambiguation is enabled which improves // the ability to distinguish between certain key presses like "enter" and // "shift+enter" or "tab" and "ctrl+i". v.KeyboardEnhancements.ReportEventTypes = true return v } func (m *model) updateStyles(isDark bool) { // Initialize styles. lightDark := lipgloss.LightDark(isDark) grey := lightDark(lipgloss.Color("239"), lipgloss.Color("245")) darkGray := lightDark(lipgloss.Color("245"), lipgloss.Color("239")) m.styles.ui = lipgloss.NewStyle(). Foreground(grey). Border(lipgloss.NormalBorder(), true, false, false, false). BorderForeground(darkGray) } func initialModel() model { m := model{} m.updateStyles(true) // default to dark styles. return m } func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { fmt.Fprintf(os.Stderr, "Urgh: %v\n", err) os.Exit(1) } } ================================================ FILE: examples/list-default/README.md ================================================ # Default List ================================================ FILE: examples/list-default/main.go ================================================ package main import ( "fmt" "os" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var docStyle = lipgloss.NewStyle().Margin(1, 2) type item struct { title, desc string } func (i item) Title() string { return i.title } func (i item) Description() string { return i.desc } func (i item) FilterValue() string { return i.title } type model struct { list list.Model } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if msg.String() == "ctrl+c" { return m, tea.Quit } case tea.WindowSizeMsg: h, v := docStyle.GetFrameSize() m.list.SetSize(msg.Width-h, msg.Height-v) } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } func (m model) View() tea.View { v := tea.NewView(docStyle.Render(m.list.View())) v.AltScreen = true return v } func main() { items := []list.Item{ item{title: "Raspberry Pi’s", desc: "I have ’em all over my house"}, item{title: "Nutella", desc: "It's good on toast"}, item{title: "Bitter melon", desc: "It cools you down"}, item{title: "Nice socks", desc: "And by that I mean socks without holes"}, item{title: "Eight hours of sleep", desc: "I had this once"}, item{title: "Cats", desc: "Usually"}, item{title: "Plantasia, the album", desc: "My plants love it too"}, item{title: "Pour over coffee", desc: "It takes forever to make though"}, item{title: "VR", desc: "Virtual reality...what is there to say?"}, item{title: "Noguchi Lamps", desc: "Such pleasing organic forms"}, item{title: "Linux", desc: "Pretty much the best OS"}, item{title: "Business school", desc: "Just kidding"}, item{title: "Pottery", desc: "Wet clay is a great feeling"}, item{title: "Shampoo", desc: "Nothing like clean hair"}, item{title: "Table tennis", desc: "It’s surprisingly exhausting"}, item{title: "Milk crates", desc: "Great for packing in your extra stuff"}, item{title: "Afternoon tea", desc: "Especially the tea sandwich part"}, item{title: "Stickers", desc: "The thicker the vinyl the better"}, item{title: "20° Weather", desc: "Celsius, not Fahrenheit"}, item{title: "Warm light", desc: "Like around 2700 Kelvin"}, item{title: "The vernal equinox", desc: "The autumnal equinox is pretty good too"}, item{title: "Gaffer’s tape", desc: "Basically sticky fabric"}, item{title: "Terrycloth", desc: "In other words, towel fabric"}, } m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 0)} m.list.Title = "My Fave Things" p := tea.NewProgram(m) if _, err := p.Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } ================================================ FILE: examples/list-fancy/README.md ================================================ # Fancy List ================================================ FILE: examples/list-fancy/delegate.go ================================================ package main import ( "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" ) func newItemDelegate(keys *delegateKeyMap, styles *styles) list.DefaultDelegate { d := list.NewDefaultDelegate() d.UpdateFunc = func(msg tea.Msg, m *list.Model) tea.Cmd { var title string if i, ok := m.SelectedItem().(item); ok { title = i.Title() } else { return nil } switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, keys.choose): return m.NewStatusMessage(styles.statusMessage.Render("You chose " + title)) case key.Matches(msg, keys.remove): index := m.Index() m.RemoveItem(index) if len(m.Items()) == 0 { keys.remove.SetEnabled(false) } return m.NewStatusMessage(styles.statusMessage.Render("Deleted " + title)) } } return nil } help := []key.Binding{keys.choose, keys.remove} d.ShortHelpFunc = func() []key.Binding { return help } d.FullHelpFunc = func() [][]key.Binding { return [][]key.Binding{help} } return d } type delegateKeyMap struct { choose key.Binding remove key.Binding } // Additional short help entries. This satisfies the help.KeyMap interface and // is entirely optional. func (d delegateKeyMap) ShortHelp() []key.Binding { return []key.Binding{ d.choose, d.remove, } } // Additional full help entries. This satisfies the help.KeyMap interface and // is entirely optional. func (d delegateKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ { d.choose, d.remove, }, } } func newDelegateKeyMap() *delegateKeyMap { return &delegateKeyMap{ choose: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "choose"), ), remove: key.NewBinding( key.WithKeys("x", "backspace"), key.WithHelp("x", "delete"), ), } } ================================================ FILE: examples/list-fancy/main.go ================================================ package main import ( "fmt" "os" "sync" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) type styles struct { app lipgloss.Style title lipgloss.Style statusMessage lipgloss.Style } func newStyles(darkBG bool) styles { lightDark := lipgloss.LightDark(darkBG) return styles{ app: lipgloss.NewStyle(). Padding(1, 2), title: lipgloss.NewStyle(). Foreground(lipgloss.Color("#FFFDF5")). Background(lipgloss.Color("#25A065")). Padding(0, 1), statusMessage: lipgloss.NewStyle(). Foreground(lightDark(lipgloss.Color("#04B575"), lipgloss.Color("#04B575"))), } } type item struct { title string description string } func (i item) Title() string { return i.title } func (i item) Description() string { return i.description } func (i item) FilterValue() string { return i.title } type listKeyMap struct { toggleSpinner key.Binding toggleTitleBar key.Binding toggleStatusBar key.Binding togglePagination key.Binding toggleHelpMenu key.Binding insertItem key.Binding } func newListKeyMap() *listKeyMap { return &listKeyMap{ insertItem: key.NewBinding( key.WithKeys("a"), key.WithHelp("a", "add item"), ), toggleSpinner: key.NewBinding( key.WithKeys("s"), key.WithHelp("s", "toggle spinner"), ), toggleTitleBar: key.NewBinding( key.WithKeys("T"), key.WithHelp("T", "toggle title"), ), toggleStatusBar: key.NewBinding( key.WithKeys("S"), key.WithHelp("S", "toggle status"), ), togglePagination: key.NewBinding( key.WithKeys("P"), key.WithHelp("P", "toggle pagination"), ), toggleHelpMenu: key.NewBinding( key.WithKeys("H"), key.WithHelp("H", "toggle help"), ), } } type model struct { styles styles darkBG bool width, height int once *sync.Once list list.Model itemGenerator *randomItemGenerator keys *listKeyMap delegateKeys *delegateKeyMap } func (m model) Init() tea.Cmd { return tea.Batch( tea.RequestBackgroundColor, ) } func (m *model) updateListProperties() { // Update list size. h, v := m.styles.app.GetFrameSize() m.list.SetSize(m.width-h, m.height-v) // Update the model and list styles. m.styles = newStyles(m.darkBG) m.list.Styles.Title = m.styles.title } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.BackgroundColorMsg: m.darkBG = msg.IsDark() m.updateListProperties() return m, nil case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateListProperties() return m, nil } switch msg := msg.(type) { case tea.KeyPressMsg: // Don't match any of the keys below if we're actively filtering. if m.list.FilterState() == list.Filtering { break } switch { case key.Matches(msg, m.keys.toggleSpinner): cmd := m.list.ToggleSpinner() return m, cmd case key.Matches(msg, m.keys.toggleTitleBar): v := !m.list.ShowTitle() m.list.SetShowTitle(v) m.list.SetShowFilter(v) m.list.SetFilteringEnabled(v) return m, nil case key.Matches(msg, m.keys.toggleStatusBar): m.list.SetShowStatusBar(!m.list.ShowStatusBar()) return m, nil case key.Matches(msg, m.keys.togglePagination): m.list.SetShowPagination(!m.list.ShowPagination()) return m, nil case key.Matches(msg, m.keys.toggleHelpMenu): m.list.SetShowHelp(!m.list.ShowHelp()) return m, nil case key.Matches(msg, m.keys.insertItem): m.delegateKeys.remove.SetEnabled(true) newItem := m.itemGenerator.next() insCmd := m.list.InsertItem(0, newItem) statusCmd := m.list.NewStatusMessage(m.styles.statusMessage.Render("Added " + newItem.Title())) return m, tea.Batch(insCmd, statusCmd) } } // This will also call our delegate's update function. newListModel, cmd := m.list.Update(msg) m.list = newListModel cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) View() tea.View { v := tea.NewView(m.styles.app.Render(m.list.View())) v.AltScreen = true return v } func initialModel() model { // Initialize the model and list. m := model{} m.styles = newStyles(false) // default to dark background styles delegateKeys := newDelegateKeyMap() listKeys := newListKeyMap() // Make initial list of items. var itemGenerator randomItemGenerator const numItems = 24 items := make([]list.Item, numItems) for i := range numItems { items[i] = itemGenerator.next() } // Setup list. delegate := newItemDelegate(delegateKeys, &m.styles) groceryList := list.New(items, delegate, 0, 0) groceryList.Title = "Groceries" groceryList.Styles.Title = m.styles.title groceryList.AdditionalFullHelpKeys = func() []key.Binding { return []key.Binding{ listKeys.toggleSpinner, listKeys.insertItem, listKeys.toggleTitleBar, listKeys.toggleStatusBar, listKeys.togglePagination, listKeys.toggleHelpMenu, } } m.list = groceryList m.keys = listKeys m.delegateKeys = delegateKeys m.itemGenerator = &itemGenerator return m } func main() { if _, err := tea.NewProgram(initialModel()).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } ================================================ FILE: examples/list-fancy/randomitems.go ================================================ package main import ( "math/rand" "sync" ) type randomItemGenerator struct { titles []string descs []string titleIndex int descIndex int mtx *sync.Mutex shuffle *sync.Once } func (r *randomItemGenerator) reset() { r.mtx = &sync.Mutex{} r.shuffle = &sync.Once{} r.titles = []string{ "Artichoke", "Baking Flour", "Bananas", "Barley", "Bean Sprouts", "Bitter Melon", "Black Cod", "Blood Orange", "Brown Sugar", "Cashew Apple", "Cashews", "Cat Food", "Coconut Milk", "Cucumber", "Curry Paste", "Currywurst", "Dill", "Dragonfruit", "Dried Shrimp", "Eggs", "Fish Cake", "Furikake", "Garlic", "Gherkin", "Ginger", "Granulated Sugar", "Grapefruit", "Green Onion", "Hazelnuts", "Heavy whipping cream", "Honey Dew", "Horseradish", "Jicama", "Kohlrabi", "Leeks", "Lentils", "Licorice Root", "Meyer Lemons", "Milk", "Molasses", "Muesli", "Nectarine", "Niagamo Root", "Nopal", "Nutella", "Oat Milk", "Oatmeal", "Olives", "Papaya", "Party Gherkin", "Peppers", "Persian Lemons", "Pickle", "Pineapple", "Plantains", "Pocky", "Powdered Sugar", "Quince", "Radish", "Ramps", "Star Anise", "Sweet Potato", "Tamarind", "Unsalted Butter", "Watermelon", "Weißwurst", "Yams", "Yeast", "Yuzu", "Snow Peas", } r.descs = []string{ "A little weird", "Bold flavor", "Can’t get enough", "Delectable", "Expensive", "Expired", "Exquisite", "Fresh", "Gimme", "In season", "Kind of spicy", "Looks fresh", "Looks good to me", "Maybe not", "My favorite", "Oh my", "On sale", "Organic", "Questionable", "Really fresh", "Refreshing", "Salty", "Scrumptious", "Delectable", "Slightly sweet", "Smells great", "Tasty", "Too ripe", "At last", "What?", "Wow", "Yum", "Maybe", "Sure, why not?", } r.shuffle.Do(func() { shuf := func(x []string) { rand.Shuffle(len(x), func(i, j int) { x[i], x[j] = x[j], x[i] }) } shuf(r.titles) shuf(r.descs) }) } func (r *randomItemGenerator) next() item { if r.mtx == nil { r.reset() } r.mtx.Lock() defer r.mtx.Unlock() i := item{ title: r.titles[r.titleIndex], description: r.descs[r.descIndex], } r.titleIndex++ if r.titleIndex >= len(r.titles) { r.titleIndex = 0 } r.descIndex++ if r.descIndex >= len(r.descs) { r.descIndex = 0 } return i } ================================================ FILE: examples/list-simple/README.md ================================================ # Simple List ================================================ FILE: examples/list-simple/main.go ================================================ package main import ( "fmt" "io" "os" "strings" "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) const listHeight = 14 type styles struct { title lipgloss.Style item lipgloss.Style selectedItem lipgloss.Style pagination lipgloss.Style help lipgloss.Style quitText lipgloss.Style } func newStyles(darkBG bool) styles { var s styles s.title = lipgloss.NewStyle().MarginLeft(2) s.item = lipgloss.NewStyle().PaddingLeft(4) s.selectedItem = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) s.pagination = list.DefaultStyles(darkBG).PaginationStyle.PaddingLeft(4) s.help = list.DefaultStyles(darkBG).HelpStyle.PaddingLeft(4).PaddingBottom(1) s.quitText = lipgloss.NewStyle().Margin(1, 0, 2, 4) return s } type item string func (i item) FilterValue() string { return "" } type itemDelegate struct { styles *styles } func (d itemDelegate) Height() int { return 1 } func (d itemDelegate) Spacing() int { return 0 } func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { i, ok := listItem.(item) if !ok { return } str := fmt.Sprintf("%d. %s", index+1, i) fn := d.styles.item.Render if index == m.Index() { fn = func(s ...string) string { return d.styles.selectedItem.Render("> " + strings.Join(s, " ")) } } fmt.Fprint(w, fn(str)) } type model struct { list list.Model choice string styles styles quitting bool } func initialModel() model { items := []list.Item{ item("Ramen"), item("Tomato Soup"), item("Hamburgers"), item("Cheeseburgers"), item("Currywurst"), item("Okonomiyaki"), item("Pasta"), item("Fillet Mignon"), item("Caviar"), item("Just Wine"), } const defaultWidth = 20 l := list.New(items, itemDelegate{}, defaultWidth, listHeight) l.Title = "What do you want for dinner?" l.SetShowStatusBar(false) l.SetFilteringEnabled(false) m := model{list: l} m.updateStyles(true) // default to dark styles. return m } func (m *model) updateStyles(isDark bool) { m.styles = newStyles(isDark) m.list.Styles.Title = m.styles.title m.list.Styles.PaginationStyle = m.styles.pagination m.list.Styles.HelpStyle = m.styles.help m.list.SetDelegate(itemDelegate{styles: &m.styles}) } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.list.SetWidth(msg.Width) return m, nil case tea.KeyPressMsg: switch keypress := msg.String(); keypress { case "q", "ctrl+c": m.quitting = true return m, tea.Quit case "enter": i, ok := m.list.SelectedItem().(item) if ok { m.choice = string(i) } return m, tea.Quit } } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } func (m model) View() tea.View { if m.choice != "" { return tea.NewView(m.styles.quitText.Render(fmt.Sprintf("%s? Sounds good to me.", m.choice))) } if m.quitting { return tea.NewView(m.styles.quitText.Render("Not hungry? That’s cool.")) } return tea.NewView("\n" + m.list.View()) } func main() { if _, err := tea.NewProgram(initialModel()).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } ================================================ FILE: examples/mouse/main.go ================================================ package main // A simple program that opens the alternate screen buffer and displays mouse // coordinates and events. import ( "log" tea "charm.land/bubbletea/v2" ) func main() { p := tea.NewProgram(model{}) if _, err := p.Run(); err != nil { log.Fatal(err) } } type model struct{} func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if s := msg.String(); s == "ctrl+c" || s == "q" || s == "esc" { return m, tea.Quit } case tea.MouseMsg: mouse := msg.Mouse() return m, tea.Printf("(X: %d, Y: %d) %s", mouse.X, mouse.Y, mouse) } return m, nil } func (m model) View() tea.View { v := tea.NewView("Do mouse stuff. When you're done press q to quit.\n") v.MouseMode = tea.MouseModeAllMotion return v } ================================================ FILE: examples/package-manager/README.md ================================================ # Package Manager ================================================ FILE: examples/package-manager/main.go ================================================ package main import ( "fmt" "math/rand" "os" "strings" "time" "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) type model struct { packages []string index int width int height int spinner spinner.Model progress progress.Model done bool } var ( currentPkgNameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) doneStyle = lipgloss.NewStyle().Margin(1, 2) checkMark = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") ) func newModel() model { p := progress.New( progress.WithDefaultBlend(), progress.WithWidth(40), progress.WithoutPercentage(), ) s := spinner.New() s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) return model{ packages: getPackages(), spinner: s, progress: p, } } func (m model) Init() tea.Cmd { return tea.Batch(downloadAndInstall(m.packages[m.index]), m.spinner.Tick) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "esc", "q": return m, tea.Quit } case installedPkgMsg: pkg := m.packages[m.index] if m.index >= len(m.packages)-1 { // Everything's been installed. We're done! m.done = true return m, tea.Sequence( tea.Printf("%s %s", checkMark, pkg), // print the last success message tea.Quit, // exit the program ) } // Update progress bar m.index++ progressCmd := m.progress.SetPercent(float64(m.index) / float64(len(m.packages))) return m, tea.Batch( progressCmd, tea.Printf("%s %s", checkMark, pkg), // print success message above our program downloadAndInstall(m.packages[m.index]), // download the next package ) case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case progress.FrameMsg: var cmd tea.Cmd m.progress, cmd = m.progress.Update(msg) return m, cmd } return m, nil } func (m model) View() tea.View { n := len(m.packages) w := lipgloss.Width(fmt.Sprintf("%d", n)) if m.done { return tea.NewView(doneStyle.Render(fmt.Sprintf("Done! Installed %d packages.\n", n))) } pkgCount := fmt.Sprintf(" %*d/%*d", w, m.index, w, n) spin := m.spinner.View() + " " prog := m.progress.View() cellsAvail := max(0, m.width-lipgloss.Width(spin+prog+pkgCount)) pkgName := currentPkgNameStyle.Render(m.packages[m.index]) info := lipgloss.NewStyle().MaxWidth(cellsAvail).Render("Installing " + pkgName) cellsRemaining := max(0, m.width-lipgloss.Width(spin+info+prog+pkgCount)) gap := strings.Repeat(" ", cellsRemaining) return tea.NewView(spin + info + gap + prog + pkgCount) } type installedPkgMsg string func downloadAndInstall(pkg string) tea.Cmd { // This is where you'd do i/o stuff to download and install packages. In // our case we're just pausing for a moment to simulate the process. d := time.Millisecond * time.Duration(rand.Intn(500)) //nolint:gosec return tea.Tick(d, func(t time.Time) tea.Msg { return installedPkgMsg(pkg) }) } func main() { if _, err := tea.NewProgram(newModel()).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } ================================================ FILE: examples/package-manager/packages.go ================================================ package main import ( "fmt" "math/rand" ) var packages = []string{ "vegeutils", "libgardening", "currykit", "spicerack", "fullenglish", "eggy", "bad-kitty", "chai", "hojicha", "libtacos", "babys-monads", "libpurring", "currywurst-devel", "xmodmeow", "licorice-utils", "cashew-apple", "rock-lobster", "standmixer", "coffee-CUPS", "libesszet", "zeichenorientierte-benutzerschnittstellen", "schnurrkit", "old-socks-devel", "jalapeño", "molasses-utils", "xkohlrabi", "party-gherkin", "snow-peas", "libyuzu", } func getPackages() []string { pkgs := packages copy(pkgs, packages) rand.Shuffle(len(pkgs), func(i, j int) { pkgs[i], pkgs[j] = pkgs[j], pkgs[i] }) for k := range pkgs { pkgs[k] += fmt.Sprintf("-%d.%d.%d", rand.Intn(10), rand.Intn(10), rand.Intn(10)) //nolint:gosec } return pkgs } ================================================ FILE: examples/pager/README.md ================================================ # Pager ================================================ FILE: examples/pager/artichoke.md ================================================ Glow ==== A casual introduction. 你好世界! ## Let’s talk about artichokes The _artichoke_ is mentioned as a garden plant in the 8th century BC by Homer **and** Hesiod. The naturally occurring variant of the artichoke, the cardoon, which is native to the Mediterranean area, also has records of use as a food among the ancient Greeks and Romans. Pliny the Elder mentioned growing of _carduus_ in Carthage and Cordoba. > He holds him with a skinny hand, > ‘There was a ship,’ quoth he. > ‘Hold off! unhand me, grey-beard loon!’ > An artichoke, dropt he. --Samuel Taylor Coleridge, [The Rime of the Ancient Mariner][rime] [rime]: https://poetryfoundation.org/poems/43997/ ## Other foods worth mentioning 1. Carrots 1. Celery 1. Tacos * Soft * Hard 1. Cucumber ## Things to eat today * [x] Carrots * [x] Ramen * [ ] Currywurst ### Power levels of the aforementioned foods | Name | Power | Comment | | --- | --- | --- | | Carrots | 9001 | It’s over 9000?! | | Ramen | 9002 | Also over 9000?! | | Currywurst | 10000 | What?! | ## Currying Artichokes Here’s a bit of code in [Haskell](https://haskell.org), because we are fancy. Remember that to compile Haskell you’ll need `ghc`. ```haskell module Main where import Data.Function ( (&) ) import Data.List ( intercalculate ) hello :: String -> String hello s = "Hello, " ++ s ++ "." main :: IO () main = map hello [ "artichoke", "alcachofa" ] & intercalculate "\n" & putStrLn ``` *** _Alcachofa_, if you were wondering, is artichoke in Spanish. ================================================ FILE: examples/pager/main.go ================================================ package main // An example program demonstrating the pager component from the Bubbles // component library. import ( "fmt" "os" "regexp" "strings" "charm.land/bubbles/v2/viewport" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var ( titleStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() b.Right = "├" return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) }() infoStyle = func() lipgloss.Style { b := lipgloss.RoundedBorder() b.Left = "┤" return titleStyle.BorderStyle(b) }() ) type model struct { content string ready bool viewport viewport.Model } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var ( cmd tea.Cmd cmds []tea.Cmd ) switch msg := msg.(type) { case tea.KeyPressMsg: if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" { return m, tea.Quit } case tea.WindowSizeMsg: headerHeight := lipgloss.Height(m.headerView()) footerHeight := lipgloss.Height(m.footerView()) verticalMarginHeight := headerHeight + footerHeight if !m.ready { // Since this program is using the full size of the viewport we // need to wait until we've received the window dimensions before // we can initialize the viewport. The initial dimensions come in // quickly, though asynchronously, which is why we wait for them // here. m.viewport = viewport.New(viewport.WithWidth(msg.Width), viewport.WithHeight(msg.Height-verticalMarginHeight)) m.viewport.YPosition = headerHeight m.viewport.LeftGutterFunc = func(info viewport.GutterContext) string { if info.Soft { return " │ " } if info.Index >= info.TotalLines { return " ~ │ " } return fmt.Sprintf("%4d │ ", info.Index+1) } m.viewport.HighlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Background(lipgloss.Color("34")) m.viewport.SelectedHighlightStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Background(lipgloss.Color("47")) m.viewport.SetContent(m.content) m.viewport.SetHighlights(regexp.MustCompile("artichoke").FindAllStringIndex(m.content, -1)) m.viewport.HighlightNext() m.ready = true } else { m.viewport.SetWidth(msg.Width) m.viewport.SetHeight(msg.Height - verticalMarginHeight) } } // Handle keyboard and mouse events in the viewport m.viewport, cmd = m.viewport.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) View() tea.View { var v tea.View v.AltScreen = true // use the full size of the terminal in its "alternate screen buffer" v.MouseMode = tea.MouseModeCellMotion // turn on mouse support so we can track the mouse wheel if !m.ready { v.SetContent("\n Initializing...") } else { v.SetContent(fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())) } return v } func (m model) headerView() string { title := titleStyle.Render("Mr. Pager") line := strings.Repeat("─", max(0, m.viewport.Width()-lipgloss.Width(title))) return lipgloss.JoinHorizontal(lipgloss.Center, title, line) } func (m model) footerView() string { info := infoStyle.Render(fmt.Sprintf("%3.f%%:%3.f%%", m.viewport.ScrollPercent()*100, m.viewport.HorizontalScrollPercent()*100)) line := strings.Repeat("─", max(0, m.viewport.Width()-lipgloss.Width(info))) return lipgloss.JoinHorizontal(lipgloss.Center, line, info) } func main() { // Load some text for our viewport content, err := os.ReadFile("artichoke.md") if err != nil { fmt.Println("could not load file:", err) os.Exit(1) } p := tea.NewProgram( model{content: string(content)}, ) if _, err := p.Run(); err != nil { fmt.Println("could not run program:", err) os.Exit(1) } } ================================================ FILE: examples/paginator/README.md ================================================ # Paginator ================================================ FILE: examples/paginator/main.go ================================================ package main // A simple program demonstrating the paginator component from the Bubbles // component library. import ( "fmt" "log" "strings" "charm.land/bubbles/v2/paginator" "charm.land/lipgloss/v2" tea "charm.land/bubbletea/v2" ) type styles struct { activeDot lipgloss.Style inactiveDot lipgloss.Style } func newStyles(bgIsDark bool) (s styles) { lightDark := lipgloss.LightDark(bgIsDark) s.activeDot = lipgloss.NewStyle().Foreground(lightDark(lipgloss.Color("235"), lipgloss.Color("252"))).SetString("•") s.inactiveDot = s.activeDot.Foreground(lightDark(lipgloss.Color("250"), lipgloss.Color("238"))).SetString("•") return s } type model struct { items []string paginator paginator.Model } func newModel() model { var items []string for i := 1; i < 101; i++ { text := fmt.Sprintf("Item %d", i) items = append(items, text) } p := paginator.New() p.Type = paginator.Dots p.PerPage = 10 p.SetTotalPages(len(items)) m := model{ paginator: p, items: items, } m.updateStyles(true) // default to dark styles return m } func (m *model) updateStyles(isDark bool) { styles := newStyles(isDark) m.paginator.ActiveDot = styles.activeDot.String() m.paginator.InactiveDot = styles.inactiveDot.String() } func (m model) Init() tea.Cmd { return tea.RequestBackgroundColor } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.BackgroundColorMsg: m.updateStyles(msg.IsDark()) return m, nil case tea.KeyPressMsg: switch msg.String() { case "q", "esc", "ctrl+c": return m, tea.Quit } } m.paginator, cmd = m.paginator.Update(msg) return m, cmd } func (m model) View() tea.View { var b strings.Builder b.WriteString("\n Paginator Example\n\n") start, end := m.paginator.GetSliceBounds(len(m.items)) for _, item := range m.items[start:end] { b.WriteString(" • " + item + "\n\n") } b.WriteString(" " + m.paginator.View()) b.WriteString("\n\n h/l ←/→ page • q: quit\n") return tea.NewView(b.String()) } func main() { p := tea.NewProgram(newModel()) if _, err := p.Run(); err != nil { log.Fatal(err) } } ================================================ FILE: examples/pipe/README.md ================================================ # Pipe ================================================ FILE: examples/pipe/main.go ================================================ package main // An example illustrating how to pipe in data to a Bubble Tea application. // More so, this serves as proof that Bubble Tea will automatically listen for // keystrokes when input is not a TTY, such as when data is piped or redirected // in. import ( "bufio" "fmt" "io" "os" "strings" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) func main() { stat, err := os.Stdin.Stat() if err != nil { panic(err) } if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 { fmt.Println("Try piping in some text.") os.Exit(1) } reader := bufio.NewReader(os.Stdin) var b strings.Builder for { r, _, err := reader.ReadRune() if err != nil && err == io.EOF { break } _, err = b.WriteRune(r) if err != nil { fmt.Println("Error getting input:", err) os.Exit(1) } } model := newModel(strings.TrimSpace(b.String())) if _, err := tea.NewProgram(model).Run(); err != nil { fmt.Println("Couldn't start program:", err) os.Exit(1) } } type model struct { userInput textinput.Model } func newModel(initialValue string) (m model) { i := textinput.New() i.Prompt = "" s := i.Styles() s.Cursor.Color = lipgloss.Color("63") i.SetStyles(s) i.SetWidth(48) i.SetValue(initialValue) i.CursorEnd() i.Focus() m.userInput = i return m } func (m model) Init() tea.Cmd { return textinput.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { switch key.String() { case "ctrl+c", "esc", "enter": return m, tea.Quit } } var cmd tea.Cmd m.userInput, cmd = m.userInput.Update(msg) return m, cmd } func (m model) View() tea.View { return tea.NewView(fmt.Sprintf( "\nYou piped in: %s\n\nPress ^C to exit", m.userInput.View(), )) } ================================================ FILE: examples/prevent-quit/main.go ================================================ package main // A program demonstrating how to use the WithFilter option to intercept events. import ( "fmt" "log" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var ( choiceStyle = lipgloss.NewStyle().PaddingLeft(1).Foreground(lipgloss.Color("241")) saveTextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")) quitViewStyle = lipgloss.NewStyle().Padding(1, 3).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("170")) ) func main() { p := tea.NewProgram(initialModel(), tea.WithFilter(filter)) if _, err := p.Run(); err != nil { log.Fatal(err) } } func filter(teaModel tea.Model, msg tea.Msg) tea.Msg { if _, ok := msg.(tea.QuitMsg); !ok { return msg } m := teaModel.(model) if m.hasChanges { return nil } return msg } type model struct { textarea textarea.Model help help.Model keymap keymap saveText string hasChanges bool quitting bool } type keymap struct { save key.Binding quit key.Binding } func initialModel() model { ti := textarea.New() ti.Placeholder = "Only the best words" ti.Focus() return model{ textarea: ti, help: help.New(), keymap: keymap{ save: key.NewBinding( key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "save"), ), quit: key.NewBinding( key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "quit"), ), }, } } func (m model) Init() tea.Cmd { return textarea.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.quitting { return m.updatePromptView(msg) } return m.updateTextView(msg) } func (m model) updateTextView(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: m.saveText = "" switch { case key.Matches(msg, m.keymap.save): m.saveText = "Changes saved!" m.hasChanges = false case key.Matches(msg, m.keymap.quit): m.quitting = true return m, tea.Quit case len(msg.Text) > 0: m.saveText = "" m.hasChanges = true fallthrough default: if !m.textarea.Focused() { cmd = m.textarea.Focus() cmds = append(cmds, cmd) } } } m.textarea, cmd = m.textarea.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) updatePromptView(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: // For simplicity's sake, we'll treat any key besides "y" as "no" if key.Matches(msg, m.keymap.quit) || msg.String() == "y" { m.hasChanges = false return m, tea.Quit } m.quitting = false } return m, nil } func (m model) View() tea.View { if m.quitting { if m.hasChanges { text := lipgloss.JoinHorizontal(lipgloss.Top, "You have unsaved changes. Quit without saving?", choiceStyle.Render("[yN]")) return tea.NewView(quitViewStyle.Render(text)) } return tea.NewView("Very important. Thank you.\n") } helpView := m.help.ShortHelpView([]key.Binding{ m.keymap.save, m.keymap.quit, }) return tea.NewView(fmt.Sprintf( "Type some important things.\n%s\n %s\n %s", m.textarea.View(), saveTextStyle.Render(m.saveText), helpView, ) + "\n\n") } ================================================ FILE: examples/print-key/main.go ================================================ package main import ( "log" tea "charm.land/bubbletea/v2" ) type model struct{} func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyboardEnhancementsMsg: return m, tea.Printf("Keyboard enhancements: EventTypes: %v\n", msg.SupportsEventTypes()) case tea.KeyMsg: key := msg.Key() switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c": return m, tea.Quit } } format := "(%T) You pressed: %s" args := []any{msg, msg.String()} if len(key.Text) > 0 { format += " (text: %q)" args = append(args, key.Text) } return m, tea.Printf(format, args...) } return m, nil } func (m model) View() tea.View { v := tea.NewView("Press any key to see its details printed to the terminal. Press 'ctrl+c' to quit.") v.KeyboardEnhancements.ReportEventTypes = true return v } func main() { p := tea.NewProgram(model{}) if _, err := p.Run(); err != nil { log.Printf("Error running program: %v", err) } } ================================================ FILE: examples/progress-animated/README.md ================================================ # Animated Progress ================================================ FILE: examples/progress-animated/main.go ================================================ package main // A simple example that shows how to render an animated progress bar. In this // example we bump the progress by 25% every two seconds, animating our // progress bar to its new target state. // // It's also possible to render a progress bar in a more static fashion without // transitions. For details on that approach see the progress-static example. import ( "fmt" "os" "strings" "time" "charm.land/bubbles/v2/progress" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) const ( padding = 2 maxWidth = 80 ) var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render func main() { m := model{ progress: progress.New(progress.WithDefaultBlend()), } if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Oh no!", err) os.Exit(1) } } type tickMsg time.Time type model struct { progress progress.Model } func (m model) Init() tea.Cmd { return tickCmd() } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit case tea.WindowSizeMsg: m.progress.SetWidth(msg.Width - padding*2 - 4) if m.progress.Width() > maxWidth { m.progress.SetWidth(maxWidth) } return m, nil case tickMsg: if m.progress.Percent() == 1.0 { return m, tea.Quit } // Note that you can also use progress.Model.SetPercent to set the // percentage value explicitly, too. cmd := m.progress.IncrPercent(0.25) return m, tea.Batch(tickCmd(), cmd) // FrameMsg is sent when the progress bar wants to animate itself case progress.FrameMsg: var cmd tea.Cmd m.progress, cmd = m.progress.Update(msg) return m, cmd default: return m, nil } } func (m model) View() tea.View { pad := strings.Repeat(" ", padding) return tea.NewView("\n" + pad + m.progress.View() + "\n\n" + pad + helpStyle("Press any key to quit")) } func tickCmd() tea.Cmd { return tea.Tick(time.Second*1, func(t time.Time) tea.Msg { return tickMsg(t) }) } ================================================ FILE: examples/progress-bar/main.go ================================================ package main import ( "log" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var body = lipgloss.NewStyle().Padding(1, 2) type model struct { value int width int state tea.ProgressBarState } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "up", "k": if m.value < 100 { m.value += 10 } case "down", "j": if m.value > 0 { m.value -= 10 } case "left", "h": if m.state > 0 { m.state-- } case "right", "l": if m.state < 4 { m.state++ } } } return m, nil } func (m model) View() tea.View { s := body.Width(m.width - body.GetHorizontalPadding()).Render( "This demo requires a terminal emulator that supports an indeterminate progress bar, such a Windows Terminal or Ghostty. In other terminals (including tmux in a supporting terminal) nothing will happen.\n\nPress up/down to change value, left/right to change state, q to quit.", ) v := tea.NewView(s) v.ProgressBar = tea.NewProgressBar(m.state, m.value) return v } func main() { p := tea.NewProgram(model{value: 50, state: tea.ProgressBarIndeterminate}) if _, err := p.Run(); err != nil { log.Fatalf("Error: %v", err) } } ================================================ FILE: examples/progress-download/README.md ================================================ # Download Progress This example demonstrates how to download a file from a URL and show its progress with a [Progress Bubble][progress]. In this case we're getting download progress with an [`io.TeeReader`][tee] and sending progress `Msg`s to the `Program` with `Program.Send()`. ## How to Run Build the application with `go build .`, then run with a `--url` argument specifying the URL of the file to download. For example: ``` ./progress-download --url="https://download.blender.org/demo/color_vortex.blend" ``` Note that in this example a TUI will not be shown for URLs that do not respond with a ContentLength header. * * * This example originally came from [this discussion][discussion]. * * * The Charm logo Charm热爱开源 • Charm loves open source [progress]: https://github.com/charmbracelet/bubbles/ [tee]: https://pkg.go.dev/io#TeeReader [discussion]: https://github.com/charmbracelet/bubbles/discussions/127 ================================================ FILE: examples/progress-download/main.go ================================================ package main import ( "flag" "fmt" "io" "log" "net/http" "os" "path/filepath" "charm.land/bubbles/v2/progress" tea "charm.land/bubbletea/v2" ) var p *tea.Program type progressWriter struct { total int downloaded int file *os.File reader io.Reader onProgress func(float64) } func (pw *progressWriter) Start() { // TeeReader calls pw.Write() each time a new response is received _, err := io.Copy(pw.file, io.TeeReader(pw.reader, pw)) if err != nil { p.Send(progressErrMsg{err}) } } func (pw *progressWriter) Write(p []byte) (int, error) { pw.downloaded += len(p) if pw.total > 0 && pw.onProgress != nil { pw.onProgress(float64(pw.downloaded) / float64(pw.total)) } return len(p), nil } func getResponse(url string) (*http.Response, error) { resp, err := http.Get(url) // nolint:gosec if err != nil { log.Fatal(err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("receiving status of %d for url: %s", resp.StatusCode, url) } return resp, nil } func main() { url := flag.String("url", "", "url for the file to download") flag.Parse() if *url == "" { flag.Usage() os.Exit(1) } resp, err := getResponse(*url) if err != nil { fmt.Println("could not get response", err) os.Exit(1) } defer resp.Body.Close() // nolint:errcheck // Don't add TUI if the header doesn't include content size // it's impossible see progress without total if resp.ContentLength <= 0 { fmt.Println("can't parse content length, aborting download") os.Exit(1) } filename := filepath.Base(*url) file, err := os.Create(filename) if err != nil { fmt.Println("could not create file:", err) os.Exit(1) } defer file.Close() // nolint:errcheck pw := &progressWriter{ total: int(resp.ContentLength), file: file, reader: resp.Body, onProgress: func(ratio float64) { p.Send(progressMsg(ratio)) }, } m := model{ pw: pw, progress: progress.New(progress.WithDefaultBlend()), } // Start Bubble Tea p = tea.NewProgram(m) // Start the download go pw.Start() if _, err := p.Run(); err != nil { fmt.Println("error running program:", err) os.Exit(1) } } ================================================ FILE: examples/progress-download/tui.go ================================================ package main import ( "strings" "time" "charm.land/bubbles/v2/progress" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render const ( padding = 2 maxWidth = 80 ) type progressMsg float64 type progressErrMsg struct{ err error } func finalPause() tea.Cmd { return tea.Tick(time.Millisecond*750, func(_ time.Time) tea.Msg { return nil }) } type model struct { pw *progressWriter progress progress.Model err error } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit case tea.WindowSizeMsg: m.progress.SetWidth(msg.Width - padding*2 - 4) if m.progress.Width() > maxWidth { m.progress.SetWidth(maxWidth) } return m, nil case progressErrMsg: m.err = msg.err return m, tea.Quit case progressMsg: var cmds []tea.Cmd if msg >= 1.0 { cmds = append(cmds, tea.Sequence(finalPause(), tea.Quit)) } cmds = append(cmds, m.progress.SetPercent(float64(msg))) return m, tea.Batch(cmds...) // FrameMsg is sent when the progress bar wants to animate itself case progress.FrameMsg: var cmd tea.Cmd m.progress, cmd = m.progress.Update(msg) return m, cmd default: return m, nil } } func (m model) View() tea.View { if m.err != nil { return tea.NewView("Error downloading: " + m.err.Error() + "\n") } pad := strings.Repeat(" ", padding) return tea.NewView("\n" + pad + m.progress.View() + "\n\n" + pad + helpStyle("Press any key to quit")) } ================================================ FILE: examples/progress-static/README.md ================================================ # Static Progress ================================================ FILE: examples/progress-static/main.go ================================================ package main // A simple example that shows how to render a progress bar in a "pure" // fashion. In this example we bump the progress by 25% every second, // maintaining the progress state on our top level model using the progress bar // model's ViewAs method only for rendering. // // The signature for ViewAs is: // // func (m Model) ViewAs(percent float64) string // // So it takes a float between 0 and 1, and renders the progress bar // accordingly. When using the progress bar in this "pure" fashion and there's // no need to call an Update method. // // The progress bar is also able to animate itself, however. For details see // the progress-animated example. import ( "fmt" "os" "strings" "time" "charm.land/bubbles/v2/progress" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) const ( padding = 2 maxWidth = 80 ) var ( helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render yellow = lipgloss.Color("#FDFF8C") pink = lipgloss.Color("#FF7CCB") ) func main() { prog := progress.New(progress.WithScaled(true), progress.WithColors(pink, yellow)) if _, err := tea.NewProgram(model{progress: prog}).Run(); err != nil { fmt.Println("Oh no!", err) os.Exit(1) } } type tickMsg time.Time type model struct { percent float64 progress progress.Model } func (m model) Init() tea.Cmd { return tickCmd() } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit case tea.WindowSizeMsg: m.progress.SetWidth(msg.Width - padding*2 - 4) if m.progress.Width() > maxWidth { m.progress.SetWidth(maxWidth) } return m, nil case tickMsg: m.percent += 0.25 if m.percent > 1.0 { m.percent = 1.0 return m, tea.Quit } return m, tickCmd() default: return m, nil } } func (m model) View() tea.View { pad := strings.Repeat(" ", padding) return tea.NewView("\n" + pad + m.progress.ViewAs(m.percent) + "\n\n" + pad + helpStyle("Press any key to quit")) } func tickCmd() tea.Cmd { return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } ================================================ FILE: examples/query-term/main.go ================================================ // This example uses a textinput to send the terminal ANSI sequences to query // it for capabilities. package main import ( "fmt" "io" "log" "os" "strconv" "strings" "unicode" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" ) func newModel() model { ti := textinput.New() ti.Focus() ti.CharLimit = 156 ti.SetWidth(20) ti.SetVirtualCursor(false) return model{input: ti} } type model struct { input textinput.Model err error } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: m.err = nil switch msg.String() { case "ctrl+c": return m, tea.Quit case "enter": // Write the sequence to the terminal. val := m.input.Value() val = "\"" + val + "\"" // Unescape the sequence. seq, err := strconv.Unquote(val) if err != nil { m.err = err return m, nil } if !strings.HasPrefix(seq, "\x1b") { m.err = fmt.Errorf("sequence is not an ANSI escape sequence") return m, nil } m.input.SetValue("") // Write the sequence to the terminal. return m, func() tea.Msg { io.WriteString(os.Stdout, seq) return nil } } default: _, typ, ok := strings.Cut(fmt.Sprintf("%T", msg), ".") if ok && unicode.IsUpper(rune(typ[0])) { // Only log messages that are exported types. cmds = append(cmds, tea.Printf("Received message: %T %+v", msg, msg)) } } var cmd tea.Cmd m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) View() tea.View { var s strings.Builder s.WriteString(m.input.View()) if m.err != nil { s.WriteString("\n\nError: " + m.err.Error()) } s.WriteString("\n\nPress ctrl+c to quit, enter to write the sequence to terminal") v := tea.NewView(s.String()) v.Cursor = m.input.Cursor() return v } func main() { p := tea.NewProgram(newModel()) if _, err := p.Run(); err != nil { log.Fatal(err) } } ================================================ FILE: examples/realtime/README.md ================================================ # Real Time ================================================ FILE: examples/realtime/main.go ================================================ package main // A simple example that shows how to send activity to Bubble Tea in real-time // through a channel. import ( "fmt" "math/rand" "os" "time" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" ) // A message used to indicate that activity has occurred. In the real world (for // example, chat) this would contain actual data. type responseMsg struct{} // Simulate a process that sends events at an irregular interval in real time. // In this case, we'll send events on the channel at a random interval between // 100 to 1000 milliseconds. As a command, Bubble Tea will run this // asynchronously. func listenForActivity(sub chan struct{}) tea.Cmd { return func() tea.Msg { for { time.Sleep(time.Millisecond * time.Duration(rand.Int63n(900)+100)) // nolint:gosec sub <- struct{}{} } } } // A command that waits for the activity on a channel. func waitForActivity(sub chan struct{}) tea.Cmd { return func() tea.Msg { return responseMsg(<-sub) } } type model struct { sub chan struct{} // where we'll receive activity notifications responses int // how many responses we've received spinner spinner.Model quitting bool } func (m model) Init() tea.Cmd { return tea.Batch( m.spinner.Tick, listenForActivity(m.sub), // generate activity waitForActivity(m.sub), // wait for activity ) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case tea.KeyPressMsg: m.quitting = true return m, tea.Quit case responseMsg: m.responses++ // record external activity return m, waitForActivity(m.sub) // wait for next event case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd default: return m, nil } } func (m model) View() tea.View { s := fmt.Sprintf("\n %s Events received: %d\n\n Press any key to exit\n", m.spinner.View(), m.responses) if m.quitting { s += "\n" } return tea.NewView(s) } func main() { p := tea.NewProgram(model{ sub: make(chan struct{}), spinner: spinner.New(), }) if _, err := p.Run(); err != nil { fmt.Println("could not start program:", err) os.Exit(1) } } ================================================ FILE: examples/result/README.md ================================================ # Result ================================================ FILE: examples/result/main.go ================================================ package main // A simple example that shows how to retrieve a value from a Bubble Tea // program after the Bubble Tea has exited. import ( "fmt" "os" "strings" tea "charm.land/bubbletea/v2" ) var choices = []string{"Taro", "Coffee", "Lychee"} type model struct { cursor int choice string } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q", "esc": return m, tea.Quit case "enter": // Send the choice on the channel and exit. m.choice = choices[m.cursor] return m, tea.Quit case "down", "j": m.cursor++ if m.cursor >= len(choices) { m.cursor = 0 } case "up", "k": m.cursor-- if m.cursor < 0 { m.cursor = len(choices) - 1 } } } return m, nil } func (m model) View() tea.View { s := strings.Builder{} s.WriteString("What kind of Bubble Tea would you like to order?\n\n") for i := range choices { if m.cursor == i { s.WriteString("(•) ") } else { s.WriteString("( ) ") } s.WriteString(choices[i]) s.WriteString("\n") } s.WriteString("\n(press q to quit)\n") return tea.NewView(s.String()) } func main() { p := tea.NewProgram(model{}) // Run returns the model as a tea.Model. m, err := p.Run() if err != nil { fmt.Println("Oh no:", err) os.Exit(1) } // Assert the final tea.Model to our local model and print the choice. if m, ok := m.(model); ok && m.choice != "" { fmt.Printf("\n---\nYou chose %s!\n", m.choice) } } ================================================ FILE: examples/send-msg/README.md ================================================ # Send Msg ================================================ FILE: examples/send-msg/main.go ================================================ package main // A simple example that shows how to send messages to a Bubble Tea program // from outside the program using Program.Send(Msg). import ( "fmt" "math/rand" "os" "strings" "time" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var ( spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Margin(1, 0) dotStyle = helpStyle.UnsetMargins() durationStyle = dotStyle appStyle = lipgloss.NewStyle().Margin(1, 2, 0, 2) ) type resultMsg struct { duration time.Duration food string } func (r resultMsg) String() string { if r.duration == 0 { return dotStyle.Render(strings.Repeat(".", 30)) } return fmt.Sprintf("🍔 Ate %s %s", r.food, durationStyle.Render(r.duration.String())) } type model struct { spinner spinner.Model results []resultMsg quitting bool } func newModel() model { const numLastResults = 5 s := spinner.New() s.Style = spinnerStyle return model{ spinner: s, results: make([]resultMsg, numLastResults), } } func (m model) Init() tea.Cmd { return m.spinner.Tick } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: m.quitting = true return m, tea.Quit case resultMsg: m.results = append(m.results[1:], msg) return m, nil case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd default: return m, nil } } func (m model) View() tea.View { var b strings.Builder if m.quitting { b.WriteString("That's all for today!") } else { b.WriteString(m.spinner.View()) b.WriteString(" Eating food...") } b.WriteString("\n\n") for _, res := range m.results { b.WriteString(res.String()) b.WriteString("\n") } if !m.quitting { b.WriteString(helpStyle.Render("Press any key to exit")) } if m.quitting { b.WriteString("\n") } return tea.NewView(appStyle.Render(b.String())) } func main() { p := tea.NewProgram(newModel()) // Simulate activity go func() { for { pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond // nolint:gosec time.Sleep(pause) // Send the Bubble Tea program a message from outside the // tea.Program. This will block until it is ready to receive // messages. p.Send(resultMsg{food: randomFood(), duration: pause}) } }() if _, err := p.Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } func randomFood() string { food := []string{ "an apple", "a pear", "a gherkin", "a party gherkin", "a kohlrabi", "some spaghetti", "tacos", "a currywurst", "some curry", "a sandwich", "some peanut butter", "some cashews", "some ramen", } return food[rand.Intn(len(food))] // nolint:gosec } ================================================ FILE: examples/sequence/README.md ================================================ # Sequence ================================================ FILE: examples/sequence/main.go ================================================ package main // A simple example illustrating how to run a series of commands in order. import ( "fmt" "os" "time" tea "charm.land/bubbletea/v2" ) type model struct{} func (m model) Init() tea.Cmd { // A tea.Sequence is a command that runs a series of commands in // order. Contrast this with tea.Batch, which runs a series of commands // concurrently, with no order guarantees. return tea.Sequence( tea.Batch( tea.Sequence( SleepPrintln("1-1-1", 1000), SleepPrintln("1-1-2", 1000), ), tea.Batch( SleepPrintln("1-2-1", 1500), SleepPrintln("1-2-2", 1250), ), ), tea.Println("2"), tea.Sequence( tea.Batch( SleepPrintln("3-1-1", 500), SleepPrintln("3-1-2", 1000), ), tea.Sequence( SleepPrintln("3-2-1", 750), SleepPrintln("3-2-2", 500), ), ), tea.Quit, ) } // print string after stopping for a certain period of time func SleepPrintln(s string, milisecond int) tea.Cmd { printCmd := tea.Println(s) return func() tea.Msg { time.Sleep(time.Duration(milisecond) * time.Millisecond) return printCmd() } } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case tea.KeyPressMsg: return m, tea.Quit } return m, nil } func (m model) View() tea.View { return tea.NewView("") } func main() { if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Uh oh:", err) os.Exit(1) } } ================================================ FILE: examples/set-terminal-color/main.go ================================================ package main import ( "image/color" "log" "strings" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/lucasb-eyer/go-colorful" ) type colorType int const ( foreground colorType = iota + 1 background cursor ) func (c colorType) String() string { switch c { case foreground: return "Foreground" case background: return "Background" case cursor: return "Cursor" default: return "Unknown" } } type state int const ( chooseState state = iota inputState ) type model struct { ti textinput.Model choice colorType state state choiceIndex int err error fg, bg, cc color.Color } func (m model) Init() tea.Cmd { return textinput.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit } switch m.state { case chooseState: m.ti.Blur() switch msg.String() { case "j", "down": m.choiceIndex++ if m.choiceIndex > 2 { m.choiceIndex = 0 } case "k", "up": m.choiceIndex-- if m.choiceIndex < 0 { m.choiceIndex = 2 } case "enter": m.state = inputState m.ti.Focus() switch m.choiceIndex { case 0: m.choice = foreground case 1: m.choice = background case 2: m.choice = cursor } } case inputState: m.ti.Focus() switch msg.String() { case "esc": m.choice = 0 m.choiceIndex = 0 m.state = chooseState m.err = nil m.ti.Blur() case "enter": val := m.ti.Value() col, err := colorful.Hex(val) if err != nil { m.err = err } else { m.err = nil choice := m.choice m.choice = 0 m.choiceIndex = 0 m.state = chooseState // Reset the text input m.ti.Reset() switch choice { case foreground: m.fg = col case background: m.bg = col case cursor: m.cc = col } } m.ti.Blur() default: var cmd tea.Cmd m.ti, cmd = m.ti.Update(msg) return m, cmd } } } return m, nil } func (m model) View() tea.View { var s strings.Builder instructions := lipgloss.NewStyle().Width(40).Render("Choose a terminal-wide color to set. All settings will be cleared on exit.") switch m.state { case chooseState: s.WriteString(instructions + "\n\n") for i, c := range []colorType{foreground, background, cursor} { if i == m.choiceIndex { s.WriteString(" > ") } else { s.WriteString(" ") } s.WriteString(c.String()) s.WriteString("\n") } case inputState: s.WriteString("Enter a color in hex format:\n\n") s.WriteString(m.ti.View()) s.WriteString("\n") } if m.err != nil { s.WriteString("\nError: ") s.WriteString(m.err.Error()) } s.WriteString("\nPress q to quit") switch m.state { case chooseState: s.WriteString(", j/k to move, and enter to select") case inputState: s.WriteString(", and enter to submit, esc to go back") } s.WriteString("\n") v := tea.NewView(s.String()) if m.ti.Focused() { v.Cursor = m.ti.Cursor() v.Cursor.Y += 2 // account for the prompt v.Cursor.Color = m.cc } v.BackgroundColor = m.bg v.ForegroundColor = m.fg return v } func main() { ti := textinput.New() ti.Placeholder = "#ff00ff" ti.CharLimit = 156 ti.SetWidth(20) ti.SetVirtualCursor(false) p := tea.NewProgram(model{ ti: ti, }) _, err := p.Run() if err != nil { log.Fatalf("Error running program: %v", err) } } ================================================ FILE: examples/set-window-title/main.go ================================================ package main // A simple example illustrating how to set a window title. import ( "fmt" "os" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) const windowTitle = "Hello, Bubble Tea" type model struct{} func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.(type) { case tea.KeyPressMsg: return m, tea.Quit } return m, nil } func (m model) View() tea.View { wrap := lipgloss.NewStyle().Width(78).Render v := tea.NewView(wrap("The window title has been set to '"+windowTitle+"'. It will be cleared on exit.") + "\n\nPress any key to quit.") v.WindowTitle = windowTitle return v } func main() { if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Uh oh:", err) os.Exit(1) } } ================================================ FILE: examples/simple/README.md ================================================ # Simple ================================================ FILE: examples/simple/main.go ================================================ package main // A simple program that counts down from 5 and then exits. import ( "fmt" "log" "os" "time" tea "charm.land/bubbletea/v2" ) func main() { // Log to a file. Useful in debugging since you can't really log to stdout. // Not required. logfilePath := os.Getenv("BUBBLETEA_LOG") if logfilePath != "" { if _, err := tea.LogToFile(logfilePath, "simple"); err != nil { log.Fatal(err) } } // Initialize our program p := tea.NewProgram(model(5)) if _, err := p.Run(); err != nil { log.Fatal(err) } } // A model can be more or less any type of data. It holds all the data for a // program, so often it's a struct. For this simple example, however, all // we'll need is a simple integer. type model int // Init optionally returns an initial command we should run. In this case we // want to start the timer. func (m model) Init() tea.Cmd { return tick } // Update is called when messages are received. The idea is that you inspect the // message and send back an updated model accordingly. You can also return // a command, which is a function that performs I/O and returns a message. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "ctrl+z": return m, tea.Suspend } case tickMsg: m-- if m <= 0 { return m, tea.Quit } return m, tick } return m, nil } // View returns a string based on data in the model. That string which will be // rendered to the terminal. func (m model) View() tea.View { return tea.NewView(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)) } // Messages are events that we respond to in our Update function. This // particular one indicates that the timer has ticked. type tickMsg time.Time func tick() tea.Msg { time.Sleep(time.Second) return tickMsg{} } ================================================ FILE: examples/simple/main_test.go ================================================ package main /* func TestApp(t *testing.T) { // TODO: Enable this test again // Since we added colorprofile.Writer to standard_renderer.go, this test // keeps failing. This is because the output is colored and has escape // sequences but the test runs against a buffer output and not a terminal, // tty, or pty. One way to fix this is to pass a color profile to the test // program using [tea.WithColorProfile(Ascii)]. t.Skip("this test is currently disabled") m := model(10) tm := teatest.NewTestModel( t, m, teatest.WithInitialTermSize(70, 30), ) t.Cleanup(func() { if err := tm.Quit(); err != nil { t.Fatal(err) } }) time.Sleep(time.Second + time.Millisecond*200) tm.Type("I'm typing things, but it'll be ignored by my program") tm.Send("ignored msg") tm.Send(tea.KeyPressMsg{ Code: tea.KeyEnter, }) if err := tm.Quit(); err != nil { t.Fatal(err) } out := readBts(t, tm.FinalOutput(t)) if !regexp.MustCompile(`This program will exit in \d+ seconds`).Match(out) { t.Fatalf("output does not match the given regular expression: %s", string(out)) } teatest.RequireEqualOutput(t, out) if tm.FinalModel(t).(model) != 9 { t.Errorf("expected model to be 10, was %d", m) } } func TestAppInteractive(t *testing.T) { t.Skip("This test is flaky and needs to be fixed.\n" + "We need a more concrete way to set the initial terminal size") m := model(10) tm := teatest.NewTestModel( t, m, teatest.WithInitialTermSize(70, 30), ) time.Sleep(time.Second + time.Millisecond*200) tm.Send("ignored msg") if bts := readBts(t, tm.Output()); !bytes.Contains(bts, []byte("This program will exit in 9 seconds")) { t.Fatalf("output does not match: expected %q", string(bts)) } teatest.WaitFor(t, tm.Output(), func(out []byte) bool { return bytes.Contains(out, []byte("This program will exit in 7 seconds")) }, teatest.WithDuration(5*time.Second)) tm.Send(tea.KeyPressMsg{ Code: tea.KeyEnter, }) if err := tm.Quit(); err != nil { t.Fatal(err) } if tm.FinalModel(t).(model) != 7 { t.Errorf("expected model to be 7, was %d", m) } } func readBts(tb testing.TB, r io.Reader) []byte { tb.Helper() bts, err := io.ReadAll(r) if err != nil { tb.Fatal(err) } return bts } */ ================================================ FILE: examples/simple/testdata/TestApp.golden ================================================ [?2004h[?25l[=1;1u[?u Hi. This program will exit in 10 seconds. To quit sooner press ctrl-c, or press ctrl-z to suspend... 9 [?25l [?25h[0 q[?25h[?2004l ================================================ FILE: examples/space/main.go ================================================ package main import ( "fmt" "image/color" "math/rand" "os" "strings" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) // An example to show the FPS count of a moving space-like background. // // This was ported from the talented Orhun Parmaksız (@orhun)'s space example // from his blog post "Why stdout is faster than stderr?". type model struct { colors [][]color.Color lastWidth int lastHeight int frameCount int width int height int } func (m model) Init() tea.Cmd { return tea.Batch( tickCmd(), ) } func tickCmd() tea.Cmd { return tea.Tick(time.Second/60, func(time.Time) tea.Msg { return tickMsg{} }) } type tickMsg struct{} func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height if m.width != m.lastWidth || m.height != m.lastHeight { m.setupColors() m.lastWidth = m.width m.lastHeight = m.height } case tickMsg: m.frameCount++ return m, tickCmd() } return m, nil } func (m *model) setupColors() { height := m.height * 2 // double height for half blocks m.colors = make([][]color.Color, height) for y := range height { m.colors[y] = make([]color.Color, m.width) randomnessFactor := float64(height-y) / float64(height) for x := range m.width { baseValue := randomnessFactor * (float64(height-y) / float64(height)) randomOffset := (rand.Float64() * 0.2) - 0.1 value := clamp(baseValue+randomOffset, 0, 1) // Convert value to grayscale color (0-255) gray := uint8(value * 255) m.colors[y][x] = lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", gray, gray, gray)) } } } func clamp(value, min, max float64) float64 { if value < min { return min } if value > max { return max } return value } func (m model) View() tea.View { // Title title := lipgloss.NewStyle().Bold(true).Render("Space") // Color display var s strings.Builder height := m.height - 1 // leave one line for title for y := range height { for x := range m.width { xi := (x + m.frameCount) % m.width fg := m.colors[y*2][xi] bg := m.colors[y*2+1][xi] st := lipgloss.NewStyle().Foreground(fg).Background(bg) s.WriteString(st.Render("▀")) } if y < height-1 { s.WriteString("\n") } } v := tea.NewView(strings.Join([]string{ title, s.String(), }, "\n")) v.AltScreen = true return v } func main() { p := tea.NewProgram(model{}, tea.WithFPS(120)) _, err := p.Run() if err != nil { fmt.Printf("Error running program: %v", err) os.Exit(1) } } ================================================ FILE: examples/spinner/README.md ================================================ # Spinner ================================================ FILE: examples/spinner/main.go ================================================ package main // A simple program demonstrating the spinner component from the Bubbles // component library. import ( "fmt" "os" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) type errMsg error type model struct { spinner spinner.Model quitting bool err error } func initialModel() model { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) return model{spinner: s} } func (m model) Init() tea.Cmd { return m.spinner.Tick } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "q", "esc", "ctrl+c": m.quitting = true return m, tea.Quit default: return m, nil } case errMsg: m.err = msg return m, nil default: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd } } func (m model) View() tea.View { if m.err != nil { return tea.NewView(m.err.Error()) } str := fmt.Sprintf("\n\n %s Loading forever...press q to quit\n\n", m.spinner.View()) if m.quitting { return tea.NewView(str + "\n") } return tea.NewView(str) } func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { fmt.Println(err) os.Exit(1) } } ================================================ FILE: examples/spinners/README.md ================================================ # Spinners ================================================ FILE: examples/spinners/main.go ================================================ package main import ( "fmt" "os" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var ( // Available spinners spinners = []spinner.Spinner{ spinner.Line, spinner.Dot, spinner.MiniDot, spinner.Jump, spinner.Pulse, spinner.Points, spinner.Globe, spinner.Moon, spinner.Monkey, } textStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Render spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render ) func main() { m := model{} m.resetSpinner() if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Println("could not run program:", err) os.Exit(1) } } type model struct { index int spinner spinner.Model } func (m model) Init() tea.Cmd { return m.spinner.Tick } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q", "esc": return m, tea.Quit case "h", "left": m.index-- if m.index < 0 { m.index = len(spinners) - 1 } m.resetSpinner() return m, m.spinner.Tick case "l", "right": m.index++ if m.index >= len(spinners) { m.index = 0 } m.resetSpinner() return m, m.spinner.Tick default: return m, nil } case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd default: return m, nil } } func (m *model) resetSpinner() { m.spinner = spinner.New() m.spinner.Style = spinnerStyle m.spinner.Spinner = spinners[m.index] } func (m model) View() tea.View { var gap string switch m.index { case 1: gap = "" default: gap = " " } var s string s += fmt.Sprintf("\n %s%s%s\n\n", m.spinner.View(), gap, textStyle("Spinning...")) s += helpStyle("h/l, ←/→: change spinner • q: exit\n") return tea.NewView(s) } ================================================ FILE: examples/splash/main.go ================================================ package main import ( "fmt" "image/color" "math" "strings" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) // This example was ported from the awesome Textualize project by @willmcgugan. // Check it out here: // https://github.com/Textualize/textual/blob/main/examples/splash.py // Color gradient var colors = []color.Color{ lipgloss.Color("#881177"), lipgloss.Color("#aa3355"), lipgloss.Color("#cc6666"), lipgloss.Color("#ee9944"), lipgloss.Color("#eedd00"), lipgloss.Color("#99dd55"), lipgloss.Color("#44dd88"), lipgloss.Color("#22ccbb"), lipgloss.Color("#00bbcc"), lipgloss.Color("#0099cc"), lipgloss.Color("#3366bb"), lipgloss.Color("#663399"), } type model struct { width int height int rate int64 } func (m model) Init() tea.Cmd { return tick } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: return m, tea.Quit case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height case tickMsg: return m, tick } return m, nil } func (m model) View() tea.View { var v tea.View v.AltScreen = true if m.width == 0 { v.SetContent("Initializing...") return v } v.SetContent(m.gradient()) return v } func (m model) gradient() string { // Time-based angle for animation t := float64(time.Now().UnixNano()*m.rate) / float64(time.Second) angleRadians := -t * math.Pi / 180.0 sinAngle := math.Sin(angleRadians) cosAngle := math.Cos(angleRadians) centerX := float64(m.width) / 2 centerY := float64(m.height) var output strings.Builder for lineY := range m.height { pointY := float64(lineY)*2 - centerY pointX := 0.0 - centerX x1 := (centerX + (pointX*cosAngle - pointY*sinAngle)) / float64(m.width) x2 := (centerX + (pointX*cosAngle - (pointY+1.0)*sinAngle)) / float64(m.width) pointX = float64(m.width) - centerX endX1 := (centerX + (pointX*cosAngle - pointY*sinAngle)) / float64(m.width) deltaX := (endX1 - x1) / float64(m.width) if math.Abs(deltaX) < 0.0001 { // Special case for verticals color1 := getGradientColor(x1) color2 := getGradientColor(x2) style := lipgloss.NewStyle(). Foreground(color1). Background(color2) output.WriteString(style.Render(strings.Repeat("▀", m.width))) } else { // Render each column in the row for x := range m.width { pos1 := x1 + float64(x)*deltaX pos2 := x2 + float64(x)*deltaX color1 := getGradientColor(pos1) color2 := getGradientColor(pos2) style := lipgloss.NewStyle(). Foreground(color1). Background(color2) output.WriteString(style.Render("▀")) } } if lineY < m.height-1 { output.WriteString("\n") } } return output.String() } func getGradientColor(position float64) color.Color { // Normalize position to [0,1] if position <= 0 { position = 0 } if position >= 1 { position = 1 } // Calculate the color index idx := position * float64(len(colors)-1) i1 := int(math.Floor(idx)) i2 := int(math.Ceil(idx)) // Ensure indices are within bounds i1 = i1 % len(colors) i2 = i2 % len(colors) if i1 < 0 { i1 += len(colors) } if i2 < 0 { i2 += len(colors) } // Interpolate between colors t := idx - float64(i1) return interpolateColors(colors[i1], colors[i2], t) } func interpolateColors(color1, color2 color.Color, t float64) color.Color { // Parse hex colors r1, g1, b1, _ := color1.RGBA() r1, g1, b1 = r1>>8, g1>>8, b1>>8 r2, g2, b2, _ := color2.RGBA() r2, g2, b2 = r2>>8, g2>>8, b2>>8 // Interpolate r := int(float64(r1)*(1-t) + float64(r2)*t) g := int(float64(g1)*(1-t) + float64(g2)*t) b := int(float64(b1)*(1-t) + float64(b2)*t) return color.RGBA{uint8(r), uint8(g), uint8(b), 255} } type tickMsg time.Time func tick() tea.Msg { return tickMsg(time.Now()) } func main() { p := tea.NewProgram( model{rate: 90}, ) if _, err := p.Run(); err != nil { fmt.Printf("Error running program: %v", err) } } ================================================ FILE: examples/split-editors/README.md ================================================ # Split Editors ================================================ FILE: examples/split-editors/main.go ================================================ package main import ( "fmt" "os" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) const ( initialInputs = 2 maxInputs = 6 minInputs = 1 helpHeight = 5 ) var ( cursorColor = lipgloss.Color("212") cursorLineStyle = lipgloss.NewStyle(). Background(lipgloss.Color("57")). Foreground(lipgloss.Color("230")) placeholderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("238")) focusedPlaceholderStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("99")) focusedBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("238")) blurredBorderStyle = lipgloss.NewStyle(). Border(lipgloss.HiddenBorder()) endOfBufferStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("235")) ) type keymap = struct { next, prev, add, remove, quit key.Binding } func newTextarea() textarea.Model { t := textarea.New() t.Prompt = "" t.Placeholder = "Type something" t.ShowLineNumbers = true t.SetVirtualCursor(true) s := t.Styles() s.Cursor.Color = cursorColor s.Focused.Placeholder = focusedPlaceholderStyle s.Blurred.Placeholder = placeholderStyle s.Focused.CursorLine = cursorLineStyle s.Focused.CursorLineNumber = cursorLineStyle s.Focused.Base = focusedBorderStyle s.Blurred.Base = blurredBorderStyle s.Focused.EndOfBuffer = endOfBufferStyle s.Blurred.EndOfBuffer = endOfBufferStyle t.SetStyles(s) t.KeyMap.DeleteWordBackward.SetEnabled(false) t.KeyMap.LineNext = key.NewBinding(key.WithKeys("down")) t.KeyMap.LinePrevious = key.NewBinding(key.WithKeys("up")) t.Blur() return t } type model struct { width int height int keymap keymap help help.Model inputs []textarea.Model focus int } func newModel() model { m := model{ inputs: make([]textarea.Model, initialInputs), help: help.New(), keymap: keymap{ next: key.NewBinding( key.WithKeys("tab"), key.WithHelp("tab", "next"), ), prev: key.NewBinding( key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev"), ), add: key.NewBinding( key.WithKeys("ctrl+n"), key.WithHelp("ctrl+n", "add an editor"), ), remove: key.NewBinding( key.WithKeys("ctrl+w"), key.WithHelp("ctrl+w", "remove an editor"), ), quit: key.NewBinding( key.WithKeys("esc", "ctrl+c"), key.WithHelp("esc", "quit"), ), }, } for i := range initialInputs { m.inputs[i] = newTextarea() } m.inputs[m.focus].Focus() m.updateKeybindings() return m } func (m model) Init() tea.Cmd { return textarea.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, m.keymap.quit): for i := range m.inputs { m.inputs[i].Blur() } return m, tea.Quit case key.Matches(msg, m.keymap.next): m.inputs[m.focus].Blur() m.focus++ if m.focus > len(m.inputs)-1 { m.focus = 0 } cmd := m.inputs[m.focus].Focus() cmds = append(cmds, cmd) case key.Matches(msg, m.keymap.prev): m.inputs[m.focus].Blur() m.focus-- if m.focus < 0 { m.focus = len(m.inputs) - 1 } cmd := m.inputs[m.focus].Focus() cmds = append(cmds, cmd) case key.Matches(msg, m.keymap.add): m.inputs = append(m.inputs, newTextarea()) case key.Matches(msg, m.keymap.remove): m.inputs = m.inputs[:len(m.inputs)-1] if m.focus > len(m.inputs)-1 { m.focus = len(m.inputs) - 1 } } case tea.WindowSizeMsg: m.height = msg.Height m.width = msg.Width } m.updateKeybindings() m.sizeInputs() // Update all textareas for i := range m.inputs { newModel, cmd := m.inputs[i].Update(msg) m.inputs[i] = newModel cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) } func (m *model) sizeInputs() { for i := range m.inputs { m.inputs[i].SetWidth(m.width / len(m.inputs)) m.inputs[i].SetHeight(m.height - helpHeight) } } func (m *model) updateKeybindings() { m.keymap.add.SetEnabled(len(m.inputs) < maxInputs) m.keymap.remove.SetEnabled(len(m.inputs) > minInputs) } func (m model) inputViews() []string { var views []string for i := range m.inputs { views = append(views, m.inputs[i].View()) } return views } func (m model) View() tea.View { help := m.help.ShortHelpView([]key.Binding{ m.keymap.next, m.keymap.prev, m.keymap.add, m.keymap.remove, m.keymap.quit, }) v := tea.NewView(lipgloss.JoinHorizontal(lipgloss.Top, m.inputViews()...) + "\n\n" + help) v.AltScreen = true return v } func (m model) Cursor() *tea.Cursor { focusedInput := m.inputs[m.focus] if focusedInput.VirtualCursor() { return nil } views := m.inputViews() c := focusedInput.Cursor() // Find textrea offset to position real cursor. // // To do this we calculate the width of all textareas to the left of // the focused one. for i := range m.focus { c.X += lipgloss.Width(views[i]) } return c } func main() { if _, err := tea.NewProgram(newModel()).Run(); err != nil { fmt.Println("Error while running program:", err) os.Exit(1) } } ================================================ FILE: examples/stopwatch/README.md ================================================ # Stopwatch ================================================ FILE: examples/stopwatch/main.go ================================================ package main import ( "fmt" "os" "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/stopwatch" tea "charm.land/bubbletea/v2" ) type model struct { stopwatch stopwatch.Model keymap keymap help help.Model quitting bool } type keymap struct { start key.Binding stop key.Binding reset key.Binding quit key.Binding } func (m model) Init() tea.Cmd { return m.stopwatch.Init() } func (m model) View() tea.View { // Note: you could further customize the time output by getting the // duration from m.stopwatch.Elapsed(), which returns a time.Duration, and // skip m.stopwatch.View() altogether. s := m.stopwatch.View() + "\n" if !m.quitting { s = "Elapsed: " + s s += m.helpView() } return tea.NewView(s) } func (m model) helpView() string { return "\n" + m.help.ShortHelpView([]key.Binding{ m.keymap.start, m.keymap.stop, m.keymap.reset, m.keymap.quit, }) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { case key.Matches(msg, m.keymap.quit): m.quitting = true return m, tea.Quit case key.Matches(msg, m.keymap.reset): return m, m.stopwatch.Reset() case key.Matches(msg, m.keymap.start, m.keymap.stop): m.keymap.stop.SetEnabled(!m.stopwatch.Running()) m.keymap.start.SetEnabled(m.stopwatch.Running()) return m, m.stopwatch.Toggle() } } var cmd tea.Cmd m.stopwatch, cmd = m.stopwatch.Update(msg) return m, cmd } func main() { m := model{ stopwatch: stopwatch.New(stopwatch.WithInterval(time.Millisecond)), keymap: keymap{ start: key.NewBinding( key.WithKeys("s"), key.WithHelp("s", "start"), ), stop: key.NewBinding( key.WithKeys("s"), key.WithHelp("s", "stop"), ), reset: key.NewBinding( key.WithKeys("r"), key.WithHelp("r", "reset"), ), quit: key.NewBinding( key.WithKeys("ctrl+c", "q"), key.WithHelp("q", "quit"), ), }, help: help.New(), } m.keymap.start.SetEnabled(false) if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Oh no, it didn't work:", err) os.Exit(1) } } ================================================ FILE: examples/suspend/main.go ================================================ package main import ( "errors" "fmt" "os" tea "charm.land/bubbletea/v2" ) type model struct { quitting bool suspending bool } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.ResumeMsg: m.suspending = false return m, nil case tea.KeyPressMsg: switch msg.String() { case "q", "esc": m.quitting = true return m, tea.Quit case "ctrl+c": m.quitting = true return m, tea.Interrupt case "ctrl+z": m.suspending = true return m, tea.Suspend } } return m, nil } func (m model) View() tea.View { if m.suspending || m.quitting { return tea.NewView("") } return tea.NewView("\nPress ctrl-z to suspend, ctrl+c to interrupt, q, or esc to exit\n") } func main() { if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Println("Error running program:", err) if errors.Is(err, tea.ErrInterrupted) { os.Exit(130) } os.Exit(1) } } ================================================ FILE: examples/table/README.md ================================================ # Table ================================================ FILE: examples/table/main.go ================================================ package main import ( "fmt" "os" "charm.land/bubbles/v2/table" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var baseStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")) type model struct { table table.Model } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "esc": if m.table.Focused() { m.table.Blur() } else { m.table.Focus() } case "q", "ctrl+c": return m, tea.Quit case "enter": return m, tea.Batch( tea.Printf("Let's go to %s!", m.table.SelectedRow()[1]), ) } } m.table, cmd = m.table.Update(msg) return m, cmd } func (m model) View() tea.View { return tea.NewView(baseStyle.Render(m.table.View()) + "\n " + m.table.HelpView() + "\n") } func main() { columns := []table.Column{ {Title: "Rank", Width: 4}, {Title: "City", Width: 10}, {Title: "Country", Width: 10}, {Title: "Population", Width: 10}, } rows := []table.Row{ {"1", "Tokyo", "Japan", "37,274,000"}, {"2", "Delhi", "India", "32,065,760"}, {"3", "Shanghai", "China", "28,516,904"}, {"4", "Dhaka", "Bangladesh", "22,478,116"}, {"5", "São Paulo", "Brazil", "22,429,800"}, {"6", "Mexico City", "Mexico", "22,085,140"}, {"7", "Cairo", "Egypt", "21,750,020"}, {"8", "Beijing", "China", "21,333,332"}, {"9", "Mumbai", "India", "20,961,472"}, {"10", "Osaka", "Japan", "19,059,856"}, {"11", "Chongqing", "China", "16,874,740"}, {"12", "Karachi", "Pakistan", "16,839,950"}, {"13", "Istanbul", "Turkey", "15,636,243"}, {"14", "Kinshasa", "DR Congo", "15,628,085"}, {"15", "Lagos", "Nigeria", "15,387,639"}, {"16", "Buenos Aires", "Argentina", "15,369,919"}, {"17", "Kolkata", "India", "15,133,888"}, {"18", "Manila", "Philippines", "14,406,059"}, {"19", "Tianjin", "China", "14,011,828"}, {"20", "Guangzhou", "China", "13,964,637"}, {"21", "Rio De Janeiro", "Brazil", "13,634,274"}, {"22", "Lahore", "Pakistan", "13,541,764"}, {"23", "Bangalore", "India", "13,193,035"}, {"24", "Shenzhen", "China", "12,831,330"}, {"25", "Moscow", "Russia", "12,640,818"}, {"26", "Chennai", "India", "11,503,293"}, {"27", "Bogota", "Colombia", "11,344,312"}, {"28", "Paris", "France", "11,142,303"}, {"29", "Jakarta", "Indonesia", "11,074,811"}, {"30", "Lima", "Peru", "11,044,607"}, {"31", "Bangkok", "Thailand", "10,899,698"}, {"32", "Hyderabad", "India", "10,534,418"}, {"33", "Seoul", "South Korea", "9,975,709"}, {"34", "Nagoya", "Japan", "9,571,596"}, {"35", "London", "United Kingdom", "9,540,576"}, {"36", "Chengdu", "China", "9,478,521"}, {"37", "Nanjing", "China", "9,429,381"}, {"38", "Tehran", "Iran", "9,381,546"}, {"39", "Ho Chi Minh City", "Vietnam", "9,077,158"}, {"40", "Luanda", "Angola", "8,952,496"}, {"41", "Wuhan", "China", "8,591,611"}, {"42", "Xi An Shaanxi", "China", "8,537,646"}, {"43", "Ahmedabad", "India", "8,450,228"}, {"44", "Kuala Lumpur", "Malaysia", "8,419,566"}, {"45", "New York City", "United States", "8,177,020"}, {"46", "Hangzhou", "China", "8,044,878"}, {"47", "Surat", "India", "7,784,276"}, {"48", "Suzhou", "China", "7,764,499"}, {"49", "Hong Kong", "Hong Kong", "7,643,256"}, {"50", "Riyadh", "Saudi Arabia", "7,538,200"}, {"51", "Shenyang", "China", "7,527,975"}, {"52", "Baghdad", "Iraq", "7,511,920"}, {"53", "Dongguan", "China", "7,511,851"}, {"54", "Foshan", "China", "7,497,263"}, {"55", "Dar Es Salaam", "Tanzania", "7,404,689"}, {"56", "Pune", "India", "6,987,077"}, {"57", "Santiago", "Chile", "6,856,939"}, {"58", "Madrid", "Spain", "6,713,557"}, {"59", "Haerbin", "China", "6,665,951"}, {"60", "Toronto", "Canada", "6,312,974"}, {"61", "Belo Horizonte", "Brazil", "6,194,292"}, {"62", "Khartoum", "Sudan", "6,160,327"}, {"63", "Johannesburg", "South Africa", "6,065,354"}, {"64", "Singapore", "Singapore", "6,039,577"}, {"65", "Dalian", "China", "5,930,140"}, {"66", "Qingdao", "China", "5,865,232"}, {"67", "Zhengzhou", "China", "5,690,312"}, {"68", "Ji Nan Shandong", "China", "5,663,015"}, {"69", "Barcelona", "Spain", "5,658,472"}, {"70", "Saint Petersburg", "Russia", "5,535,556"}, {"71", "Abidjan", "Ivory Coast", "5,515,790"}, {"72", "Yangon", "Myanmar", "5,514,454"}, {"73", "Fukuoka", "Japan", "5,502,591"}, {"74", "Alexandria", "Egypt", "5,483,605"}, {"75", "Guadalajara", "Mexico", "5,339,583"}, {"76", "Ankara", "Turkey", "5,309,690"}, {"77", "Chittagong", "Bangladesh", "5,252,842"}, {"78", "Addis Ababa", "Ethiopia", "5,227,794"}, {"79", "Melbourne", "Australia", "5,150,766"}, {"80", "Nairobi", "Kenya", "5,118,844"}, {"81", "Hanoi", "Vietnam", "5,067,352"}, {"82", "Sydney", "Australia", "5,056,571"}, {"83", "Monterrey", "Mexico", "5,036,535"}, {"84", "Changsha", "China", "4,809,887"}, {"85", "Brasilia", "Brazil", "4,803,877"}, {"86", "Cape Town", "South Africa", "4,800,954"}, {"87", "Jiddah", "Saudi Arabia", "4,780,740"}, {"88", "Urumqi", "China", "4,710,203"}, {"89", "Kunming", "China", "4,657,381"}, {"90", "Changchun", "China", "4,616,002"}, {"91", "Hefei", "China", "4,496,456"}, {"92", "Shantou", "China", "4,490,411"}, {"93", "Xinbei", "Taiwan", "4,470,672"}, {"94", "Kabul", "Afghanistan", "4,457,882"}, {"95", "Ningbo", "China", "4,405,292"}, {"96", "Tel Aviv", "Israel", "4,343,584"}, {"97", "Yaounde", "Cameroon", "4,336,670"}, {"98", "Rome", "Italy", "4,297,877"}, {"99", "Shijiazhuang", "China", "4,285,135"}, {"100", "Montreal", "Canada", "4,276,526"}, } t := table.New( table.WithColumns(columns), table.WithRows(rows), table.WithFocused(true), table.WithHeight(7), table.WithWidth(42), ) s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")). BorderBottom(true). Bold(false) s.Selected = s.Selected. Foreground(lipgloss.Color("229")). Background(lipgloss.Color("57")). Bold(false) t.SetStyles(s) m := model{t} if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } ================================================ FILE: examples/table-resize/main.go ================================================ package main import ( "fmt" "image/color" "os" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "charm.land/lipgloss/v2/table" ) // Pokemon types. const ( None = "" Bug = "Bug" Electric = "Electric" Fire = "Fire" Flying = "Flying" Grass = "Grass" Ground = "Ground" Normal = "Normal" Poison = "Poison" Water = "Water" ) type model struct { table *table.Table } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: m.table = m.table.Width(msg.Width) m.table = m.table.Height(msg.Height) case tea.KeyPressMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit case "enter": } } return m, cmd } func (m model) View() tea.View { v := tea.NewView("\n" + m.table.String() + "\n") v.AltScreen = true return v } func main() { baseStyle := lipgloss.NewStyle().Padding(0, 1) headerStyle := baseStyle.Foreground(lipgloss.Color("252")).Bold(true) selectedStyle := baseStyle.Foreground(lipgloss.Color("#01BE85")).Background(lipgloss.Color("#00432F")) typeColors := map[string]color.Color{ Bug: lipgloss.Color("#D7FF87"), Electric: lipgloss.Color("#FDFF90"), Fire: lipgloss.Color("#FF7698"), Flying: lipgloss.Color("#FF87D7"), Grass: lipgloss.Color("#75FBAB"), Ground: lipgloss.Color("#FF875F"), Normal: lipgloss.Color("#929292"), Poison: lipgloss.Color("#7D5AFC"), Water: lipgloss.Color("#00E2C7"), } dimTypeColors := map[string]color.Color{ Bug: lipgloss.Color("#97AD64"), Electric: lipgloss.Color("#FCFF5F"), Fire: lipgloss.Color("#BA5F75"), Flying: lipgloss.Color("#C97AB2"), Grass: lipgloss.Color("#59B980"), Ground: lipgloss.Color("#C77252"), Normal: lipgloss.Color("#727272"), Poison: lipgloss.Color("#634BD0"), Water: lipgloss.Color("#439F8E"), } headers := []string{"#", "NAME", "TYPE 1", "TYPE 2", "JAPANESE", "OFFICIAL ROM."} rows := [][]string{ {"1", "Bulbasaur", Grass, Poison, "フシギダネ", "Bulbasaur"}, {"2", "Ivysaur", Grass, Poison, "フシギソウ", "Ivysaur"}, {"3", "Venusaur", Grass, Poison, "フシギバナ", "Venusaur"}, {"4", "Charmander", Fire, None, "ヒトカゲ", "Hitokage"}, {"5", "Charmeleon", Fire, None, "リザード", "Lizardo"}, {"6", "Charizard", Fire, Flying, "リザードン", "Lizardon"}, {"7", "Squirtle", Water, None, "ゼニガメ", "Zenigame"}, {"8", "Wartortle", Water, None, "カメール", "Kameil"}, {"9", "Blastoise", Water, None, "カメックス", "Kamex"}, {"10", "Caterpie", Bug, None, "キャタピー", "Caterpie"}, {"11", "Metapod", Bug, None, "トランセル", "Trancell"}, {"12", "Butterfree", Bug, Flying, "バタフリー", "Butterfree"}, {"13", "Weedle", Bug, Poison, "ビードル", "Beedle"}, {"14", "Kakuna", Bug, Poison, "コクーン", "Cocoon"}, {"15", "Beedrill", Bug, Poison, "スピアー", "Spear"}, {"16", "Pidgey", Normal, Flying, "ポッポ", "Poppo"}, {"17", "Pidgeotto", Normal, Flying, "ピジョン", "Pigeon"}, {"18", "Pidgeot", Normal, Flying, "ピジョット", "Pigeot"}, {"19", "Rattata", Normal, None, "コラッタ", "Koratta"}, {"20", "Raticate", Normal, None, "ラッタ", "Ratta"}, {"21", "Spearow", Normal, Flying, "オニスズメ", "Onisuzume"}, {"22", "Fearow", Normal, Flying, "オニドリル", "Onidrill"}, {"23", "Ekans", Poison, None, "アーボ", "Arbo"}, {"24", "Arbok", Poison, None, "アーボック", "Arbok"}, {"25", "Pikachu", Electric, None, "ピカチュウ", "Pikachu"}, {"26", "Raichu", Electric, None, "ライチュウ", "Raichu"}, {"27", "Sandshrew", Ground, None, "サンド", "Sand"}, {"28", "Sandslash", Ground, None, "サンドパン", "Sandpan"}, } t := table.New(). Headers(headers...). Rows(rows...). Border(lipgloss.NormalBorder()). BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("238"))). StyleFunc(func(row, col int) lipgloss.Style { if row == 0 { return headerStyle } rowIndex := row - 1 if rowIndex < 0 || rowIndex >= len(rows) { return baseStyle } if rows[rowIndex][1] == "Pikachu" { return selectedStyle } even := row%2 == 0 switch col { case 2, 3: // Type 1 + 2 c := typeColors if even { c = dimTypeColors } if col >= len(rows[rowIndex]) { return baseStyle } color, ok := c[rows[rowIndex][col]] if !ok { return baseStyle } return baseStyle.Foreground(color) } if even { return baseStyle.Foreground(lipgloss.Color("245")) } return baseStyle.Foreground(lipgloss.Color("252")) }). Border(lipgloss.ThickBorder()) if _, err := tea.NewProgram(model{t}).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } ================================================ FILE: examples/tabs/README.md ================================================ # Tabs ================================================ FILE: examples/tabs/main.go ================================================ package main import ( "fmt" "os" "strings" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) type styles struct { doc lipgloss.Style highlight lipgloss.Style inactiveTab lipgloss.Style activeTab lipgloss.Style window lipgloss.Style } func newStyles(bgIsDark bool) *styles { lightDark := lipgloss.LightDark(bgIsDark) inactiveTabBorder := tabBorderWithBottom("┴", "─", "┴") activeTabBorder := tabBorderWithBottom("┘", " ", "└") highlightColor := lightDark(lipgloss.Color("#874BFD"), lipgloss.Color("#7D56F4")) s := new(styles) s.doc = lipgloss.NewStyle(). Padding(1, 2, 1, 2) s.inactiveTab = lipgloss.NewStyle(). Border(inactiveTabBorder, true). BorderForeground(highlightColor). Padding(0, 1) s.activeTab = s.inactiveTab. Border(activeTabBorder, true) s.window = lipgloss.NewStyle(). BorderForeground(highlightColor). Padding(2, 0). Align(lipgloss.Center). Border(lipgloss.NormalBorder()). UnsetBorderTop() return s } type model struct { Tabs []string TabContent []string styles *styles activeTab int } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch keypress := msg.String(); keypress { case "ctrl+c", "q": return m, tea.Quit case "right", "l", "n", "tab": m.activeTab = min(m.activeTab+1, len(m.Tabs)-1) return m, nil case "left", "h", "p", "shift+tab": m.activeTab = max(m.activeTab-1, 0) return m, nil } } return m, nil } func tabBorderWithBottom(left, middle, right string) lipgloss.Border { border := lipgloss.RoundedBorder() border.BottomLeft = left border.Bottom = middle border.BottomRight = right return border } func (m model) View() tea.View { if m.styles == nil { return tea.NewView("") } doc := strings.Builder{} s := m.styles var renderedTabs []string for i, t := range m.Tabs { var style lipgloss.Style isFirst, isLast, isActive := i == 0, i == len(m.Tabs)-1, i == m.activeTab if isActive { style = s.activeTab } else { style = s.inactiveTab } border, _, _, _, _ := style.GetBorder() if isFirst && isActive { border.BottomLeft = "│" } else if isFirst && !isActive { border.BottomLeft = "├" } else if isLast && isActive { border.BottomRight = "│" } else if isLast && !isActive { border.BottomRight = "┤" } style = style.Border(border) renderedTabs = append(renderedTabs, style.Render(t)) } row := lipgloss.JoinHorizontal(lipgloss.Top, renderedTabs...) doc.WriteString(row) doc.WriteString("\n") doc.WriteString(s.window.Width((lipgloss.Width(row))).Render(m.TabContent[m.activeTab])) return tea.NewView(s.doc.Render(doc.String())) } func main() { tabs := []string{"Lip Gloss", "Blush", "Eye Shadow", "Mascara", "Foundation"} tabContent := []string{"Lip Gloss Tab", "Blush Tab", "Eye Shadow Tab", "Mascara Tab", "Foundation Tab"} m := model{Tabs: tabs, TabContent: tabContent, styles: newStyles(true)} // default to dark styles. if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } } ================================================ FILE: examples/textarea/README.md ================================================ # Text Area ================================================ FILE: examples/textarea/main.go ================================================ package main // A simple program demonstrating the textarea component from the Bubbles // component library. import ( "log" "strings" "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { log.Fatal(err) } } type errMsg error type model struct { textarea textarea.Model err error } func initialModel() model { ti := textarea.New() ti.Placeholder = "Once upon a time..." ti.SetVirtualCursor(false) ti.SetStyles(textarea.DefaultStyles(true)) // default to dark styles. ti.Focus() return model{ textarea: ti, err: nil, } } func (m model) Init() tea.Cmd { return tea.Batch(textarea.Blink, tea.RequestBackgroundColor) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd switch msg := msg.(type) { case tea.BackgroundColorMsg: // Update styling now that we know the background color. m.textarea.SetStyles(textarea.DefaultStyles(msg.IsDark())) case tea.KeyPressMsg: switch msg.String() { case "esc": if m.textarea.Focused() { m.textarea.Blur() } case "ctrl+c": return m, tea.Quit default: if !m.textarea.Focused() { cmd = m.textarea.Focus() cmds = append(cmds, cmd) } } // We handle errors just like any other message case errMsg: m.err = msg return m, nil } m.textarea, cmd = m.textarea.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } func (m model) headerView() string { return "Tell me a story.\n" } func (m model) View() tea.View { const ( footer = "\n(ctrl+c to quit)\n" ) var c *tea.Cursor if !m.textarea.VirtualCursor() { c = m.textarea.Cursor() // Set the y offset of the cursor based on the position of the textarea // in the application. offset := lipgloss.Height(m.headerView()) c.Y += offset } f := strings.Join([]string{ m.headerView(), m.textarea.View(), footer, }, "\n") v := tea.NewView(f) v.Cursor = c return v } ================================================ FILE: examples/textinput/README.md ================================================ # Text Input ================================================ FILE: examples/textinput/main.go ================================================ package main // A simple program demonstrating the text input component from the Bubbles // component library. import ( "log" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { log.Fatal(err) } } type ( errMsg error ) type model struct { textInput textinput.Model err error quitting bool } func initialModel() model { ti := textinput.New() ti.Placeholder = "Pikachu" ti.SetVirtualCursor(false) ti.Focus() ti.CharLimit = 156 ti.SetWidth(20) return model{textInput: ti} } func (m model) Init() tea.Cmd { return textinput.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "enter", "ctrl+c", "esc": m.quitting = true return m, tea.Quit } } m.textInput, cmd = m.textInput.Update(msg) return m, cmd } func (m model) View() tea.View { var c *tea.Cursor if !m.textInput.VirtualCursor() { c = m.textInput.Cursor() c.Y += lipgloss.Height(m.headerView()) } str := lipgloss.JoinVertical(lipgloss.Top, m.headerView(), m.textInput.View(), m.footerView()) if m.quitting { str += "\n" } v := tea.NewView(str) v.Cursor = c return v } func (m model) headerView() string { return "What’s your favorite Pokémon?\n" } func (m model) footerView() string { return "\n(esc to quit)" } ================================================ FILE: examples/textinputs/README.md ================================================ # Text Inputs ================================================ FILE: examples/textinputs/main.go ================================================ package main // A simple example demonstrating the use of multiple text input components // from the Bubbles component library. import ( "fmt" "os" "strings" "charm.land/bubbles/v2/cursor" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) var ( focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) cursorStyle = focusedStyle noStyle = lipgloss.NewStyle() helpStyle = blurredStyle cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) focusedButton = focusedStyle.Render("[ Submit ]") blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) ) type model struct { focusIndex int inputs []textinput.Model cursorMode cursor.Mode quitting bool } func initialModel() model { m := model{ inputs: make([]textinput.Model, 3), } var t textinput.Model for i := range m.inputs { t = textinput.New() t.CharLimit = 32 s := t.Styles() s.Cursor.Color = lipgloss.Color("205") s.Focused.Prompt = focusedStyle s.Focused.Text = focusedStyle s.Blurred.Prompt = blurredStyle s.Focused.Text = focusedStyle t.SetStyles(s) switch i { case 0: t.Placeholder = "Nickname" t.Focus() case 1: t.Placeholder = "Email" t.CharLimit = 64 case 2: t.Placeholder = "Password" t.EchoMode = textinput.EchoPassword t.EchoCharacter = '•' } m.inputs[i] = t } return m } func (m model) Init() tea.Cmd { return textinput.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "esc": m.quitting = true return m, tea.Quit // Change cursor mode case "ctrl+r": m.cursorMode++ if m.cursorMode > cursor.CursorHide { m.cursorMode = cursor.CursorBlink } cmds := make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { s := m.inputs[i].Styles() s.Cursor.Blink = m.cursorMode == cursor.CursorBlink m.inputs[i].SetStyles(s) } return m, tea.Batch(cmds...) // Set focus to next input case "tab", "shift+tab", "enter", "up", "down": s := msg.String() // Did the user press enter while the submit button was focused? // If so, exit. if s == "enter" && m.focusIndex == len(m.inputs) { return m, tea.Quit } // Cycle indexes if s == "up" || s == "shift+tab" { m.focusIndex-- } else { m.focusIndex++ } if m.focusIndex > len(m.inputs) { m.focusIndex = 0 } else if m.focusIndex < 0 { m.focusIndex = len(m.inputs) } cmds := make([]tea.Cmd, len(m.inputs)) for i := 0; i <= len(m.inputs)-1; i++ { if i == m.focusIndex { // Set focused state cmds[i] = m.inputs[i].Focus() continue } // Remove focused state m.inputs[i].Blur() } return m, tea.Batch(cmds...) } } // Handle character input and blinking cmd := m.updateInputs(msg) return m, cmd } func (m *model) updateInputs(msg tea.Msg) tea.Cmd { cmds := make([]tea.Cmd, len(m.inputs)) // Only text inputs with Focus() set will respond, so it's safe to simply // update all of them here without any further logic. for i := range m.inputs { m.inputs[i], cmds[i] = m.inputs[i].Update(msg) } return tea.Batch(cmds...) } func (m model) View() tea.View { var b strings.Builder var c *tea.Cursor for i, in := range m.inputs { b.WriteString(m.inputs[i].View()) if i < len(m.inputs)-1 { b.WriteRune('\n') } if m.cursorMode != cursor.CursorHide && in.Focused() { c = in.Cursor() if c != nil { c.Y += i } } } button := &blurredButton if m.focusIndex == len(m.inputs) { button = &focusedButton } fmt.Fprintf(&b, "\n\n%s\n\n", *button) b.WriteString(helpStyle.Render("cursor mode is ")) b.WriteString(cursorModeHelpStyle.Render(m.cursorMode.String())) b.WriteString(helpStyle.Render(" (ctrl+r to change style)")) if m.quitting { b.WriteRune('\n') } v := tea.NewView(b.String()) v.Cursor = c return v } func main() { if _, err := tea.NewProgram(initialModel()).Run(); err != nil { fmt.Printf("could not start program: %s\n", err) os.Exit(1) } } ================================================ FILE: examples/timer/README.md ================================================ # Timer ================================================ FILE: examples/timer/main.go ================================================ package main import ( "fmt" "os" "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "charm.land/bubbles/v2/timer" tea "charm.land/bubbletea/v2" ) const timeout = time.Second * 5 type model struct { timer timer.Model keymap keymap help help.Model quitting bool } type keymap struct { start key.Binding stop key.Binding reset key.Binding quit key.Binding } func (m model) Init() tea.Cmd { return m.timer.Init() } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case timer.TickMsg: var cmd tea.Cmd m.timer, cmd = m.timer.Update(msg) return m, cmd case timer.StartStopMsg: var cmd tea.Cmd m.timer, cmd = m.timer.Update(msg) m.keymap.stop.SetEnabled(m.timer.Running()) m.keymap.start.SetEnabled(!m.timer.Running()) return m, cmd case timer.TimeoutMsg: m.quitting = true return m, tea.Quit case tea.KeyPressMsg: switch { case key.Matches(msg, m.keymap.quit): m.quitting = true return m, tea.Quit case key.Matches(msg, m.keymap.reset): m.timer.Timeout = timeout case key.Matches(msg, m.keymap.start, m.keymap.stop): return m, m.timer.Toggle() } } return m, nil } func (m model) helpView() string { return "\n" + m.help.ShortHelpView([]key.Binding{ m.keymap.start, m.keymap.stop, m.keymap.reset, m.keymap.quit, }) } func (m model) View() tea.View { // For a more detailed timer view you could read m.timer.Timeout to get // the remaining time as a time.Duration and skip calling m.timer.View() // entirely. s := m.timer.View() if m.timer.Timedout() { s = "All done!" } s += "\n" if !m.quitting { s = "Exiting in " + s s += m.helpView() } return tea.NewView(s) } func main() { m := model{ timer: timer.New(timeout, timer.WithInterval(time.Millisecond)), keymap: keymap{ start: key.NewBinding( key.WithKeys("s"), key.WithHelp("s", "start"), ), stop: key.NewBinding( key.WithKeys("s"), key.WithHelp("s", "stop"), ), reset: key.NewBinding( key.WithKeys("r"), key.WithHelp("r", "reset"), ), quit: key.NewBinding( key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit"), ), }, help: help.New(), } m.keymap.start.SetEnabled(false) if _, err := tea.NewProgram(m).Run(); err != nil { fmt.Println("Uh oh, we encountered an error:", err) os.Exit(1) } } ================================================ FILE: examples/tui-daemon-combo/README.md ================================================ # TUI Daemon ================================================ FILE: examples/tui-daemon-combo/main.go ================================================ package main import ( "flag" "fmt" "io" "log" "math/rand" "os" "time" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/mattn/go-isatty" ) var ( helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render mainStyle = lipgloss.NewStyle().MarginLeft(1) ) func main() { var ( daemonMode bool showHelp bool ) flag.BoolVar(&daemonMode, "d", false, "run as a daemon") flag.BoolVar(&showHelp, "h", false, "show help") flag.Parse() if showHelp { flag.Usage() os.Exit(0) } opts := []tea.ProgramOption{} if daemonMode || !isatty.IsTerminal(os.Stdout.Fd()) { // If we're in daemon mode don't render the TUI opts = append(opts, tea.WithoutRenderer()) } else { // If we're in TUI mode, discard log output log.SetOutput(io.Discard) } p := tea.NewProgram(newModel(), opts...) if _, err := p.Run(); err != nil { fmt.Println("Error starting Bubble Tea program:", err) os.Exit(1) } } type result struct { duration time.Duration emoji string } type model struct { spinner spinner.Model results []result quitting bool } func newModel() model { const showLastResults = 5 sp := spinner.New() sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("206")) return model{ spinner: sp, results: make([]result, showLastResults), } } func (m model) Init() tea.Cmd { log.Println("Starting work...") return tea.Batch( m.spinner.Tick, runPretendProcess, ) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: m.quitting = true return m, tea.Quit case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case processFinishedMsg: d := time.Duration(msg) res := result{emoji: randomEmoji(), duration: d} log.Printf("%s Job finished in %s", res.emoji, res.duration) m.results = append(m.results[1:], res) return m, runPretendProcess default: return m, nil } } func (m model) View() tea.View { s := "\n" + m.spinner.View() + " Doing some work...\n\n" for _, res := range m.results { if res.duration == 0 { s += "........................\n" } else { s += fmt.Sprintf("%s Job finished in %s\n", res.emoji, res.duration) } } s += helpStyle("\nPress any key to exit\n") if m.quitting { s += "\n" } return tea.NewView(mainStyle.Render(s)) } // processFinishedMsg is sent when a pretend process completes. type processFinishedMsg time.Duration // pretendProcess simulates a long-running process. func runPretendProcess() tea.Msg { pause := time.Duration(rand.Int63n(899)+100) * time.Millisecond // nolint:gosec time.Sleep(pause) return processFinishedMsg(pause) } func randomEmoji() string { emojis := []rune("🍦🧋🍡🤠👾😭🦊🐯🦆🥨🎏🍔🍒🍥🎮📦🦁🐶🐸🍕🥐🧲🚒🥇🏆🌽") return string(emojis[rand.Intn(len(emojis))]) // nolint:gosec } ================================================ FILE: examples/vanish/main.go ================================================ package main import ( "fmt" "os" tea "charm.land/bubbletea/v2" ) type model bool func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if _, ok := msg.(tea.KeyPressMsg); ok { m = true return m, tea.Quit } return m, nil } func (m model) View() tea.View { if m { return tea.NewView("") } return tea.NewView("Press any key to quit.\n(When this program quits, it will vanish without a trace.)") } func main() { p := tea.NewProgram(model(false)) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, "Oh no:", err) } } ================================================ FILE: examples/views/README.md ================================================ # Views ================================================ FILE: examples/views/main.go ================================================ package main // An example demonstrating an application with multiple views. // // Note that this example was produced before the Bubbles progress component // was available (github.com/charmbracelet/bubbles/progress) and thus, we're // implementing a progress bar from scratch here. import ( "fmt" "math" "strconv" "strings" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/fogleman/ease" "github.com/lucasb-eyer/go-colorful" ) const ( progressBarWidth = 71 progressFullChar = "█" progressEmptyChar = "░" dotChar = " • " ) // General stuff for styling the view var ( keywordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) ticksStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("79")) checkboxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) progressEmpty = subtleStyle.Render(progressEmptyChar) dotStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")).Render(dotChar) mainStyle = lipgloss.NewStyle().MarginLeft(2) // Gradient colors we'll use for the progress bar ramp = makeRampStyles("#B14FFF", "#00FFA3", progressBarWidth) ) func main() { initialModel := model{0, false, 10, 0, 0, false, false} p := tea.NewProgram(initialModel) if _, err := p.Run(); err != nil { fmt.Println("could not start program:", err) } } type ( tickMsg struct{} frameMsg struct{} ) func tick() tea.Cmd { return tea.Tick(time.Second, func(time.Time) tea.Msg { return tickMsg{} }) } func frame() tea.Cmd { return tea.Tick(time.Second/60, func(time.Time) tea.Msg { return frameMsg{} }) } type model struct { Choice int Chosen bool Ticks int Frames int Progress float64 Loaded bool Quitting bool } func (m model) Init() tea.Cmd { return tick() } // Main update function. func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Make sure these keys always quit if msg, ok := msg.(tea.KeyMsg); ok { k := msg.String() if k == "q" || k == "esc" || k == "ctrl+c" { m.Quitting = true return m, tea.Quit } } // Hand off the message and model to the appropriate update function for the // appropriate view based on the current state. if !m.Chosen { return updateChoices(msg, m) } return updateChosen(msg, m) } // The main view, which just calls the appropriate sub-view func (m model) View() tea.View { var s string if m.Quitting { return tea.NewView("\n See you later!\n\n") } if !m.Chosen { s = choicesView(m) } else { s = chosenView(m) } return tea.NewView(mainStyle.Render("\n" + s + "\n")) } // Sub-update functions // Update loop for the first view where you're choosing a task. func updateChoices(msg tea.Msg, m model) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "j", "down": m.Choice++ if m.Choice > 3 { m.Choice = 3 } case "k", "up": m.Choice-- if m.Choice < 0 { m.Choice = 0 } case "enter": m.Chosen = true return m, frame() } case tickMsg: if m.Ticks == 0 { m.Quitting = true return m, tea.Quit } m.Ticks-- return m, tick() } return m, nil } // Update loop for the second view after a choice has been made func updateChosen(msg tea.Msg, m model) (tea.Model, tea.Cmd) { switch msg.(type) { case frameMsg: if !m.Loaded { m.Frames++ m.Progress = ease.OutBounce(float64(m.Frames) / float64(100)) if m.Progress >= 1 { m.Progress = 1 m.Loaded = true m.Ticks = 3 return m, tick() } return m, frame() } case tickMsg: if m.Loaded { if m.Ticks == 0 { m.Quitting = true return m, tea.Quit } m.Ticks-- return m, tick() } } return m, nil } // Sub-views // The first view, where you're choosing a task func choicesView(m model) string { c := m.Choice tpl := "What to do today?\n\n" tpl += "%s\n\n" tpl += "Program quits in %s seconds\n\n" tpl += subtleStyle.Render("j/k, up/down: select") + dotStyle + subtleStyle.Render("enter: choose") + dotStyle + subtleStyle.Render("q, esc: quit") choices := fmt.Sprintf( "%s\n%s\n%s\n%s", checkbox("Plant carrots", c == 0), checkbox("Go to the market", c == 1), checkbox("Read something", c == 2), checkbox("See friends", c == 3), ) return fmt.Sprintf(tpl, choices, ticksStyle.Render(strconv.Itoa(m.Ticks))) } // The second view, after a task has been chosen func chosenView(m model) string { var msg string switch m.Choice { case 0: msg = fmt.Sprintf("Carrot planting?\n\nCool, we'll need %s and %s...", keywordStyle.Render("libgarden"), keywordStyle.Render("vegeutils")) case 1: msg = fmt.Sprintf("A trip to the market?\n\nOkay, then we should install %s and %s...", keywordStyle.Render("marketkit"), keywordStyle.Render("libshopping")) case 2: msg = fmt.Sprintf("Reading time?\n\nOkay, cool, then we’ll need a library. Yes, an %s.", keywordStyle.Render("actual library")) default: msg = fmt.Sprintf("It’s always good to see friends.\n\nFetching %s and %s...", keywordStyle.Render("social-skills"), keywordStyle.Render("conversationutils")) } label := "Downloading..." if m.Loaded { label = fmt.Sprintf("Downloaded. Exiting in %s seconds...", ticksStyle.Render(strconv.Itoa(m.Ticks))) } return msg + "\n\n" + label + "\n" + progressbar(m.Progress) + "%" } func checkbox(label string, checked bool) string { if checked { return checkboxStyle.Render("[x] " + label) } return fmt.Sprintf("[ ] %s", label) } func progressbar(percent float64) string { w := float64(progressBarWidth) fullSize := int(math.Round(w * percent)) var fullCells string for i := range fullSize { fullCells += ramp[i].Render(progressFullChar) } emptySize := int(w) - fullSize emptyCells := strings.Repeat(progressEmpty, emptySize) return fmt.Sprintf("%s%s %3.0f", fullCells, emptyCells, math.Round(percent*100)) } // Utils // Generate a blend of colors. func makeRampStyles(colorA, colorB string, steps float64) (s []lipgloss.Style) { cA, _ := colorful.Hex(colorA) cB, _ := colorful.Hex(colorB) for i := 0.0; i < steps; i++ { c := cA.BlendLuv(cB, i/steps) s = append(s, lipgloss.NewStyle().Foreground(lipgloss.Color(colorToHex(c)))) } return s } // Convert a colorful.Color to a hexadecimal format. func colorToHex(c colorful.Color) string { return fmt.Sprintf("#%s%s%s", colorFloatToHex(c.R), colorFloatToHex(c.G), colorFloatToHex(c.B)) } // Helper function for converting colors to hex. Assumes a value between 0 and // 1. func colorFloatToHex(f float64) (s string) { s = strconv.FormatInt(int64(f*255), 16) if len(s) == 1 { s = "0" + s } return s } ================================================ FILE: examples/window-size/main.go ================================================ package main // A simple program that queries and displays the window-size. import ( "log" tea "charm.land/bubbletea/v2" ) func main() { p := tea.NewProgram(model{}) if _, err := p.Run(); err != nil { log.Fatal(err) } } type model struct{} func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: if s := msg.String(); s == "ctrl+c" || s == "q" || s == "esc" { return m, tea.Quit } return m, tea.RequestWindowSize case tea.WindowSizeMsg: return m, tea.Printf("The window size is: %dx%d", msg.Width, msg.Height) } return m, nil } func (m model) View() tea.View { return tea.NewView("\nWhen you're done press q to quit.\nPress any other key to query the window-size.\n") } ================================================ FILE: exec.go ================================================ package tea import ( "io" "os" "os/exec" ) // execMsg is used internally to run an ExecCommand sent with Exec. type execMsg struct { cmd ExecCommand fn ExecCallback } // Exec is used to perform arbitrary I/O in a blocking fashion, effectively // pausing the Program while execution is running and resuming it when // execution has completed. // // Most of the time you'll want to use ExecProcess, which runs an exec.Cmd. // // For non-interactive i/o you should use a Cmd (that is, a tea.Cmd). func Exec(c ExecCommand, fn ExecCallback) Cmd { return func() Msg { return execMsg{cmd: c, fn: fn} } } // ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively // pausing the Program while the command is running. After the *exec.Cmd exists // the Program resumes. It's useful for spawning other interactive applications // such as editors and shells from within a Program. // // To produce the command, pass an *exec.Cmd and a function which returns // a message containing the error which may have occurred when running the // ExecCommand. // // type VimFinishedMsg struct { err error } // // c := exec.Command("vim", "file.txt") // // cmd := ExecProcess(c, func(err error) Msg { // return VimFinishedMsg{err: err} // }) // // Or, if you don't care about errors, you could simply: // // cmd := ExecProcess(exec.Command("vim", "file.txt"), nil) // // For non-interactive i/o you should use a Cmd (that is, a tea.Cmd). func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd { return Exec(wrapExecCommand(c), fn) } // ExecCallback is used when executing an *exec.Command to return a message // with an error, which may or may not be nil. type ExecCallback func(error) Msg // ExecCommand can be implemented to execute things in a blocking fashion in // the current terminal. type ExecCommand interface { Run() error SetStdin(io.Reader) SetStdout(io.Writer) SetStderr(io.Writer) } // wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand // interface so it can be used with Exec. func wrapExecCommand(c *exec.Cmd) ExecCommand { return &osExecCommand{Cmd: c} } // osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand // interface. type osExecCommand struct{ *exec.Cmd } // SetStdin sets stdin on underlying exec.Cmd to the given io.Reader. func (c *osExecCommand) SetStdin(r io.Reader) { // If unset, have the command use the same input as the terminal. if c.Stdin == nil { c.Stdin = r } } // SetStdout sets stdout on underlying exec.Cmd to the given io.Writer. func (c *osExecCommand) SetStdout(w io.Writer) { // If unset, have the command use the same output as the terminal. if c.Stdout == nil { c.Stdout = w } } // SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer. func (c *osExecCommand) SetStderr(w io.Writer) { // If unset, use stderr for the command's stderr if c.Stderr == nil { c.Stderr = w } } // exec runs an ExecCommand and delivers the results to the program as a Msg. func (p *Program) exec(c ExecCommand, fn ExecCallback) { if err := p.releaseTerminal(false); err != nil { // If we can't release input, abort. if fn != nil { go p.Send(fn(err)) } return } c.SetStdin(p.input) c.SetStdout(p.output) c.SetStderr(os.Stderr) // Execute system command. if err := c.Run(); err != nil { _ = p.RestoreTerminal() // also try to restore the terminal. if fn != nil { go p.Send(fn(err)) } return } // Have the program re-capture input. err := p.RestoreTerminal() if fn != nil { go p.Send(fn(err)) } } ================================================ FILE: exec_test.go ================================================ package tea import ( "bytes" "os/exec" "runtime" "testing" ) type execFinishedMsg struct{ err error } type testExecModel struct { cmd string err error } func (m *testExecModel) Init() Cmd { c := exec.Command(m.cmd) //nolint:gosec return ExecProcess(c, func(err error) Msg { return execFinishedMsg{err} }) } func (m *testExecModel) Update(msg Msg) (Model, Cmd) { switch msg := msg.(type) { case execFinishedMsg: if msg.err != nil { m.err = msg.err } return m, Quit } return m, nil } func (m *testExecModel) View() View { return NewView("\n") } type spyRenderer struct { renderer calledReset bool } func TestTeaExec(t *testing.T) { type test struct { name string cmd string expectErr bool } // TODO: add more tests for windows tests := []test{ { name: "invalid command", cmd: "invalid", expectErr: true, }, } if runtime.GOOS != "windows" { tests = append(tests, []test{ { name: "true", cmd: "true", expectErr: false, }, { name: "false", cmd: "false", expectErr: true, }, }...) } for _, test := range tests { t.Run(test.name, func(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer m := &testExecModel{cmd: test.cmd} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) if _, err := p.Run(); err != nil { t.Error(err) } p.renderer = &spyRenderer{renderer: p.renderer} if m.err != nil && !test.expectErr { t.Errorf("expected no error, got %v", m.err) if !p.renderer.(*spyRenderer).calledReset { t.Error("expected renderer to be reset") } } if m.err == nil && test.expectErr { t.Error("expected error, got nil") } }) } } ================================================ FILE: focus.go ================================================ package tea // FocusMsg represents a terminal focus message. // This occurs when the terminal gains focus. type FocusMsg struct{} // BlurMsg represents a terminal blur message. // This occurs when the terminal loses focus. type BlurMsg struct{} ================================================ FILE: go.mod ================================================ module charm.land/bubbletea/v2 retract v2.0.0-beta1 // We add a "." after the "beta" in the version number. go 1.24.6 require ( github.com/charmbracelet/colorprofile v0.4.2 github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 github.com/charmbracelet/x/ansi v0.11.6 github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f github.com/charmbracelet/x/term v0.2.2 github.com/lucasb-eyer/go-colorful v1.3.0 github.com/muesli/cancelreader v0.2.2 golang.org/x/sys v0.41.0 ) require ( github.com/aymanbagabas/go-udiff v0.2.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.19.0 // indirect ) ================================================ FILE: go.sum ================================================ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= ================================================ FILE: input.go ================================================ package tea import ( uv "github.com/charmbracelet/ultraviolet" ) // translateInputEvent translates an input event into a Bubble Tea Msg. func (p *Program) translateInputEvent(e uv.Event) Msg { switch e := e.(type) { case uv.ClipboardEvent: return ClipboardMsg(e) case uv.ForegroundColorEvent: return ForegroundColorMsg(e) case uv.BackgroundColorEvent: return BackgroundColorMsg(e) case uv.CursorColorEvent: return CursorColorMsg(e) case uv.CursorPositionEvent: return CursorPositionMsg(e) case uv.FocusEvent: return FocusMsg(e) case uv.BlurEvent: return BlurMsg(e) case uv.KeyPressEvent: return KeyPressMsg(e) case uv.KeyReleaseEvent: return KeyReleaseMsg(e) case uv.MouseClickEvent: return MouseClickMsg(e) case uv.MouseMotionEvent: return MouseMotionMsg(e) case uv.MouseReleaseEvent: return MouseReleaseMsg(e) case uv.MouseWheelEvent: return MouseWheelMsg(e) case uv.PasteEvent: return PasteMsg(e) case uv.PasteStartEvent: return PasteStartMsg(e) case uv.PasteEndEvent: return PasteEndMsg(e) case uv.WindowSizeEvent: return WindowSizeMsg(e) case uv.CapabilityEvent: return CapabilityMsg(e) case uv.TerminalVersionEvent: return TerminalVersionMsg(e) case uv.KeyboardEnhancementsEvent: return KeyboardEnhancementsMsg(e) case uv.ModeReportEvent: return ModeReportMsg(e) } return e } ================================================ FILE: key.go ================================================ package tea import ( "fmt" uv "github.com/charmbracelet/ultraviolet" ) const ( // KeyExtended is a special key code used to signify that a key event // contains multiple runes. KeyExtended = uv.KeyExtended ) // Special key symbols. const ( // Special keys. KeyUp = uv.KeyUp KeyDown = uv.KeyDown KeyRight = uv.KeyRight KeyLeft = uv.KeyLeft KeyBegin = uv.KeyBegin KeyFind = uv.KeyFind KeyInsert = uv.KeyInsert KeyDelete = uv.KeyDelete KeySelect = uv.KeySelect KeyPgUp = uv.KeyPgUp KeyPgDown = uv.KeyPgDown KeyHome = uv.KeyHome KeyEnd = uv.KeyEnd // Keypad keys. KeyKpEnter = uv.KeyKpEnter KeyKpEqual = uv.KeyKpEqual KeyKpMultiply = uv.KeyKpMultiply KeyKpPlus = uv.KeyKpPlus KeyKpComma = uv.KeyKpComma KeyKpMinus = uv.KeyKpMinus KeyKpDecimal = uv.KeyKpDecimal KeyKpDivide = uv.KeyKpDivide KeyKp0 = uv.KeyKp0 KeyKp1 = uv.KeyKp1 KeyKp2 = uv.KeyKp2 KeyKp3 = uv.KeyKp3 KeyKp4 = uv.KeyKp4 KeyKp5 = uv.KeyKp5 KeyKp6 = uv.KeyKp6 KeyKp7 = uv.KeyKp7 KeyKp8 = uv.KeyKp8 KeyKp9 = uv.KeyKp9 // The following are keys defined in the Kitty keyboard protocol. // XXX: Investigate the names of these keys. KeyKpSep = uv.KeyKpSep KeyKpUp = uv.KeyKpUp KeyKpDown = uv.KeyKpDown KeyKpLeft = uv.KeyKpLeft KeyKpRight = uv.KeyKpRight KeyKpPgUp = uv.KeyKpPgUp KeyKpPgDown = uv.KeyKpPgDown KeyKpHome = uv.KeyKpHome KeyKpEnd = uv.KeyKpEnd KeyKpInsert = uv.KeyKpInsert KeyKpDelete = uv.KeyKpDelete KeyKpBegin = uv.KeyKpBegin // Function keys. KeyF1 = uv.KeyF1 KeyF2 = uv.KeyF2 KeyF3 = uv.KeyF3 KeyF4 = uv.KeyF4 KeyF5 = uv.KeyF5 KeyF6 = uv.KeyF6 KeyF7 = uv.KeyF7 KeyF8 = uv.KeyF8 KeyF9 = uv.KeyF9 KeyF10 = uv.KeyF10 KeyF11 = uv.KeyF11 KeyF12 = uv.KeyF12 KeyF13 = uv.KeyF13 KeyF14 = uv.KeyF14 KeyF15 = uv.KeyF15 KeyF16 = uv.KeyF16 KeyF17 = uv.KeyF17 KeyF18 = uv.KeyF18 KeyF19 = uv.KeyF19 KeyF20 = uv.KeyF20 KeyF21 = uv.KeyF21 KeyF22 = uv.KeyF22 KeyF23 = uv.KeyF23 KeyF24 = uv.KeyF24 KeyF25 = uv.KeyF25 KeyF26 = uv.KeyF26 KeyF27 = uv.KeyF27 KeyF28 = uv.KeyF28 KeyF29 = uv.KeyF29 KeyF30 = uv.KeyF30 KeyF31 = uv.KeyF31 KeyF32 = uv.KeyF32 KeyF33 = uv.KeyF33 KeyF34 = uv.KeyF34 KeyF35 = uv.KeyF35 KeyF36 = uv.KeyF36 KeyF37 = uv.KeyF37 KeyF38 = uv.KeyF38 KeyF39 = uv.KeyF39 KeyF40 = uv.KeyF40 KeyF41 = uv.KeyF41 KeyF42 = uv.KeyF42 KeyF43 = uv.KeyF43 KeyF44 = uv.KeyF44 KeyF45 = uv.KeyF45 KeyF46 = uv.KeyF46 KeyF47 = uv.KeyF47 KeyF48 = uv.KeyF48 KeyF49 = uv.KeyF49 KeyF50 = uv.KeyF50 KeyF51 = uv.KeyF51 KeyF52 = uv.KeyF52 KeyF53 = uv.KeyF53 KeyF54 = uv.KeyF54 KeyF55 = uv.KeyF55 KeyF56 = uv.KeyF56 KeyF57 = uv.KeyF57 KeyF58 = uv.KeyF58 KeyF59 = uv.KeyF59 KeyF60 = uv.KeyF60 KeyF61 = uv.KeyF61 KeyF62 = uv.KeyF62 KeyF63 = uv.KeyF63 // The following are keys defined in the Kitty keyboard protocol. // XXX: Investigate the names of these keys. KeyCapsLock = uv.KeyCapsLock KeyScrollLock = uv.KeyScrollLock KeyNumLock = uv.KeyNumLock KeyPrintScreen = uv.KeyPrintScreen KeyPause = uv.KeyPause KeyMenu = uv.KeyMenu KeyMediaPlay = uv.KeyMediaPlay KeyMediaPause = uv.KeyMediaPause KeyMediaPlayPause = uv.KeyMediaPlayPause KeyMediaReverse = uv.KeyMediaReverse KeyMediaStop = uv.KeyMediaStop KeyMediaFastForward = uv.KeyMediaFastForward KeyMediaRewind = uv.KeyMediaRewind KeyMediaNext = uv.KeyMediaNext KeyMediaPrev = uv.KeyMediaPrev KeyMediaRecord KeyLowerVol = uv.KeyLowerVol KeyRaiseVol = uv.KeyRaiseVol KeyMute = uv.KeyMute KeyLeftShift = uv.KeyLeftShift KeyLeftAlt = uv.KeyLeftAlt KeyLeftCtrl = uv.KeyLeftCtrl KeyLeftSuper = uv.KeyLeftSuper KeyLeftHyper = uv.KeyLeftHyper KeyLeftMeta = uv.KeyLeftMeta KeyRightShift = uv.KeyRightShift KeyRightAlt = uv.KeyRightAlt KeyRightCtrl = uv.KeyRightCtrl KeyRightSuper = uv.KeyRightSuper KeyRightHyper = uv.KeyRightHyper KeyRightMeta = uv.KeyRightMeta KeyIsoLevel3Shift = uv.KeyIsoLevel3Shift KeyIsoLevel5Shift = uv.KeyIsoLevel5Shift // Special names in C0. KeyBackspace = uv.KeyBackspace KeyTab = uv.KeyTab KeyEnter = uv.KeyEnter KeyReturn = uv.KeyReturn KeyEscape = uv.KeyEscape KeyEsc = uv.KeyEsc // Special names in G0. KeySpace = uv.KeySpace ) // KeyPressMsg represents a key press message. type KeyPressMsg Key // String implements [fmt.Stringer] and is quite useful for matching key // events. For details, on what this returns see [Key.String]. func (k KeyPressMsg) String() string { return Key(k).String() } // Keystroke returns the keystroke representation of the [Key]. While less type // safe than looking at the individual fields, it will usually be more // convenient and readable to use this method when matching against keys. // // Note that modifier keys are always printed in the following order: // - ctrl // - alt // - shift // - meta // - hyper // - super // // For example, you'll always see "ctrl+shift+alt+a" and never // "shift+ctrl+alt+a". func (k KeyPressMsg) Keystroke() string { return uv.Key(k).Keystroke() } // Key returns the underlying key event. This is a syntactic sugar for casting // the key event to a [Key]. func (k KeyPressMsg) Key() Key { return Key(k) } // KeyReleaseMsg represents a key release message. type KeyReleaseMsg Key // String implements [fmt.Stringer] and is quite useful for matching key // events. For details, on what this returns see [Key.String]. func (k KeyReleaseMsg) String() string { return Key(k).String() } // Keystroke returns the keystroke representation of the [Key]. While less type // safe than looking at the individual fields, it will usually be more // convenient and readable to use this method when matching against keys. // // Note that modifier keys are always printed in the following order: // - ctrl // - alt // - shift // - meta // - hyper // - super // // For example, you'll always see "ctrl+shift+alt+a" and never // "shift+ctrl+alt+a". func (k KeyReleaseMsg) Keystroke() string { return uv.Key(k).Keystroke() } // Key returns the underlying key event. This is a convenience method and // syntactic sugar to satisfy the [KeyMsg] interface, and cast the key event to // [Key]. func (k KeyReleaseMsg) Key() Key { return Key(k) } // KeyMsg represents a key event. This can be either a key press or a key // release event. type KeyMsg interface { fmt.Stringer // Key returns the underlying key event. Key() Key } // Key represents a Key press or release event. It contains information about // the Key pressed, like the runes, the type of Key, and the modifiers pressed. // There are a couple general patterns you could use to check for key presses // or releases: // // // Switch on the string representation of the key (shorter) // switch msg := msg.(type) { // case KeyPressMsg: // switch msg.String() { // case "enter": // fmt.Println("you pressed enter!") // case "a": // fmt.Println("you pressed a!") // } // } // // // Switch on the key type (more foolproof) // switch msg := msg.(type) { // case KeyMsg: // // catch both KeyPressMsg and KeyReleaseMsg // switch key := msg.Key(); key.Code { // case KeyEnter: // fmt.Println("you pressed enter!") // default: // switch key.Text { // case "a": // fmt.Println("you pressed a!") // } // } // } // // Note that [Key.Text] will be empty for special keys like [KeyEnter], // [KeyTab], and for keys that don't represent printable characters like key // combos with modifier keys. In other words, [Key.Text] is populated only for // keys that represent printable characters shifted or unshifted (like 'a', // 'A', '1', '!', etc.). type Key struct { // Text contains the actual characters received. This usually the same as // [Key.Code]. When [Key.Text] is non-empty, it indicates that the key // pressed represents printable character(s). Text string // Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on. Mod KeyMod // Code represents the key pressed. This is usually a special key like // [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'. Code rune // ShiftedCode is the actual, shifted key pressed by the user. For example, // if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will // be 'A' and [Key.Code] will be 'a'. // // In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the // unshifted key on the keyboard. // // This is only available with the Kitty Keyboard Protocol or the Windows // Console API. ShiftedCode rune // BaseCode is the key pressed according to the standard PC-101 key layout. // On international keyboards, this is the key that would be pressed if the // keyboard was set to US PC-101 layout. // // For example, if the user presses 'q' on a French AZERTY keyboard, // [Key.BaseCode] will be 'q'. // // This is only available with the Kitty Keyboard Protocol or the Windows // Console API. BaseCode rune // IsRepeat indicates whether the key is being held down and sending events // repeatedly. // // This is only available with the Kitty Keyboard Protocol or the Windows // Console API. IsRepeat bool } // String implements [fmt.Stringer] and is quite useful for matching key // events. It will return the textual representation of the [Key] if there is // one, otherwise, it will fallback to [Key.Keystroke]. // // For example, you'll always get "?" and instead of "shift+/" on a US ANSI // keyboard. func (k Key) String() string { return uv.Key(k).String() } // Keystroke returns the keystroke representation of the [Key]. While less type // safe than looking at the individual fields, it will usually be more // convenient and readable to use this method when matching against keys. // // Note that modifier keys are always printed in the following order: // - ctrl // - alt // - shift // - meta // - hyper // - super // // For example, you'll always see "ctrl+shift+alt+a" and never // "shift+ctrl+alt+a". func (k Key) Keystroke() string { return uv.Key(k).Keystroke() } ================================================ FILE: keyboard.go ================================================ package tea import ( "github.com/charmbracelet/x/ansi" ) // KeyboardEnhancementsMsg is a message that gets sent when the terminal // supports keyboard enhancements. type KeyboardEnhancementsMsg struct { // Flags is a bitmask of enabled keyboard enhancement features. A non-zero // value indicates that at least we have key disambiguation support. // // See [ansi.KittyReportEventTypes] and other constants for details. // // Example: // // ```go // // The hard way // if msg.Flags&ansi.KittyReportEventTypes != 0 { // // Terminal supports reporting different key event types // } // // // The easy way // if msg.SupportsEventTypes() { // // Terminal supports reporting different key event types // } // ``` Flags int } // SupportsKeyDisambiguation returns whether the terminal supports key // disambiguation (e.g., distinguishing between different modifier keys). func (k KeyboardEnhancementsMsg) SupportsKeyDisambiguation() bool { return k.Flags > 0 } // SupportsEventTypes returns whether the terminal supports reporting // different types of key events (press, release, and repeat). func (k KeyboardEnhancementsMsg) SupportsEventTypes() bool { return k.Flags&ansi.KittyReportEventTypes != 0 } ================================================ FILE: logging.go ================================================ package tea import ( "fmt" "io" "log" "os" "unicode" ) // LogToFile sets up default logging to log to a file. This is helpful as we // can't print to the terminal since our TUI is occupying it. If the file // doesn't exist it will be created. // // Don't forget to close the file when you're done with it. // // f, err := LogToFile("debug.log", "debug") // if err != nil { // fmt.Println("fatal:", err) // os.Exit(1) // } // defer f.Close() func LogToFile(path string, prefix string) (*os.File, error) { return LogToFileWith(path, prefix, log.Default()) } // LogOptionsSetter is an interface implemented by stdlib's log and charm's log // libraries. type LogOptionsSetter interface { SetOutput(io.Writer) SetPrefix(string) } // LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter. func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd if err != nil { return nil, fmt.Errorf("error opening file for logging: %w", err) } log.SetOutput(f) // Add a space after the prefix if a prefix is being specified and it // doesn't already have a trailing space. if len(prefix) > 0 { finalChar := prefix[len(prefix)-1] if !unicode.IsSpace(rune(finalChar)) { prefix += " " } } log.SetPrefix(prefix) return f, nil } ================================================ FILE: logging_test.go ================================================ package tea import ( "log" "os" "path/filepath" "testing" ) func TestLogToFile(t *testing.T) { path := filepath.Join(t.TempDir(), "log.txt") prefix := "logprefix" f, err := LogToFile(path, prefix) if err != nil { t.Error(err) } log.SetFlags(log.Lmsgprefix) log.Println("some test log") if closeErr := f.Close(); closeErr != nil { t.Error(closeErr) } out, err := os.ReadFile(path) if err != nil { t.Error(err) } if string(out) != prefix+" some test log\n" { t.Fatalf("wrong log msg: %q", string(out)) } } ================================================ FILE: mod.go ================================================ package tea import uv "github.com/charmbracelet/ultraviolet" // KeyMod represents modifier keys. type KeyMod = uv.KeyMod // Modifier keys. const ( ModShift = uv.ModShift ModAlt = uv.ModAlt ModCtrl = uv.ModCtrl ModMeta = uv.ModMeta // These modifiers are used with the Kitty protocol. // XXX: Meta and Super are swapped in the Kitty protocol, // this is to preserve compatibility with XTerm modifiers. ModHyper = uv.ModHyper ModSuper = uv.ModSuper // Windows/Command keys // These are key lock states. ModCapsLock = uv.ModCapsLock ModNumLock = uv.ModNumLock ModScrollLock = uv.ModScrollLock // Defined in Windows API only ) ================================================ FILE: mouse.go ================================================ package tea import ( "fmt" uv "github.com/charmbracelet/ultraviolet" ) // MouseButton represents the button that was pressed during a mouse message. type MouseButton = uv.MouseButton // Mouse event buttons // // This is based on X11 mouse button codes. // // 1 = left button // 2 = middle button (pressing the scroll wheel) // 3 = right button // 4 = turn scroll wheel up // 5 = turn scroll wheel down // 6 = push scroll wheel left // 7 = push scroll wheel right // 8 = 4th button (aka browser backward button) // 9 = 5th button (aka browser forward button) // 10 // 11 // // Other buttons are not supported. const ( MouseNone = uv.MouseNone MouseLeft = uv.MouseLeft MouseMiddle = uv.MouseMiddle MouseRight = uv.MouseRight MouseWheelUp = uv.MouseWheelUp MouseWheelDown = uv.MouseWheelDown MouseWheelLeft = uv.MouseWheelLeft MouseWheelRight = uv.MouseWheelRight MouseBackward = uv.MouseBackward MouseForward = uv.MouseForward MouseButton10 = uv.MouseButton10 MouseButton11 ) // MouseMsg represents a mouse message. This is a generic mouse message that // can represent any kind of mouse event. type MouseMsg interface { fmt.Stringer // Mouse returns the underlying mouse event. Mouse() Mouse } // Mouse represents a Mouse message. Use [MouseMsg] to represent all mouse // messages. // // The X and Y coordinates are zero-based, with (0,0) being the upper left // corner of the terminal. // // // Catch all mouse events // switch msg := msg.(type) { // case MouseMsg: // m := msg.Mouse() // fmt.Println("Mouse event:", m.X, m.Y, m) // } // // // Only catch mouse click events // switch msg := msg.(type) { // case MouseClickMsg: // fmt.Println("Mouse click event:", msg.X, msg.Y, msg) // } type Mouse struct { X, Y int Button MouseButton Mod KeyMod } // String returns a string representation of the mouse message. func (m Mouse) String() (s string) { return uv.Mouse(m).String() } // MouseClickMsg represents a mouse button click message. type MouseClickMsg Mouse // String returns a string representation of the mouse click message. func (e MouseClickMsg) String() string { return Mouse(e).String() } // Mouse returns the underlying mouse event. This is a convenience method and // syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse // event to [Mouse]. func (e MouseClickMsg) Mouse() Mouse { return Mouse(e) } // MouseReleaseMsg represents a mouse button release message. type MouseReleaseMsg Mouse // String returns a string representation of the mouse release message. func (e MouseReleaseMsg) String() string { return Mouse(e).String() } // Mouse returns the underlying mouse event. This is a convenience method and // syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse // event to [Mouse]. func (e MouseReleaseMsg) Mouse() Mouse { return Mouse(e) } // MouseWheelMsg represents a mouse wheel message event. type MouseWheelMsg Mouse // String returns a string representation of the mouse wheel message. func (e MouseWheelMsg) String() string { return Mouse(e).String() } // Mouse returns the underlying mouse event. This is a convenience method and // syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse // event to [Mouse]. func (e MouseWheelMsg) Mouse() Mouse { return Mouse(e) } // MouseMotionMsg represents a mouse motion message. type MouseMotionMsg Mouse // String returns a string representation of the mouse motion message. func (e MouseMotionMsg) String() string { m := Mouse(e) if m.Button != 0 { return m.String() + "+motion" } return m.String() + "motion" } // Mouse returns the underlying mouse event. This is a convenience method and // syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse // event to [Mouse]. func (e MouseMotionMsg) Mouse() Mouse { return Mouse(e) } ================================================ FILE: nil_renderer.go ================================================ package tea import ( "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/ansi" ) // nilRenderer is a no-op renderer. It implements the Renderer interface but // doesn't render anything to the terminal. type nilRenderer struct{} var _ renderer = nilRenderer{} // start implements renderer. func (n nilRenderer) start() {} // clearScreen implements renderer. func (n nilRenderer) clearScreen() {} // insertAbove implements renderer. func (n nilRenderer) insertAbove(string) error { return nil } // resize implements renderer. func (n nilRenderer) resize(int, int) {} // setColorProfile implements renderer. func (n nilRenderer) setColorProfile(colorprofile.Profile) {} // flush implements the Renderer interface. func (nilRenderer) flush(bool) error { return nil } // close implements the Renderer interface. func (nilRenderer) close() error { return nil } // render implements the Renderer interface. func (nilRenderer) render(View) {} // reset implements the Renderer interface. func (nilRenderer) reset() {} // writeString implements the Renderer interface. func (nilRenderer) writeString(string) (int, error) { return 0, nil } // setSyncdUpdates implements the Renderer interface. func (n nilRenderer) setSyncdUpdates(bool) {} // setWidthMethod implements the Renderer interface. func (n nilRenderer) setWidthMethod(ansi.Method) {} // onMouse implements the Renderer interface. func (n nilRenderer) onMouse(MouseMsg) Cmd { return nil } ================================================ FILE: options.go ================================================ package tea import ( "context" "io" "sync/atomic" "github.com/charmbracelet/colorprofile" ) // ProgramOption is used to set options when initializing a Program. Program can // accept a variable number of options. // // Example usage: // // p := NewProgram(model, WithInput(someInput), WithOutput(someOutput)) type ProgramOption func(*Program) // WithContext lets you specify a context in which to run the Program. This is // useful if you want to cancel the execution from outside. When a Program gets // cancelled it will exit with an error ErrProgramKilled. func WithContext(ctx context.Context) ProgramOption { return func(p *Program) { p.externalCtx = ctx } } // WithOutput sets the output which, by default, is stdout. In most cases you // won't need to use this. func WithOutput(output io.Writer) ProgramOption { return func(p *Program) { p.output = output } } // WithInput sets the input which, by default, is stdin. In most cases you // won't need to use this. To disable input entirely pass nil. // // p := NewProgram(model, WithInput(nil)) func WithInput(input io.Reader) ProgramOption { return func(p *Program) { p.input = input p.disableInput = input == nil } } // WithEnvironment sets the environment variables that the program will use. // This useful when the program is running in a remote session (e.g. SSH) and // you want to pass the environment variables from the remote session to the // program. // // Example: // // var sess ssh.Session // ssh.Session is a type from the github.com/charmbracelet/ssh package // pty, _, _ := sess.Pty() // environ := append(sess.Environ(), "TERM="+pty.Term) // p := tea.NewProgram(model, tea.WithEnvironment(environ) func WithEnvironment(env []string) ProgramOption { return func(p *Program) { p.environ = env } } // WithoutSignalHandler disables the signal handler that Bubble Tea sets up for // Programs. This is useful if you want to handle signals yourself. func WithoutSignalHandler() ProgramOption { return func(p *Program) { p.disableSignalHandler = true } } // WithoutCatchPanics disables the panic catching that Bubble Tea does by // default. If panic catching is disabled the terminal will be in a fairly // unusable state after a panic because Bubble Tea will not perform its usual // cleanup on exit. func WithoutCatchPanics() ProgramOption { return func(p *Program) { p.disableCatchPanics = true } } // WithoutSignals will ignore OS signals. // This is mainly useful for testing. func WithoutSignals() ProgramOption { return func(p *Program) { atomic.StoreUint32(&p.ignoreSignals, 1) } } // WithoutRenderer disables the renderer. When this is set output and log // statements will be plainly sent to stdout (or another output if one is set) // without any rendering and redrawing logic. In other words, printing and // logging will behave the same way it would in a non-TUI commandline tool. // This can be useful if you want to use the Bubble Tea framework for a non-TUI // application, or to provide an additional non-TUI mode to your Bubble Tea // programs. For example, your program could behave like a daemon if output is // not a TTY. func WithoutRenderer() ProgramOption { return func(p *Program) { p.disableRenderer = true } } // WithFilter supplies an event filter that will be invoked before Bubble Tea // processes a tea.Msg. The event filter can return any tea.Msg which will then // get handled by Bubble Tea instead of the original event. If the event filter // returns nil, the event will be ignored and Bubble Tea will not process it. // // As an example, this could be used to prevent a program from shutting down if // there are unsaved changes. // // Example: // // func filter(m tea.Model, msg tea.Msg) tea.Msg { // if _, ok := msg.(tea.QuitMsg); !ok { // return msg // } // // model := m.(myModel) // if model.hasChanges { // return nil // } // // return msg // } // // p := tea.NewProgram(Model{}, tea.WithFilter(filter)); // // if _,err := p.Run(); err != nil { // fmt.Println("Error running program:", err) // os.Exit(1) // } func WithFilter(filter func(Model, Msg) Msg) ProgramOption { return func(p *Program) { p.filter = filter } } // WithFPS sets a custom maximum FPS at which the renderer should run. If // less than 1, the default value of 60 will be used. If over 120, the FPS // will be capped at 120. func WithFPS(fps int) ProgramOption { return func(p *Program) { p.fps = fps } } // WithColorProfile sets the color profile that the program will use. This is // useful when you want to force a specific color profile. By default, Bubble // Tea will try to detect the terminal's color profile from environment // variables and terminfo capabilities. Use [tea.WithEnvironment] to set custom // environment variables. func WithColorProfile(profile colorprofile.Profile) ProgramOption { return func(p *Program) { p.profile = &profile } } // WithWindowSize sets the initial size of the terminal window. This is useful // when you need to set the initial size of the terminal window, for example // during testing or when you want to run your program in a non-interactive // environment. func WithWindowSize(width, height int) ProgramOption { return func(p *Program) { p.width = width p.height = height } } ================================================ FILE: options_test.go ================================================ package tea import ( "bytes" "context" "os" "sync/atomic" "testing" ) func TestOptions(t *testing.T) { t.Run("output", func(t *testing.T) { var b bytes.Buffer p := NewProgram(nil, WithOutput(&b)) if f, ok := p.output.(*os.File); ok { t.Errorf("expected output to custom, got %v", f.Fd()) } }) t.Run("renderer", func(t *testing.T) { p := NewProgram(nil, WithoutRenderer()) if !p.disableRenderer { t.Errorf("expected renderer to be a nilRenderer, got %v", p.renderer) } }) t.Run("without signals", func(t *testing.T) { p := NewProgram(nil, WithoutSignals()) if atomic.LoadUint32(&p.ignoreSignals) == 0 { t.Errorf("ignore signals should have been set") } }) t.Run("filter", func(t *testing.T) { p := NewProgram(nil, WithFilter(func(_ Model, msg Msg) Msg { return msg })) if p.filter == nil { t.Errorf("expected filter to be set") } }) t.Run("external context", func(t *testing.T) { extCtx, extCancel := context.WithCancel(context.Background()) defer extCancel() p := NewProgram(nil, WithContext(extCtx)) if p.externalCtx != extCtx || p.externalCtx == context.Background() { t.Errorf("expected passed in external context, got default") } }) t.Run("input options", func(t *testing.T) { exercise := func(t *testing.T, opt ProgramOption, fn func(*Program)) { p := NewProgram(nil, opt) fn(p) } t.Run("nil input", func(t *testing.T) { exercise(t, WithInput(nil), func(p *Program) { if !p.disableInput || p.input != nil { t.Errorf("expected input to be disabled, got %v", p.input) } }) }) t.Run("custom input", func(t *testing.T) { var b bytes.Buffer exercise(t, WithInput(&b), func(p *Program) { if p.input != &b { t.Errorf("expected input to be custom, got %v", p.input) } }) }) }) t.Run("startup options", func(t *testing.T) { exercise := func(t *testing.T, opt ProgramOption, fn func(*Program)) { p := NewProgram(nil, opt) fn(p) } t.Run("without catch panics", func(t *testing.T) { exercise(t, WithoutCatchPanics(), func(p *Program) { if !p.disableCatchPanics { t.Errorf("expected catch panics to be disabled") } }) }) t.Run("without signal handler", func(t *testing.T) { exercise(t, WithoutSignalHandler(), func(p *Program) { if !p.disableSignalHandler { t.Errorf("expected signal handler to be disabled") } }) }) }) } ================================================ FILE: paste.go ================================================ package tea // PasteMsg is an message that is emitted when a terminal receives pasted text // using bracketed-paste. type PasteMsg struct { Content string } // String returns the pasted content as a string. func (p PasteMsg) String() string { return p.Content } // PasteStartMsg is an message that is emitted when the terminal starts the // bracketed-paste text. type PasteStartMsg struct{} // PasteEndMsg is an message that is emitted when the terminal ends the // bracketed-paste text. type PasteEndMsg struct{} ================================================ FILE: profile.go ================================================ package tea import "github.com/charmbracelet/colorprofile" // ColorProfileMsg is a message that describes the terminal's color profile. // This message is send to the program's update function when the program is // started. // // To upgrade the terminal color profile, use the `tea.RequestCapability` // command to request the `RGB` and `Tc` terminfo capabilities. Bubble Tea will // then cache the terminal's color profile and send a `ColorProfileMsg` to the // program's update function. type ColorProfileMsg struct { colorprofile.Profile } ================================================ FILE: raw.go ================================================ package tea // RawMsg is a message that contains a string to be printed to the terminal // without any intermediate processing. type RawMsg struct { Msg any } // Raw is a command that prints the given string to the terminal without any // formatting. // // This is intended for advanced use cases where you need to query the terminal // or send escape sequences directly. Don't use this unless you know what // you're doing :) // // Example: // // func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // switch msg := msg.(type) { // case input.PrimaryDeviceAttributesEvent: // for _, attr := range msg { // if attr == 4 { // // We have Sixel graphics support! // break // } // } // } // // // Request the terminal primary device attributes to detect Sixel graphics // // support. // return m, tea.Raw(ansi.RequestPrimaryDeviceAttributes) // } func Raw(r any) Cmd { return func() Msg { return RawMsg{r} } } ================================================ FILE: renderer.go ================================================ package tea import ( "fmt" "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/ansi" ) const ( // defaultFramerate specifies the maximum interval at which we should // update the view. defaultFPS = 60 maxFPS = 120 ) // renderer is the interface for Bubble Tea renderers. type renderer interface { // start starts the renderer. start() // close closes the renderer and flushes any remaining data. close() error // render renders a frame to the output. render(View) // flush flushes the renderer's buffer to the output. flush(closing bool) error // reset resets the renderer's state to its initial state. reset() // insertAbove inserts unmanaged lines above the renderer. insertAbove(string) error // setSyncdUpdates sets whether to use synchronized updates. setSyncdUpdates(bool) // setWidthMethod sets the method for calculating the width of the terminal. setWidthMethod(ansi.Method) // resize notify the renderer of a terminal resize. resize(int, int) // setColorProfile sets the color profile. setColorProfile(colorprofile.Profile) // clearScreen clears the screen. clearScreen() // writeString writes a string to the renderer's output. writeString(string) (int, error) // onMouse handles a mouse event. onMouse(MouseMsg) Cmd } type printLineMessage struct { messageBody string } // Println prints above the Program. This output is unmanaged by the program and // will persist across renders by the Program. // // Unlike fmt.Println (but similar to log.Println) the message will be print on // its own line. // // If the altscreen is active no output will be printed. func Println(args ...any) Cmd { return func() Msg { return printLineMessage{ messageBody: fmt.Sprint(args...), } } } // Printf prints above the Program. It takes a format template followed by // values similar to fmt.Printf. This output is unmanaged by the program and // will persist across renders by the Program. // // Unlike fmt.Printf (but similar to log.Printf) the message will be print on // its own line. // // If the altscreen is active no output will be printed. func Printf(template string, args ...any) Cmd { return func() Msg { return printLineMessage{ messageBody: fmt.Sprintf(template, args...), } } } // encodeCursorStyle returns the integer value for the given cursor style and // blink state. func encodeCursorStyle(style CursorShape, blink bool) int { // We're using the ANSI escape sequence values for cursor styles. // We need to map both [style] and [steady] to the correct value. style = (style * 2) + 1 //nolint:mnd if !blink { style++ } return int(style) } ================================================ FILE: screen.go ================================================ package tea import "github.com/charmbracelet/x/ansi" // WindowSizeMsg is used to report the terminal size. It's sent to Update once // initially and then on every terminal resize. Note that Windows does not // have support for reporting when resizes occur as it does not support the // SIGWINCH signal. type WindowSizeMsg struct { Width int Height int } // ClearScreen is a special command that tells the program to clear the screen // before the next update. This can be used to move the cursor to the top left // of the screen and clear visual clutter when the alt screen is not in use. // // Note that it should never be necessary to call ClearScreen() for regular // redraws. func ClearScreen() Msg { return clearScreenMsg{} } // clearScreenMsg is an internal message that signals to clear the screen. // You can send a clearScreenMsg with ClearScreen. type clearScreenMsg struct{} // ModeReportMsg is a message that represents a mode report event (DECRPM). // // This is sent by the terminal in response to a request for a terminal mode // report (DECRQM). It indicates the current setting of a specific terminal // mode like cursor visibility, mouse tracking, etc. // // Example: // // ```go // func (m model) Init() tea.Cmd { // // Does my terminal support reporting focus events? // return tea.Raw(ansi.RequestModeFocusEvent) // } // // func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // switch msg := msg.(type) { // case tea.ModeReportMsg: // if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() { // // Terminal supports focus events // m.supportsFocus = true // } // } // return m, nil // } // // func (m model) View() tea.View { // var view tea.View // view.ReportFocus = m.supportsFocus // view.SetContent(fmt.Sprintf("Terminal supports focus events: %v", m.supportsFocus)) // return view // } // ``` // // See: https://vt100.net/docs/vt510-rm/DECRPM.html type ModeReportMsg struct { // Mode is the mode number. Mode ansi.Mode // Value is the mode value. Value ansi.ModeSetting } ================================================ FILE: screen_test.go ================================================ package tea import ( "bytes" "image/color" "testing" "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/exp/golden" ) type testViewOpts struct { altScreen bool mouseMode MouseMode showCursor bool disableBp bool keyReleases bool bgColor color.Color } func testViewOptsCmds(opts ...testViewOpts) []Cmd { cmds := make([]Cmd, len(opts)) for i, o := range opts { o := o cmds[i] = func() Msg { return o } } return cmds } type testViewModel struct { *testModel opts testViewOpts } func (m *testViewModel) Update(msg Msg) (Model, Cmd) { switch msg := msg.(type) { case testViewOpts: m.opts = msg return m, nil } tm, cmd := m.testModel.Update(msg) m.testModel = tm.(*testModel) return m, cmd } func (m *testViewModel) View() View { v := m.testModel.View() v.AltScreen = m.opts.altScreen v.MouseMode = m.opts.mouseMode v.DisableBracketedPasteMode = m.opts.disableBp v.KeyboardEnhancements.ReportEventTypes = m.opts.keyReleases v.BackgroundColor = m.opts.bgColor if m.opts.showCursor { v.Cursor = NewCursor(0, 0) } return v } func TestViewModel(t *testing.T) { tests := []struct { name string opts []testViewOpts }{ { name: "altscreen", opts: []testViewOpts{ {altScreen: true}, {altScreen: false}, }, }, { name: "altscreen_autoexit", opts: []testViewOpts{ {altScreen: true}, }, }, { name: "mouse_cellmotion", opts: []testViewOpts{ {mouseMode: MouseModeCellMotion}, }, }, { name: "mouse_allmotion", opts: []testViewOpts{ {mouseMode: MouseModeAllMotion}, }, }, { name: "mouse_disable", opts: []testViewOpts{ {mouseMode: MouseModeAllMotion}, {mouseMode: MouseModeNone}, }, }, { name: "cursor_hide", opts: []testViewOpts{ {}, }, }, { name: "cursor_hideshow", opts: []testViewOpts{ {showCursor: false}, {showCursor: true}, }, }, { name: "bp_stop_start", opts: []testViewOpts{ {disableBp: true}, {disableBp: false}, }, }, { name: "kitty_stop_startreleases", opts: []testViewOpts{ {}, {keyReleases: true}, }, }, { name: "bg_set_color", opts: []testViewOpts{ {bgColor: color.RGBA{255, 255, 255, 255}}, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer m := &testViewModel{testModel: &testModel{}} p := NewProgram(m, // Set the initial window size for the program. WithWindowSize(80, 24), // Use ANSI256 to increase test coverage. WithColorProfile(colorprofile.ANSI256), // always use xterm and 256 colors for tests WithEnvironment([]string{"TERM=xterm-256color"}), WithInput(&in), WithOutput(&buf), ) go p.Send(append(sequenceMsg(testViewOptsCmds(test.opts...)), Quit)) if _, err := p.Run(); err != nil { t.Fatal(err) } golden.RequireEqual(t, buf.Bytes()) }) } } func TestClearMsg(t *testing.T) { type test struct { name string cmds sequenceMsg } tests := []test{ { name: "clear_screen", cmds: []Cmd{ClearScreen}, }, { name: "read_set_clipboard", cmds: []Cmd{ReadClipboard, SetClipboard("success")}, }, { name: "bg_fg_cur_color", cmds: []Cmd{RequestForegroundColor, RequestBackgroundColor, RequestCursorColor}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer m := &testModel{} p := NewProgram(m, // Set the initial window size for the program. WithWindowSize(80, 24), // Use ANSI256 to increase test coverage. WithColorProfile(colorprofile.ANSI256), // always use xterm and 256 colors for tests WithEnvironment([]string{"TERM=xterm-256color"}), WithInput(&in), WithOutput(&buf), ) go p.Send(append(test.cmds, Quit)) if _, err := p.Run(); err != nil { t.Fatal(err) } golden.RequireEqual(t, buf.Bytes()) }) } } ================================================ FILE: signals_unix.go ================================================ //go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos // +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos package tea import ( "os" "os/signal" "syscall" ) // listenForResize sends messages (or errors) when the terminal resizes. // Argument output should be the file descriptor for the terminal; usually // os.Stdout. func (p *Program) listenForResize(done chan struct{}) { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGWINCH) defer func() { signal.Stop(sig) close(done) }() for { select { case <-p.ctx.Done(): return case <-sig: } p.checkResize() } } ================================================ FILE: signals_windows.go ================================================ //go:build windows // +build windows package tea // listenForResize is not available on windows because windows does not // implement syscall.SIGWINCH. func (p *Program) listenForResize(done chan struct{}) { close(done) } ================================================ FILE: tea.go ================================================ // Package tea provides a framework for building rich terminal user interfaces // based on the paradigms of The Elm Architecture. It's well-suited for simple // and complex terminal applications, either inline, full-window, or a mix of // both. It's been battle-tested in several large projects and is // production-ready. // // A tutorial is available at https://github.com/charmbracelet/bubbletea/tree/master/tutorials // // Example programs can be found at https://github.com/charmbracelet/bubbletea/tree/master/examples package tea import ( "bytes" "context" "errors" "fmt" "image/color" "io" "log" "os" "os/signal" "runtime" "runtime/debug" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/charmbracelet/colorprofile" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/term" "github.com/muesli/cancelreader" ) // ErrProgramPanic is returned by [Program.Run] when the program recovers from a panic. var ErrProgramPanic = errors.New("program experienced a panic") // ErrProgramKilled is returned by [Program.Run] when the program gets killed. var ErrProgramKilled = errors.New("program was killed") // ErrInterrupted is returned by [Program.Run] when the program get a SIGINT // signal, or when it receives a [InterruptMsg]. var ErrInterrupted = errors.New("program was interrupted") // Msg contain data from the result of a IO operation. Msgs trigger the update // function and, henceforth, the UI. type Msg = uv.Event // Model contains the program's state as well as its core functions. type Model interface { // Init is the first function that will be called. It returns an optional // initial command. To not perform an initial command return nil. Init() Cmd // Update is called when a message is received. Use it to inspect messages // and, in response, update the model and/or send a command. Update(Msg) (Model, Cmd) // View renders the program's UI, which can be a string or a [Layer]. The // view is rendered after every Update. View() View } // NewView is a helper function to create a new [View] with the given styled // string. A styled string represents text with styles and hyperlinks encoded // as ANSI escape codes. // // Example: // // ```go // v := tea.NewView("Hello, World!") // ``` func NewView(s string) View { var view View view.SetContent(s) return view } // View represents a terminal view that can be composed of multiple layers. // It can also contain a cursor that will be rendered on top of the layers. type View struct { // Content is the screen content of the view. It holds styled strings that // will be rendered to the terminal when the view is rendered. // // A styled string represents text with styles and hyperlinks encoded as // ANSI escape codes. // // Example: // // ```go // v := tea.NewView("Hello, World!") // ``` Content string // OnMouse is an optional mouse message handler that can be used to // intercept mouse messages that depends on view content from last render. // It can be useful for implementing view-specific behavior without // breaking the unidirectional data flow of Bubble Tea. // // Example: // // ```go // content := "Hello, World!" // v := tea.NewView(content) // v.OnMouse = func(msg tea.MouseMsg) tea.Cmd { // return func() tea.Msg { // m := msg.Mouse() // // Check if the mouse is within the bounds of "World!" // start := strings.Index(content, "World!") // end := start + len("World!") // if m.Y == 0 && m.X >= start && m.X < end { // // Mouse is over "World!" // return MyCustomMsg{ // MouseMsg: msg, // } // } // } // } // return nil // } // return v // ``` OnMouse func(msg MouseMsg) Cmd // Cursor represents the cursor position, style, and visibility on the // screen. When not nil, the cursor will be shown at the specified // position. Cursor *Cursor // BackgroundColor when not nil, sets the terminal background color. Use // nil to reset to the terminal's default background color. BackgroundColor color.Color // ForegroundColor when not nil, sets the terminal foreground color. Use // nil to reset to the terminal's default foreground color. ForegroundColor color.Color // WindowTitle sets the terminal window title. Support depends on the // terminal. WindowTitle string // ProgressBar when not nil, shows a progress bar in the terminal's // progress bar section. Support depends on the terminal. ProgressBar *ProgressBar // AltScreen puts the program in the alternate screen buffer // (i.e. the program goes into full window mode). Note that the altscreen will // be automatically exited when the program quits. // // Example: // // func (m model) View() tea.View { // v := tea.NewView("Hello, World!") // v.AltScreen = true // return v // } // AltScreen bool // ReportFocus enables reporting when the terminal gains and loses focus. // When this is enabled [FocusMsg] and [BlurMsg] messages will be sent to // your Update method. // // Note that while most terminals and multiplexers support focus reporting, // some do not. Also note that tmux needs to be configured to report focus // events. ReportFocus bool // DisableBracketedPasteMode disables bracketed paste mode for this view. DisableBracketedPasteMode bool // MouseMode sets the mouse mode for this view. It can be one of // [MouseModeNone], [MouseModeCellMotion], or [MouseModeAllMotion]. MouseMode MouseMode // KeyboardEnhancements describes what keyboard enhancement features Bubble // Tea should request from the terminal. // // Bubble Tea supports requesting the following keyboard enhancement features: // - ReportEventTypes: requests the terminal to report key repeat and // release events. // // If the terminal supports any of these features, your program will // receive a [KeyboardEnhancementsMsg] that indicates which features are // available. KeyboardEnhancements KeyboardEnhancements } // KeyboardEnhancements describes the requested keyboard enhancement features. // If the terminal supports any of them, it will respond with a // [KeyboardEnhancementsMsg] that indicates which features are supported. // KeyboardEnhancements defines different keyboard enhancement features that // can be requested from the terminal. // KeyboardEnhancements defines different keyboard enhancement features that // can be requested from the terminal. // // By default, Bubble Tea requests basic key disambiguation features from the // terminal. If the terminal supports keyboard enhancements, or any of its // additional features, it will respond with a [KeyboardEnhancementsMsg] that // indicates which features are supported. // // Example: // // ```go // func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // switch msg := msg.(type) { // case tea.KeyboardEnhancementsMsg: // // We have basic key disambiguation support. // // We can handle "shift+enter", "ctrl+i", etc. // m.keyboardEnhancements = msg // if msg.ReportEventTypes { // // Even better! We can now handle key repeat and release events. // } // case tea.KeyPressMsg: // switch msg.String() { // case "shift+enter": // // Handle shift+enter // // This would not be possible without keyboard enhancements. // case "ctrl+j": // // Handle ctrl+j // } // case tea.KeyReleaseMsg: // // Whoa! A key was released! // } // // return m, nil // } // // func (m model) View() tea.View { // v := tea.NewView("Press some keys!") // // Request reporting key repeat and release events. // v.KeyboardEnhancements.ReportEventTypes = true // return v // } // ``` type KeyboardEnhancements struct { // ReportEventTypes requests the terminal to report key repeat and release // events. // If supported, your program will receive [KeyReleaseMsg]s and // [KeyPressMsg] with the [Key.IsRepeat] field set indicating that this is // a it's part of a key repeat sequence. ReportEventTypes bool } // SetContent is a helper method to set the content of a [View] with a styled // string. A styled string represents text with styles and hyperlinks encoded // as ANSI escape codes. // // Example: // // ```go // var v tea.View // v.SetContent("Hello, World!") // ``` func (v *View) SetContent(s string) { v.Content = s } // MouseMode represents the mouse mode of a view. type MouseMode int const ( // MouseModeNone disables mouse events. MouseModeNone MouseMode = iota // MouseModeCellMotion enables mouse click, release, and wheel events. // Mouse movement events are also captured if a mouse button is pressed // (i.e., drag events). Cell motion mode is better supported than all // motion mode. // // This will try to enable the mouse in extended mode (SGR), if that is not // supported by the terminal it will fall back to normal mode (X10). MouseModeCellMotion // MouseModeAllMotion enables all mouse events, including click, release, // wheel, and movement events. You will receive mouse movement events even // when no buttons are pressed. // // This will try to enable the mouse in extended mode (SGR), if that is not // supported by the terminal it will fall back to normal mode (X10). MouseModeAllMotion ) // ProgressBarState represents the state of the progress bar. type ProgressBarState int // Progress bar states. const ( ProgressBarNone ProgressBarState = iota ProgressBarDefault ProgressBarError ProgressBarIndeterminate ProgressBarWarning ) // String return a human-readable value for the given [ProgressBarState]. func (s ProgressBarState) String() string { return [...]string{ "None", "Default", "Error", "Indeterminate", "Warning", }[s] } // ProgressBar represents the terminal progress bar. // // Support depends on the terminal. // // See https://learn.microsoft.com/en-us/windows/terminal/tutorials/progress-bar-sequences type ProgressBar struct { // State is the current state of the progress bar. It can be one of // [ProgressBarNone], [ProgressBarDefault], [ProgressBarError], // [ProgressBarIndeterminate], and [ProgressBarWarn]. State ProgressBarState // Value is the current value of the progress bar. It should be between // 0 and 100. Value int } // NewProgressBar returns a new progress bar with the given state and value. // The value is ignored if the state is [ProgressBarNone] or // [ProgressBarIndeterminate]. func NewProgressBar(state ProgressBarState, value int) *ProgressBar { return &ProgressBar{ State: state, Value: min(max(value, 0), 100), } } // Cursor represents a cursor on the terminal screen. type Cursor struct { // Position is a [Position] that determines the cursor's position on the // screen relative to the top left corner of the frame. Position // Color is a [color.Color] that determines the cursor's color. Color color.Color // Shape is a [CursorShape] that determines the cursor's shape. Shape CursorShape // Blink is a boolean that determines whether the cursor should blink. Blink bool } // NewCursor returns a new cursor with the default settings and the given // position. func NewCursor(x, y int) *Cursor { return &Cursor{ Position: Position{X: x, Y: y}, Color: nil, Shape: CursorBlock, Blink: true, } } // Cmd is an IO operation that returns a message when it's complete. If it's // nil it's considered a no-op. Use it for things like HTTP requests, timers, // saving and loading from disk, and so on. // // Note that there's almost never a reason to use a command to send a message // to another part of your program. That can almost always be done in the // update function. type Cmd func() Msg // channelHandlers manages the series of channels returned by various processes. // It allows us to wait for those processes to terminate before exiting the // program. type channelHandlers struct { handlers []chan struct{} mu sync.RWMutex } // Adds a channel to the list of handlers. We wait for all handlers to terminate // gracefully on shutdown. func (h *channelHandlers) add(ch chan struct{}) { h.mu.Lock() h.handlers = append(h.handlers, ch) h.mu.Unlock() } // shutdown waits for all handlers to terminate. func (h *channelHandlers) shutdown() { var wg sync.WaitGroup h.mu.RLock() defer h.mu.RUnlock() for _, ch := range h.handlers { wg.Add(1) go func(ch chan struct{}) { <-ch wg.Done() }(ch) } wg.Wait() } // Program is a terminal user interface. type Program struct { // disableInput disables all input. This is useful for programs that // don't need input, like a progress bar or a spinner. disableInput bool // disableSignalHandler disables the signal handler that Bubble Tea sets up // for Programs. This is useful if you want to handle signals yourself. disableSignalHandler bool // disableCatchPanics disables the panic catching that Bubble Tea does by // default. If panic catching is disabled the terminal will be in a fairly // unusable state after a panic because Bubble Tea will not perform its usual // cleanup on exit. disableCatchPanics bool // filter supplies an event filter that will be invoked before Bubble Tea // processes a tea.Msg. The event filter can return any tea.Msg which will // then get handled by Bubble Tea instead of the original event. If the // event filter returns nil, the event will be ignored and Bubble Tea will // not process it. // // As an example, this could be used to prevent a program from shutting // down if there are unsaved changes. // // Example: // // func filter(m tea.Model, msg tea.Msg) tea.Msg { // if _, ok := msg.(tea.QuitMsg); !ok { // return msg // } // // model := m.(myModel) // if model.hasChanges { // return nil // } // // return msg // } // // p := tea.NewProgram(Model{}); // p.filter = filter // // if _,err := p.Run(context.Background()); err != nil { // fmt.Println("Error running program:", err) // os.Exit(1) // } filter func(Model, Msg) Msg // fps sets a custom maximum fps at which the renderer should run. If less // than 1, the default value of 60 will be used. If over 120, the fps will // be capped at 120. fps int // initialModel is the initial model for the program and is the only // required field when creating a new program. initialModel Model // disableRenderer prevents the program from rendering to the terminal. // This can be useful for running daemon-like programs that don't require a // UI but still want to take advantage of Bubble Tea's architecture. disableRenderer bool // handlers is a list of channels that need to be waited on before the // program can exit. handlers channelHandlers // ctx is the programs's internal context for signalling internal teardown. // It is built and derived from the externalCtx in NewProgram(). ctx context.Context cancel context.CancelFunc // externalCtx is a context that was passed in via WithContext, otherwise defaulting // to ctx.Background() (in case it was not), the internal context is derived from it. externalCtx context.Context msgs chan Msg errs chan error finished chan struct{} shutdownOnce sync.Once profile *colorprofile.Profile // the terminal color profile // where to send output, this will usually be os.Stdout. output io.Writer outputBuf bytes.Buffer // buffer used to queue commands to be sent to the output // ttyOutput is null if output is not a TTY. ttyOutput term.File previousOutputState *term.State renderer renderer // the environment variables for the program, defaults to os.Environ(). environ uv.Environ // the program's logger for debugging. logger uv.Logger // where to read inputs from, this will usually be os.Stdin. input io.Reader // ttyInput is null if input is not a TTY. ttyInput term.File previousTtyInputState *term.State cancelReader cancelreader.CancelReader inputScanner *uv.TerminalReader readLoopDone chan struct{} // modes keeps track of terminal modes that have been enabled or disabled. ignoreSignals uint32 // ticker is the ticker that will be used to write to the renderer. ticker *time.Ticker // once is used to stop the renderer. once sync.Once // rendererDone is used to stop the renderer. rendererDone chan struct{} // Initial window size. Mainly used for testing. width, height int // whether to use hard tabs to optimize cursor movements useHardTabs bool // whether to use backspace to optimize cursor movements useBackspace bool mu sync.Mutex } // Quit is a special command that tells the Bubble Tea program to exit. func Quit() Msg { return QuitMsg{} } // QuitMsg signals that the program should quit. You can send a [QuitMsg] with // [Quit]. type QuitMsg struct{} // Suspend is a special command that tells the Bubble Tea program to suspend. func Suspend() Msg { return SuspendMsg{} } // SuspendMsg signals the program should suspend. // This usually happens when ctrl+z is pressed on common programs, but since // bubbletea puts the terminal in raw mode, we need to handle it in a // per-program basis. // // You can send this message with [Suspend()]. type SuspendMsg struct{} // ResumeMsg can be listen to do something once a program is resumed back // from a suspend state. type ResumeMsg struct{} // InterruptMsg signals the program should suspend. // This usually happens when ctrl+c is pressed on common programs, but since // bubbletea puts the terminal in raw mode, we need to handle it in a // per-program basis. // // You can send this message with [Interrupt()]. type InterruptMsg struct{} // Interrupt is a special command that tells the Bubble Tea program to // interrupt. func Interrupt() Msg { return InterruptMsg{} } // NewProgram creates a new [Program]. func NewProgram(model Model, opts ...ProgramOption) *Program { p := &Program{ initialModel: model, msgs: make(chan Msg), errs: make(chan error, 1), rendererDone: make(chan struct{}), } // Apply all options to the program. for _, opt := range opts { opt(p) } // A context can be provided with a ProgramOption, but if none was provided // we'll use the default background context. if p.externalCtx == nil { p.externalCtx = context.Background() } // Initialize context and teardown channel. p.ctx, p.cancel = context.WithCancel(p.externalCtx) // if no output was set, set it to stdout if p.output == nil { p.output = os.Stdout } // if no environment was set, set it to os.Environ() if p.environ == nil { p.environ = os.Environ() } if p.fps < 1 { p.fps = defaultFPS } else if p.fps > maxFPS { p.fps = maxFPS } tracePath, traceOk := os.LookupEnv("TEA_TRACE") if traceOk && len(tracePath) > 0 { // We have a trace filepath. if f, err := os.OpenFile(tracePath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0o600); err == nil { p.logger = log.New(f, "bubbletea: ", log.LstdFlags|log.Lshortfile) } } return p } func (p *Program) handleSignals() chan struct{} { ch := make(chan struct{}) // Listen for SIGINT and SIGTERM. // // In most cases ^C will not send an interrupt because the terminal will be // in raw mode and ^C will be captured as a keystroke and sent along to // Program.Update as a KeyMsg. When input is not a TTY, however, ^C will be // caught here. // // SIGTERM is sent by unix utilities (like kill) to terminate a process. go func() { sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) defer func() { signal.Stop(sig) close(ch) }() for { select { case <-p.ctx.Done(): return case s := <-sig: if atomic.LoadUint32(&p.ignoreSignals) == 0 { switch s { case syscall.SIGINT: p.msgs <- InterruptMsg{} default: p.msgs <- QuitMsg{} } return } } } }() return ch } // handleResize handles terminal resize events. func (p *Program) handleResize() chan struct{} { ch := make(chan struct{}) if p.ttyOutput != nil { // Listen for window resizes. go p.listenForResize(ch) } else { close(ch) } return ch } // handleCommands runs commands in a goroutine and sends the result to the // program's message channel. func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { ch := make(chan struct{}) go func() { defer close(ch) for { select { case <-p.ctx.Done(): return case cmd := <-cmds: if cmd == nil { continue } // Don't wait on these goroutines, otherwise the shutdown // latency would get too large as a Cmd can run for some time // (e.g. tick commands that sleep for half a second). It's not // possible to cancel them so we'll have to leak the goroutine // until Cmd returns. go func() { // Recover from panics. if !p.disableCatchPanics { defer func() { if r := recover(); r != nil { p.recoverFromPanic(r) } }() } msg := cmd() // this can be long. p.Send(msg) }() } } }() return ch } // eventLoop is the central message loop. It receives and handles the default // Bubble Tea messages, update the model and triggers redraws. func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { for { select { case <-p.ctx.Done(): return model, nil case err := <-p.errs: return model, err case msg := <-p.msgs: msg = p.translateInputEvent(msg) // Filter messages. if p.filter != nil { msg = p.filter(model, msg) } if msg == nil { continue } // Handle special internal messages. switch msg := msg.(type) { case QuitMsg: return model, nil case InterruptMsg: return model, ErrInterrupted case SuspendMsg: if suspendSupported { p.suspend() } case CapabilityMsg: switch msg.Content { case "RGB", "Tc": if *p.profile != colorprofile.TrueColor { tc := colorprofile.TrueColor p.profile = &tc go p.Send(ColorProfileMsg{*p.profile}) } } case ModeReportMsg: switch msg.Mode { case ansi.ModeSynchronizedOutput: if msg.Value == ansi.ModeReset { // The terminal supports synchronized output and it's // currently disabled, so we can enable it on the renderer. p.renderer.setSyncdUpdates(true) } case ansi.ModeUnicodeCore: if msg.Value == ansi.ModeReset || msg.Value == ansi.ModeSet || msg.Value == ansi.ModePermanentlySet { p.renderer.setWidthMethod(ansi.GraphemeWidth) } } case MouseMsg: switch msg.(type) { case MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, MouseMotionMsg: // Only send mouse messages to the renderer if they are an // actual mouse event. if cmd := p.renderer.onMouse(msg); cmd != nil { go p.Send(cmd()) } } case readClipboardMsg: p.execute(ansi.RequestSystemClipboard) case setClipboardMsg: p.execute(ansi.SetSystemClipboard(string(msg))) case readPrimaryClipboardMsg: p.execute(ansi.RequestPrimaryClipboard) case setPrimaryClipboardMsg: p.execute(ansi.SetPrimaryClipboard(string(msg))) case backgroundColorMsg: p.execute(ansi.RequestBackgroundColor) case foregroundColorMsg: p.execute(ansi.RequestForegroundColor) case cursorColorMsg: p.execute(ansi.RequestCursorColor) case execMsg: // NB: this blocks. p.exec(msg.cmd, msg.fn) case terminalVersion: p.execute(ansi.RequestNameVersion) case requestCapabilityMsg: p.execute(ansi.RequestTermcap(string(msg))) case BatchMsg: go p.execBatchMsg(msg) continue case sequenceMsg: go p.execSequenceMsg(msg) continue case WindowSizeMsg: p.renderer.resize(msg.Width, msg.Height) case windowSizeMsg: go p.checkResize() case requestCursorPosMsg: p.execute(ansi.RequestCursorPositionReport) case RawMsg: p.execute(fmt.Sprint(msg.Msg)) case printLineMessage: p.renderer.insertAbove(msg.messageBody) //nolint:errcheck,gosec case clearScreenMsg: p.renderer.clearScreen() case ColorProfileMsg: p.renderer.setColorProfile(msg.Profile) } var cmd Cmd model, cmd = model.Update(msg) // run update select { case <-p.ctx.Done(): return model, nil case cmds <- cmd: // process command (if any) } p.render(model) // render view } } } // render renders the given view to the renderer. func (p *Program) render(model Model) { if p.renderer != nil { p.renderer.render(model.View()) // send view to renderer } } func (p *Program) execSequenceMsg(msg sequenceMsg) { if !p.disableCatchPanics { defer func() { if r := recover(); r != nil { p.recoverFromGoPanic(r) } }() } // Execute commands one at a time, in order. for _, cmd := range msg { if cmd == nil { continue } msg := cmd() switch msg := msg.(type) { case BatchMsg: p.execBatchMsg(msg) case sequenceMsg: p.execSequenceMsg(msg) default: p.Send(msg) } } } func (p *Program) execBatchMsg(msg BatchMsg) { if !p.disableCatchPanics { defer func() { if r := recover(); r != nil { p.recoverFromGoPanic(r) } }() } // Execute commands one at a time. var wg sync.WaitGroup for _, cmd := range msg { if cmd == nil { continue } wg.Add(1) go func() { defer wg.Done() if !p.disableCatchPanics { defer func() { if r := recover(); r != nil { p.recoverFromGoPanic(r) } }() } msg := cmd() switch msg := msg.(type) { case BatchMsg: p.execBatchMsg(msg) case sequenceMsg: p.execSequenceMsg(msg) default: p.Send(msg) } }() } wg.Wait() // wait for all commands from batch msg to finish } // shouldQuerySynchronizedOutput determines whether the terminal should be // queried for various capabilities. // // This function checks for terminals that are known to support mode 2026, // while excluding SSH sessions which may be unreliable, unless it's a // known-good terminal like Windows Terminal. // // The function returns true for: // - Terminals without TERM_PROGRAM set and not in SSH sessions // - Windows Terminal (WT_SESSION is set) // - Terminals with TERM_PROGRAM set (except Apple Terminal) and not in SSH sessions // - Specific terminal types: ghostty, wezterm, alacritty, kitty, rio func shouldQuerySynchronizedOutput(environ uv.Environ) bool { termType := environ.Getenv("TERM") termProg, okTermProg := environ.LookupEnv("TERM_PROGRAM") _, okSSHTTY := environ.LookupEnv("SSH_TTY") _, okWTSession := environ.LookupEnv("WT_SESSION") return (!okTermProg && !okSSHTTY) || okWTSession || (okTermProg && !strings.Contains(termProg, "Apple") && !okSSHTTY) || strings.Contains(termType, "ghostty") || strings.Contains(termType, "wezterm") || strings.Contains(termType, "alacritty") || strings.Contains(termType, "kitty") || strings.Contains(termType, "rio") } // Run initializes the program and runs its event loops, blocking until it gets // terminated by either [Program.Quit], [Program.Kill], or its signal handler. // Returns the final model. func (p *Program) Run() (returnModel Model, returnErr error) { if p.initialModel == nil { return nil, errors.New("bubbletea: InitialModel cannot be nil") } // Initialize context and teardown channel. p.handlers = channelHandlers{} cmds := make(chan Cmd) p.finished = make(chan struct{}) defer func() { close(p.finished) }() defer p.cancel() if p.disableInput { p.input = nil } else if p.input == nil { p.input = os.Stdin if !term.IsTerminal(os.Stdin.Fd()) { ttyIn, _, err := OpenTTY() if err != nil { return p.initialModel, fmt.Errorf("bubbletea: error opening TTY: %w", err) } p.input = ttyIn } } // Handle signals. if !p.disableSignalHandler { p.handlers.add(p.handleSignals()) } // Recover from panics. if !p.disableCatchPanics { defer func() { if r := recover(); r != nil { returnErr = fmt.Errorf("%w: %w", ErrProgramKilled, ErrProgramPanic) p.recoverFromPanic(r) } }() } // Check if output is a TTY before entering raw mode, hiding the cursor and // so on. if err := p.initTerminal(); err != nil { return p.initialModel, err } // Get the initial window size. width, height := p.width, p.height if p.ttyOutput != nil { // Set the initial size of the terminal. w, h, err := term.GetSize(p.ttyOutput.Fd()) if err != nil { return p.initialModel, fmt.Errorf("bubbletea: error getting terminal size: %w", err) } width, height = w, h } p.width, p.height = width, height resizeMsg := WindowSizeMsg{Width: p.width, Height: p.height} if p.renderer == nil { if p.disableRenderer { p.renderer = &nilRenderer{} } else { // If no renderer is set use the cursed one. r := newCursedRenderer( p.output, p.environ, p.width, p.height, ) r.setLogger(p.logger) // XXX: This breaks many things especially when we want the output // to be compatible with terminals that are not necessary a TTY. // This was originally done to work around a Wish emulated-pty // issue where when a PTY session is detected, and we don't // allocate a real PTY, the terminal settings (Termios and WinCon) // don't change and the we end up working in cooked mode instead of // raw mode. See issue #1572. mapNl := runtime.GOOS != "windows" && p.ttyInput == nil r.setOptimizations(p.useHardTabs, p.useBackspace, mapNl) p.renderer = r } } // Get the color profile and send it to the program. if p.profile == nil { cp := colorprofile.Detect(p.output, p.environ) p.profile = &cp } // Set the color profile on the renderer and send it to the program. p.renderer.setColorProfile(*p.profile) go p.Send(ColorProfileMsg{*p.profile}) // Send the initial size to the program. go p.Send(resizeMsg) p.renderer.resize(resizeMsg.Width, resizeMsg.Height) // Send the environment variables used by the program. go p.Send(EnvMsg(p.environ)) // Init the input reader and initial model. model := p.initialModel if p.input != nil { if err := p.initInputReader(false); err != nil { return model, err } } // Start the renderer. p.startRenderer() if !p.disableRenderer && shouldQuerySynchronizedOutput(p.environ) { // Query for synchronized updates support (mode 2026) and unicode core // (mode 2027). If the terminal supports it, the renderer will enable // it once we get the response. p.execute(ansi.RequestModeSynchronizedOutput + ansi.RequestModeUnicodeCore) } // Initialize the program. initCmd := model.Init() if initCmd != nil { ch := make(chan struct{}) p.handlers.add(ch) go func() { defer close(ch) select { case cmds <- initCmd: case <-p.ctx.Done(): } }() } // Render the initial view. p.render(model) // Handle resize events. p.handlers.add(p.handleResize()) // Process commands. p.handlers.add(p.handleCommands(cmds)) // Run event loop, handle updates and draw. var err error model, err = p.eventLoop(model, cmds) if err == nil && len(p.errs) > 0 { err = <-p.errs // Drain a leftover error in case eventLoop crashed. } killed := p.externalCtx.Err() != nil || p.ctx.Err() != nil || err != nil if killed { if err == nil && p.externalCtx.Err() != nil { // Return also as context error the cancellation of an external context. // This is the context the user knows about and should be able to act on. err = fmt.Errorf("%w: %w", ErrProgramKilled, p.externalCtx.Err()) } else if err == nil && p.ctx.Err() != nil { // Return only that the program was killed (not the internal mechanism). // The user does not know or need to care about the internal program context. err = ErrProgramKilled } else { // Return that the program was killed and also the error that caused it. err = fmt.Errorf("%w: %w", ErrProgramKilled, err) } } else { // Graceful shutdown of the program (not killed): // Ensure we rendered the final state of the model. p.render(model) } // Restore terminal state. p.shutdown(killed) return model, err } // Send sends a message to the main update function, effectively allowing // messages to be injected from outside the program for interoperability // purposes. // // If the program hasn't started yet this will be a blocking operation. // If the program has already been terminated this will be a no-op, so it's safe // to send messages after the program has exited. func (p *Program) Send(msg Msg) { select { case <-p.ctx.Done(): case p.msgs <- msg: } } // Quit is a convenience function for quitting Bubble Tea programs. Use it // when you need to shut down a Bubble Tea program from the outside. // // If you wish to quit from within a Bubble Tea program use the Quit command. // // If the program is not running this will be a no-op, so it's safe to call // if the program is unstarted or has already exited. func (p *Program) Quit() { p.Send(Quit()) } // Kill stops the program immediately and restores the former terminal state. // The final render that you would normally see when quitting will be skipped. // [program.Run] returns a [ErrProgramKilled] error. func (p *Program) Kill() { p.shutdown(true) } // Wait waits/blocks until the underlying Program finished shutting down. func (p *Program) Wait() { <-p.finished } // execute writes the given sequence to the program output. func (p *Program) execute(seq string) { p.mu.Lock() _, _ = p.outputBuf.WriteString(seq) p.mu.Unlock() } // flush flushes the output buffer to the program output. func (p *Program) flush() error { p.mu.Lock() defer p.mu.Unlock() if p.outputBuf.Len() == 0 { return nil } if p.logger != nil { p.logger.Printf("output: %q", p.outputBuf.String()) } _, err := p.output.Write(p.outputBuf.Bytes()) p.outputBuf.Reset() if err != nil { return fmt.Errorf("error writing to output: %w", err) } return nil } // shutdown performs operations to free up resources and restore the terminal // to its original state. func (p *Program) shutdown(kill bool) { p.shutdownOnce.Do(func() { p.cancel() // Wait for all handlers to finish. p.handlers.shutdown() // Check if the cancel reader has been setup before waiting and closing. if p.cancelReader != nil { // Wait for input loop to finish. if p.cancelReader.Cancel() { if !kill { p.waitForReadLoop() } } _ = p.cancelReader.Close() } if p.renderer != nil { p.stopRenderer(kill) } _ = p.restoreTerminalState() }) } // recoverFromPanic recovers from a panic, prints the stack trace, and restores // the terminal to a usable state. func (p *Program) recoverFromPanic(r interface{}) { select { case p.errs <- ErrProgramPanic: default: } p.shutdown(true) // Ok to call here, p.Run() cannot do it anymore. // We use "\r\n" to ensure the output is formatted even when restoring the // terminal does not work or when raw mode is still active. rec := strings.ReplaceAll(fmt.Sprintf("%s", r), "\n", "\r\n") fmt.Fprintf(os.Stderr, "Caught panic:\r\n\r\n%s\r\n\r\nRestoring terminal...\r\n\r\n", rec) stack := strings.ReplaceAll(fmt.Sprintf("%s\n", debug.Stack()), "\n", "\r\n") fmt.Fprint(os.Stderr, stack) if v, err := strconv.ParseBool(os.Getenv("TEA_DEBUG")); err == nil && v { f, err := os.Create(fmt.Sprintf("bubbletea-panic-%d.log", time.Now().Unix())) if err == nil { defer f.Close() //nolint:errcheck fmt.Fprintln(f, rec) //nolint:errcheck fmt.Fprintln(f) //nolint:errcheck fmt.Fprintln(f, stack) //nolint:errcheck } } } // recoverFromGoPanic recovers from a goroutine panic, prints a stack trace and // signals for the program to be killed and terminal restored to a usable state. func (p *Program) recoverFromGoPanic(r interface{}) { select { case p.errs <- ErrProgramPanic: default: } p.cancel() // We use "\r\n" to ensure the output is formatted even when restoring the // terminal does not work or when raw mode is still active. rec := strings.ReplaceAll(fmt.Sprintf("%s", r), "\n", "\r\n") fmt.Fprintf(os.Stderr, "Caught panic:\r\n\r\n%s\r\n\r\nRestoring terminal...\r\n\r\n", rec) stack := strings.ReplaceAll(fmt.Sprintf("%s\n", debug.Stack()), "\n", "\r\n") fmt.Fprint(os.Stderr, stack) if v, err := strconv.ParseBool(os.Getenv("TEA_DEBUG")); err == nil && v { f, err := os.Create(fmt.Sprintf("bubbletea-panic-%d.log", time.Now().Unix())) if err == nil { defer f.Close() //nolint:errcheck fmt.Fprintln(f, rec) //nolint:errcheck fmt.Fprintln(f) //nolint:errcheck fmt.Fprintln(f, stack) //nolint:errcheck } } } // ReleaseTerminal restores the original terminal state and cancels the input // reader. You can return control to the Program with RestoreTerminal. func (p *Program) ReleaseTerminal() error { return p.releaseTerminal(false) } func (p *Program) releaseTerminal(reset bool) error { atomic.StoreUint32(&p.ignoreSignals, 1) if p.cancelReader != nil { p.cancelReader.Cancel() } p.waitForReadLoop() if p.renderer != nil { p.stopRenderer(false) if reset { p.renderer.reset() } } return p.restoreTerminalState() } // RestoreTerminal reinitializes the Program's input reader, restores the // terminal to the former state when the program was running, and repaints. // Use it to reinitialize a Program after running ReleaseTerminal. func (p *Program) RestoreTerminal() error { atomic.StoreUint32(&p.ignoreSignals, 0) if err := p.initTerminal(); err != nil { return err } if err := p.initInputReader(false); err != nil { return err } p.startRenderer() // If the output is a terminal, it may have been resized while another // process was at the foreground, in which case we may not have received // SIGWINCH. Detect any size change now and propagate the new size as // needed. go p.checkResize() // Flush queued commands. return p.flush() } // Println prints above the Program. This output is unmanaged by the program // and will persist across renders by the Program. // // If the altscreen is active no output will be printed. func (p *Program) Println(args ...any) { p.msgs <- printLineMessage{ messageBody: fmt.Sprint(args...), } } // Printf prints above the Program. It takes a format template followed by // values similar to fmt.Printf. This output is unmanaged by the program and // will persist across renders by the Program. // // Unlike fmt.Printf (but similar to log.Printf) the message will be print on // its own line. // // If the altscreen is active no output will be printed. func (p *Program) Printf(template string, args ...any) { p.msgs <- printLineMessage{ messageBody: fmt.Sprintf(template, args...), } } // startRenderer starts the renderer. func (p *Program) startRenderer() { framerate := time.Second / time.Duration(p.fps) if p.ticker == nil { p.ticker = time.NewTicker(framerate) } else { // If the ticker already exists, it has been stopped and we need to // reset it. p.ticker.Reset(framerate) } // Since the renderer can be restarted after a stop, we need to reset // the done channel and its corresponding sync.Once. p.once = sync.Once{} // Start the renderer. p.renderer.start() go func() { for { select { case <-p.rendererDone: p.ticker.Stop() return case <-p.ticker.C: _ = p.flush() _ = p.renderer.flush(false) } } }() } // stopRenderer stops the renderer. // If kill is true, the renderer will be stopped immediately without flushing // the last frame. func (p *Program) stopRenderer(kill bool) { // Stop the renderer before acquiring the mutex to avoid a deadlock. p.once.Do(func() { p.rendererDone <- struct{}{} }) if !kill { // flush locks the mutex _ = p.renderer.flush(true) } _ = p.renderer.close() } ================================================ FILE: tea_test.go ================================================ package tea import ( "bytes" "context" "errors" "io" "strings" "sync" "sync/atomic" "testing" "time" ) type ctxImplodeMsg struct { cancel context.CancelFunc } type incrementMsg struct{} type panicMsg struct{} func panicCmd() Msg { panic("testing goroutine panic behavior") } type testModel struct { executed atomic.Value counter atomic.Value } func (m *testModel) Init() Cmd { return nil } func (m *testModel) Update(msg Msg) (Model, Cmd) { switch msg := msg.(type) { case ctxImplodeMsg: msg.cancel() time.Sleep(100 * time.Millisecond) case incrementMsg: i := m.counter.Load() if i == nil { m.counter.Store(1) } else { m.counter.Store(i.(int) + 1) } case KeyPressMsg: switch msg.String() { case "q", "ctrl+c": return m, Quit } case panicMsg: panic("testing panic behavior") } return m, nil } func (m *testModel) View() View { m.executed.Store(true) return NewView("success") } func TestTeaModel(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer in.Write([]byte("q")) ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) defer cancel() p := NewProgram(&testModel{}, WithContext(ctx), WithInput(&in), WithOutput(&buf), ) if _, err := p.Run(); err != nil { t.Fatal(err) } if buf.Len() == 0 { t.Fatal("no output") } } func TestTeaQuit(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go func() { for { time.Sleep(time.Millisecond) if m.executed.Load() != nil { p.Quit() return } } }() if _, err := p.Run(); err != nil { t.Fatal(err) } } func TestTeaWaitQuit(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer progStarted := make(chan struct{}) waitStarted := make(chan struct{}) errChan := make(chan error, 1) m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go func() { _, err := p.Run() errChan <- err }() go func() { for { time.Sleep(time.Millisecond) if m.executed.Load() != nil { close(progStarted) <-waitStarted time.Sleep(50 * time.Millisecond) p.Quit() return } } }() <-progStarted var wg sync.WaitGroup for range 5 { wg.Add(1) go func() { p.Wait() wg.Done() }() } close(waitStarted) wg.Wait() err := <-errChan if err != nil { t.Fatalf("Expected nil, got %v", err) } } func TestTeaWaitKill(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer progStarted := make(chan struct{}) waitStarted := make(chan struct{}) errChan := make(chan error, 1) m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go func() { _, err := p.Run() errChan <- err }() go func() { for { time.Sleep(time.Millisecond) if m.executed.Load() != nil { close(progStarted) <-waitStarted time.Sleep(50 * time.Millisecond) p.Kill() return } } }() <-progStarted var wg sync.WaitGroup for range 5 { wg.Add(1) go func() { p.Wait() wg.Done() }() } close(waitStarted) wg.Wait() err := <-errChan if !errors.Is(err, ErrProgramKilled) { t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) } } func TestTeaWithFilter(t *testing.T) { testTeaWithFilter(t, 0) testTeaWithFilter(t, 1) testTeaWithFilter(t, 2) } func testTeaWithFilter(t *testing.T, preventCount uint32) { var buf bytes.Buffer var in bytes.Buffer m := &testModel{} shutdowns := uint32(0) p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) p.filter = func(_ Model, msg Msg) Msg { if _, ok := msg.(QuitMsg); !ok { return msg } if shutdowns < preventCount { atomic.AddUint32(&shutdowns, 1) return nil } return msg } go func() { for atomic.LoadUint32(&shutdowns) <= preventCount { time.Sleep(time.Millisecond) p.Quit() } }() if _, err := p.Run(); err != nil { t.Fatal(err) } if shutdowns != preventCount { t.Errorf("Expected %d prevented shutdowns, got %d", preventCount, shutdowns) } } func TestTeaKill(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go func() { for { time.Sleep(time.Millisecond) if m.executed.Load() != nil { p.Kill() return } } }() _, err := p.Run() if !errors.Is(err, ErrProgramKilled) { t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) } if errors.Is(err, context.Canceled) { // The end user should not know about the program's internal context state. // The program should only report external context cancellation as a context error. t.Fatalf("Internal context cancellation was reported as context error!") } } func TestTeaContext(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) var buf bytes.Buffer var in bytes.Buffer m := &testModel{} p := NewProgram(m, WithContext(ctx), WithInput(&in), WithOutput(&buf), ) go func() { for { time.Sleep(time.Millisecond) if m.executed.Load() != nil { cancel() return } } }() _, err := p.Run() if !errors.Is(err, ErrProgramKilled) { t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) } if !errors.Is(err, context.Canceled) { // The end user should know that their passed in context caused the kill. t.Fatalf("Expected %v, got %v", context.Canceled, err) } } func TestTeaContextImplodeDeadlock(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) var buf bytes.Buffer var in bytes.Buffer m := &testModel{} p := NewProgram(m, WithContext(ctx), WithInput(&in), WithOutput(&buf), ) go func() { for { time.Sleep(time.Millisecond) if m.executed.Load() != nil { p.Send(ctxImplodeMsg{cancel: cancel}) return } } }() if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) { t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) } } func TestTeaContextBatchDeadlock(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) var buf bytes.Buffer var in bytes.Buffer inc := func() Msg { cancel() return incrementMsg{} } m := &testModel{} p := NewProgram(m, WithContext(ctx), WithInput(&in), WithOutput(&buf), ) go func() { for { time.Sleep(time.Millisecond) if m.executed.Load() != nil { batch := make(BatchMsg, 100) for i := range batch { batch[i] = inc } p.Send(batch) return } } }() if _, err := p.Run(); !errors.Is(err, ErrProgramKilled) { t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) } } func TestTeaBatchMsg(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer inc := func() Msg { return incrementMsg{} } m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go func() { p.Send(BatchMsg{inc, inc}) for { time.Sleep(time.Millisecond) i := m.counter.Load() if i != nil && i.(int) >= 2 { p.Quit() return } } }() if _, err := p.Run(); err != nil { t.Fatal(err) } if m.counter.Load() != 2 { t.Fatalf("counter should be 2, got %d", m.counter.Load()) } } func TestTeaSequenceMsg(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer inc := func() Msg { return incrementMsg{} } m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go p.Send(sequenceMsg{inc, inc, Quit}) if _, err := p.Run(); err != nil { t.Fatal(err) } if m.counter.Load() != 2 { t.Fatalf("counter should be 2, got %d", m.counter.Load()) } } func TestTeaSequenceMsgWithBatchMsg(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer inc := func() Msg { return incrementMsg{} } batch := func() Msg { return BatchMsg{inc, inc} } m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go p.Send(sequenceMsg{batch, inc, Quit}) if _, err := p.Run(); err != nil { t.Fatal(err) } if m.counter.Load() != 3 { t.Fatalf("counter should be 3, got %d", m.counter.Load()) } } func TestTeaNestedSequenceMsg(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer inc := func() Msg { return incrementMsg{} } m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go p.Send(sequenceMsg{inc, Sequence(inc, inc, Batch(inc, inc)), Quit}) if _, err := p.Run(); err != nil { t.Fatal(err) } if m.counter.Load() != 5 { t.Fatalf("counter should be 5, got %d", m.counter.Load()) } } func TestTeaSend(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) // sending before the program is started is a blocking operation go p.Send(Quit()) if _, err := p.Run(); err != nil { t.Fatal(err) } // sending a message after program has quit is a no-op p.Send(Quit()) } func TestTeaNoRun(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer m := &testModel{} NewProgram(m, WithInput(&in), WithOutput(&buf), ) } func TestTeaPanic(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go func() { for { time.Sleep(time.Millisecond) if m.executed.Load() != nil { p.Send(panicMsg{}) return } } }() _, err := p.Run() if !errors.Is(err, ErrProgramPanic) { t.Fatalf("Expected %v, got %v", ErrProgramPanic, err) } if !errors.Is(err, ErrProgramKilled) { t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) } } func TestTeaGoroutinePanic(t *testing.T) { var buf bytes.Buffer var in bytes.Buffer m := &testModel{} p := NewProgram(m, WithInput(&in), WithOutput(&buf), ) go func() { for { time.Sleep(time.Millisecond) if m.executed.Load() != nil { batch := make(BatchMsg, 10) for i := 0; i < len(batch); i += 2 { batch[i] = Sequence(panicCmd) batch[i+1] = Batch(panicCmd) } p.Send(batch) return } } }() _, err := p.Run() if !errors.Is(err, ErrProgramPanic) { t.Fatalf("Expected %v, got %v", ErrProgramPanic, err) } if !errors.Is(err, ErrProgramKilled) { t.Fatalf("Expected %v, got %v", ErrProgramKilled, err) } } type benchModel struct { t testing.TB } func (m benchModel) Init() Cmd { return nil } func (m benchModel) Update(msg Msg) (Model, Cmd) { switch msg := msg.(type) { case KeyPressMsg: switch msg.String() { case "q", "ctrl+c": return m, Quit } } return m, nil } func (m benchModel) View() View { view := strings.Join([]string{ " \x1b[38;5;63m╭─────────────────────────╮\x1b[m", " \x1b[38;5;63m│\x1b[m\x1b[25X\x1b[28G\x1b[38;5;63m│\x1b[m", " \x1b[38;5;63m│\x1b[m \x1b[38;5;231mHello There!\x1b[m \x1b[38;5;63m│\x1b[m", " \x1b[38;5;63m│\x1b[m\x1b[25X\x1b[28G\x1b[38;5;63m│\x1b[m", " \x1b[38;5;63m╰─────────────────────────╯\x1b[m", }, "\n") return NewView(view) } func BenchmarkTeaRun(b *testing.B) { for i := 0; i < b.N; i++ { var buf bytes.Buffer m := benchModel{b} r, w := io.Pipe() p := NewProgram(m, WithInput(r), WithOutput(&buf), ) go func() { for _, input := range "abcdefghijklmnopq" { time.Sleep(10 * time.Millisecond) w.Write([]byte(string(input))) } }() if _, err := p.Run(); err != nil { b.Fatalf("Run failed: %v", err) } _ = r.CloseWithError(io.EOF) } } ================================================ FILE: termcap.go ================================================ package tea // requestCapabilityMsg is an internal message that requests the terminal to // send its Termcap/Terminfo response. type requestCapabilityMsg string // RequestCapability is a command that requests the terminal to send its // Termcap/Terminfo response for the given capability. // // Bubble Tea recognizes the following capabilities and will use them to // upgrade the program's color profile: // - "RGB" Xterm direct color // - "Tc" True color support // // Note: that some terminal's like Apple's Terminal.app do not support this and // will send the wrong response to the terminal breaking the program's output. // // When the Bubble Tea advertises a non-TrueColor profile, you can use this // command to query the terminal for its color capabilities. Example: // // switch msg := msg.(type) { // case tea.ColorProfileMsg: // if msg.Profile != colorprofile.TrueColor { // return m, tea.Batch( // tea.RequestCapability("RGB"), // tea.RequestCapability("Tc"), // ) // } // } func RequestCapability(s string) Cmd { return func() Msg { return requestCapabilityMsg(s) } } // CapabilityMsg represents a Termcap/Terminfo response event. Termcap // responses are generated by the terminal in response to RequestTermcap // (XTGETTCAP) requests. // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands type CapabilityMsg struct { Content string } // String returns the capability content as a string. func (c CapabilityMsg) String() string { return c.Content } ================================================ FILE: termios_bsd.go ================================================ //go:build dragonfly || freebsd // +build dragonfly freebsd package tea import ( "github.com/charmbracelet/x/term" "golang.org/x/sys/unix" ) func (p *Program) checkOptimizedMovements(s *term.State) { p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0 } ================================================ FILE: termios_other.go ================================================ //go:build !windows && !darwin && !dragonfly && !freebsd && !linux && !solaris && !aix // +build !windows,!darwin,!dragonfly,!freebsd,!linux,!solaris,!aix package tea import "github.com/charmbracelet/x/term" func (*Program) checkOptimizedMovements(*term.State) {} ================================================ FILE: termios_unix.go ================================================ //go:build darwin || linux || solaris || aix // +build darwin linux solaris aix package tea import ( "github.com/charmbracelet/x/term" "golang.org/x/sys/unix" ) func (p *Program) checkOptimizedMovements(s *term.State) { p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0 p.useBackspace = s.Lflag&unix.BSDLY == unix.BS0 } ================================================ FILE: termios_windows.go ================================================ //go:build windows // +build windows package tea import "github.com/charmbracelet/x/term" func (p *Program) checkOptimizedMovements(*term.State) { p.useHardTabs = true p.useBackspace = true } ================================================ FILE: testdata/TestClearMsg/bg_fg_cur_color.golden ================================================ [?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p]10;?]11;?]12;? ================================================ FILE: testdata/TestClearMsg/clear_screen.golden ================================================ [?25l [?2004h[>4;2m[=1;1usuccess[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p ================================================ FILE: testdata/TestClearMsg/read_set_clipboard.golden ================================================ [?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p]52;c;?]52;c;c3VjY2Vzcw== ================================================ FILE: testdata/TestViewModel/altscreen.golden ================================================ [?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p ================================================ FILE: testdata/TestViewModel/altscreen_autoexit.golden ================================================ [>4m[=0;1u[?1049h[?25l[?2004h[>4;2m[=1;1usuccess[>4m[=0;1u [?1049l[?25h[?2004l[?2026$p[?2027$p ================================================ FILE: testdata/TestViewModel/bg_set_color.golden ================================================ [?25l[?2004h[>4;2m[=1;1u]11;#ffffff success[>4m[=0;1u [?25h[?2004l]111[?2026$p[?2027$p ================================================ FILE: testdata/TestViewModel/bp_stop_start.golden ================================================ [?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p ================================================ FILE: testdata/TestViewModel/cursor_hide.golden ================================================ [?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p ================================================ FILE: testdata/TestViewModel/cursor_hideshow.golden ================================================ [?2004h[>4;2m[=1;1u[1 q success [?25h[>4m[=0;1u[?2004l[?2026$p[?2027$p ================================================ FILE: testdata/TestViewModel/kitty_stop_startreleases.golden ================================================ [?25l[?2004h[>4;2m[=3;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p ================================================ FILE: testdata/TestViewModel/mouse_allmotion.golden ================================================ [?25l[?2004h[?1003h[?1006h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p ================================================ FILE: testdata/TestViewModel/mouse_cellmotion.golden ================================================ [?25l[?2004h[?1002h[?1006h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?1002l[?1003l[?1006l[?2026$p[?2027$p ================================================ FILE: testdata/TestViewModel/mouse_disable.golden ================================================ [?25l[?2004h[>4;2m[=1;1u success[>4m[=0;1u [?25h[?2004l[?2026$p[?2027$p ================================================ FILE: tty.go ================================================ package tea import ( "fmt" "os" "time" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/x/term" ) func (p *Program) suspend() { if err := p.releaseTerminal(true); err != nil { // If we can't release input, abort. return } suspendProcess() _ = p.RestoreTerminal() go p.Send(ResumeMsg{}) } func (p *Program) initTerminal() error { if p.disableRenderer { return nil } return p.initInput() } // restoreTerminalState restores the terminal to the state prior to running the // Bubble Tea program. func (p *Program) restoreTerminalState() error { // Flush queued commands. _ = p.flush() return p.restoreInput() } // restoreInput restores the tty input to its original state. func (p *Program) restoreInput() error { if p.ttyInput != nil && p.previousTtyInputState != nil { if err := term.Restore(p.ttyInput.Fd(), p.previousTtyInputState); err != nil { return fmt.Errorf("bubbletea: error restoring console: %w", err) } } if p.ttyOutput != nil && p.previousOutputState != nil { if err := term.Restore(p.ttyOutput.Fd(), p.previousOutputState); err != nil { return fmt.Errorf("bubbletea: error restoring console: %w", err) } } return nil } // initInputReader (re)commences reading inputs. func (p *Program) initInputReader(cancel bool) error { if cancel && p.cancelReader != nil { p.cancelReader.Cancel() p.waitForReadLoop() } term := p.environ.Getenv("TERM") // Initialize the input reader. // This need to be done after the terminal has been initialized and set to // raw mode. var err error p.cancelReader, err = uv.NewCancelReader(p.input) if err != nil { return fmt.Errorf("bubbletea: could not create cancelable reader: %w", err) } drv := uv.NewTerminalReader(p.cancelReader, term) drv.SetLogger(p.logger) p.inputScanner = drv p.readLoopDone = make(chan struct{}) go p.readLoop() return nil } func (p *Program) readLoop() { defer close(p.readLoopDone) if err := p.inputScanner.StreamEvents(p.ctx, p.msgs); err != nil { select { case <-p.ctx.Done(): return case p.errs <- err: } } } // waitForReadLoop waits for the cancelReader to finish its read loop. func (p *Program) waitForReadLoop() { select { case <-p.readLoopDone: case <-time.After(500 * time.Millisecond): //nolint:mnd // The read loop hangs, which means the input // cancelReader's cancel function has returned true even // though it was not able to cancel the read. } } // checkResize detects the current size of the output and informs the program // via a WindowSizeMsg. func (p *Program) checkResize() { if p.ttyOutput == nil { // can't query window size return } w, h, err := term.GetSize(p.ttyOutput.Fd()) if err != nil { select { case <-p.ctx.Done(): case p.errs <- err: } return } p.width, p.height = w, h p.Send(WindowSizeMsg{Width: w, Height: h}) } // OpenTTY opens the running terminal's TTY for reading and writing. func OpenTTY() (*os.File, *os.File, error) { in, out, err := uv.OpenTTY() if err != nil { return nil, nil, fmt.Errorf("bubbletea: could not open TTY: %w", err) } return in, out, nil } ================================================ FILE: tty_unix.go ================================================ //go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos // +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos package tea import ( "fmt" "os" "os/signal" "syscall" "github.com/charmbracelet/x/term" ) func (p *Program) initInput() (err error) { // Check if input is a terminal if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { p.ttyInput = f p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) if err != nil { return fmt.Errorf("error entering raw mode: %w", err) } // OPTIM: We can use hard tabs and backspaces to optimize cursor // movements. This is based on termios settings support and whether // they exist and enabled. p.checkOptimizedMovements(p.previousTtyInputState) } if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) { p.ttyOutput = f } return nil } const suspendSupported = true // Send SIGTSTP to the entire process group. func suspendProcess() { c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGCONT) _ = syscall.Kill(0, syscall.SIGTSTP) // blocks until a CONT happens... <-c } ================================================ FILE: tty_windows.go ================================================ //go:build windows // +build windows package tea import ( "fmt" "github.com/charmbracelet/x/term" "golang.org/x/sys/windows" ) func (p *Program) initInput() (err error) { // Save stdin state and enable VT input // We also need to enable VT // input here. if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) { p.ttyInput = f p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd()) if err != nil { return fmt.Errorf("error making terminal raw: %w", err) } // Enable VT input var mode uint32 if err := windows.GetConsoleMode(windows.Handle(p.ttyInput.Fd()), &mode); err != nil { return fmt.Errorf("error getting console mode: %w", err) } if err := windows.SetConsoleMode(windows.Handle(p.ttyInput.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT); err != nil { return fmt.Errorf("error setting console mode: %w", err) } } // Save output screen buffer state and enable VT processing. if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) { p.ttyOutput = f p.previousOutputState, err = term.GetState(f.Fd()) if err != nil { return fmt.Errorf("error getting terminal state: %w", err) } var mode uint32 if err := windows.GetConsoleMode(windows.Handle(p.ttyOutput.Fd()), &mode); err != nil { return fmt.Errorf("error getting console mode: %w", err) } if err := windows.SetConsoleMode(windows.Handle(p.ttyOutput.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING| windows.DISABLE_NEWLINE_AUTO_RETURN); err != nil { return fmt.Errorf("error setting console mode: %w", err) } //nolint:godox // TODO: check if we can optimize cursor movements on Windows. p.checkOptimizedMovements(p.previousOutputState) } return //nolint:nakedret } const suspendSupported = false func suspendProcess() {} ================================================ FILE: tutorials/basics/README.md ================================================ # Bubble Tea Basics Bubble Tea is based on the functional design paradigms of [The Elm Architecture][elm], which happens to work nicely with Go. It’s a delightful way to build applications. This tutorial assumes you have a working knowledge of Go. By the way, the non-annotated source code for this program is available [on GitHub][tut-source]. [elm]: https://guide.elm-lang.org/architecture/ [tut-source]: https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics ## Enough! Let’s get to it. For this tutorial, we’re making a shopping list. To start we’ll define our package and import some libraries. Our only external import will be the Bubble Tea library, which we’ll call `tea` for short. ```go package main import ( "fmt" "os" tea "charm.land/bubbletea/v2" ) ``` Bubble Tea programs are comprised of a **model** that describes the application state and three simple methods on that model: - **Init**, a function that returns an initial command for the application to run. - **Update**, a function that handles incoming events and updates the model accordingly. - **View**, a function that renders the UI based on the data in the model. ## The Model So let’s start by defining our model which will store our application’s state. It can be any type, but a `struct` usually makes the most sense. ```go type model struct { choices []string // items on the to-do list cursor int // which to-do list item our cursor is pointing at selected map[int]struct{} // which to-do items are selected } ``` ## Initialization Next, we’ll define our application’s initial state. In this case, we’re defining a function to return our initial model, however, we could just as easily define the initial model as a variable elsewhere, too. ```go func initialModel() model { return model{ // Our to-do list is a grocery list choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, // A map which indicates which choices are selected. We're using // the map like a mathematical set. The keys refer to the indexes // of the `choices` slice, above. selected: make(map[int]struct{}), } } ``` After that, we’ll define the `Init` method. `Init` can return a `Cmd` that could perform some initial I/O. For now, we don’t need to do any I/O, so for the command, we’ll just return `nil`, which translates to "no command." ```go func (m model) Init() tea.Cmd { return nil } ``` ## The Update Method Next up is the update method. The update function is called when “things happen.” Its job is to look at what has happened and return an updated model in response. It can also return a `Cmd` to make more things happen, but for now don’t worry about that part. In our case, when a user presses the down arrow, `Update`’s job is to notice that the down arrow was pressed and move the cursor accordingly (or not). The “something happened” comes in the form of a `Msg`, which can be any type. Messages are the result of some I/O that took place, such as a keypress, timer tick, or a response from a server. We usually figure out which type of `Msg` we received with a type switch, but you could also use a type assertion. For now, we’ll just deal with `tea.KeyPressMsg` messages, which are automatically sent to the update function when keys are pressed. ```go func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { // Is it a key press? case tea.KeyPressMsg: // Cool, what was the actual key pressed? switch msg.String() { // These keys should exit the program. case "ctrl+c", "q": return m, tea.Quit // The "up" and "k" keys move the cursor up case "up", "k": if m.cursor > 0 { m.cursor-- } // The "down" and "j" keys move the cursor down case "down", "j": if m.cursor < len(m.choices)-1 { m.cursor++ } // The enter key and the space bar toggle the selected state for the // item that the cursor is pointing at. case "enter", "space": _, ok := m.selected[m.cursor] if ok { delete(m.selected, m.cursor) } else { m.selected[m.cursor] = struct{}{} } } } // Return the updated model to the Bubble Tea runtime for processing. // Note that we're not returning a command. return m, nil } ``` You may have noticed that ctrl+c and q above return a `tea.Quit` command with the model. That’s a special command which instructs the Bubble Tea runtime to quit, exiting the program. ## The View Method At last, it’s time to render our UI. Of all the methods, the view is the simplest. We look at the model in its current state and use it to return a `tea.View`. The view declares our UI content and, optionally, terminal features like full-window mode (aka, altscreen mode), mouse tracking, cursor position, and more. Because the view describes the entire UI of your application, you don’t have to worry about redrawing logic and stuff like that. Bubble Tea takes care of it for you. ```go func (m model) View() tea.View { // The header s := "What should we buy at the market?\n\n" // Iterate over our choices for i, choice := range m.choices { // Is the cursor pointing at this choice? cursor := " " // no cursor if m.cursor == i { cursor = ">" // cursor! } // Is this choice selected? checked := " " // not selected if _, ok := m.selected[i]; ok { checked = "x" // selected! } // Render the row s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) } // The footer s += "\nPress q to quit.\n" // Send the UI for rendering return tea.NewView(s) } ``` ## All Together Now The last step is to simply run our program. We pass our initial model to `tea.NewProgram` and let it rip: ```go func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) } } ``` ## What’s Next? This tutorial covers the basics of building an interactive terminal UI, but in the real world you’ll also need to perform I/O. To learn about that have a look at the [Command Tutorial][cmd]. It’s pretty simple. There are also several [Bubble Tea examples][examples] available and, of course, there are [Go Docs][docs]. [cmd]: http://github.com/charmbracelet/bubbletea/tree/master/tutorials/commands/ [examples]: http://github.com/charmbracelet/bubbletea/tree/master/examples [docs]: https://pkg.go.dev/charm.land/bubbletea/v2?tab=doc ## Additional Resources - [Libraries we use with Bubble Tea](https://github.com/charmbracelet/bubbletea/#libraries-we-use-with-bubble-tea) - [Bubble Tea in the Wild](https://github.com/charmbracelet/bubbletea/#bubble-tea-in-the-wild) ### Feedback We’d love to hear your thoughts on this tutorial. Feel free to drop us a note! - [Twitter](https://twitter.com/charmcli) - [The Fediverse](https://mastodon.social/@charmcli) - [Discord](https://charm.sh/chat) --- Part of [Charm](https://charm.sh). The Charm logo Charm热爱开源 • Charm loves open source ================================================ FILE: tutorials/basics/main.go ================================================ package main import ( "fmt" "os" tea "charm.land/bubbletea/v2" ) type model struct { cursor int choices []string selected map[int]struct{} } func initialModel() model { return model{ // Our to-do list is a grocery list choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"}, // A map which indicates which choices are selected. We're using // the map like a mathematical set. The keys refer to the indexes // of the `choices` slice, above. selected: make(map[int]struct{}), } } func (m model) Init() tea.Cmd { return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "up", "k": if m.cursor > 0 { m.cursor-- } case "down", "j": if m.cursor < len(m.choices)-1 { m.cursor++ } case "enter", "space": _, ok := m.selected[m.cursor] if ok { delete(m.selected, m.cursor) } else { m.selected[m.cursor] = struct{}{} } } } return m, nil } func (m model) View() tea.View { s := "What should we buy at the market?\n\n" for i, choice := range m.choices { cursor := " " if m.cursor == i { cursor = ">" } checked := " " if _, ok := m.selected[i]; ok { checked = "x" } s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice) } s += "\nPress q to quit.\n" v := tea.NewView(s) v.WindowTitle = "Grocery List" return v } func main() { p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) } } ================================================ FILE: tutorials/commands/README.md ================================================ # Commands in Bubble Tea This is the second tutorial for Bubble Tea covering commands, which deal with I/O. The tutorial assumes you have a working knowledge of Go and a decent understanding of [the first tutorial][basics]. You can find the non-annotated version of this program [on GitHub][source]. [basics]: https://github.com/charmbracelet/bubbletea/tree/master/tutorials/basics [source]: https://github.com/charmbracelet/bubbletea/blob/master/tutorials/commands/main.go ## Let’s Go! For this tutorial we’re building a very simple program that makes an HTTP request to a server and reports the status code of the response. We’ll import a few necessary packages and put the URL we’re going to check in a `const`. ```go package main import ( "fmt" "net/http" "os" "time" tea "charm.land/bubbletea/v2" ) const url = "https://charm.sh/" ``` ## The Model Next we’ll define our model. The only things we need to store are the status code of the HTTP response and a possible error. ```go type model struct { status int err error } ``` ## Commands and Messages `Cmd`s are functions that perform some I/O and then return a `Msg`. Checking the time, ticking a timer, reading from the disk, and network stuff are all I/O and should be run through commands. That might sound harsh, but it will keep your Bubble Tea program straightforward and simple. Anyway, let’s write a `Cmd` that makes a request to a server and returns the result as a `Msg`. ```go func checkServer() tea.Msg { // Create an HTTP client and make a GET request. c := &http.Client{Timeout: 10 * time.Second} res, err := c.Get(url) if err != nil { // There was an error making our request. Wrap the error we received // in a message and return it. return errMsg{err} } // We received a response from the server. Return the HTTP status code // as a message. return statusMsg(res.StatusCode) } type statusMsg int type errMsg struct{ err error } // For messages that contain errors it's often handy to also implement the // error interface on the message. func (e errMsg) Error() string { return e.err.Error() } ``` And notice that we’ve defined two new `Msg` types. They can be any type, even an empty struct. We’ll come back to them later in our update function. First, let’s write our initialization function. ## The Initialization Method The initialization method is very simple: we return the `Cmd` we made earlier. Note that we don’t call the function; the Bubble Tea runtime will do that when the time is right. ```go func (m model) Init() tea.Cmd { return checkServer } ``` ## The Update Method Internally, `Cmd`s run asynchronously in a goroutine. The `Msg` they return is collected and sent to our update function for handling. Remember those message types we made earlier when we were making the `checkServer` command? We handle them here. This makes dealing with many asynchronous operations very easy. ```go func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case statusMsg: // The server returned a status message. Save it to our model. Also // tell the Bubble Tea runtime we want to exit because we have nothing // else to do. We'll still be able to render a final view with our // status message. m.status = int(msg) return m, tea.Quit case errMsg: // There was an error. Note it in the model. And tell the runtime // we're done and want to quit. m.err = msg return m, tea.Quit case tea.KeyPressMsg: // Ctrl+c exits. Even with short running programs it's good to have // a quit key, just in case your logic is off. Users will be very // annoyed if they can't exit. if msg.Mod == tea.ModCtrl && msg.Code == 'c' { return m, tea.Quit } } // If we happen to get any other messages, don't do anything. return m, nil } ``` ## The View Function Our view is very straightforward. We look at the current model and build a `tea.View` accordingly: ```go func (m model) View() tea.View { // If there's an error, print it out and don't do anything else. if m.err != nil { return tea.NewView(fmt.Sprintf("\nWe had some trouble: %v\n\n", m.err)) } // Tell the user we're doing something. s := fmt.Sprintf("Checking %s ... ", url) // When the server responds with a status, add it to the current line. if m.status > 0 { s += fmt.Sprintf("%d %s!", m.status, http.StatusText(m.status)) } // Send off whatever we came up with above for rendering. return tea.NewView("\n" + s + "\n\n") } ``` ## Run the program The only thing left to do is run the program, so let’s do that! Our initial model doesn’t need any data at all in this case, we just initialize it with a `model` struct with default values. ```go func main() { if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Printf("Uh oh, there was an error: %v\n", err) os.Exit(1) } } ``` And that’s that. There’s one more thing that is helpful to know about `Cmd`s, though. ## One More Thing About Commands `Cmd`s are defined in Bubble Tea as `type Cmd func() Msg`. So they’re just functions that don’t take any arguments and return a `Msg`, which can be any type. If you need to pass arguments to a command, you just make a function that returns a command. For example: ```go func cmdWithArg(id int) tea.Cmd { return func() tea.Msg { return someMsg{id: id} } } ``` A more real-world example looks like: ```go func checkSomeUrl(url string) tea.Cmd { return func() tea.Msg { c := &http.Client{Timeout: 10 * time.Second} res, err := c.Get(url) if err != nil { return errMsg{err} } return statusMsg(res.StatusCode) } } ``` Anyway, just make sure you do as much stuff as you can in the innermost function, because that’s the one that runs asynchronously. ## Now What? After doing this tutorial and [the previous one][basics] you should be ready to build a Bubble Tea program of your own. We also recommend that you look at the Bubble Tea [example programs][examples] as well as [Bubbles][bubbles], a component library for Bubble Tea. And, of course, check out the [Go Docs][docs]. [bubbles]: https://github.com/charmbracelet/bubbles [docs]: https://pkg.go.dev/charm.land/bubbletea/v2?tab=doc [examples]: https://github.com/charmbracelet/bubbletea/tree/master/examples ## Additional Resources - [Libraries we use with Bubble Tea](https://github.com/charmbracelet/bubbletea/#libraries-we-use-with-bubble-tea) - [Bubble Tea in the Wild](https://github.com/charmbracelet/bubbletea/#bubble-tea-in-the-wild) ### Feedback We’d love to hear your thoughts on this tutorial. Feel free to drop us a note! - [Twitter](https://twitter.com/charmcli) - [The Fediverse](https://mastodon.social/@charmcli) - [Discord](https://charm.sh/chat) --- Part of [Charm](https://charm.sh). The Charm logo Charm热爱开源 • Charm loves open source ================================================ FILE: tutorials/commands/main.go ================================================ package main import ( "fmt" "net/http" "os" "time" tea "charm.land/bubbletea/v2" ) const url = "https://charm.sh/" type model struct { status int err error } func checkServer() tea.Msg { c := &http.Client{Timeout: 10 * time.Second} res, err := c.Get(url) if err != nil { return errMsg{err} } defer res.Body.Close() // nolint:errcheck return statusMsg(res.StatusCode) } type statusMsg int type errMsg struct{ err error } // For messages that contain errors it's often handy to also implement the // error interface on the message. func (e errMsg) Error() string { return e.err.Error() } func (m model) Init() tea.Cmd { return checkServer } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case statusMsg: m.status = int(msg) return m, tea.Quit case errMsg: m.err = msg return m, tea.Quit case tea.KeyPressMsg: if msg.Mod == tea.ModCtrl && msg.Code == 'c' { return m, tea.Quit } } return m, nil } func (m model) View() tea.View { if m.err != nil { return tea.NewView(fmt.Sprintf("\nWe had some trouble: %v\n\n", m.err)) } s := fmt.Sprintf("Checking %s ... ", url) if m.status > 0 { s += fmt.Sprintf("%d %s!", m.status, http.StatusText(m.status)) } return tea.NewView("\n" + s + "\n\n") } func main() { if _, err := tea.NewProgram(model{}).Run(); err != nil { fmt.Printf("Uh oh, there was an error: %v\n", err) os.Exit(1) } } ================================================ FILE: tutorials/go.mod ================================================ module tutorial go 1.24.2 toolchain go1.24.10 require charm.land/bubbletea/v2 v2.0.0-00010101000000-000000000000 require ( github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect ) replace charm.land/bubbletea/v2 => ../ ================================================ FILE: tutorials/go.sum ================================================ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= ================================================ FILE: xterm.go ================================================ package tea // TerminalVersionMsg is a message that represents the terminal version. type TerminalVersionMsg struct { Name string } // String returns the terminal name as a string. func (t TerminalVersionMsg) String() string { return t.Name } // terminalVersion is an internal message that queries the terminal for its // version using XTVERSION. type terminalVersion struct{} // RequestTerminalVersion is a command that queries the terminal for its // version using XTVERSION. Note that some terminals may not support this // command. func RequestTerminalVersion() Msg { return terminalVersion{} }