pax_global_header00006660000000000000000000000064141157300140014506gustar00rootroot0000000000000052 comment=da9248f877c9cd40fb79916d45f4111c9774ba39 bubbles-0.9.0/000077500000000000000000000000001411573001400131325ustar00rootroot00000000000000bubbles-0.9.0/.github/000077500000000000000000000000001411573001400144725ustar00rootroot00000000000000bubbles-0.9.0/.github/workflows/000077500000000000000000000000001411573001400165275ustar00rootroot00000000000000bubbles-0.9.0/.github/workflows/build.yml000066400000000000000000000011151411573001400203470ustar00rootroot00000000000000name: build on: [push, pull_request] jobs: test: strategy: matrix: go-version: [~1.13, ^1] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} env: GO111MODULE: "on" steps: - name: Install Go uses: actions/setup-go@v2 with: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v2 - name: Download Go modules run: go mod download - name: Build run: go build -v ./... - name: Test run: go test ./... bubbles-0.9.0/.github/workflows/coverage.yml000066400000000000000000000013211411573001400210420ustar00rootroot00000000000000name: 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@v2 with: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v2 - name: Coverage env: COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | go test -race -covermode atomic -coverprofile=profile.cov ./... GO111MODULE=off go get github.com/mattn/goveralls $(go env GOPATH)/bin/goveralls -coverprofile=profile.cov -service=github bubbles-0.9.0/.github/workflows/lint.yml000066400000000000000000000010561411573001400202220ustar00rootroot00000000000000name: lint on: [push, pull_request] jobs: golangci: name: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: golangci-lint uses: golangci/golangci-lint-action@v2 with: # Optional: golangci-lint command line arguments. args: --issues-exit-code=0 # Optional: working directory, useful for monorepos # working-directory: somedir # Optional: show only new issues if it's a pull request. The default value is `false`. only-new-issues: true bubbles-0.9.0/.gitignore000066400000000000000000000000121411573001400151130ustar00rootroot00000000000000.DS_Store bubbles-0.9.0/.golangci.yml000066400000000000000000000007121411573001400155160ustar00rootroot00000000000000run: tests: false issues: include: - EXC0001 - EXC0005 - EXC0011 - EXC0012 - EXC0013 max-issues-per-linter: 0 max-same-issues: 0 linters: enable: - bodyclose - dupl - exportloopref - goconst - godot - godox - goimports - goprintffuncname - gosec - ifshort - misspell - prealloc - revive - rowserrcheck - sqlclosecheck - unconvert - unparam - whitespace bubbles-0.9.0/LICENSE000066400000000000000000000020631411573001400141400ustar00rootroot00000000000000MIT License Copyright (c) 2020 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. bubbles-0.9.0/README.md000066400000000000000000000147701411573001400144220ustar00rootroot00000000000000Bubbles =======

The Bubbles Logo

[![Latest Release](https://img.shields.io/github/release/charmbracelet/bubbles.svg)](https://github.com/charmbracelet/bubbles/releases) [![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/charmbracelet/bubbles) [![Build Status](https://github.com/charmbracelet/bubbles/workflows/build/badge.svg)](https://github.com/charmbracelet/bubbles/actions) [![Go ReportCard](https://goreportcard.com/badge/charmbracelet/bubbles)](https://goreportcard.com/report/charmbracelet/bubbles) Some components for [Bubble Tea](https://github.com/charmbracelet/bubbletea) applications. These components are used in production in [Glow][glow], [Charm][charm] and [many other applications][otherstuff]. [glow]: https://github.com/charmbracelet/glow [charm]: https://github.com/charmbracelet/charm [otherstuff]: https://github.com/charmbracelet/bubbletea/#bubble-tea-in-the-wild ## Spinner Spinner Example A spinner, useful for indicating that some kind an operation is happening. There are a couple default ones, but you can also pass your own ”frames.” * [Example code, basic spinner](https://github.com/charmbracelet/tea/tree/master/examples/spinner/main.go) * [Example code, various spinners](https://github.com/charmbracelet/tea/tree/master/examples/spinners/main.go) ## Text Input Text Input Example A text input field, akin to an `` in HTML. Supports unicode, pasting, in-place scrolling when the value exceeds the width of the element and the common, and many customization options. * [Example code, one field](https://github.com/charmbracelet/tea/tree/master/examples/textinput/main.go) * [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinputs/main.go) ## Progress Progressbar Example A simple, customizable progress meter, with optional animation via [Harmonica][harmonica]. Supports solid and gradient fills. The empty and filled runes can be set to whatever you'd like. The percentage readout is customizable and can also be omitted entirely. * [Animated example](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress-animated/main.go) * [Static example](https://github.com/charmbracelet/bubbletea/blob/master/examples/progress-static/main.go) [harmonica]: https://github.com/charmbracelet/harmonica ## Paginator Paginator Example A component for handling pagination logic and optionally drawing pagination UI. Supports "dot-style" pagination (similar to what you might see on iOS) and numeric page numbering, but you could also just use this component for the logic and visualize pagination however you like. * [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/pager/main.go) ## Viewport Viewport Example A viewport for vertically scrolling content. Optionally includes standard pager keybindings and mouse wheel support. A high performance mode is available for applications which make use of the alternate screen buffer. * [Example code](https://github.com/charmbracelet/tea/tree/master/examples/pager/main.go) This component is well complimented with [Reflow][reflow] for ANSI-aware indenting and text wrapping. [reflow]: https://github.com/muesli/reflow ## List List Example A customizable, batteries-included component for browsing a set of items. Features pagination, fuzzy filtering, auto-generated help, an activity spinner, and status messages, all of which can be enabled and disabled as needed. Extrapolated from [Glow][glow]. * [Example code, default list](https://github.com/charmbracelet/tea/tree/master/examples/list-default/main.go) * [Example code, simple list](https://github.com/charmbracelet/tea/tree/master/examples/list-simple/main.go) * [Example code, all features](https://github.com/charmbracelet/tea/tree/master/examples/list-fancy/main.go) ## Help Help Example A customizable horizontal mini help view that automatically generates itself from your keybindings. It features single and multi-line modes, which the user can optionally toggle between. It will truncate gracefully if the terminal is too wide for the content. * [Example code](https://github.com/charmbracelet/bubbletea/blob/master/examples/help/main.go) ## Key A non-visual component for managing keybindings. It’s useful for allowing users to remap keybindings as well as generating help views corresponding to your keybindings. ```go type KeyMap struct { Up key.Binding Down key.Binding } var DefaultKeyMap = KeyMap{ Up: key.NewBinding( key.WithKeys("k", "up"), // actual keybindings key.WithHelp("↑/k", "move up"), // corresponding help text ), Down: key.NewBinding( WithKeys("j", "down"), WithHelp("↓/j", "move down"), ), } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch { case key.Matches(msg, DefaultKeyMap.Up): // The user pressed up case key.Matches(msg, DefaultKeyMap.Down): // The user pressed down } } return m, nil } ``` ## Additional Bubbles * [promptkit](https://github.com/erikgeiser/promptkit): A collection of common prompts for cases like selection, text input, and confirmation. Each prompt comes with sensible defaults, remappable keybindings, any many customization options. * [mritd/bubbles](https://github.com/mritd/bubbles): Some general-purpose bubbles. Inputs with validation, menu selection, a modified progressbar, and so on. If you’ve built a Bubble you think should be listed here, [let us know](mailto:vt100@charm.sh). ## License [MIT](https://github.com/charmbracelet/teaparty/raw/master/LICENSE) *** Part of [Charm](https://charm.sh). The Charm logo Charm热爱开源 • Charm loves open source bubbles-0.9.0/bubbles.go000066400000000000000000000000201411573001400150670ustar00rootroot00000000000000package bubbles bubbles-0.9.0/go.mod000066400000000000000000000007121411573001400142400ustar00rootroot00000000000000module github.com/charmbracelet/bubbles go 1.13 require ( github.com/atotto/clipboard v0.1.2 github.com/charmbracelet/bubbletea v0.14.1 github.com/charmbracelet/harmonica v0.1.0 github.com/charmbracelet/lipgloss v0.3.0 github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.13 github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.9.0 github.com/sahilm/fuzzy v0.1.0 ) bubbles-0.9.0/go.sum000066400000000000000000000076431411573001400142770ustar00rootroot00000000000000github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/charmbracelet/bubbletea v0.14.1 h1:pD/bM5LBEH/nDo7nKcgNUgi4uRHQhpWTIHZbG5vuSlc= github.com/charmbracelet/bubbletea v0.14.1/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE= github.com/charmbracelet/harmonica v0.1.0 h1:lFKeSd6OAckQ/CEzPVd2mqj+YMEubQ/3FM2IYY3xNm0= github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.3.0 h1:5MysOD6sHr4RP4jkZNWGVIul5GKoOsP12NgbgXPvAlA= github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk= github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc= github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= 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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= bubbles-0.9.0/help/000077500000000000000000000000001411573001400140625ustar00rootroot00000000000000bubbles-0.9.0/help/help.go000066400000000000000000000124321411573001400153430ustar00rootroot00000000000000package help import ( "strings" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // KeyMap is a map of keybindings used to generate help. Since it's an // interface it can be any type, though struct or a map[string][]key.Binding // are likely candidates. // // Note that if a key is disabled (via key.Binding.SetEnabled) it will not be // rendered in the help view, so in theory generated help should self-manage. type KeyMap interface { // ShortHelp returns a slice of bindings to be displayed in the short // version of the help. The help bubble will render help in the order in // which the help items are returned here. ShortHelp() []key.Binding // MoreHelp returns an extended group of help items, grouped by columns. // The help bubble will render the help in the order in which the help // items are returned here. FullHelp() [][]key.Binding } // Styles is a set of available style definitions for the Help bubble. type Styles struct { Ellipsis lipgloss.Style // Styling for the short help ShortKey lipgloss.Style ShortDesc lipgloss.Style ShortSeparator lipgloss.Style // Styling for the full help FullKey lipgloss.Style FullDesc lipgloss.Style FullSeparator lipgloss.Style } // Model contains the state of the help view. type Model struct { Width int ShowAll bool // if true, render the "full" help menu ShortSeparator string FullSeparator string // The symbol we use in the short help when help items have been truncated // due to width. Periods of ellipsis by default. Ellipsis string Styles Styles } // NewModel creates a new help view with some useful defaults. func NewModel() Model { keyStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ Light: "#909090", Dark: "#626262", }) descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ Light: "#B2B2B2", Dark: "#4A4A4A", }) sepStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{ Light: "#DDDADA", Dark: "#3C3C3C", }) return Model{ ShortSeparator: " • ", FullSeparator: " ", Ellipsis: "…", Styles: Styles{ ShortKey: keyStyle, ShortDesc: descStyle, ShortSeparator: sepStyle, Ellipsis: sepStyle.Copy(), FullKey: keyStyle.Copy(), FullDesc: descStyle.Copy(), FullSeparator: sepStyle.Copy(), }, } } // Update helps satisfy the Bubble Tea Model interface. It's a no-op. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } // View renders the help view's current state. func (m Model) View(k KeyMap) string { if m.ShowAll { return m.FullHelpView(k.FullHelp()) } return m.ShortHelpView(k.ShortHelp()) } // ShortHelpView renders a single line help view from a slice of keybindings. // If the line is longer than the maximum width it will be gracefully // truncated, showing only as many help items as possible. func (m Model) ShortHelpView(bindings []key.Binding) string { if len(bindings) == 0 { return "" } var b strings.Builder var totalWidth int var separator = m.Styles.ShortSeparator.Inline(true).Render(m.ShortSeparator) for i, kb := range bindings { if !kb.Enabled() { continue } var sep string if totalWidth > 0 && i < len(bindings) { sep = separator } str := sep + m.Styles.ShortKey.Inline(true).Render(kb.Help().Key) + " " + m.Styles.ShortDesc.Inline(true).Render(kb.Help().Desc) w := lipgloss.Width(str) // If adding this help item would go over the available width, stop // drawing. if m.Width > 0 && totalWidth+w > m.Width { // Although if there's room for an ellipsis, print that. tail := " " + m.Styles.Ellipsis.Inline(true).Render(m.Ellipsis) tailWidth := lipgloss.Width(tail) if totalWidth+tailWidth < m.Width { b.WriteString(tail) } break } totalWidth += w b.WriteString(str) } return b.String() } // FullHelpView renders help columns from a slice of key binding slices. Each // top level slice entry renders into a column. func (m Model) FullHelpView(groups [][]key.Binding) string { if len(groups) == 0 { return "" } var ( out []string totalWidth int sep = m.Styles.FullSeparator.Render(m.FullSeparator) sepWidth = lipgloss.Width(sep) ) // Iterate over groups to build columns for i, group := range groups { if group == nil || !shouldRenderColumn(group) { continue } var ( keys []string descriptions []string ) // Separate keys and descriptions into different slices for _, kb := range group { if !kb.Enabled() { continue } keys = append(keys, kb.Help().Key) descriptions = append(descriptions, kb.Help().Desc) } col := lipgloss.JoinHorizontal(lipgloss.Top, m.Styles.FullKey.Render(strings.Join(keys, "\n")), m.Styles.FullKey.Render(" "), m.Styles.FullDesc.Render(strings.Join(descriptions, "\n")), ) // Column totalWidth += lipgloss.Width(col) if totalWidth > m.Width { break } out = append(out, col) // Separator if i < len(group)-1 { totalWidth += sepWidth if totalWidth > m.Width { break } } out = append(out, sep) } return lipgloss.JoinHorizontal(lipgloss.Top, out...) } func shouldRenderColumn(b []key.Binding) (ok bool) { for _, v := range b { if v.Enabled() { return true } } return false } bubbles-0.9.0/key/000077500000000000000000000000001411573001400137225ustar00rootroot00000000000000bubbles-0.9.0/key/key.go000066400000000000000000000070621411573001400150460ustar00rootroot00000000000000// Package key provides some types and functions for generating user-definable // keymappings useful in Bubble Tea components. There are a few different ways // you can define a keymapping with this package. Here's one example: // // type KeyMap struct { // Up key.Binding // Down key.Binding // } // // var DefaultKeyMap = KeyMap{ // Up: key.NewBinding( // key.WithKeys("k", "up"), // actual keybindings // key.WithHelp("↑/k", "move up"), // corresponding help text // ), // Down: key.NewBinding( // WithKeys("j", "down"), // WithHelp("↓/j", "move down"), // ), // } // // func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // switch msg := msg.(type) { // case tea.KeyMsg: // switch { // case key.Matches(msg, DefaultKeyMap.Up): // // The user pressed up // case key.Matches(msg, DefaultKeyMap.Down): // // The user pressed down // } // } // // // ... // } // // The help information, which is not used in the example above, can be used // to render help text for keystrokes in your views. package key import ( tea "github.com/charmbracelet/bubbletea" ) // Binding describes a set of keybindings and, optionally, their associated // help text. type Binding struct { keys []string help Help disabled bool } // BindingOpt is an initialization option for a keybinding. It's used as an // argument to NewBinding. type BindingOpt func(*Binding) // NewBinding returns a new keybinding from a set of BindingOpt options. func NewBinding(opts ...BindingOpt) Binding { b := &Binding{} for _, opt := range opts { opt(b) } return *b } // WithKeys initializes a keybinding with the given keystrokes. func WithKeys(keys ...string) BindingOpt { return func(b *Binding) { b.keys = keys } } // WithHelp initializes a keybinding with the given help text. func WithHelp(key, desc string) BindingOpt { return func(b *Binding) { b.help = Help{Key: key, Desc: desc} } } // WithDisabled initializes a disabled keybinding. func WithDisabled() BindingOpt { return func(b *Binding) { b.disabled = true } } // SetKeys sets the keys for the keybinding. func (b *Binding) SetKeys(keys ...string) { b.keys = keys } // Keys returns the keys for the keybinding. func (b Binding) Keys() []string { return b.keys } // SetHelp sets the help text for the keybinding. func (b *Binding) SetHelp(key, desc string) { b.help = Help{Key: key, Desc: desc} } // Help returns the Help information for the keybinding. func (b Binding) Help() Help { return b.help } // Enabled returns whether or not the keybinding is enabled. Disabled // keybindings won't be activated and won't show up in help. Keybindings are // enabled by default. func (b Binding) Enabled() bool { return !b.disabled } // SetEnabled enables or disables the keybinding. func (b *Binding) SetEnabled(v bool) { b.disabled = !v } // Unbind removes the keys and help from this binding, effectively nullifying // it. This is a step beyond disabling it, since applications can enable // or disable key bindings based on application state. func (b *Binding) Unbind() { b.keys = nil b.help = Help{} } // Help is help information for a given keybinding. type Help struct { Key string Desc string } // Matches checks if the given KeyMsg matches a given binding. func Matches(k tea.KeyMsg, b Binding) bool { for _, v := range b.keys { if k.String() == v && b.Enabled() { return true } } return false } bubbles-0.9.0/list/000077500000000000000000000000001411573001400141055ustar00rootroot00000000000000bubbles-0.9.0/list/defaultitem.go000066400000000000000000000132551411573001400167450ustar00rootroot00000000000000package list import ( "fmt" "io" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/truncate" ) // DefaultItemStyles defines styling for a default list item. // See DefaultItemView for when these come into play. type DefaultItemStyles struct { // The Normal state. NormalTitle lipgloss.Style NormalDesc lipgloss.Style // The selected item state. SelectedTitle lipgloss.Style SelectedDesc lipgloss.Style // The dimmed state, for when the filter input is initially activated. DimmedTitle lipgloss.Style DimmedDesc lipgloss.Style // Charcters matching the current filter, if any. FilterMatch lipgloss.Style } // NewDefaultItemStyles returns style definitions for a default item. See // DefaultItemView for when these come into play. func NewDefaultItemStyles() (s DefaultItemStyles) { s.NormalTitle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}). Padding(0, 0, 0, 2) s.NormalDesc = s.NormalTitle.Copy(). Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}) s.SelectedTitle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, false, true). BorderForeground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}). Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}). Padding(0, 0, 0, 1) s.SelectedDesc = s.SelectedTitle.Copy(). Foreground(lipgloss.AdaptiveColor{Light: "#F793FF", Dark: "#AD58B4"}) s.DimmedTitle = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). Padding(0, 0, 0, 2) s.DimmedDesc = s.DimmedTitle.Copy(). Foreground(lipgloss.AdaptiveColor{Light: "#C2B8C2", Dark: "#4D4D4D"}) s.FilterMatch = lipgloss.NewStyle().Underline(true) return s } // DefaultItem describes an items designed to work with DefaultDelegate. type DefaultItem interface { Item Title() string Description() string } // DefaultDelegate is a standard delegate designed to work in lists. It's // styled by DefaultItemStyles, which can be customized as you like. // // The description line can be hidden by setting Description to false, which // renders the list as single-line-items. The spacing between items can be set // with the SetSpacing method. // // Setting UpdateFunc is optional. If it's set it will be called when the // ItemDelegate called, which is called when the list's Update function is // invoked. // // Settings ShortHelpFunc and FullHelpFunc is optional. They can can be set to // include items in the list's default short and full help menus. type DefaultDelegate struct { ShowDescription bool Styles DefaultItemStyles UpdateFunc func(tea.Msg, *Model) tea.Cmd ShortHelpFunc func() []key.Binding FullHelpFunc func() [][]key.Binding spacing int } // NewDefaultDelegate creates a new delegate with default styles. func NewDefaultDelegate() DefaultDelegate { return DefaultDelegate{ ShowDescription: true, Styles: NewDefaultItemStyles(), spacing: 1, } } // Height returns the delegate's preferred height. func (d DefaultDelegate) Height() int { if d.ShowDescription { return 2 //nolint:gomnd } return 1 } // SetSpacing set the delegate's spacing. func (d *DefaultDelegate) SetSpacing(i int) { d.spacing = i } // Spacing returns the delegate's spacing. func (d DefaultDelegate) Spacing() int { return d.spacing } // Update checks whether the delegate's UpdateFunc is set and calls it. func (d DefaultDelegate) Update(msg tea.Msg, m *Model) tea.Cmd { if d.UpdateFunc == nil { return nil } return d.UpdateFunc(msg, m) } // Render prints an item. func (d DefaultDelegate) Render(w io.Writer, m Model, index int, item Item) { var ( title, desc string matchedRunes []int s = &d.Styles ) if i, ok := item.(DefaultItem); ok { title = i.Title() desc = i.Description() } else { return } // Prevent text from exceeding list width if m.width > 0 { textwidth := uint(m.width - s.NormalTitle.GetPaddingLeft() - s.NormalTitle.GetPaddingRight()) title = truncate.StringWithTail(title, textwidth, ellipsis) desc = truncate.StringWithTail(desc, textwidth, ellipsis) } // Conditions var ( isSelected = index == m.Index() emptyFilter = m.FilterState() == Filtering && m.FilterValue() == "" isFiltered = m.FilterState() == Filtering || m.FilterState() == FilterApplied ) if isFiltered && index < len(m.filteredItems) { // Get indices of matched characters matchedRunes = m.MatchesForItem(index) } if emptyFilter { title = s.DimmedTitle.Render(title) desc = s.DimmedDesc.Render(desc) } else if isSelected && m.FilterState() != Filtering { if isFiltered { // Highlight matches unmatched := s.SelectedTitle.Inline(true) matched := unmatched.Copy().Inherit(s.FilterMatch) title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) } title = s.SelectedTitle.Render(title) desc = s.SelectedDesc.Render(desc) } else { if isFiltered { // Highlight matches unmatched := s.NormalTitle.Inline(true) matched := unmatched.Copy().Inherit(s.FilterMatch) title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) } title = s.NormalTitle.Render(title) desc = s.NormalDesc.Render(desc) } if d.ShowDescription { fmt.Fprintf(w, "%s\n%s", title, desc) return } fmt.Fprintf(w, "%s", title) } // ShortHelp returns the delegate's short help. func (d DefaultDelegate) ShortHelp() []key.Binding { if d.ShortHelpFunc != nil { return d.ShortHelpFunc() } return nil } // FullHelp returns the delegate's full help. func (d DefaultDelegate) FullHelp() [][]key.Binding { if d.FullHelpFunc != nil { return d.FullHelpFunc() } return nil } bubbles-0.9.0/list/keys.go000066400000000000000000000045751411573001400154220ustar00rootroot00000000000000package list import "github.com/charmbracelet/bubbles/key" // KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which // is used to render the menu menu. type KeyMap struct { // Keybindings used when browsing the list. CursorUp key.Binding CursorDown key.Binding NextPage key.Binding PrevPage key.Binding GoToStart key.Binding GoToEnd key.Binding Filter key.Binding ClearFilter key.Binding // Keybindings used when setting a filter. CancelWhileFiltering key.Binding AcceptWhileFiltering key.Binding // Help toggle keybindings. ShowFullHelp key.Binding CloseFullHelp key.Binding // The quit keybinding. This won't be caught when filtering. Quit key.Binding // The quit-no-matter-what keybinding. This will be caught when filtering. ForceQuit key.Binding } // DefaultKeyMap returns a default set of keybindings. func DefaultKeyMap() KeyMap { return KeyMap{ // Browsing. CursorUp: key.NewBinding( key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"), ), CursorDown: key.NewBinding( key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"), ), PrevPage: key.NewBinding( key.WithKeys("left", "h", "pgup", "b", "u"), key.WithHelp("←/h/pgup", "prev page"), ), NextPage: key.NewBinding( key.WithKeys("right", "l", "pgdown", "f", "d"), key.WithHelp("→/l/pgdn", "next page"), ), GoToStart: key.NewBinding( key.WithKeys("home", "g"), key.WithHelp("g/home", "go to start"), ), GoToEnd: key.NewBinding( key.WithKeys("end", "G"), key.WithHelp("G/end", "go to end"), ), Filter: key.NewBinding( key.WithKeys("/"), key.WithHelp("/", "filter"), ), ClearFilter: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "clear filter"), ), // Filtering. CancelWhileFiltering: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "cancel"), ), AcceptWhileFiltering: key.NewBinding( key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"), key.WithHelp("enter", "apply filter"), ), // Toggle help. ShowFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "more"), ), CloseFullHelp: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "close help"), ), // Quitting. Quit: key.NewBinding( key.WithKeys("q", "esc"), key.WithHelp("q", "quit"), ), ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")), } } bubbles-0.9.0/list/list.go000066400000000000000000000725061411573001400154210ustar00rootroot00000000000000// Package list provides a feature-rich Bubble Tea component for browsing // a general purpose list of items. It features optional filtering, pagination, // help, status messages, and a spinner to indicate activity. package list import ( "fmt" "io" "sort" "strings" "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/paginator" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" "github.com/sahilm/fuzzy" ) // Item is an item that appears in the list. type Item interface { // Filter value is the value we use when filtering against this item when // we're filtering the list. FilterValue() string } // ItemDelegate encapsulates the general functionality for all list items. The // benefit to separating this logic from the item itself is that you can change // the functionality of items without changing the actual items themselves. // // Note that if the delegate also implements help.KeyMap delegate-related // help items will be added to the help view. type ItemDelegate interface { // Render renders the item's view. Render(w io.Writer, m Model, index int, item Item) // Height is the height of the list item. Height() int // Spacing is the size of the horizontal gap between list items in cells. Spacing() int // Update is the update loop for items. All messages in the list's update // loop will pass through here except when the user is setting a filter. // Use this method to perform item-level updates appropriate to this // delegate. Update(msg tea.Msg, m *Model) tea.Cmd } type filteredItem struct { item Item // item matched matches []int // rune indices of matched items } type filteredItems []filteredItem func (f filteredItems) items() []Item { agg := make([]Item, len(f)) for i, v := range f { agg[i] = v.item } return agg } func (f filteredItems) matches() [][]int { agg := make([][]int, len(f)) for i, v := range f { agg[i] = v.matches } return agg } type filterMatchesMsg []filteredItem type statusMessageTimeoutMsg struct{} // FilterState describes the current filtering state on the model. type FilterState int // Possible filter states. const ( Unfiltered FilterState = iota // no filter set Filtering // user is actively setting a filter FilterApplied // a filter is applied and user is not editing filter ) // String returns a human-readable string of the current filter state. func (f FilterState) String() string { return [...]string{ "unfiltered", "filtering", "filter applied", }[f] } // Model contains the state of this component. type Model struct { showTitle bool showFilter bool showStatusBar bool showPagination bool showHelp bool filteringEnabled bool Title string Styles Styles // Key mappings for navigating the list. KeyMap KeyMap // Additional key mappings for the short and full help views. This allows // you to add additional key mappings to the help menu without // re-implementing the help component. Of course, you can also disable the // list's help component and implement a new one if you need more // flexibility. AdditionalShortHelpKeys func() []key.Binding AdditionalFullHelpKeys func() []key.Binding spinner spinner.Model showSpinner bool width int height int Paginator paginator.Model cursor int Help help.Model FilterInput textinput.Model filterState FilterState // How long status messages should stay visible. By default this is // 1 second. StatusMessageLifetime time.Duration statusMessage string statusMessageTimer *time.Timer // The master set of items we're working with. items []Item // Filtered items we're currently displaying. Filtering, toggles and so on // will alter this slice so we can show what is relevant. For that reason, // this field should be considered ephemeral. filteredItems filteredItems delegate ItemDelegate } // NewModel returns a new model with sensible defaults. func NewModel(items []Item, delegate ItemDelegate, width, height int) Model { styles := DefaultStyles() sp := spinner.NewModel() sp.Spinner = spinner.Line sp.Style = styles.Spinner filterInput := textinput.NewModel() filterInput.Prompt = "Filter: " filterInput.PromptStyle = styles.FilterPrompt filterInput.CursorStyle = styles.FilterCursor filterInput.CharLimit = 64 filterInput.Focus() p := paginator.NewModel() p.Type = paginator.Dots p.ActiveDot = styles.ActivePaginationDot.String() p.InactiveDot = styles.InactivePaginationDot.String() m := Model{ showTitle: true, showFilter: true, showStatusBar: true, showPagination: true, showHelp: true, filteringEnabled: true, KeyMap: DefaultKeyMap(), Styles: styles, Title: "List", FilterInput: filterInput, StatusMessageLifetime: time.Second, width: width, height: height, delegate: delegate, items: items, Paginator: p, spinner: sp, Help: help.NewModel(), } m.updatePagination() m.updateKeybindings() return m } // SetFilteringEnabled enables or disables filtering. Note that this is different // from ShowFilter, which merely hides or shows the input view. func (m *Model) SetFilteringEnabled(v bool) { m.filteringEnabled = v if !v { m.resetFiltering() } m.updateKeybindings() } // FilteringEnabled returns whether or not filtering is enabled. func (m Model) FilteringEnabled() bool { return m.filteringEnabled } // SetShowTitle shows or hides the title bar. func (m *Model) SetShowTitle(v bool) { m.showTitle = v m.updatePagination() } // ShowTitle returns whether or not the title bar is set to be rendered. func (m Model) ShowTitle() bool { return m.showTitle } // SetShowFilter shows or hides the filer bar. Note that this does not disable // filtering, it simply hides the built-in filter view. This allows you to // use the FilterInput to render the filtering UI differently without having to // re-implement filtering from scratch. // // To disable filtering entirely use EnableFiltering. func (m *Model) SetShowFilter(v bool) { m.showFilter = v m.updatePagination() } // ShowFilter returns whether or not the filter is set to be rendered. Note // that this is separate from FilteringEnabled, so filtering can be hidden yet // still invoked. This allows you to render filtering differently without // having to re-implement it from scratch. func (m Model) ShowFilter() bool { return m.showFilter } // SetShowStatusBar shows or hides the view that displays metadata about the // list, such as item counts. func (m *Model) SetShowStatusBar(v bool) { m.showStatusBar = v m.updatePagination() } // ShowStatusBar returns whether or not the status bar is set to be rendered. func (m Model) ShowStatusBar() bool { return m.showStatusBar } // ShowingPagination hides or shoes the paginator. Note that pagination will // still be active, it simply won't be displayed. func (m *Model) SetShowPagination(v bool) { m.showPagination = v m.updatePagination() } // ShowPagination returns whether the pagination is visible. func (m *Model) ShowPagination() bool { return m.showPagination } // SetShowHelp shows or hides the help view. func (m *Model) SetShowHelp(v bool) { m.showHelp = v m.updatePagination() } // ShowHelp returns whether or not the help is set to be rendered. func (m Model) ShowHelp() bool { return m.showHelp } // Items returns the items in the list. func (m Model) Items() []Item { return m.items } // Set the items available in the list. This returns a command. func (m *Model) SetItems(i []Item) tea.Cmd { var cmd tea.Cmd m.items = i if m.filterState != Unfiltered { m.filteredItems = nil cmd = filterItems(*m) } m.updatePagination() return cmd } // Select selects the given index of the list and goes to its respective page. func (m *Model) Select(index int) { m.Paginator.Page = index / m.Paginator.PerPage m.cursor = index % m.Paginator.PerPage } // ResetSelected resets the selected item to the first item in the first page of the list. func (m *Model) ResetSelected() { m.Select(0) } // ResetFilter resets the current filtering state. func (m *Model) ResetFilter() { m.resetFiltering() } // Replace an item at the given index. This returns a command. func (m *Model) SetItem(index int, item Item) tea.Cmd { var cmd tea.Cmd m.items[index] = item if m.filterState != Unfiltered { cmd = filterItems(*m) } m.updatePagination() return cmd } // Insert an item at the given index. This returns a command. func (m *Model) InsertItem(index int, item Item) tea.Cmd { var cmd tea.Cmd m.items = insertItemIntoSlice(m.items, item, index) if m.filterState != Unfiltered { cmd = filterItems(*m) } m.updatePagination() return cmd } // RemoveItem removes an item at the given index. If the index is out of bounds // this will be a no-op. O(n) complexity, which probably won't matter in the // case of a TUI. func (m *Model) RemoveItem(index int) { m.items = removeItemFromSlice(m.items, index) if m.filterState != Unfiltered { m.filteredItems = removeFilterMatchFromSlice(m.filteredItems, index) if len(m.filteredItems) == 0 { m.resetFiltering() } } m.updatePagination() } // Set the item delegate. func (m *Model) SetDelegate(d ItemDelegate) { m.delegate = d m.updatePagination() } // VisibleItems returns the total items available to be shown. func (m Model) VisibleItems() []Item { if m.filterState != Unfiltered { return m.filteredItems.items() } return m.items } // SelectedItems returns the current selected item in the list. func (m Model) SelectedItem() Item { i := m.Index() items := m.VisibleItems() if i < 0 || len(items) == 0 || len(items) <= i { return nil } return items[i] } // MatchesForItem returns rune positions matched by the current filter, if any. // Use this to style runes matched by the active filter. // // See DefaultItemView for a usage example. func (m Model) MatchesForItem(index int) []int { if m.filteredItems == nil || index >= len(m.filteredItems) { return nil } return m.filteredItems[index].matches } // Index returns the index of the currently selected item as it appears in the // entire slice of items. func (m Model) Index() int { return m.Paginator.Page*m.Paginator.PerPage + m.cursor } // Cursor returns the index of the cursor on the current page. func (m Model) Cursor() int { return m.cursor } // CursorUp moves the cursor up. This can also move the state to the previous // page. func (m *Model) CursorUp() { m.cursor-- // If we're at the start, stop if m.cursor < 0 && m.Paginator.Page == 0 { m.cursor = 0 return } // Move the cursor as normal if m.cursor >= 0 { return } // Go to the previous page m.Paginator.PrevPage() m.cursor = m.Paginator.ItemsOnPage(len(m.VisibleItems())) - 1 } // CursorDown moves the cursor down. This can also advance the state to the // next page. func (m *Model) CursorDown() { itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) m.cursor++ // If we're at the end, stop if m.cursor < itemsOnPage { return } // Go to the next page if !m.Paginator.OnLastPage() { m.Paginator.NextPage() m.cursor = 0 return } // During filtering the cursor position can exceed the number of // itemsOnPage. It's more intuitive to start the cursor at the // topmost position when moving it down in this scenario. if m.cursor > itemsOnPage { m.cursor = 0 return } m.cursor = itemsOnPage - 1 } // PrevPage moves to the previous page, if available. func (m Model) PrevPage() { m.Paginator.PrevPage() } // NextPage moves to the next page, if available. func (m Model) NextPage() { m.Paginator.NextPage() } // FilterState returns the current filter state. func (m Model) FilterState() FilterState { return m.filterState } // FilterValue returns the current value of the filter. func (m Model) FilterValue() string { return m.FilterInput.Value() } // SettingFilter returns whether or not the user is currently editing the // filter value. It's purely a convenience method for the following: // // m.FilterState() == Filtering // // It's included here because it's a common thing to check for when // implementing this component. func (m Model) SettingFilter() bool { return m.filterState == Filtering } // Width returns the current width setting. func (m Model) Width() int { return m.width } // Height returns the current height setting. func (m Model) Height() int { return m.height } // SetSpinner allows to set the spinner style. func (m *Model) SetSpinner(spinner spinner.Spinner) { m.spinner.Spinner = spinner } // Toggle the spinner. Note that this also returns a command. func (m *Model) ToggleSpinner() tea.Cmd { if !m.showSpinner { return m.StartSpinner() } m.StopSpinner() return nil } // StartSpinner starts the spinner. Note that this returns a command. func (m *Model) StartSpinner() tea.Cmd { m.showSpinner = true return spinner.Tick } // StopSpinner stops the spinner. func (m *Model) StopSpinner() { m.showSpinner = false } // Helper for disabling the keybindings used for quitting, incase you want to // handle this elsewhere in your application. func (m *Model) DisableQuitKeybindings() { m.KeyMap.Quit.SetEnabled(false) m.KeyMap.ForceQuit.SetEnabled(false) } // NewStatusMessage sets a new status message, which will show for a limited // amount of time. Note that this also returns a command. func (m *Model) NewStatusMessage(s string) tea.Cmd { m.statusMessage = s if m.statusMessageTimer != nil { m.statusMessageTimer.Stop() } m.statusMessageTimer = time.NewTimer(m.StatusMessageLifetime) // Wait for timeout return func() tea.Msg { <-m.statusMessageTimer.C return statusMessageTimeoutMsg{} } } // SetSize sets the width and height of this component. func (m *Model) SetSize(width, height int) { m.setSize(width, height) } // SetWidth sets the width of this component. func (m *Model) SetWidth(v int) { m.setSize(v, m.height) } // SetHeight sets the height of this component. func (m *Model) SetHeight(v int) { m.setSize(m.width, v) } func (m *Model) setSize(width, height int) { promptWidth := lipgloss.Width(m.Styles.Title.Render(m.FilterInput.Prompt)) m.width = width m.height = height m.Help.Width = width m.FilterInput.Width = width - promptWidth - lipgloss.Width(m.spinnerView()) m.updatePagination() } func (m *Model) resetFiltering() { if m.filterState == Unfiltered { return } m.filterState = Unfiltered m.FilterInput.Reset() m.filteredItems = nil m.updatePagination() m.updateKeybindings() } func (m Model) itemsAsFilterItems() filteredItems { fi := make([]filteredItem, len(m.items)) for i, item := range m.items { fi[i] = filteredItem{ item: item, } } return filteredItems(fi) } // Set keybindings according to the filter state. func (m *Model) updateKeybindings() { switch m.filterState { case Filtering: m.KeyMap.CursorUp.SetEnabled(false) m.KeyMap.CursorDown.SetEnabled(false) m.KeyMap.NextPage.SetEnabled(false) m.KeyMap.PrevPage.SetEnabled(false) m.KeyMap.GoToStart.SetEnabled(false) m.KeyMap.GoToEnd.SetEnabled(false) m.KeyMap.Filter.SetEnabled(false) m.KeyMap.ClearFilter.SetEnabled(false) m.KeyMap.CancelWhileFiltering.SetEnabled(true) m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") m.KeyMap.Quit.SetEnabled(true) m.KeyMap.ShowFullHelp.SetEnabled(false) m.KeyMap.CloseFullHelp.SetEnabled(false) default: hasItems := m.items != nil m.KeyMap.CursorUp.SetEnabled(hasItems) m.KeyMap.CursorDown.SetEnabled(hasItems) hasPages := m.Paginator.TotalPages > 1 m.KeyMap.NextPage.SetEnabled(hasPages) m.KeyMap.PrevPage.SetEnabled(hasPages) m.KeyMap.GoToStart.SetEnabled(hasItems) m.KeyMap.GoToEnd.SetEnabled(hasItems) m.KeyMap.Filter.SetEnabled(m.filteringEnabled && hasItems) m.KeyMap.ClearFilter.SetEnabled(m.filterState == FilterApplied) m.KeyMap.CancelWhileFiltering.SetEnabled(false) m.KeyMap.AcceptWhileFiltering.SetEnabled(false) m.KeyMap.Quit.SetEnabled(true) if m.Help.ShowAll { m.KeyMap.ShowFullHelp.SetEnabled(true) m.KeyMap.CloseFullHelp.SetEnabled(true) } else { minHelp := countEnabledBindings(m.FullHelp()) > 1 m.KeyMap.ShowFullHelp.SetEnabled(minHelp) m.KeyMap.CloseFullHelp.SetEnabled(minHelp) } } } // Update pagination according to the amount of items for the current state. func (m *Model) updatePagination() { index := m.Index() availHeight := m.height if m.showTitle || (m.showFilter && m.filteringEnabled) { availHeight -= lipgloss.Height(m.titleView()) } if m.showStatusBar { availHeight -= lipgloss.Height(m.statusView()) } if m.showPagination { availHeight -= lipgloss.Height(m.paginationView()) } if m.showHelp { availHeight -= lipgloss.Height(m.helpView()) } m.Paginator.PerPage = max(1, availHeight/(m.delegate.Height()+m.delegate.Spacing())) if pages := len(m.VisibleItems()); pages < 1 { m.Paginator.SetTotalPages(1) } else { m.Paginator.SetTotalPages(pages) } // Restore index m.Paginator.Page = index / m.Paginator.PerPage m.cursor = index % m.Paginator.PerPage // Make sure the page stays in bounds if m.Paginator.Page >= m.Paginator.TotalPages-1 { m.Paginator.Page = max(0, m.Paginator.TotalPages-1) } } func (m *Model) hideStatusMessage() { m.statusMessage = "" if m.statusMessageTimer != nil { m.statusMessageTimer.Stop() } } // Update is the Bubble Tea update loop. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: if key.Matches(msg, m.KeyMap.ForceQuit) { return m, tea.Quit } case filterMatchesMsg: m.filteredItems = filteredItems(msg) return m, nil case spinner.TickMsg: newSpinnerModel, cmd := m.spinner.Update(msg) m.spinner = newSpinnerModel if m.showSpinner { cmds = append(cmds, cmd) } case statusMessageTimeoutMsg: m.hideStatusMessage() } if m.filterState == Filtering { cmds = append(cmds, m.handleFiltering(msg)) } else { cmds = append(cmds, m.handleBrowsing(msg)) } return m, tea.Batch(cmds...) } // Updates for when a user is browsing the list. func (m *Model) handleBrowsing(msg tea.Msg) tea.Cmd { var cmds []tea.Cmd numItems := len(m.VisibleItems()) switch msg := msg.(type) { case tea.KeyMsg: switch { // Note: we match clear filter before quit because, by default, they're // both mapped to escape. case key.Matches(msg, m.KeyMap.ClearFilter): m.resetFiltering() case key.Matches(msg, m.KeyMap.Quit): return tea.Quit case key.Matches(msg, m.KeyMap.CursorUp): m.CursorUp() case key.Matches(msg, m.KeyMap.CursorDown): m.CursorDown() case key.Matches(msg, m.KeyMap.PrevPage): m.Paginator.PrevPage() case key.Matches(msg, m.KeyMap.NextPage): m.Paginator.NextPage() case key.Matches(msg, m.KeyMap.GoToStart): m.Paginator.Page = 0 m.cursor = 0 case key.Matches(msg, m.KeyMap.GoToEnd): m.Paginator.Page = m.Paginator.TotalPages - 1 m.cursor = m.Paginator.ItemsOnPage(numItems) - 1 case key.Matches(msg, m.KeyMap.Filter): m.hideStatusMessage() if m.FilterInput.Value() == "" { // Populate filter with all items only if the filter is empty. m.filteredItems = m.itemsAsFilterItems() } m.Paginator.Page = 0 m.cursor = 0 m.filterState = Filtering m.FilterInput.CursorEnd() m.FilterInput.Focus() m.updateKeybindings() return textinput.Blink case key.Matches(msg, m.KeyMap.ShowFullHelp): fallthrough case key.Matches(msg, m.KeyMap.CloseFullHelp): m.Help.ShowAll = !m.Help.ShowAll m.updatePagination() } } cmd := m.delegate.Update(msg, m) cmds = append(cmds, cmd) // Keep the index in bounds when paginating itemsOnPage := m.Paginator.ItemsOnPage(len(m.VisibleItems())) if m.cursor > itemsOnPage-1 { m.cursor = max(0, itemsOnPage-1) } return tea.Batch(cmds...) } // Updates for when a user is in the filter editing interface. func (m *Model) handleFiltering(msg tea.Msg) tea.Cmd { var cmds []tea.Cmd // Handle keys if msg, ok := msg.(tea.KeyMsg); ok { switch { case key.Matches(msg, m.KeyMap.CancelWhileFiltering): m.resetFiltering() m.KeyMap.Filter.SetEnabled(true) m.KeyMap.ClearFilter.SetEnabled(false) case key.Matches(msg, m.KeyMap.AcceptWhileFiltering): m.hideStatusMessage() if len(m.items) == 0 { break } h := m.VisibleItems() // If we've filtered down to nothing, clear the filter if len(h) == 0 { m.resetFiltering() break } m.FilterInput.Blur() m.filterState = FilterApplied m.updateKeybindings() if m.FilterInput.Value() == "" { m.resetFiltering() } } } // Update the filter text input component newFilterInputModel, inputCmd := m.FilterInput.Update(msg) filterChanged := m.FilterInput.Value() != newFilterInputModel.Value() m.FilterInput = newFilterInputModel cmds = append(cmds, inputCmd) // If the filtering input has changed, request updated filtering if filterChanged { cmds = append(cmds, filterItems(*m)) m.KeyMap.AcceptWhileFiltering.SetEnabled(m.FilterInput.Value() != "") } // Update pagination m.updatePagination() return tea.Batch(cmds...) } // ShortHelp returns bindings to show in the abbreviated help view. It's part // of the help.KeyMap interface. func (m Model) ShortHelp() []key.Binding { kb := []key.Binding{ m.KeyMap.CursorUp, m.KeyMap.CursorDown, } filtering := m.filterState == Filtering // If the delegate implements the help.KeyMap interface add the short help // items to the short help after the cursor movement keys. if !filtering { if b, ok := m.delegate.(help.KeyMap); ok { kb = append(kb, b.ShortHelp()...) } } kb = append(kb, m.KeyMap.Filter, m.KeyMap.ClearFilter, m.KeyMap.AcceptWhileFiltering, m.KeyMap.CancelWhileFiltering, ) if !filtering && m.AdditionalShortHelpKeys != nil { kb = append(kb, m.AdditionalShortHelpKeys()...) } return append(kb, m.KeyMap.Quit, m.KeyMap.ShowFullHelp, ) } // FullHelp returns bindings to show the full help view. It's part of the // help.KeyMap interface. func (m Model) FullHelp() [][]key.Binding { kb := [][]key.Binding{{ m.KeyMap.CursorUp, m.KeyMap.CursorDown, m.KeyMap.NextPage, m.KeyMap.PrevPage, m.KeyMap.GoToStart, m.KeyMap.GoToEnd, }} filtering := m.filterState == Filtering // If the delegate implements the help.KeyMap interface add full help // keybindings to a special section of the full help. if !filtering { if b, ok := m.delegate.(help.KeyMap); ok { kb = append(kb, b.FullHelp()...) } } listLevelBindings := []key.Binding{ m.KeyMap.Filter, m.KeyMap.ClearFilter, m.KeyMap.AcceptWhileFiltering, m.KeyMap.CancelWhileFiltering, } if !filtering && m.AdditionalFullHelpKeys != nil { listLevelBindings = append(listLevelBindings, m.AdditionalFullHelpKeys()...) } return append(kb, listLevelBindings, []key.Binding{ m.KeyMap.Quit, m.KeyMap.CloseFullHelp, }) } // View renders the component. func (m Model) View() string { var ( sections []string availHeight = m.height ) if m.showTitle || (m.showFilter && m.filteringEnabled) { v := m.titleView() sections = append(sections, v) availHeight -= lipgloss.Height(v) } if m.showStatusBar { v := m.statusView() sections = append(sections, v) availHeight -= lipgloss.Height(v) } var pagination string if m.showPagination { pagination = m.paginationView() availHeight -= lipgloss.Height(pagination) } var help string if m.showHelp { help = m.helpView() availHeight -= lipgloss.Height(help) } content := lipgloss.NewStyle().Height(availHeight).Render(m.populatedView()) sections = append(sections, content) if m.showPagination { sections = append(sections, pagination) } if m.showHelp { sections = append(sections, help) } return lipgloss.JoinVertical(lipgloss.Left, sections...) } func (m Model) titleView() string { var ( view string titleBarStyle = m.Styles.TitleBar.Copy() // We need to account for the size of the spinner, even if we don't // render it, to reserve some space for it should we turn it on later. spinnerView = m.spinnerView() spinnerWidth = lipgloss.Width(spinnerView) spinnerLeftGap = " " spinnerOnLeft = titleBarStyle.GetPaddingLeft() >= spinnerWidth+lipgloss.Width(spinnerLeftGap) && m.showSpinner ) // If the filter's showing, draw that. Otherwise draw the title. if m.showFilter && m.filterState == Filtering { view += m.FilterInput.View() } else if m.showTitle { if m.showSpinner && spinnerOnLeft { view += spinnerView + spinnerLeftGap titleBarGap := titleBarStyle.GetPaddingLeft() titleBarStyle = titleBarStyle.PaddingLeft(titleBarGap - spinnerWidth - lipgloss.Width(spinnerLeftGap)) } view += m.Styles.Title.Render(m.Title) // Status message if m.filterState != Filtering { view += " " + m.statusMessage view = truncate.StringWithTail(view, uint(m.width-spinnerWidth), ellipsis) } } // Spinner if m.showSpinner && !spinnerOnLeft { // Place spinner on the right availSpace := m.width - lipgloss.Width(m.Styles.TitleBar.Render(view)) if availSpace > spinnerWidth { view += strings.Repeat(" ", availSpace-spinnerWidth) view += spinnerView } } return titleBarStyle.Render(view) } func (m Model) statusView() string { var status string totalItems := len(m.items) visibleItems := len(m.VisibleItems()) plural := "" if visibleItems != 1 { plural = "s" } if m.filterState == Filtering { // Filter results if visibleItems == 0 { status = m.Styles.StatusEmpty.Render("Nothing matched") } else { status = fmt.Sprintf("%d item%s", visibleItems, plural) } } else if len(m.items) == 0 { // Not filtering: no items. status = m.Styles.StatusEmpty.Render("No items") } else { // Normal filtered := m.FilterState() == FilterApplied if filtered { f := strings.TrimSpace(m.FilterInput.Value()) f = truncate.StringWithTail(f, 10, "…") status += fmt.Sprintf("“%s” ", f) } status += fmt.Sprintf("%d item%s", visibleItems, plural) } numFiltered := totalItems - visibleItems if numFiltered > 0 { status += m.Styles.DividerDot.String() status += m.Styles.StatusBarFilterCount.Render(fmt.Sprintf("%d filtered", numFiltered)) } return m.Styles.StatusBar.Render(status) } func (m Model) paginationView() string { if m.Paginator.TotalPages < 2 { //nolint:gomnd return "" } s := m.Paginator.View() // If the dot pagination is wider than the width of the window // use the arabic paginator. if ansi.PrintableRuneWidth(s) > m.width { m.Paginator.Type = paginator.Arabic s = m.Styles.ArabicPagination.Render(m.Paginator.View()) } style := m.Styles.PaginationStyle if m.delegate.Spacing() == 0 && style.GetMarginTop() == 0 { style = style.Copy().MarginTop(1) } return style.Render(s) } func (m Model) populatedView() string { items := m.VisibleItems() var b strings.Builder // Empty states if len(items) == 0 { if m.filterState == Filtering { return "" } m.Styles.NoItems.Render("No items found.") } if len(items) > 0 { start, end := m.Paginator.GetSliceBounds(len(items)) docs := items[start:end] for i, item := range docs { m.delegate.Render(&b, m, i+start, item) if i != len(docs)-1 { fmt.Fprint(&b, strings.Repeat("\n", m.delegate.Spacing()+1)) } } } // If there aren't enough items to fill up this page (always the last page) // then we need to add some newlines to fill up the space where items would // have been. itemsOnPage := m.Paginator.ItemsOnPage(len(items)) if itemsOnPage < m.Paginator.PerPage { n := (m.Paginator.PerPage - itemsOnPage) * (m.delegate.Height() + m.delegate.Spacing()) if len(items) == 0 { n -= m.delegate.Height() - 1 } fmt.Fprint(&b, strings.Repeat("\n", n)) } return b.String() } func (m Model) helpView() string { return m.Styles.HelpStyle.Render(m.Help.View(m)) } func (m Model) spinnerView() string { return m.spinner.View() } func filterItems(m Model) tea.Cmd { return func() tea.Msg { if m.FilterInput.Value() == "" || m.filterState == Unfiltered { return filterMatchesMsg(m.itemsAsFilterItems()) // return nothing } targets := []string{} items := m.items for _, t := range items { targets = append(targets, t.FilterValue()) } var ranks fuzzy.Matches = fuzzy.Find(m.FilterInput.Value(), targets) sort.Stable(ranks) filterMatches := []filteredItem{} for _, r := range ranks { filterMatches = append(filterMatches, filteredItem{ item: items[r.Index], matches: r.MatchedIndexes, }) } return filterMatchesMsg(filterMatches) } } func insertItemIntoSlice(items []Item, item Item, index int) []Item { if items == nil { return []Item{item} } if index >= len(items) { return append(items, item) } index = max(0, index) items = append(items, nil) copy(items[index+1:], items[index:]) items[index] = item return items } // Remove an item from a slice of items at the given index. This runs in O(n). func removeItemFromSlice(i []Item, index int) []Item { if index >= len(i) { return i // noop } copy(i[index:], i[index+1:]) i[len(i)-1] = nil return i[:len(i)-1] } func removeFilterMatchFromSlice(i []filteredItem, index int) []filteredItem { if index >= len(i) { return i // noop } copy(i[index:], i[index+1:]) i[len(i)-1] = filteredItem{} return i[:len(i)-1] } func countEnabledBindings(groups [][]key.Binding) (agg int) { for _, group := range groups { for _, kb := range group { if kb.Enabled() { agg++ } } } return agg } func max(a, b int) int { if a > b { return a } return b } bubbles-0.9.0/list/style.go000066400000000000000000000054631411573001400156040ustar00rootroot00000000000000package list import ( "github.com/charmbracelet/lipgloss" ) const ( bullet = "•" ellipsis = "…" ) // Styles contains style definitions for this list component. By default, these // values are generated by DefaultStyles. type Styles struct { TitleBar lipgloss.Style Title lipgloss.Style Spinner lipgloss.Style FilterPrompt lipgloss.Style FilterCursor lipgloss.Style // Default styling for matched characters in a filter. This can be // overridden by delegates. DefaultFilterCharacterMatch lipgloss.Style StatusBar lipgloss.Style StatusEmpty lipgloss.Style StatusBarActiveFilter lipgloss.Style StatusBarFilterCount lipgloss.Style NoItems lipgloss.Style PaginationStyle lipgloss.Style HelpStyle lipgloss.Style // Styled characters. ActivePaginationDot lipgloss.Style InactivePaginationDot lipgloss.Style ArabicPagination lipgloss.Style DividerDot lipgloss.Style } // DefaultStyles returns a set of default style definitions for this list // component. func DefaultStyles() (s Styles) { verySubduedColor := lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"} subduedColor := lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"} s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 2) s.Title = lipgloss.NewStyle(). Background(lipgloss.Color("62")). Foreground(lipgloss.Color("230")). Padding(0, 1) s.Spinner = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"}) s.FilterPrompt = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#04B575", Dark: "#ECFD65"}) s.FilterCursor = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}) s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true) s.StatusBar = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"}). Padding(0, 0, 1, 2) s.StatusEmpty = lipgloss.NewStyle().Foreground(subduedColor) s.StatusBarActiveFilter = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#dddddd"}) s.StatusBarFilterCount = lipgloss.NewStyle().Foreground(verySubduedColor) s.NoItems = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#909090", Dark: "#626262"}) s.ArabicPagination = lipgloss.NewStyle().Foreground(subduedColor) s.PaginationStyle = lipgloss.NewStyle().PaddingLeft(2) //nolint:gomnd s.HelpStyle = lipgloss.NewStyle().Padding(1, 0, 0, 2) s.ActivePaginationDot = lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#847A85", Dark: "#979797"}). SetString(bullet) s.InactivePaginationDot = lipgloss.NewStyle(). Foreground(verySubduedColor). SetString(bullet) s.DividerDot = lipgloss.NewStyle(). Foreground(verySubduedColor). SetString(" " + bullet + " ") return s } bubbles-0.9.0/paginator/000077500000000000000000000000001411573001400151165ustar00rootroot00000000000000bubbles-0.9.0/paginator/paginator.go000066400000000000000000000104151411573001400174320ustar00rootroot00000000000000// Package paginator provides a Bubble Tea package for calulating pagination // and rendering pagination info. Note that this package does not render actual // pages: it's purely for handling keystrokes related to pagination, and // rendering pagination status. package paginator import ( "fmt" tea "github.com/charmbracelet/bubbletea" ) // Type specifies the way we render pagination. type Type int // Pagination rendering options. const ( Arabic Type = iota Dots ) // Model is the Bubble Tea model for this user interface. type Model struct { Type Type Page int PerPage int TotalPages int ActiveDot string InactiveDot string ArabicFormat string UsePgUpPgDownKeys bool UseLeftRightKeys bool UseUpDownKeys bool UseHLKeys bool UseJKKeys bool } // SetTotalPages is a helper function for calculating the total number of pages // from a given number of items. It's use is optional since this pager can be // used for other things beyond navigating sets. Note that it both returns the // number of total pages and alters the model. func (m *Model) SetTotalPages(items int) int { if items < 1 { return m.TotalPages } n := items / m.PerPage if items%m.PerPage > 0 { n++ } m.TotalPages = n return n } // ItemsOnPage is a helper function for returning the numer of items on the // current page given the total numer of items passed as an argument. func (m Model) ItemsOnPage(totalItems int) int { if totalItems < 1 { return 0 } start, end := m.GetSliceBounds(totalItems) return end - start } // GetSliceBounds is a helper function for paginating slices. Pass the length // of the slice you're rendering and you'll receive the start and end bounds // corresponding the to pagination. For example: // // bunchOfStuff := []stuff{...} // start, end := model.GetSliceBounds(len(bunchOfStuff)) // sliceToRender := bunchOfStuff[start:end] // func (m *Model) GetSliceBounds(length int) (start int, end int) { start = m.Page * m.PerPage end = min(m.Page*m.PerPage+m.PerPage, length) return start, end } // PrevPage is a number function for navigating one page backward. It will not // page beyond the first page (i.e. page 0). func (m *Model) PrevPage() { if m.Page > 0 { m.Page-- } } // NextPage is a helper function for navigating one page forward. It will not // page beyond the last page (i.e. totalPages - 1). func (m *Model) NextPage() { if !m.OnLastPage() { m.Page++ } } // OnLastPage returns whether or not we're on the last page. func (m Model) OnLastPage() bool { return m.Page == m.TotalPages-1 } // NewModel creates a new model with defaults. func NewModel() Model { return Model{ Type: Arabic, Page: 0, PerPage: 1, TotalPages: 1, ActiveDot: "•", InactiveDot: "○", ArabicFormat: "%d/%d", UsePgUpPgDownKeys: true, UseLeftRightKeys: true, UseUpDownKeys: false, UseHLKeys: true, UseJKKeys: false, } } // Update is the Tea update function which binds keystrokes to pagination. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if m.UsePgUpPgDownKeys { switch msg.String() { case "pgup": m.PrevPage() case "pgdown": m.NextPage() } } if m.UseLeftRightKeys { switch msg.String() { case "left": m.PrevPage() case "right": m.NextPage() } } if m.UseUpDownKeys { switch msg.String() { case "up": m.PrevPage() case "down": m.NextPage() } } if m.UseHLKeys { switch msg.String() { case "h": m.PrevPage() case "l": m.NextPage() } } if m.UseJKKeys { switch msg.String() { case "j": m.PrevPage() case "k": m.NextPage() } } } return m, nil } // View renders the pagination to a string. func (m Model) View() string { switch m.Type { case Dots: return m.dotsView() default: return m.arabicView() } } func (m Model) dotsView() string { var s string for i := 0; i < m.TotalPages; i++ { if i == m.Page { s += m.ActiveDot continue } s += m.InactiveDot } return s } func (m Model) arabicView() string { return fmt.Sprintf(m.ArabicFormat, m.Page+1, m.TotalPages) } func min(a, b int) int { if a < b { return a } return b } bubbles-0.9.0/progress/000077500000000000000000000000001411573001400147765ustar00rootroot00000000000000bubbles-0.9.0/progress/progress.go000066400000000000000000000212451411573001400171750ustar00rootroot00000000000000package progress import ( "fmt" "math" "strings" "sync" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/harmonica" "github.com/charmbracelet/lipgloss" "github.com/lucasb-eyer/go-colorful" "github.com/muesli/reflow/ansi" "github.com/muesli/termenv" ) // Internal ID management. Used during animating to assure that frame messages // can only be received by progress components that sent them. var ( lastID int idMtx sync.Mutex ) // Return the next ID we should use on the model. func nextID() int { idMtx.Lock() defer idMtx.Unlock() lastID++ return lastID } const ( fps = 60 defaultWidth = 40 defaultFrequency = 18.0 defaultDamping = 1.0 ) var color func(string) termenv.Color = termenv.ColorProfile().Color // Option is used to set options in NewModel. For example: // // progress := NewModel( // WithRamp("#ff0000", "#0000ff"), // WithoutPercentage(), // ) // type Option func(*Model) // WithDefaultGradient sets a gradient fill with default colors. func WithDefaultGradient() Option { return WithGradient("#5A56E0", "#EE6FF8") } // WithGradient sets a gradient fill blending between two colors. func WithGradient(colorA, colorB string) Option { return func(m *Model) { m.setRamp(colorA, colorB, false) } } // WithDefaultScaledGradient sets a gradient with default colors, and scales the // gradient to fit the filled portion of the ramp. func WithDefaultScaledGradient() Option { return WithScaledGradient("#5A56E0", "#EE6FF8") } // WithScaledGradient scales the gradient to fit the width of the filled portion of // the progress bar. func WithScaledGradient(colorA, colorB string) Option { return func(m *Model) { m.setRamp(colorA, colorB, true) } } // WithSolidFill sets the progress to use a solid fill with the given color. func WithSolidFill(color string) Option { return func(m *Model) { m.FullColor = color m.useRamp = false } } // WithoutPercentage hides the numeric percentage. func WithoutPercentage() Option { return func(m *Model) { m.ShowPercentage = false } } // WithWidth sets the initial width of the progress bar. Note that you can also // set the width via the Width property, which can come in handy if you're // waiting for a tea.WindowSizeMsg. func WithWidth(w int) Option { return func(m *Model) { m.Width = w } } func WithSpringOptions(frequency, damping float64) Option { return func(m *Model) { m.SetSpringOptions(frequency, damping) m.springCustomized = true } } // FrameMsg indicates that an animation step should occur. type FrameMsg struct { id int tag int } // Model stores values we'll use when rendering the progress bar. type Model struct { // An identifier to keep us from receiving messages intended for other // progress bars. id int // An identifier to keep us from receiving frame messages too quickly. tag int // Total width of the progress bar, including percentage, if set. Width int // "Filled" sections of the progress bar. Full rune FullColor string // "Empty" sections of progress bar. Empty rune EmptyColor string // Settings for rendering the numeric percentage. ShowPercentage bool PercentFormat string // a fmt string for a float PercentageStyle lipgloss.Style // Members for animated transitions. spring harmonica.Spring springCustomized bool percent float64 targetPercent float64 velocity float64 // Gradient settings useRamp bool rampColorA colorful.Color rampColorB colorful.Color // When true, we scale the gradient to fit the width of the filled section // of the progress bar. When false, the width of the gradient will be set // to the full width of the progress bar. scaleRamp bool } // NewModel returns a model with default values. func NewModel(opts ...Option) Model { m := Model{ id: nextID(), Width: defaultWidth, Full: '█', FullColor: "#7571F9", Empty: '░', EmptyColor: "#606060", ShowPercentage: true, PercentFormat: " %3.0f%%", } if !m.springCustomized { m.SetSpringOptions(defaultFrequency, defaultDamping) } for _, opt := range opts { opt(&m) } return m } // Init exists satisfy the tea.Model interface. func (m Model) Init() tea.Cmd { return nil } // Update is used to animation the progress bar during transitions. Use // SetPercent to create the command you'll need to trigger the animation. // // If you're rendering with ViewAs you won't need this. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case FrameMsg: if msg.id != m.id || msg.tag != m.tag { return m, nil } // If we've more or less reached equilibrium, stop updating. dist := math.Abs(m.percent - m.targetPercent) if dist < 0.001 && m.velocity < 0.01 { return m, nil } m.percent, m.velocity = m.spring.Update(m.percent, m.velocity, m.targetPercent) return m, m.nextFrame() default: return m, nil } } // SetSpringOptions sets the frequency and damping for the current spring. // Frequency corresponds to speed, and damping to bounciness. For details see: // https://github.com/charmbracelet/harmonica. func (m *Model) SetSpringOptions(frequency, damping float64) { m.spring = harmonica.NewSpring(harmonica.FPS(fps), frequency, damping) } // Percent returns the current percentage state of the model. This is only // relevant when you're animating the progress bar. // // If you're rendering with ViewAs you won't need this. func (m Model) Percent() float64 { return m.targetPercent } // SetPercent sets the percentage state of the model as well as a command // necessary for animating the progress bar to this new percentage. // // If you're rendering with ViewAs you won't need this. func (m *Model) SetPercent(p float64) tea.Cmd { m.targetPercent = math.Max(0, math.Min(1, p)) m.tag++ return m.nextFrame() } // IncrPercent increments the percentage by a given amount, returning a command // necessary to animate the progress bar to the new percentage. // // If you're rendering with ViewAs you won't need this. func (m *Model) IncrPercent(v float64) tea.Cmd { return m.SetPercent(m.Percent() + v) } // DecrPercent decrements the percentage by a given amount, returning a command // necessary to animate the progress bar to the new percentage. // // If you're rendering with ViewAs you won't need this. func (m *Model) DecrPercent(v float64) tea.Cmd { return m.SetPercent(m.Percent() - v) } // View renders the an animated progress bar in its current state. To render // a static progress bar based on your own calculations use ViewAs instead. func (m Model) View() string { return m.ViewAs(m.percent) } // ViewAs renders the progress bar with a given percentage. func (m Model) ViewAs(percent float64) string { b := strings.Builder{} percentView := m.percentageView(percent) m.barView(&b, percent, ansi.PrintableRuneWidth(percentView)) b.WriteString(percentView) return b.String() } func (m *Model) nextFrame() tea.Cmd { return tea.Tick(time.Second/time.Duration(fps), func(time.Time) tea.Msg { return FrameMsg{id: m.id, tag: m.tag} }) } func (m Model) barView(b *strings.Builder, percent float64, textWidth int) { var ( tw = max(0, m.Width-textWidth) // total width fw = int(math.Round((float64(tw) * percent))) // filled width p float64 ) fw = max(0, min(tw, fw)) if m.useRamp { // Gradient fill for i := 0; i < fw; i++ { if m.scaleRamp { p = float64(i) / float64(fw) } else { p = float64(i) / float64(tw) } c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex() b.WriteString(termenv. String(string(m.Full)). Foreground(color(c)). String(), ) } } else { // Solid fill s := termenv.String(string(m.Full)).Foreground(color(m.FullColor)).String() b.WriteString(strings.Repeat(s, fw)) } // Empty fill e := termenv.String(string(m.Empty)).Foreground(color(m.EmptyColor)).String() n := max(0, tw-fw) b.WriteString(strings.Repeat(e, n)) } func (m Model) percentageView(percent float64) string { if !m.ShowPercentage { return "" } percent = math.Max(0, math.Min(1, percent)) percentage := fmt.Sprintf(m.PercentFormat, percent*100) //nolint:gomnd percentage = m.PercentageStyle.Inline(true).Render(percentage) return percentage } func (m *Model) setRamp(colorA, colorB string, scaled bool) { // In the event of an error colors here will default to black. For // usability's sake, and because such an error is only cosmetic, we're // ignoring the error for sake of usability. a, _ := colorful.Hex(colorA) b, _ := colorful.Hex(colorB) m.useRamp = true m.scaleRamp = scaled m.rampColorA = a m.rampColorB = b } func max(a, b int) int { if a > b { return a } return b } func min(a, b int) int { if a < b { return a } return b } bubbles-0.9.0/spinner/000077500000000000000000000000001411573001400146105ustar00rootroot00000000000000bubbles-0.9.0/spinner/spinner.go000066400000000000000000000157751411573001400166340ustar00rootroot00000000000000package spinner import ( "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/reflow/ansi" ) // Spinner is a set of frames used in animating the spinner. type Spinner struct { Frames []string FPS time.Duration } // Some spinners to choose from. You could also make your own. var ( Line = Spinner{ Frames: []string{"|", "/", "-", "\\"}, FPS: time.Second / 10, //nolint:gomnd } Dot = Spinner{ Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "}, FPS: time.Second / 10, //nolint:gomnd } MiniDot = Spinner{ Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, FPS: time.Second / 12, //nolint:gomnd } Jump = Spinner{ Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"}, FPS: time.Second / 10, //nolint:gomnd } Pulse = Spinner{ Frames: []string{"█", "▓", "▒", "░"}, FPS: time.Second / 8, //nolint:gomnd } Points = Spinner{ Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"}, FPS: time.Second / 7, //nolint:gomnd } Globe = Spinner{ Frames: []string{"🌍", "🌎", "🌏"}, FPS: time.Second / 4, //nolint:gomnd } Moon = Spinner{ Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"}, FPS: time.Second / 8, //nolint:gomnd } Monkey = Spinner{ Frames: []string{"🙈", "🙉", "🙊"}, FPS: time.Second / 3, //nolint:gomnd } ) // Model contains the state for the spinner. Use NewModel to create new models // rather than using Model as a struct literal. type Model struct { // Spinner settings to use. See type Spinner. Spinner Spinner // Style sets the styling for the spinner. Most of the time you'll just // want foreground and background coloring, and potentially some padding. // // For an introduction to styling with Lip Gloss see: // https://github.com/charmbracelet/lipgloss Style lipgloss.Style // MinimumLifetime is the minimum amount of time the spinner can run. Any // logic around this can be implemented in view that implements this // spinner. If HideFor is set MinimumLifetime will be added on top of // HideFor. In other words, if HideFor is 100ms and MinimumLifetime is // 200ms then MinimumLifetime will expire after 300ms. // // MinimumLifetime is optional. // // This is considered experimental and may not appear in future versions of // this library. MinimumLifetime time.Duration // HideFor can be used to wait to show the spinner until a certain amount // of time has passed. This can be useful for preventing flicking when load // times are very fast. // Optional. // // This is considered experimental and may not appear in future versions of // this library. HideFor time.Duration frame int startTime time.Time tag int } // Start resets resets the spinner start time. For use with MinimumLifetime and // MinimumStartTime. Optional. // // This function is optional and generally considered for advanced use only. // Most of the time your application logic will obviate the need for this // method. // // This is considered experimental and may not appear in future versions of // this library. func (m *Model) Start() { m.startTime = time.Now() } // Finish sets the internal timer to a completed state so long as the spinner // isn't flagged to be showing. If it is showing, finish has no effect. The // idea here is that you call Finish if your operation has completed and, if // the spinner isn't showing yet (by virtue of HideFor) then Visible() doesn't // show the spinner at all. // // This is intended to work in conjunction with MinimumLifetime and // MinimumStartTime, is completely optional. // // This function is optional and generally considered for advanced use only. // Most of the time your application logic will obviate the need for this // method. // // This is considered experimental and may not appear in future versions of // this library. func (m *Model) Finish() { if m.hidden() { m.startTime = time.Time{} } } // advancedMode returns whether or not the user is making use of HideFor and // MinimumLifetime properties. func (m Model) advancedMode() bool { return m.HideFor > 0 && m.MinimumLifetime > 0 } // hidden returns whether or not Model.HideFor is in effect. func (m Model) hidden() bool { if m.startTime.IsZero() { return false } if m.HideFor == 0 { return false } return m.startTime.Add(m.HideFor).After(time.Now()) } // finished returns whether the minimum lifetime of this spinner has been // exceeded. func (m Model) finished() bool { if m.startTime.IsZero() || m.MinimumLifetime == 0 { return true } return m.startTime.Add(m.HideFor).Add(m.MinimumLifetime).Before(time.Now()) } // Visible returns whether or not the view should be rendered. Works in // conjunction with Model.HideFor and Model.MinimumLifetimeReached. You should // use this method directly to determine whether or not to render this view in // the parent view and whether to continue sending spin messaging in the // parent update function. // // This function is optional and generally considered for advanced use only. // Most of the time your application logic will obviate the need for this // method. // // This is considered experimental and may not appear in future versions of // this library. func (m Model) Visible() bool { return !m.hidden() && !m.finished() } // NewModel returns a model with default values. func NewModel() Model { return Model{Spinner: Line} } // TickMsg indicates that the timer has ticked and we should render a frame. type TickMsg struct { Time time.Time tag int } // Update is the Tea update function. This will advance the spinner one frame // every time it's called, regardless the message passed, so be sure the logic // is setup so as not to call this Update needlessly. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case TickMsg: // If a tag is set, and it's not the one we expect, reject the message. // This prevents the spinner from receiving too many messages and // thus spinning too fast. if msg.tag > 0 && msg.tag != m.tag { return m, nil } m.frame++ if m.frame >= len(m.Spinner.Frames) { m.frame = 0 } m.tag++ return m, m.tick(m.tag) default: return m, nil } } // View renders the model's view. func (m Model) View() string { if m.frame >= len(m.Spinner.Frames) { return "(error)" } frame := m.Spinner.Frames[m.frame] // If we're using the fine-grained hide/show spinner rules and those rules // deem that the spinner should be hidden, draw an empty space in place of // the spinner. if m.advancedMode() && !m.Visible() { frame = strings.Repeat(" ", ansi.PrintableRuneWidth(frame)) } return m.Style.Render(frame) } // Tick is the command used to advance the spinner one frame. Use this command // to effectively start the spinner. func Tick() tea.Msg { return TickMsg{Time: time.Now()} } func (m Model) tick(tag int) tea.Cmd { return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg { return TickMsg{ Time: t, tag: tag, } }) } bubbles-0.9.0/textinput/000077500000000000000000000000001411573001400151765ustar00rootroot00000000000000bubbles-0.9.0/textinput/textinput.go000066400000000000000000000443521411573001400176010ustar00rootroot00000000000000package textinput import ( "context" "strings" "sync" "time" "unicode" "github.com/atotto/clipboard" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" rw "github.com/mattn/go-runewidth" ) const defaultBlinkSpeed = time.Millisecond * 530 // Internal ID management for text inputs. Necessary for blink integrity when // multiple text inputs are involved. var ( lastID int idMtx sync.Mutex ) // Return the next ID we should use on the Model. func nextID() int { idMtx.Lock() defer idMtx.Unlock() lastID++ return lastID } // initialBlinkMsg initializes cursor blinking. type initialBlinkMsg struct{} // blinkMsg signals that the cursor should blink. It contains metadata that // allows us to tell if the blink message is the one we're expecting. type blinkMsg struct { id int tag int } // blinkCanceled is sent when a blink operation is canceled. type blinkCanceled struct{} // Internal messages for clipboard operations. type pasteMsg string type pasteErrMsg struct{ error } // EchoMode sets the input behavior of the text input field. type EchoMode int const ( // EchoNormal displays text as is. This is the default behavior. EchoNormal EchoMode = iota // EchoPassword displays the EchoCharacter mask instead of actual // characters. This is commonly used for password fields. EchoPassword // EchoNone displays nothing as characters are entered. This is commonly // seen for password fields on the command line. EchoNone // EchoOnEdit ) // blinkCtx manages cursor blinking. type blinkCtx struct { ctx context.Context cancel context.CancelFunc } // CursorMode describes the behavior of the cursor. type CursorMode int // Available cursor modes. const ( CursorBlink CursorMode = iota CursorStatic CursorHide ) // String returns a the cursor mode in a human-readable format. This method is // provisional and for informational purposes only. func (c CursorMode) String() string { return [...]string{ "blink", "static", "hidden", }[c] } // Model is the Bubble Tea model for this text input element. type Model struct { Err error // General settings. Prompt string Placeholder string BlinkSpeed time.Duration EchoMode EchoMode EchoCharacter rune // Styles. These will be applied as inline styles. // // For an introduction to styling with Lip Gloss see: // https://github.com/charmbracelet/lipgloss PromptStyle lipgloss.Style TextStyle lipgloss.Style BackgroundStyle lipgloss.Style PlaceholderStyle lipgloss.Style CursorStyle lipgloss.Style // CharLimit is the maximum amount of characters this input element will // accept. If 0 or less, there's no limit. CharLimit int // Width is the maximum number of characters that can be displayed at once. // It essentially treats the text field like a horizontally scrolling // viewport. If 0 or less this setting is ignored. Width int // The ID of this Model as it relates to other textinput Models. id int // The ID of the blink message we're expecting to receive. blinkTag int // Underlying text value. value []rune // focus indicates whether user input focus should be on this input // component. When false, ignore keyboard input and hide the cursor. focus bool // Cursor blink state. blink bool // Cursor position. pos int // Used to emulate a viewport when width is set and the content is // overflowing. offset int offsetRight int // Used to manage cursor blink blinkCtx *blinkCtx // cursorMode determines the behavior of the cursor cursorMode CursorMode } // NewModel creates a new model with default settings. func NewModel() Model { return Model{ Prompt: "> ", BlinkSpeed: defaultBlinkSpeed, EchoCharacter: '*', CharLimit: 0, PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), id: nextID(), value: nil, focus: false, blink: true, pos: 0, cursorMode: CursorBlink, blinkCtx: &blinkCtx{ ctx: context.Background(), }, } } // SetValue sets the value of the text input. func (m *Model) SetValue(s string) { runes := []rune(s) if m.CharLimit > 0 && len(runes) > m.CharLimit { m.value = runes[:m.CharLimit] } else { m.value = runes } if m.pos == 0 || m.pos > len(m.value) { m.setCursor(len(m.value)) } m.handleOverflow() } // Value returns the value of the text input. func (m Model) Value() string { return string(m.value) } // Cursor returns the cursor position. func (m Model) Cursor() int { return m.pos } // SetCursor moves the cursor to the given position. If the position is // out of bounds the cursor will be moved to the start or end accordingly. func (m *Model) SetCursor(pos int) { m.setCursor(pos) } // setCursor moves the cursor to the given position and returns whether or not // the cursor blink should be reset. If the position is out of bounds the // cursor will be moved to the start or end accordingly. func (m *Model) setCursor(pos int) bool { m.pos = clamp(pos, 0, len(m.value)) m.handleOverflow() // Show the cursor unless it's been explicitly hidden m.blink = m.cursorMode == CursorHide // Reset cursor blink if necessary return m.cursorMode == CursorBlink } // CursorStart moves the cursor to the start of the input field. func (m *Model) CursorStart() { m.cursorStart() } // cursorStart moves the cursor to the start of the input field and returns // whether or not the curosr blink should be reset. func (m *Model) cursorStart() bool { return m.setCursor(0) } // CursorEnd moves the cursor to the end of the input field func (m *Model) CursorEnd() { m.cursorEnd() } // CursorMode returns the model's cursor mode. For available cursor modes, see // type CursorMode. func (m Model) CursorMode() CursorMode { return m.cursorMode } // CursorMode sets the model's cursor mode. This method returns a command. // // For available cursor modes, see type CursorMode. func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd { m.cursorMode = mode m.blink = m.cursorMode == CursorHide || !m.focus if mode == CursorBlink { return Blink } return nil } // cursorEnd moves the cursor to the end of the input field and returns whether // the cursor should blink should reset. func (m *Model) cursorEnd() bool { return m.setCursor(len(m.value)) } // Focused returns the focus state on the model. func (m Model) Focused() bool { return m.focus } // Focus sets the focus state on the model. When the model is in focus it can // receive keyboard input and the cursor will be hidden. func (m *Model) Focus() tea.Cmd { m.focus = true m.blink = m.cursorMode == CursorHide // show the cursor unless we've explicitly hidden it if m.cursorMode == CursorBlink && m.focus { return m.blinkCmd() } return nil } // Blur removes the focus state on the model. When the model is blurred it can // not receive keyboard input and the cursor will be hidden. func (m *Model) Blur() { m.focus = false m.blink = true } // Reset sets the input to its default state with no input. Returns whether // or not the cursor blink should reset. func (m *Model) Reset() bool { m.value = nil return m.setCursor(0) } // handle a clipboard paste event, if supported. Returns whether or not the // cursor blink should reset. func (m *Model) handlePaste(v string) bool { paste := []rune(v) var availSpace int if m.CharLimit > 0 { availSpace = m.CharLimit - len(m.value) } // If the char limit's been reached cancel if m.CharLimit > 0 && availSpace <= 0 { return false } // If there's not enough space to paste the whole thing cut the pasted // runes down so they'll fit if m.CharLimit > 0 && availSpace < len(paste) { paste = paste[:len(paste)-availSpace] } // Stuff before and after the cursor head := m.value[:m.pos] tailSrc := m.value[m.pos:] tail := make([]rune, len(tailSrc)) copy(tail, tailSrc) // Insert pasted runes for _, r := range paste { head = append(head, r) m.pos++ if m.CharLimit > 0 { availSpace-- if availSpace <= 0 { break } } } // Put it all back together m.value = append(head, tail...) // Reset blink state if necessary and run overflow checks return m.setCursor(m.pos) } // If a max width is defined, perform some logic to treat the visible area // as a horizontally scrolling viewport. func (m *Model) handleOverflow() { if m.Width <= 0 || rw.StringWidth(string(m.value)) <= m.Width { m.offset = 0 m.offsetRight = len(m.value) return } // Correct right offset if we've deleted characters m.offsetRight = min(m.offsetRight, len(m.value)) if m.pos < m.offset { m.offset = m.pos w := 0 i := 0 runes := m.value[m.offset:] for i < len(runes) && w <= m.Width { w += rw.RuneWidth(runes[i]) if w <= m.Width+1 { i++ } } m.offsetRight = m.offset + i } else if m.pos >= m.offsetRight { m.offsetRight = m.pos w := 0 runes := m.value[:m.offsetRight] i := len(runes) - 1 for i > 0 && w < m.Width { w += rw.RuneWidth(runes[i]) if w <= m.Width { i-- } } m.offset = m.offsetRight - (len(runes) - 1 - i) } } // deleteBeforeCursor deletes all text before the cursor. Returns whether or // not the cursor blink should be reset. func (m *Model) deleteBeforeCursor() bool { m.value = m.value[m.pos:] m.offset = 0 return m.setCursor(0) } // deleteAfterCursor deletes all text after the cursor. Returns whether or not // the cursor blink should be reset. If input is masked delete everything after // the cursor so as not to reveal word breaks in the masked input. func (m *Model) deleteAfterCursor() bool { m.value = m.value[:m.pos] return m.setCursor(len(m.value)) } // deleteWordLeft deletes the word left to the cursor. Returns whether or not // the cursor blink should be reset. func (m *Model) deleteWordLeft() bool { if m.pos == 0 || len(m.value) == 0 { return false } if m.EchoMode != EchoNormal { return m.deleteBeforeCursor() } i := m.pos blink := m.setCursor(m.pos - 1) for unicode.IsSpace(m.value[m.pos]) { // ignore series of whitespace before cursor blink = m.setCursor(m.pos - 1) } for m.pos > 0 { if !unicode.IsSpace(m.value[m.pos]) { blink = m.setCursor(m.pos - 1) } else { if m.pos > 0 { // keep the previous space blink = m.setCursor(m.pos + 1) } break } } if i > len(m.value) { m.value = m.value[:m.pos] } else { m.value = append(m.value[:m.pos], m.value[i:]...) } return blink } // deleteWordRight deletes the word right to the cursor. Returns whether or not // the cursor blink should be reset. If input is masked delete everything after // the cursor so as not to reveal word breaks in the masked input. func (m *Model) deleteWordRight() bool { if m.pos >= len(m.value) || len(m.value) == 0 { return false } if m.EchoMode != EchoNormal { return m.deleteAfterCursor() } i := m.pos m.setCursor(m.pos + 1) for unicode.IsSpace(m.value[m.pos]) { // ignore series of whitespace after cursor m.setCursor(m.pos + 1) } for m.pos < len(m.value) { if !unicode.IsSpace(m.value[m.pos]) { m.setCursor(m.pos + 1) } else { break } } if m.pos > len(m.value) { m.value = m.value[:i] } else { m.value = append(m.value[:i], m.value[m.pos:]...) } return m.setCursor(i) } // wordLeft moves the cursor one word to the left. Returns whether or not the // cursor blink should be reset. If input is masked, move input to the start // so as not to reveal word breaks in the masked input. func (m *Model) wordLeft() bool { if m.pos == 0 || len(m.value) == 0 { return false } if m.EchoMode != EchoNormal { return m.cursorStart() } blink := false i := m.pos - 1 for i >= 0 { if unicode.IsSpace(m.value[i]) { blink = m.setCursor(m.pos - 1) i-- } else { break } } for i >= 0 { if !unicode.IsSpace(m.value[i]) { blink = m.setCursor(m.pos - 1) i-- } else { break } } return blink } // wordRight moves the cursor one word to the right. Returns whether or not the // cursor blink should be reset. If the input is masked, move input to the end // so as not to reveal word breaks in the masked input. func (m *Model) wordRight() bool { if m.pos >= len(m.value) || len(m.value) == 0 { return false } if m.EchoMode != EchoNormal { return m.cursorEnd() } blink := false i := m.pos for i < len(m.value) { if unicode.IsSpace(m.value[i]) { blink = m.setCursor(m.pos + 1) i++ } else { break } } for i < len(m.value) { if !unicode.IsSpace(m.value[i]) { blink = m.setCursor(m.pos + 1) i++ } else { break } } return blink } func (m Model) echoTransform(v string) string { switch m.EchoMode { case EchoPassword: return strings.Repeat(string(m.EchoCharacter), rw.StringWidth(v)) case EchoNone: return "" default: return v } } // Update is the Bubble Tea update loop. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if !m.focus { m.blink = true return m, nil } var resetBlink bool switch msg := msg.(type) { case tea.KeyMsg: switch msg.Type { case tea.KeyBackspace: // delete character before cursor if msg.Alt { resetBlink = m.deleteWordLeft() } else { if len(m.value) > 0 { m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...) if m.pos > 0 { resetBlink = m.setCursor(m.pos - 1) } } } case tea.KeyLeft, tea.KeyCtrlB: if msg.Alt { // alt+left arrow, back one word resetBlink = m.wordLeft() break } if m.pos > 0 { // left arrow, ^F, back one character resetBlink = m.setCursor(m.pos - 1) } case tea.KeyRight, tea.KeyCtrlF: if msg.Alt { // alt+right arrow, forward one word resetBlink = m.wordRight() break } if m.pos < len(m.value) { // right arrow, ^F, forward one character resetBlink = m.setCursor(m.pos + 1) } case tea.KeyCtrlW: // ^W, delete word left of cursor resetBlink = m.deleteWordLeft() case tea.KeyHome, tea.KeyCtrlA: // ^A, go to beginning resetBlink = m.cursorStart() case tea.KeyDelete, tea.KeyCtrlD: // ^D, delete char under cursor if len(m.value) > 0 && m.pos < len(m.value) { m.value = append(m.value[:m.pos], m.value[m.pos+1:]...) } case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end resetBlink = m.cursorEnd() case tea.KeyCtrlK: // ^K, kill text after cursor resetBlink = m.deleteAfterCursor() case tea.KeyCtrlU: // ^U, kill text before cursor resetBlink = m.deleteBeforeCursor() case tea.KeyCtrlV: // ^V paste return m, Paste case tea.KeyRunes: // input regular characters if msg.Alt && len(msg.Runes) == 1 { if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor resetBlink = m.deleteWordRight() break } if msg.Runes[0] == 'b' { // alt+b, back one word resetBlink = m.wordLeft() break } if msg.Runes[0] == 'f' { // alt+f, forward one word resetBlink = m.wordRight() break } } // Input a regular character if m.CharLimit <= 0 || len(m.value) < m.CharLimit { m.value = append(m.value[:m.pos], append(msg.Runes, m.value[m.pos:]...)...) resetBlink = m.setCursor(m.pos + len(msg.Runes)) } } case initialBlinkMsg: // We accept all initialBlinkMsgs genrated by the Blink command. if m.cursorMode != CursorBlink || !m.focus { return m, nil } cmd := m.blinkCmd() return m, cmd case blinkMsg: // We're choosy about whether to accept blinkMsgs so that our cursor // only exactly when it should. // Is this model blinkable? if m.cursorMode != CursorBlink || !m.focus { return m, nil } // Were we expecting this blink message? if msg.id != m.id || msg.tag != m.blinkTag { return m, nil } var cmd tea.Cmd if m.cursorMode == CursorBlink { m.blink = !m.blink cmd = m.blinkCmd() } return m, cmd case blinkCanceled: // no-op return m, nil case pasteMsg: resetBlink = m.handlePaste(string(msg)) case pasteErrMsg: m.Err = msg } var cmd tea.Cmd if resetBlink { cmd = m.blinkCmd() } m.handleOverflow() return m, cmd } // View renders the textinput in its current state. func (m Model) View() string { // Placeholder text if len(m.value) == 0 && m.Placeholder != "" { return m.placeholderView() } styleText := m.TextStyle.Inline(true).Render value := m.value[m.offset:m.offsetRight] pos := max(0, m.pos-m.offset) v := styleText(m.echoTransform(string(value[:pos]))) if pos < len(value) { v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor } else { v += m.cursorView(" ") } // If a max width and background color were set fill the empty spaces with // the background color. valWidth := rw.StringWidth(string(value)) if m.Width > 0 && valWidth <= m.Width { padding := max(0, m.Width-valWidth) if valWidth+padding <= m.Width && pos < len(value) { padding++ } v += styleText(strings.Repeat(" ", padding)) } return m.PromptStyle.Render(m.Prompt) + v } // placeholderView returns the prompt and placeholder view, if any. func (m Model) placeholderView() string { var ( v string p = m.Placeholder style = m.PlaceholderStyle.Inline(true).Render ) // Cursor if m.blink { v += m.cursorView(style(p[:1])) } else { v += m.cursorView(p[:1]) } // The rest of the placeholder text v += style(p[1:]) return m.PromptStyle.Render(m.Prompt) + v } // cursorView styles the cursor. func (m Model) cursorView(v string) string { if m.blink { return m.TextStyle.Render(v) } return m.CursorStyle.Inline(true).Reverse(true).Render(v) } // blinkCmd is an internal command used to manage cursor blinking. func (m *Model) blinkCmd() tea.Cmd { if m.cursorMode != CursorBlink { return nil } if m.blinkCtx != nil && m.blinkCtx.cancel != nil { m.blinkCtx.cancel() } ctx, cancel := context.WithTimeout(m.blinkCtx.ctx, m.BlinkSpeed) m.blinkCtx.cancel = cancel m.blinkTag++ return func() tea.Msg { defer cancel() <-ctx.Done() if ctx.Err() == context.DeadlineExceeded { return blinkMsg{id: m.id, tag: m.blinkTag} } return blinkCanceled{} } } // Blink is a command used to initialize cursor blinking. func Blink() tea.Msg { return initialBlinkMsg{} } // Paste is a command for pasting from the clipboard into the text input. func Paste() tea.Msg { str, err := clipboard.ReadAll() if err != nil { return pasteErrMsg{err} } return pasteMsg(str) } func clamp(v, low, high int) int { return min(high, max(low, v)) } func min(a, b int) int { if a < b { return a } return b } func max(a, b int) int { if a > b { return a } return b } bubbles-0.9.0/viewport/000077500000000000000000000000001411573001400150115ustar00rootroot00000000000000bubbles-0.9.0/viewport/viewport.go000066400000000000000000000220461411573001400172230ustar00rootroot00000000000000package viewport import ( "math" "strings" tea "github.com/charmbracelet/bubbletea" ) const ( spacebar = " " mouseWheelDelta = 3 ) // Model is the Bubble Tea model for this viewport element. type Model struct { Width int Height int // YOffset is the vertical scroll position. YOffset int // YPosition is the position of the viewport in relation to the terminal // window. It's used in high performance rendering. YPosition int // HighPerformanceRendering bypasses the normal Bubble Tea renderer to // provide higher performance rendering. Most of the time the normal Bubble // Tea rendering methods will suffice, but if you're passing content with // a lot of ANSI escape codes you may see improved rendering in certain // terminals with this enabled. // // This should only be used in program occupying the entire terminal, // which is usually via the alternate screen buffer. HighPerformanceRendering bool lines []string } // AtTop returns whether or not the viewport is in the very top position. func (m Model) AtTop() bool { return m.YOffset <= 0 } // AtBottom returns whether or not the viewport is at or past the very bottom // position. func (m Model) AtBottom() bool { return m.YOffset >= len(m.lines)-1-m.Height } // PastBottom returns whether or not the viewport is scrolled beyond the last // line. This can happen when adjusting the viewport height. func (m Model) PastBottom() bool { return m.YOffset > len(m.lines)-1-m.Height } // ScrollPercent returns the amount scrolled as a float between 0 and 1. func (m Model) ScrollPercent() float64 { if m.Height >= len(m.lines) { return 1.0 } y := float64(m.YOffset) h := float64(m.Height) t := float64(len(m.lines) - 1) v := y / (t - h) return math.Max(0.0, math.Min(1.0, v)) } // SetContent set the pager's text content. For high performance rendering the // Sync command should also be called. func (m *Model) SetContent(s string) { s = strings.Replace(s, "\r\n", "\n", -1) // normalize line endings m.lines = strings.Split(s, "\n") if m.YOffset > len(m.lines)-1 { m.GotoBottom() } } // Return the lines that should currently be visible in the viewport. func (m Model) visibleLines() (lines []string) { if len(m.lines) > 0 { top := max(0, m.YOffset) bottom := clamp(m.YOffset+m.Height, top, len(m.lines)) lines = m.lines[top:bottom] } return lines } // ViewDown moves the view down by the number of lines in the viewport. // Basically, "page down". func (m *Model) ViewDown() []string { if m.AtBottom() { return nil } m.YOffset = min( m.YOffset+m.Height, // target len(m.lines)-1-m.Height, // fallback ) return m.visibleLines() } // ViewUp moves the view up by one height of the viewport. Basically, "page up". func (m *Model) ViewUp() []string { if m.AtTop() { return nil } m.YOffset = max( m.YOffset-m.Height, // target 0, // fallback ) return m.visibleLines() } // HalfViewDown moves the view down by half the height of the viewport. func (m *Model) HalfViewDown() (lines []string) { if m.AtBottom() { return nil } m.YOffset = min( m.YOffset+m.Height/2, // target len(m.lines)-1-m.Height, // fallback ) if len(m.lines) > 0 { top := max(m.YOffset+m.Height/2, 0) bottom := clamp(m.YOffset+m.Height, top, len(m.lines)-1) lines = m.lines[top:bottom] } return lines } // HalfViewUp moves the view up by half the height of the viewport. func (m *Model) HalfViewUp() (lines []string) { if m.AtTop() { return nil } m.YOffset = max( m.YOffset-m.Height/2, // target 0, // fallback ) if len(m.lines) > 0 { top := max(m.YOffset, 0) bottom := clamp(m.YOffset+m.Height/2, top, len(m.lines)-1) lines = m.lines[top:bottom] } return lines } // LineDown moves the view down by the given number of lines. func (m *Model) LineDown(n int) (lines []string) { if m.AtBottom() || n == 0 { return nil } // Make sure the number of lines by which we're going to scroll isn't // greater than the number of lines we actually have left before we reach // the bottom. maxDelta := (len(m.lines) - 1) - (m.YOffset + m.Height) // number of lines - viewport bottom edge n = min(n, maxDelta) m.YOffset = min( m.YOffset+n, // target len(m.lines)-1-m.Height, // fallback ) if len(m.lines) > 0 { top := max(m.YOffset+m.Height-n, 0) bottom := clamp(m.YOffset+m.Height, top, len(m.lines)-1) lines = m.lines[top:bottom] } return lines } // LineUp moves the view down by the given number of lines. Returns the new // lines to show. func (m *Model) LineUp(n int) (lines []string) { if m.AtTop() || n == 0 { return nil } // Make sure the number of lines by which we're going to scroll isn't // greater than the number of lines we are from the top. n = min(n, m.YOffset) m.YOffset = max(m.YOffset-n, 0) if len(m.lines) > 0 { top := max(0, m.YOffset) bottom := clamp(m.YOffset+n, top, len(m.lines)-1) lines = m.lines[top:bottom] } return lines } // GotoTop sets the viewport to the top position. func (m *Model) GotoTop() (lines []string) { if m.AtTop() { return nil } m.YOffset = 0 if len(m.lines) > 0 { top := m.YOffset bottom := clamp(m.YOffset+m.Height, top, len(m.lines)-1) lines = m.lines[top:bottom] } return lines } // GotoBottom sets the viewport to the bottom position. func (m *Model) GotoBottom() (lines []string) { m.YOffset = max(len(m.lines)-1-m.Height, 0) if len(m.lines) > 0 { top := m.YOffset bottom := max(len(m.lines)-1, 0) lines = m.lines[top:bottom] } return lines } // COMMANDS // Sync tells the renderer where the viewport will be located and requests // a render of the current state of the viewport. It should be called for the // first render and after a window resize. // // For high performance rendering only. func Sync(m Model) tea.Cmd { if len(m.lines) == 0 { return nil } // TODO: we should probably use m.visibleLines() rather than these two // expressions. top := max(m.YOffset, 0) bottom := clamp(m.YOffset+m.Height, 0, len(m.lines)-1) return tea.SyncScrollArea( m.lines[top:bottom], m.YPosition, m.YPosition+m.Height, ) } // ViewDown is a high performance command that moves the viewport up by a given // numer of lines. Use Model.ViewDown to get the lines that should be rendered. // For example: // // lines := model.ViewDown(1) // cmd := ViewDown(m, lines) // func ViewDown(m Model, lines []string) tea.Cmd { if len(lines) == 0 { return nil } return tea.ScrollDown(lines, m.YPosition, m.YPosition+m.Height) } // ViewUp is a high performance command the moves the viewport down by a given // number of lines height. Use Model.ViewUp to get the lines that should be // rendered. func ViewUp(m Model, lines []string) tea.Cmd { if len(lines) == 0 { return nil } return tea.ScrollUp(lines, m.YPosition, m.YPosition+m.Height) } // UPDATE // Update runs the update loop with default keybindings similar to popular // pagers. To define your own keybindings use the methods on Model (i.e. // Model.LineDown()) and define your own update function. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { // Down one page case "pgdown", spacebar, "f": lines := m.ViewDown() if m.HighPerformanceRendering { cmd = ViewDown(m, lines) } // Up one page case "pgup", "b": lines := m.ViewUp() if m.HighPerformanceRendering { cmd = ViewUp(m, lines) } // Down half page case "d", "ctrl+d": lines := m.HalfViewDown() if m.HighPerformanceRendering { cmd = ViewDown(m, lines) } // Up half page case "u", "ctrl+u": lines := m.HalfViewUp() if m.HighPerformanceRendering { cmd = ViewUp(m, lines) } // Down one line case "down", "j": lines := m.LineDown(1) if m.HighPerformanceRendering { cmd = ViewDown(m, lines) } // Up one line case "up", "k": lines := m.LineUp(1) if m.HighPerformanceRendering { cmd = ViewUp(m, lines) } } case tea.MouseMsg: switch msg.Type { case tea.MouseWheelUp: lines := m.LineUp(mouseWheelDelta) if m.HighPerformanceRendering { cmd = ViewUp(m, lines) } case tea.MouseWheelDown: lines := m.LineDown(mouseWheelDelta) if m.HighPerformanceRendering { cmd = ViewDown(m, lines) } } } return m, cmd } // VIEW // View renders the viewport into a string. func (m Model) View() string { if m.HighPerformanceRendering { // Just send newlines since we're doing to be rendering the actual // content seprately. We still need send something that equals the // height of this view so that the Bubble Tea standard renderer can // position anything below this view properly. return strings.Repeat("\n", m.Height-1) } lines := m.visibleLines() // Fill empty space with newlines extraLines := "" if len(lines) < m.Height { extraLines = strings.Repeat("\n", m.Height-len(lines)) } return strings.Join(lines, "\n") + extraLines } // ETC func clamp(v, low, high int) int { return min(high, max(low, v)) } func min(a, b int) int { if a < b { return a } return b } func max(a, b int) int { if a > b { return a } return b }