4 Commits

Author SHA1 Message Date
Gabriel Nagy
14d9f9cd76 fix(textinput): set cursor on initial value
This reverts the code back to the logic from 4ce16e8 which fixed the
issue of the cursor not being moved when an initial value was set with
SetValue.

The fix regressed in 16053f4.
2022-07-05 10:15:12 -04:00
Leandro López
69bf367d37 feat(spinners): Construct new spinners with WithSpinner + WithStyle options (#148)
* Add spinner.New test

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Add spinner.Option type and spinner.WithSpinner option

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Allow passing options in spinner.New

This doesn't break existing code as it uses variadic arguments, so
any existing code as the following should continue to work:

    s := spinner.New()
    s.spinner = spinner.Dot

This change allows for instead of those two lines, having a call:

   s := spinner.New(spinner.WithSpinner(spinner.Dot))

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Add spinner.WithX option for each spinner.Spinner

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Refactor spinner tests

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* Add spinner.WithStyle option function

Signed-off-by: Leandro López (inkel) <inkel.ar@gmail.com>

* refactor: remove With... Spinner aliases

Co-authored-by: Maas Lalani <maas@lalani.dev>
2022-07-05 10:15:12 -04:00
Kyosuke Fujimoto
2578480343 fix(list): Disable Quit Key (#104) 2022-07-05 10:15:12 -04:00
Pablo Díaz-López
136e1f05bf fix(timer): stop should return cmd (#138) 2022-07-05 10:15:11 -04:00
24 changed files with 518 additions and 2928 deletions

View File

@@ -1,22 +0,0 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "daily"
time: "08:00"
labels:
- "dependencies"
commit-message:
prefix: "feat"
include: "scope"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "08:00"
labels:
- "dependencies"
commit-message:
prefix: "chore"
include: "scope"

View File

@@ -11,12 +11,12 @@ jobs:
GO111MODULE: "on"
steps:
- name: Install Go
uses: actions/setup-go@v3
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Download Go modules
run: go mod download

View File

@@ -12,12 +12,12 @@ jobs:
GO111MODULE: "on"
steps:
- name: Install Go
uses: actions/setup-go@v3
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v2
- name: Coverage
env:

View File

@@ -1,28 +0,0 @@
name: lint-soft
on:
push:
pull_request:
permissions:
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
pull-requests: read
jobs:
golangci:
name: lint-soft
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: ^1
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
# Optional: golangci-lint command line arguments.
args: --config .golangci-soft.yml --issues-exit-code=0
# Optional: show only new issues if it's a pull request. The default value is `false`.
only-new-issues: true

View File

@@ -1,28 +1,18 @@
name: lint
on:
push:
pull_request:
permissions:
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
pull-requests: read
on: [push, pull_request]
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v3
with:
go-version: ^1
- uses: actions/checkout@v3
- uses: actions/checkout@v2
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
uses: golangci/golangci-lint-action@v2
with:
# Optional: golangci-lint command line arguments.
#args:
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

View File

@@ -1,47 +0,0 @@
run:
tests: false
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
linters:
enable:
# - dupl
- exhaustive
# - exhaustivestruct
- goconst
- godot
- godox
- gomnd
- gomoddirectives
- goprintffuncname
- ifshort
# - lll
- misspell
- nakedret
- nestif
- noctx
- nolintlint
- prealloc
- wrapcheck
# disable default linters, they are already enabled in .golangci.yml
disable:
- deadcode
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- structcheck
- typecheck
- unused
- varcheck

View File

@@ -15,15 +15,20 @@ issues:
linters:
enable:
- bodyclose
- dupl
- exportloopref
- goconst
- godot
- godox
- goimports
- goprintffuncname
- gosec
- nilerr
- predeclared
- ifshort
- misspell
- prealloc
- revive
- rowserrcheck
- sqlclosecheck
- tparallel
- unconvert
- unparam
- whitespace

View File

@@ -26,8 +26,8 @@ applications. These components are used in production in [Glow][glow],
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/bubbletea/tree/master/examples/spinner/main.go)
* [Example code, various spinners](https://github.com/charmbracelet/bubbletea/tree/master/examples/spinners/main.go)
* [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
@@ -38,29 +38,9 @@ A text input field, akin to an `<input type="text">` 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/bubbletea/tree/master/examples/textinput/main.go)
* [Example code, many fields](https://github.com/charmbracelet/bubbletea/tree/master/examples/textinputs/main.go)
* [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)
## Text Area
<img src="https://stuff.charm.sh/bubbles-examples/textarea.gif" width="400" alt="Text Area Example">
A text area field, akin to an `<textarea />` in HTML. Allows for input that
spans multiple lines. Supports unicode, pasting, vertical scrolling when the
value exceeds the width and height of the element, and many customization
options.
* [Example code, chat input](https://github.com/charmbracelet/bubbletea/tree/master/examples/chat/main.go)
* [Example code, story time input](https://github.com/charmbracelet/bubbletea/tree/master/examples/textarea/main.go)
## Table
<img src="https://stuff.charm.sh/bubbles-examples/table.gif" width="400" alt="Table Example">
A component for displaying and navigating tabular data (columns and rows).
Supports vertical scrolling and many customization options.
* [Example code, countries and populations](https://github.com/charmbracelet/bubbletea/tree/master/examples/table/main.go)
## Progress
@@ -91,13 +71,13 @@ logic and visualize pagination however you like.
## Viewport
<img src="https://stuff.charm.sh/bubbles-examples/viewport.gif" width="600" alt="Viewport Example">
<img src="https://stuff.charm.sh/bubbles-examples/viewport.gif?0" width="600" alt="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/bubbletea/tree/master/examples/pager/main.go)
* [Example code](https://github.com/charmbracelet/tea/tree/master/examples/pager/main.go)
This component is well complemented with [Reflow][reflow] for ANSI-aware
indenting and text wrapping.
@@ -114,9 +94,9 @@ 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/bubbletea/tree/master/examples/list-default/main.go)
* [Example code, simple list](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-simple/main.go)
* [Example code, all features](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-fancy/main.go)
* [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)
## Timer
@@ -217,17 +197,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
If youve built a Bubble you think should be listed here,
[let us know](mailto:vt100@charm.sh).
## Feedback
Wed love to hear your thoughts on this project. Feel free to drop us a note!
* [Twitter](https://twitter.com/charmcli)
* [The Fediverse](https://mastodon.social/@charmcli)
* [Discord](https://charm.sh/chat)
## License
[MIT](https://github.com/charmbracelet/bubbletea/raw/master/LICENSE)
[MIT](https://github.com/charmbracelet/teaparty/raw/master/LICENSE)
***
@@ -236,3 +210,5 @@ Part of [Charm](https://charm.sh).
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-badge.jpg" width="400"></a>
Charm热爱开源 • Charm loves open source
[charm]: https://charm.sh/

View File

@@ -1,207 +0,0 @@
package cursor
import (
"context"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const defaultBlinkSpeed = time.Millisecond * 530
// 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{}
// blinkCtx manages cursor blinking.
type blinkCtx struct {
ctx context.Context
cancel context.CancelFunc
}
// Mode describes the behavior of the cursor.
type Mode int
// Available cursor modes.
const (
CursorBlink Mode = iota
CursorStatic
CursorHide
)
// String returns the cursor mode in a human-readable format. This method is
// provisional and for informational purposes only.
func (c Mode) String() string {
return [...]string{
"blink",
"static",
"hidden",
}[c]
}
// Model is the Bubble Tea model for this cursor element.
type Model struct {
BlinkSpeed time.Duration
// Style for styling the cursor block.
Style lipgloss.Style
// TextStyle is the style used for the cursor when it is hidden (when blinking).
// I.e. displaying normal text.
TextStyle lipgloss.Style
// char is the character under the cursor
char string
// The ID of this Model as it relates to other cursors
id int
// focus indicates whether the containing input is focused
focus bool
// Cursor Blink state.
Blink bool
// Used to manage cursor blink
blinkCtx *blinkCtx
// The ID of the blink message we're expecting to receive.
blinkTag int
// mode determines the behavior of the cursor
mode Mode
}
// New creates a new model with default settings.
func New() Model {
return Model{
BlinkSpeed: defaultBlinkSpeed,
Blink: true,
mode: CursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
},
}
}
// Update updates the cursor.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case initialBlinkMsg:
// We accept all initialBlinkMsgs generated by the Blink command.
if m.mode != 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 blink-able?
if m.mode != 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.mode == CursorBlink {
m.Blink = !m.Blink
cmd = m.BlinkCmd()
}
return m, cmd
case blinkCanceled: // no-op
return m, nil
}
return m, nil
}
// Mode returns the model's cursor mode. For available cursor modes, see
// type Mode.
func (m Model) Mode() Mode {
return m.mode
}
// SetMode sets the model's cursor mode. This method returns a command.
//
// For available cursor modes, see type CursorMode.
func (m *Model) SetMode(mode Mode) tea.Cmd {
m.mode = mode
m.Blink = m.mode == CursorHide || !m.focus
if mode == CursorBlink {
return Blink
}
return nil
}
// BlinkCmd is an command used to manage cursor blinking.
func (m *Model) BlinkCmd() tea.Cmd {
if m.mode != 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{}
}
// Focus focuses the cursor to allow it to blink if desired.
func (m *Model) Focus() tea.Cmd {
m.focus = true
m.Blink = m.mode == CursorHide // show the cursor unless we've explicitly hidden it
if m.mode == CursorBlink && m.focus {
return m.BlinkCmd()
}
return nil
}
// Blur blurs the cursor.
func (m *Model) Blur() {
m.focus = false
m.Blink = true
}
// SetChar sets the character under the cursor.
func (m *Model) SetChar(char string) {
m.char = char
}
// View displays the cursor.
func (m Model) View() string {
if m.Blink {
return m.TextStyle.Inline(true).Render(m.char)
}
return m.Style.Inline(true).Reverse(true).Render(m.char)
}

6
go.mod
View File

@@ -4,12 +4,12 @@ go 1.13
require (
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbletea v0.22.1
github.com/charmbracelet/bubbletea v0.21.0
github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss v0.6.0
github.com/charmbracelet/lipgloss v0.5.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.14
github.com/mattn/go-runewidth v0.0.13
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739
github.com/sahilm/fuzzy v0.1.0

30
go.sum
View File

@@ -1,31 +1,27 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.22.1 h1:z66q0LWdJNOWEH9zadiAIXp2GN1AWrwNXU8obVY9X24=
github.com/charmbracelet/bubbletea v0.22.1/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0=
github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY=
github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
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.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
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/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q=
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
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=
@@ -33,7 +29,10 @@ github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
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=
@@ -41,10 +40,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -203,7 +203,7 @@ func (m Model) FullHelpView(groups [][]key.Binding) string {
// Column
totalWidth += lipgloss.Width(col)
if m.Width > 0 && totalWidth > m.Width {
if totalWidth > m.Width {
break
}
@@ -212,7 +212,7 @@ func (m Model) FullHelpView(groups [][]key.Binding) string {
// Separator
if i < len(group)-1 {
totalWidth += sepWidth
if m.Width > 0 && totalWidth > m.Width {
if totalWidth > m.Width {
break
}
}

View File

@@ -2,35 +2,35 @@
// 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
// }
// 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(
// key.WithKeys("j", "down"),
// key.WithHelp("↓/j", "move down"),
// ),
// }
// var DefaultKeyMap = KeyMap{
// Up: key.NewBinding(
// key.WithKeys("k", "up"), // actual keybindings
// key.WithHelp("↑/k", "move up"), // corresponding help text
// ),
// Down: key.NewBinding(
// key.WithKeys("j", "down"),
// key.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
// }
// }
// 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.
@@ -106,7 +106,7 @@ func (b Binding) Help() Help {
// 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 && b.keys != nil
return !b.disabled
}
// SetEnabled enables or disables the keybinding.
@@ -130,10 +130,9 @@ type Help struct {
// Matches checks if the given KeyMsg matches the given bindings.
func Matches(k tea.KeyMsg, b ...Binding) bool {
keys := k.String()
for _, binding := range b {
for _, v := range binding.keys {
if keys == v && binding.Enabled() {
if k.String() == v && binding.Enabled() {
return true
}
}

View File

@@ -1,26 +0,0 @@
package key
import (
"testing"
)
func TestBinding_Enabled(t *testing.T) {
binding := NewBinding(
WithKeys("k", "up"),
WithHelp("↑/k", "move up"),
)
if !binding.Enabled() {
t.Errorf("expected key to be Enabled")
}
binding.SetEnabled(false)
if binding.Enabled() {
t.Errorf("expected key not to be Enabled")
}
binding.SetEnabled(true)
binding.Unbind()
if binding.Enabled() {
t.Errorf("expected key not to be Enabled")
}
}

View File

@@ -183,18 +183,18 @@ type Model struct {
func New(items []Item, delegate ItemDelegate, width, height int) Model {
styles := DefaultStyles()
sp := spinner.New()
sp := spinner.NewModel()
sp.Spinner = spinner.Line
sp.Style = styles.Spinner
filterInput := textinput.New()
filterInput := textinput.NewModel()
filterInput.Prompt = "Filter: "
filterInput.PromptStyle = styles.FilterPrompt
filterInput.CursorStyle = styles.FilterCursor
filterInput.CharLimit = 64
filterInput.Focus()
p := paginator.New()
p := paginator.NewModel()
p.Type = paginator.Dots
p.ActiveDot = styles.ActivePaginationDot.String()
p.InactiveDot = styles.InactivePaginationDot.String()
@@ -221,7 +221,7 @@ func New(items []Item, delegate ItemDelegate, width, height int) Model {
items: items,
Paginator: p,
spinner: sp,
Help: help.New(),
Help: help.NewModel(),
}
m.updatePagination()
@@ -526,7 +526,7 @@ func (m Model) FilterValue() string {
// 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
// m.FilterState() == Filtering
//
// It's included here because it's a common thing to check for when
// implementing this component.
@@ -534,14 +534,6 @@ func (m Model) SettingFilter() bool {
return m.filterState == Filtering
}
// IsFiltered returns whether or not the list is currently filtered.
// It's purely a convenience method for the following:
//
// m.FilterState() == FilterApplied
func (m Model) IsFiltered() bool {
return m.filterState == FilterApplied
}
// Width returns the current width setting.
func (m Model) Width() int {
return m.width

View File

@@ -7,7 +7,6 @@ package paginator
import (
"fmt"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
@@ -20,49 +19,20 @@ const (
Dots
)
// KeyMap is the key bindings for different actions within the paginator.
type KeyMap struct {
PrevPage key.Binding
NextPage key.Binding
}
// DefaultKeyMap is the default set of key bindings for navigating and acting
// upon the paginator.
var DefaultKeyMap = KeyMap{
PrevPage: key.NewBinding(key.WithKeys("pgup", "left", "h")),
NextPage: key.NewBinding(key.WithKeys("pgdown", "right", "l")),
}
// Model is the Bubble Tea model for this user interface.
type Model struct {
// Type configures how the pagination is rendered (Arabic, Dots).
Type Type
// Page is the current page number.
Page int
// PerPage is the number of items per page.
PerPage int
// TotalPages is the total number of pages.
TotalPages int
// ActiveDot is used to mark the current page under the Dots display type.
ActiveDot string
// InactiveDot is used to mark inactive pages under the Dots display type.
InactiveDot string
// ArabicFormat is the printf-style format to use for the Arabic display type.
ArabicFormat string
// KeyMap encodes the keybindings recognized by the widget.
KeyMap KeyMap
// Deprecated: customize KeyMap instead.
Type Type
Page int
PerPage int
TotalPages int
ActiveDot string
InactiveDot string
ArabicFormat string
UsePgUpPgDownKeys bool
// Deprecated: customize KeyMap instead.
UseLeftRightKeys bool
// Deprecated: customize KeyMap instead.
UseUpDownKeys bool
// Deprecated: customize KeyMap instead.
UseHLKeys bool
// Deprecated: customize KeyMap instead.
UseJKKeys bool
UseLeftRightKeys bool
UseUpDownKeys bool
UseHLKeys bool
UseJKKeys bool
}
// SetTotalPages is a helper function for calculating the total number of pages
@@ -95,9 +65,10 @@ func (m Model) ItemsOnPage(totalItems int) int {
// 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]
// 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)
@@ -128,14 +99,18 @@ func (m Model) OnLastPage() bool {
// New creates a new model with defaults.
func New() Model {
return Model{
Type: Arabic,
Page: 0,
PerPage: 1,
TotalPages: 1,
KeyMap: DefaultKeyMap,
ActiveDot: "",
InactiveDot: "○",
ArabicFormat: "%d/%d",
Type: Arabic,
Page: 0,
PerPage: 1,
TotalPages: 1,
ActiveDot: "•",
InactiveDot: "",
ArabicFormat: "%d/%d",
UsePgUpPgDownKeys: true,
UseLeftRightKeys: true,
UseUpDownKeys: false,
UseHLKeys: true,
UseJKKeys: false,
}
}
@@ -148,11 +123,45 @@ var NewModel = New
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.NextPage):
m.NextPage()
case key.Matches(msg, m.KeyMap.PrevPage):
m.PrevPage()
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()
}
}
}

View File

@@ -37,12 +37,13 @@ const (
defaultDamping = 1.0
)
// Option is used to set options in New. For example:
// Option is used to set options in NewModel. For example:
//
// progress := NewModel(
// WithRamp("#ff0000", "#0000ff"),
// WithoutPercentage(),
// )
//
// progress := New(
// WithRamp("#ff0000", "#0000ff"),
// WithoutPercentage(),
// )
type Option func(*Model)
// WithDefaultGradient sets a gradient fill with default colors.

View File

@@ -85,7 +85,7 @@ var (
}
)
// Model contains the state for the spinner. Use New to create new models
// 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.
@@ -208,7 +208,8 @@ func Tick() tea.Msg {
// Option is used to set options in New. For example:
//
// spinner := New(WithSpinner(Dot))
// spinner := New(WithSpinner(Dot))
//
type Option func(*Model)
// WithSpinner is an option to set the spinner.

View File

@@ -1,392 +0,0 @@
package table
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
// Model defines a state for the table widget.
type Model struct {
KeyMap KeyMap
cols []Column
rows []Row
cursor int
focus bool
styles Styles
viewport viewport.Model
}
// Row represents one line in the table.
type Row []string
// Column defines the table structure.
type Column struct {
Title string
Width int
}
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu menu.
type KeyMap struct {
LineUp key.Binding
LineDown key.Binding
PageUp key.Binding
PageDown key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
GotoTop key.Binding
GotoBottom key.Binding
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
const spacebar = " "
return KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
LineDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PageUp: key.NewBinding(
key.WithKeys("b", "pgup"),
key.WithHelp("b/pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("f", "pgdown", spacebar),
key.WithHelp("f/pgdn", "page down"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
GotoTop: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
}
}
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this table.
func DefaultStyles() Styles {
return Styles{
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1),
Cell: lipgloss.NewStyle().Padding(0, 1),
}
}
// SetStyles sets the table styles.
func (m *Model) SetStyles(s Styles) {
m.styles = s
m.UpdateViewport()
}
// Option is used to set options in New. For example:
//
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
type Option func(*Model)
// New creates a new model for the table widget.
func New(opts ...Option) Model {
m := Model{
cursor: 0,
viewport: viewport.New(0, 20),
KeyMap: DefaultKeyMap(),
styles: DefaultStyles(),
}
for _, opt := range opts {
opt(&m)
}
m.UpdateViewport()
return m
}
// WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option {
return func(m *Model) {
m.cols = cols
}
}
// WithRows sets the table rows (data).
func WithRows(rows []Row) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithHeight sets the height of the table.
func WithHeight(h int) Option {
return func(m *Model) {
m.viewport.Height = h
}
}
// WithWidth sets the width of the table.
func WithWidth(w int) Option {
return func(m *Model) {
m.viewport.Width = w
}
}
// WithFocused sets the focus state of the table.
func WithFocused(f bool) Option {
return func(m *Model) {
m.focus = f
}
}
// WithStyles sets the table styles.
func WithStyles(s Styles) Option {
return func(m *Model) {
m.styles = s
}
}
// WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) Option {
return func(m *Model) {
m.KeyMap = km
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.LineUp):
m.MoveUp(1)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.PageUp):
m.MoveUp(m.viewport.Height)
case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height)
case key.Matches(msg, m.KeyMap.HalfPageUp):
m.MoveUp(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.HalfPageDown):
m.MoveDown(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom):
m.GotoBottom()
}
}
return m, tea.Batch(cmds...)
}
// Focused returns the focus state of the table.
func (m Model) Focused() bool {
return m.focus
}
// Focus focusses the table, allowing the user to move around the rows and
// interact.
func (m *Model) Focus() {
m.focus = true
m.UpdateViewport()
}
// Blur blurs the table, preventing selection or movement.
func (m *Model) Blur() {
m.focus = false
m.UpdateViewport()
}
// View renders the component.
func (m Model) View() string {
return m.headersView() + "\n" + m.viewport.View()
}
// UpdateViewport updates the list content based on the previously defined
// columns and rows.
func (m *Model) UpdateViewport() {
renderedRows := make([]string, 0, len(m.rows))
for i := range m.rows {
renderedRows = append(renderedRows, m.renderRow(i))
}
m.viewport.SetContent(
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
)
}
// SelectedRow returns the selected row.
// You can cast it to your own implementation.
func (m Model) SelectedRow() Row {
return m.rows[m.cursor]
}
// SetRows set a new rows state.
func (m *Model) SetRows(r []Row) {
m.rows = r
m.UpdateViewport()
}
// SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) {
m.viewport.Width = w
m.UpdateViewport()
}
// SetHeight sets the height of the viewport of the table.
func (m *Model) SetHeight(h int) {
m.viewport.Height = h
m.UpdateViewport()
}
// Height returns the viewport height of the table.
func (m Model) Height() int {
return m.viewport.Height
}
// Width returns the viewport width of the table.
func (m Model) Width() int {
return m.viewport.Width
}
// Cursor returns the index of the selected row.
func (m Model) Cursor() int {
return m.cursor
}
// SetCursor sets the cursor position in the table.
func (m *Model) SetCursor(n int) {
m.cursor = clamp(n, 0, len(m.rows)-1)
m.UpdateViewport()
}
// MoveUp moves the selection up by any number of row.
// It can not go above the first row.
func (m *Model) MoveUp(n int) {
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
m.UpdateViewport()
if m.cursor < m.viewport.YOffset {
m.viewport.SetYOffset(m.cursor)
}
}
// MoveDown moves the selection down by any number of row.
// It can not go below the last row.
func (m *Model) MoveDown(n int) {
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
m.UpdateViewport()
if m.cursor > (m.viewport.YOffset + (m.viewport.Height - 1)) {
m.viewport.SetYOffset(m.cursor - (m.viewport.Height - 1))
}
}
// GotoTop moves the selection to the first row.
func (m *Model) GotoTop() {
m.MoveUp(m.cursor)
}
// GotoBottom moves the selection to the last row.
func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows))
}
// FromValues create the table rows from a simple string. It uses `\n` by
// default for getting all the rows and the given separator for the fields on
// each row.
func (m *Model) FromValues(value, separator string) {
rows := []Row{}
for _, line := range strings.Split(value, "\n") {
r := Row{}
for _, field := range strings.Split(line, separator) {
r = append(r, field)
}
rows = append(rows, r)
}
m.SetRows(rows)
}
func (m Model) headersView() string {
var s = make([]string, 0, len(m.cols))
for _, col := range m.cols {
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
s = append(s, m.styles.Header.Render(renderedCell))
}
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
}
func (m *Model) renderRow(rowID int) string {
var s = make([]string, 0, len(m.cols))
for i, value := range m.rows[rowID] {
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
renderedCell := m.styles.Cell.Render(style.Render(runewidth.Truncate(value, m.cols[i].Width, "…")))
s = append(s, renderedCell)
}
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
if rowID == m.cursor {
return m.styles.Selected.Render(row)
}
return row
}
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
}
func clamp(v, low, high int) int {
return min(max(v, low), high)
}

View File

@@ -1,54 +0,0 @@
package table
import "testing"
func TestFromValues(t *testing.T) {
input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3"
table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}}))
table.FromValues(input, ",")
if len(table.rows) != 3 {
t.Fatalf("expect table to have 3 rows but it has %d", len(table.rows))
}
expect := []Row{
{"foo1", "bar1"},
{"foo2", "bar2"},
{"foo3", "bar3"},
}
if !deepEqual(table.rows, expect) {
t.Fatal("table rows is not equals to the input")
}
}
func TestFromValuesWithTabSeparator(t *testing.T) {
input := "foo1.\tbar1\nfoo,bar,baz\tbar,2"
table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}}))
table.FromValues(input, "\t")
if len(table.rows) != 2 {
t.Fatalf("expect table to have 2 rows but it has %d", len(table.rows))
}
expect := []Row{
{"foo1.", "bar1"},
{"foo,bar,baz", "bar,2"},
}
if !deepEqual(table.rows, expect) {
t.Fatal("table rows is not equals to the input")
}
}
func deepEqual(a, b []Row) bool {
if len(a) != len(b) {
return false
}
for i, r := range a {
for j, f := range r {
if f != b[i][j] {
return false
}
}
}
return true
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,446 +0,0 @@
package textarea
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
)
func TestNew(t *testing.T) {
textarea := newTextArea()
view := textarea.View()
if !strings.Contains(view, ">") {
t.Log(view)
t.Error("Text area did not render the prompt")
}
if !strings.Contains(view, "World!") {
t.Log(view)
t.Error("Text area did not render the placeholder")
}
}
func TestInput(t *testing.T) {
textarea := newTextArea()
input := "foo"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
view := textarea.View()
if !strings.Contains(view, input) {
t.Log(view)
t.Error("Text area did not render the input")
}
if textarea.col != len(input) {
t.Log(view)
t.Error("Text area did not move the cursor to the correct position")
}
}
func TestSoftWrap(t *testing.T) {
textarea := newTextArea()
textarea.Prompt = ""
textarea.ShowLineNumbers = false
textarea.SetWidth(5)
textarea.SetHeight(5)
textarea.CharLimit = 60
textarea, _ = textarea.Update(nil)
input := "foo bar baz"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
view := textarea.View()
for _, word := range strings.Split(input, " ") {
if !strings.Contains(view, word) {
t.Log(view)
t.Error("Text area did not render the input")
}
}
// Due to the word wrapping, each word will be on a new line and the
// text area will look like this:
//
// > foo
// > bar
// > baz█
//
// However, due to soft-wrapping the column will still be at the end of the line.
if textarea.row != 0 || textarea.col != len(input) {
t.Log(view)
t.Error("Text area did not move the cursor to the correct position")
}
}
func TestCharLimit(t *testing.T) {
textarea := newTextArea()
// First input (foo bar) should be accepted as it will fall within the
// CharLimit. Second input (baz) should not appear in the input.
input := []string{"foo bar", "baz"}
textarea.CharLimit = len(input[0])
for _, k := range []rune(strings.Join(input, " ")) {
textarea, _ = textarea.Update(keyPress(k))
}
view := textarea.View()
if strings.Contains(view, input[1]) {
t.Log(view)
t.Error("Text area should not include input past the character limit")
}
}
func TestVerticalScrolling(t *testing.T) {
textarea := newTextArea()
textarea.Prompt = ""
textarea.ShowLineNumbers = false
textarea.SetHeight(1)
textarea.SetWidth(20)
textarea.CharLimit = 100
textarea, _ = textarea.Update(nil)
input := "This is a really long line that should wrap around the text area."
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
view := textarea.View()
// The view should contain the first "line" of the input.
if !strings.Contains(view, "This is a really") {
t.Log(view)
t.Error("Text area did not render the input")
}
// But we should be able to scroll to see the next line.
// Let's scroll down for each line to view the full input.
lines := []string{
"long line that",
"should wrap around",
"the text area.",
}
for _, line := range lines {
textarea.viewport.LineDown(1)
view = textarea.View()
if !strings.Contains(view, line) {
t.Log(view)
t.Error("Text area did not render the correct scrolled input")
}
}
}
func TestWordWrapOverflowing(t *testing.T) {
// An interesting edge case is when the user enters many words that fill up
// the text area and then goes back up and inserts a few words which causes
// a cascading wrap and causes an overflow of the last line.
//
// In this case, we should not let the user insert more words if, after the
// entire wrap is complete, the last line is overflowing.
textarea := newTextArea()
textarea.SetHeight(3)
textarea.SetWidth(20)
textarea.CharLimit = 500
textarea, _ = textarea.Update(nil)
input := "Testing Testing Testing Testing Testing Testing Testing Testing"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
// We have essentially filled the text area with input.
// Let's see if we can cause wrapping to overflow the last line.
textarea.row = 0
textarea.col = 0
input = "Testing"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
lastLineWidth := textarea.LineInfo().Width
if lastLineWidth > 20 {
t.Log(lastLineWidth)
t.Log(textarea.View())
t.Fail()
}
}
func TestValueSoftWrap(t *testing.T) {
textarea := newTextArea()
textarea.SetWidth(16)
textarea.SetHeight(10)
textarea.CharLimit = 500
textarea, _ = textarea.Update(nil)
input := "Testing Testing Testing Testing Testing Testing Testing Testing"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
textarea.View()
}
value := textarea.Value()
if value != input {
t.Log(value)
t.Log(input)
t.Fatal("The text area does not have the correct value")
}
}
func TestSetValue(t *testing.T) {
textarea := newTextArea()
textarea.SetValue(strings.Join([]string{"Foo", "Bar", "Baz"}, "\n"))
if textarea.row != 2 && textarea.col != 3 {
t.Log(textarea.row, textarea.col)
t.Fatal("Cursor Should be on row 2 column 3 after inserting 2 new lines")
}
value := textarea.Value()
if value != "Foo\nBar\nBaz" {
t.Fatal("Value should be Foo\nBar\nBaz")
}
// SetValue should reset text area
textarea.SetValue("Test")
value = textarea.Value()
if value != "Test" {
t.Log(value)
t.Fatal("Text area was not reset when SetValue() was called")
}
}
func TestInsertString(t *testing.T) {
textarea := newTextArea()
// Insert some text
input := "foo baz"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
// Put cursor in the middle of the text
textarea.col = 4
textarea.InsertString("bar ")
value := textarea.Value()
if value != "foo bar baz" {
t.Log(value)
t.Fatal("Expected insert string to insert bar between foo and baz")
}
}
func TestCanHandleEmoji(t *testing.T) {
textarea := newTextArea()
input := "🧋"
for _, k := range []rune(input) {
textarea, _ = textarea.Update(keyPress(k))
}
value := textarea.Value()
if value != input {
t.Log(value)
t.Fatal("Expected emoji to be inserted")
}
input = "🧋🧋🧋"
textarea.SetValue(input)
value = textarea.Value()
if value != input {
t.Log(value)
t.Fatal("Expected emoji to be inserted")
}
if textarea.col != 3 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the third character")
}
if charOffset := textarea.LineInfo().CharOffset; charOffset != 6 {
t.Log(charOffset)
t.Fatal("Expected cursor to be on the sixth character")
}
}
func TestVerticalNavigationKeepsCursorHorizontalPosition(t *testing.T) {
textarea := newTextArea()
textarea.SetWidth(20)
textarea.SetValue(strings.Join([]string{"你好你好", "Hello"}, "\n"))
textarea.row = 0
textarea.col = 2
// 你好|你好
// Hell|o
// 1234|
// Let's imagine our cursor is on the first line where the pipe is.
// We press the down arrow to get to the next line.
// The issue is that if we keep the cursor on the same column, the cursor will jump to after the `e`.
//
// 你好|你好
// He|llo
//
// But this is wrong because visually we were at the 4th character due to
// the first line containing double-width runes.
// We want to keep the cursor on the same visual column.
//
// 你好|你好
// Hell|o
//
// This test ensures that the cursor is kept on the same visual column by
// ensuring that the column offset goes from 2 -> 4.
lineInfo := textarea.LineInfo()
if lineInfo.CharOffset != 4 || lineInfo.ColumnOffset != 2 {
t.Log(lineInfo.CharOffset)
t.Log(lineInfo.ColumnOffset)
t.Fatal("Expected cursor to be on the fourth character because there two double width runes on the first line.")
}
downMsg := tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}
textarea, _ = textarea.Update(downMsg)
lineInfo = textarea.LineInfo()
if lineInfo.CharOffset != 4 || lineInfo.ColumnOffset != 4 {
t.Log(lineInfo.CharOffset)
t.Log(lineInfo.ColumnOffset)
t.Fatal("Expected cursor to be on the fourth character because we came down from the first line.")
}
}
func TestVerticalNavigationShouldRememberPositionWhileTraversing(t *testing.T) {
textarea := newTextArea()
textarea.SetWidth(40)
// Let's imagine we have a text area with the following content:
//
// Hello
// World
// This is a long line.
//
// If we are at the end of the last line and go up, we should be at the end
// of the second line.
// And, if we go up again we should be at the end of the first line.
// But, if we go back down twice, we should be at the end of the last line
// again and not the fifth (length of second line) character of the last line.
//
// In other words, we should remember the last horizontal position while
// traversing vertically.
textarea.SetValue(strings.Join([]string{"Hello", "World", "This is a long line."}, "\n"))
// We are at the end of the last line.
if textarea.col != 20 || textarea.row != 2 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 20th character of the last line")
}
// Let's go up.
upMsg := tea.KeyMsg{Type: tea.KeyUp, Alt: false, Runes: []rune{}}
textarea, _ = textarea.Update(upMsg)
// We should be at the end of the second line.
if textarea.col != 5 || textarea.row != 1 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 5th character of the second line")
}
// And, again.
textarea, _ = textarea.Update(upMsg)
// We should be at the end of the first line.
if textarea.col != 5 || textarea.row != 0 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 5th character of the first line")
}
// Let's go down, twice.
downMsg := tea.KeyMsg{Type: tea.KeyDown, Alt: false, Runes: []rune{}}
textarea, _ = textarea.Update(downMsg)
textarea, _ = textarea.Update(downMsg)
// We should be at the end of the last line.
if textarea.col != 20 || textarea.row != 2 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 20th character of the last line")
}
// Now, for correct behavior, if we move right or left, we should forget
// (reset) the saved horizontal position. Since we assume the user wants to
// keep the cursor where it is horizontally. This is how most text areas
// work.
textarea, _ = textarea.Update(upMsg)
leftMsg := tea.KeyMsg{Type: tea.KeyLeft, Alt: false, Runes: []rune{}}
textarea, _ = textarea.Update(leftMsg)
if textarea.col != 4 || textarea.row != 1 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 5th character of the second line")
}
// Going down now should keep us at the 4th column since we moved left and
// reset the horizontal position saved state.
textarea, _ = textarea.Update(downMsg)
if textarea.col != 4 || textarea.row != 2 {
t.Log(textarea.col)
t.Fatal("Expected cursor to be on the 4th character of the last line")
}
}
func TestRendersEndOfLineBuffer(t *testing.T) {
textarea := newTextArea()
textarea.ShowLineNumbers = true
textarea.SetWidth(20)
view := textarea.View()
if !strings.Contains(view, "~") {
t.Log(view)
t.Fatal("Expected to see a tilde at the end of the line")
}
}
func newTextArea() Model {
textarea := New()
textarea.Prompt = "> "
textarea.Placeholder = "Hello, World!"
textarea.Focus()
textarea, _ = textarea.Update(nil)
return textarea
}
func keyPress(key rune) tea.Msg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{key}, Alt: false}
}

View File

@@ -1,18 +1,48 @@
package textinput
import (
"context"
"strings"
"sync"
"time"
"unicode"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/key"
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 }
@@ -35,44 +65,35 @@ const (
// 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]
}
// ValidateFunc is a function that returns an error if the input is invalid.
type ValidateFunc func(string) error
// KeyMap is the key bindings for different actions within the textinput.
type KeyMap struct {
CharacterForward key.Binding
CharacterBackward key.Binding
WordForward key.Binding
WordBackward key.Binding
DeleteWordBackward key.Binding
DeleteWordForward key.Binding
DeleteAfterCursor key.Binding
DeleteBeforeCursor key.Binding
DeleteCharacterBackward key.Binding
DeleteCharacterForward key.Binding
LineStart key.Binding
LineEnd key.Binding
Paste key.Binding
}
// DefaultKeyMap is the default set of key bindings for navigating and acting
// upon the textinput.
var DefaultKeyMap = KeyMap{
CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f")),
CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b")),
WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f")),
WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b")),
DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w")),
DeleteWordForward: key.NewBinding(key.WithKeys("alte+delete", "alt+d")),
DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k")),
DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u")),
DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h")),
DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d")),
LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a")),
LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e")),
Paste: key.NewBinding(key.WithKeys("ctrl+v")),
}
// Model is the Bubble Tea model for this text input element.
type Model struct {
Err error
@@ -80,13 +101,9 @@ type Model struct {
// General settings.
Prompt string
Placeholder string
BlinkSpeed time.Duration
EchoMode EchoMode
EchoCharacter rune
Cursor cursor.Model
// Deprecated: use cursor.BlinkSpeed instead.
// This is unused and will be removed in the future.
BlinkSpeed time.Duration
// Styles. These will be applied as inline styles.
//
@@ -107,8 +124,11 @@ type Model struct {
// viewport. If 0 or less this setting is ignored.
Width int
// KeyMap encodes the keybindings recognized by the widget.
KeyMap KeyMap
// 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
@@ -117,6 +137,9 @@ type Model struct {
// component. When false, ignore keyboard input and hide the cursor.
focus bool
// Cursor blink state.
blink bool
// Cursor position.
pos int
@@ -125,6 +148,12 @@ type Model struct {
offset int
offsetRight int
// Used to manage cursor blink
blinkCtx *blinkCtx
// cursorMode determines the behavior of the cursor
cursorMode CursorMode
// Validate is a function that checks whether or not the text within the
// input is valid. If it is not valid, the `Err` field will be set to the
// error returned by the function. If the function is not defined, all
@@ -136,15 +165,21 @@ type Model struct {
func New() Model {
return Model{
Prompt: "> ",
BlinkSpeed: defaultBlinkSpeed,
EchoCharacter: '*',
CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Cursor: cursor.New(),
KeyMap: DefaultKeyMap,
value: nil,
focus: false,
pos: 0,
id: nextID(),
value: nil,
focus: false,
blink: true,
pos: 0,
cursorMode: CursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
},
}
}
@@ -162,7 +197,6 @@ func (m *Model) SetValue(s string) {
}
}
empty := len(m.value) == 0
m.Err = nil
runes := []rune(s)
@@ -171,8 +205,8 @@ func (m *Model) SetValue(s string) {
} else {
m.value = runes
}
if (m.pos == 0 && empty) || m.pos > len(m.value) {
m.SetCursor(len(m.value))
if m.pos == 0 || m.pos > len(m.value) {
m.setCursor(len(m.value))
}
m.handleOverflow()
}
@@ -182,26 +216,74 @@ func (m Model) Value() string {
return string(m.value)
}
// Position returns the cursor position.
func (m Model) Position() int {
// Cursor returns the cursor position.
func (m Model) Cursor() int {
return m.pos
}
// Blink returns whether or not to draw the cursor.
func (m Model) Blink() bool {
return m.blink
}
// 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.SetCursor(0)
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.SetCursor(len(m.value))
m.cursorEnd()
}
// CursorMode returns the model's cursor mode. For available cursor modes, see
// type CursorMode.
func (m Model) CursorMode() CursorMode {
return m.cursorMode
}
// SetCursorMode 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.
@@ -210,27 +292,34 @@ func (m Model) Focused() bool {
}
// Focus sets the focus state on the model. When the model is in focus it can
// receive keyboard input and the cursor will be shown.
// receive keyboard input and the cursor will be hidden.
func (m *Model) Focus() tea.Cmd {
m.focus = true
return m.Cursor.Focus()
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.Cursor.Blur()
m.blink = true
}
// Reset sets the input to its default state with no input.
func (m *Model) Reset() {
// 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
m.SetCursor(0)
return m.setCursor(0)
}
// handle a clipboard paste event, if supported.
func (m *Model) handlePaste(v string) {
// 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
@@ -240,7 +329,7 @@ func (m *Model) handlePaste(v string) {
// If the char limit's been reached cancel
if m.CharLimit > 0 && availSpace <= 0 {
return
return false
}
// If there's not enough space to paste the whole thing cut the pasted
@@ -277,7 +366,8 @@ func (m *Model) handlePaste(v string) {
m.pos = oldPos
}
m.SetCursor(m.pos)
// 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
@@ -325,30 +415,31 @@ func (m *Model) handleOverflow() {
}
}
// deleteBeforeCursor deletes all text before the cursor.
func (m *Model) deleteBeforeCursor() {
// 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
m.SetCursor(0)
return m.setCursor(0)
}
// deleteAfterCursor deletes all text after the cursor. If input is masked
// delete everything after the cursor so as not to reveal word breaks in the
// masked input.
func (m *Model) deleteAfterCursor() {
// 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]
m.SetCursor(len(m.value))
return m.setCursor(len(m.value))
}
// deleteWordBackward deletes the word left to the cursor.
func (m *Model) deleteWordBackward() {
// 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
return false
}
if m.EchoMode != EchoNormal {
m.deleteBeforeCursor()
return
return m.deleteBeforeCursor()
}
// Linter note: it's critical that we acquire the initial cursor position
@@ -356,22 +447,22 @@ func (m *Model) deleteWordBackward() {
// call into the corresponding if clause does not apply here.
oldPos := m.pos //nolint:ifshort
m.SetCursor(m.pos - 1)
blink := m.setCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) {
if m.pos <= 0 {
break
}
// ignore series of whitespace before cursor
m.SetCursor(m.pos - 1)
blink = m.setCursor(m.pos - 1)
}
for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) {
m.SetCursor(m.pos - 1)
blink = m.setCursor(m.pos - 1)
} else {
if m.pos > 0 {
// keep the previous space
m.SetCursor(m.pos + 1)
blink = m.setCursor(m.pos + 1)
}
break
}
@@ -382,26 +473,27 @@ func (m *Model) deleteWordBackward() {
} else {
m.value = append(m.value[:m.pos], m.value[oldPos:]...)
}
return blink
}
// deleteWordForward deletes the word right to the cursor If input is masked
// delete everything after the cursor so as not to reveal word breaks in the
// masked input.
func (m *Model) deleteWordForward() {
// 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
return false
}
if m.EchoMode != EchoNormal {
m.deleteAfterCursor()
return
return m.deleteAfterCursor()
}
oldPos := m.pos
m.SetCursor(m.pos + 1)
m.setCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor
m.SetCursor(m.pos + 1)
m.setCursor(m.pos + 1)
if m.pos >= len(m.value) {
break
@@ -410,7 +502,7 @@ func (m *Model) deleteWordForward() {
for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) {
m.SetCursor(m.pos + 1)
m.setCursor(m.pos + 1)
} else {
break
}
@@ -422,25 +514,26 @@ func (m *Model) deleteWordForward() {
m.value = append(m.value[:oldPos], m.value[m.pos:]...)
}
m.SetCursor(oldPos)
return m.setCursor(oldPos)
}
// wordBackward moves the cursor one word to the left. If input is masked, move
// input to the start so as not to reveal word breaks in the masked input.
func (m *Model) wordBackward() {
// 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
return false
}
if m.EchoMode != EchoNormal {
m.CursorStart()
return
return m.cursorStart()
}
blink := false
i := m.pos - 1
for i >= 0 {
if unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos - 1)
blink = m.setCursor(m.pos - 1)
i--
} else {
break
@@ -449,30 +542,33 @@ func (m *Model) wordBackward() {
for i >= 0 {
if !unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos - 1)
blink = m.setCursor(m.pos - 1)
i--
} else {
break
}
}
return blink
}
// wordForward moves the cursor one word to the right. If the input is masked,
// move input to the end so as not to reveal word breaks in the masked input.
func (m *Model) wordForward() {
// 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
return false
}
if m.EchoMode != EchoNormal {
m.CursorEnd()
return
return m.cursorEnd()
}
blink := false
i := m.pos
for i < len(m.value) {
if unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos + 1)
blink = m.setCursor(m.pos + 1)
i++
} else {
break
@@ -481,12 +577,14 @@ func (m *Model) wordForward() {
for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) {
m.SetCursor(m.pos + 1)
blink = m.setCursor(m.pos + 1)
i++
} else {
break
}
}
return blink
}
func (m Model) echoTransform(v string) string {
@@ -504,58 +602,76 @@ func (m Model) echoTransform(v string) string {
// 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
}
// Let's remember where the position of the cursor currently is so that if
// the cursor position changes, we can reset the blink.
oldPos := m.pos //nolint
var resetBlink bool
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.DeleteWordBackward):
switch msg.Type {
case tea.KeyBackspace: // delete character before cursor
m.Err = nil
m.deleteWordBackward()
case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
m.Err = nil
if len(m.value) > 0 {
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
if m.pos > 0 {
m.SetCursor(m.pos - 1)
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 key.Matches(msg, m.KeyMap.WordBackward):
m.wordBackward()
case key.Matches(msg, m.KeyMap.CharacterBackward):
if m.pos > 0 {
m.SetCursor(m.pos - 1)
case tea.KeyLeft, tea.KeyCtrlB:
if msg.Alt { // alt+left arrow, back one word
resetBlink = m.wordLeft()
break
}
case key.Matches(msg, m.KeyMap.WordForward):
m.wordForward()
case key.Matches(msg, m.KeyMap.CharacterForward):
if m.pos < len(m.value) {
m.SetCursor(m.pos + 1)
if m.pos > 0 { // left arrow, ^F, back one character
resetBlink = m.setCursor(m.pos - 1)
}
case key.Matches(msg, m.KeyMap.DeleteWordBackward):
m.deleteWordBackward()
case key.Matches(msg, m.KeyMap.LineStart):
m.CursorStart()
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
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 key.Matches(msg, m.KeyMap.LineEnd):
m.CursorEnd()
case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
m.deleteAfterCursor()
case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
m.deleteBeforeCursor()
case key.Matches(msg, m.KeyMap.Paste):
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 key.Matches(msg, m.KeyMap.DeleteWordForward):
m.deleteWordForward()
default:
case tea.KeyRunes, tea.KeySpace: // 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 {
runes := msg.Runes
@@ -565,31 +681,59 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
value = append(value[:m.pos], append(runes, value[m.pos:]...)...)
m.SetValue(string(value))
if m.Err == nil {
m.SetCursor(m.pos + len(runes))
resetBlink = m.setCursor(m.pos + len(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:
m.handlePaste(string(msg))
resetBlink = m.handlePaste(string(msg))
case pasteErrMsg:
m.Err = msg
}
var cmds []tea.Cmd
var cmd tea.Cmd
m.Cursor, cmd = m.Cursor.Update(msg)
cmds = append(cmds, cmd)
if oldPos != m.pos {
m.Cursor.Blink = false
cmds = append(cmds, m.Cursor.BlinkCmd())
if resetBlink {
cmd = m.blinkCmd()
}
m.handleOverflow()
return m, tea.Batch(cmds...)
return m, cmd
}
// View renders the textinput in its current state.
@@ -606,13 +750,10 @@ func (m Model) View() string {
v := styleText(m.echoTransform(string(value[:pos])))
if pos < len(value) {
char := m.echoTransform(string(value[pos]))
m.Cursor.SetChar(char)
v += m.Cursor.View() // cursor and text under it
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 {
m.Cursor.SetChar(" ")
v += m.Cursor.View()
v += m.cursorView(" ")
}
// If a max width and background color were set fill the empty spaces with
@@ -637,9 +778,12 @@ func (m Model) placeholderView() string {
style = m.PlaceholderStyle.Inline(true).Render
)
m.Cursor.TextStyle = m.PlaceholderStyle
m.Cursor.SetChar(p[:1])
v += m.Cursor.View()
// 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:])
@@ -647,9 +791,42 @@ func (m Model) placeholderView() string {
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 cursor.Blink()
return initialBlinkMsg{}
}
// Paste is a command for pasting from the clipboard into the text input.
@@ -681,31 +858,3 @@ func max(a, b int) int {
}
return b
}
// Deprecated.
// Deprecated: use cursor.Mode.
type CursorMode int
const (
// Deprecated: use cursor.CursorBlink.
CursorBlink = CursorMode(cursor.CursorBlink)
// Deprecated: use cursor.CursorStatic.
CursorStatic = CursorMode(cursor.CursorStatic)
// Deprecated: use cursor.CursorHide.
CursorHide = CursorMode(cursor.CursorHide)
)
func (c CursorMode) String() string {
return cursor.Mode(c).String()
}
// Deprecated: use cursor.Mode().
func (m Model) CursorMode() CursorMode {
return CursorMode(m.Cursor.Mode())
}
// Deprecated: use cursor.SetMode().
func (m *Model) SetCursorMode(mode CursorMode) tea.Cmd {
return m.Cursor.SetMode(cursor.Mode(mode))
}

View File

@@ -10,7 +10,7 @@ import (
)
// New returns a new model with the given width and height as well as default
// key mappings.
// keymappings.
func New(width, height int) (m Model) {
m.Width = width
m.Height = height
@@ -207,16 +207,6 @@ func (m *Model) LineUp(n int) (lines []string) {
return m.visibleLines()
}
// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport.
func (m Model) TotalLineCount() int {
return len(m.lines)
}
// VisibleLineCount returns the number of the visible lines within the viewport.
func (m Model) VisibleLineCount() int {
return len(m.visibleLines())
}
// GotoTop sets the viewport to the top position.
func (m *Model) GotoTop() (lines []string) {
if m.AtTop() {
@@ -247,11 +237,12 @@ func Sync(m Model) tea.Cmd {
}
// ViewDown is a high performance command that moves the viewport up by a given
// number of lines. Use Model.ViewDown to get the lines that should be rendered.
// numer of lines. Use Model.ViewDown to get the lines that should be rendered.
// For example:
//
// lines := model.ViewDown(1)
// cmd := ViewDown(m, lines)
// lines := model.ViewDown(1)
// cmd := ViewDown(m, lines)
//
func ViewDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
@@ -353,29 +344,24 @@ func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) {
func (m Model) View() string {
if m.HighPerformanceRendering {
// Just send newlines since we're going to be rendering the actual
// content separately. We still need to send something that equals the
// content seprately. We still need to 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", max(0, m.Height-1))
return strings.Repeat("\n", m.Height-1)
}
w, h := m.Width, m.Height
if sw := m.Style.GetWidth(); sw != 0 {
w = min(w, sw)
lines := m.visibleLines()
// Fill empty space with newlines
extraLines := ""
if len(lines) < m.Height {
extraLines = strings.Repeat("\n", max(0, m.Height-len(lines)))
}
if sh := m.Style.GetHeight(); sh != 0 {
h = min(h, sh)
}
contentWidth := w - m.Style.GetHorizontalFrameSize()
contentHeight := h - m.Style.GetVerticalFrameSize()
contents := lipgloss.NewStyle().
Height(contentHeight). // pad to height.
MaxHeight(contentHeight). // truncate height if taller.
MaxWidth(contentWidth). // truncate width.
Render(strings.Join(m.visibleLines(), "\n"))
return m.Style.Copy().
UnsetWidth().UnsetHeight(). // Style size already applied in contents.
Render(contents)
UnsetWidth().
UnsetHeight().
Render(strings.Join(lines, "\n") + extraLines)
}
func clamp(v, low, high int) int {