32 Commits
vim ... master

Author SHA1 Message Date
Adam Bull
fac7a01329 feat(viewport): line count getters (#283) 2022-11-07 10:05:03 -05:00
Christian Muehlhaeuser
0474e129b8 docs: picture tag is causing display issues 2022-10-25 03:23:29 +02:00
Christian Muehlhaeuser
405c3adc42 docs: new readme footer 2022-10-24 10:23:47 +02:00
Raphael 'kena' Poss
85da9addba feat: make the paginator key bindings customizable 2022-10-07 12:49:15 -04:00
Craig Wilson
aea42690e7 fix(help): full help renders fully when width is unset. 2022-10-07 12:36:11 -04:00
Raphael 'kena' Poss
9a48dca003 feat: support key bindings in textinput like textarea 2022-10-07 11:27:19 -04:00
hhe07
b2b7040ccf fix(textarea): paste (#250) 2022-10-06 15:54:43 -04:00
Maas Lalani
d3aed22a41 Revert "chore: move cursor bubble to internal"
This reverts commit a4ed54327f.
2022-10-06 15:33:16 -04:00
Maas Lalani
d177512114 fix: set value moves cursor only when empty and at starting position 2022-10-06 11:42:29 -04:00
dependabot[bot]
e857875f2a feat(deps): bump github.com/charmbracelet/bubbletea
Bumps [github.com/charmbracelet/bubbletea](https://github.com/charmbracelet/bubbletea) from 0.21.0 to 0.22.1.
- [Release notes](https://github.com/charmbracelet/bubbletea/releases)
- [Commits](https://github.com/charmbracelet/bubbletea/compare/v0.21.0...v0.22.1)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/bubbletea
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-26 08:26:06 +02:00
dependabot[bot]
f531ca3055 feat(deps): bump github.com/charmbracelet/lipgloss from 0.5.0 to 0.6.0
Bumps [github.com/charmbracelet/lipgloss](https://github.com/charmbracelet/lipgloss) from 0.5.0 to 0.6.0.
- [Release notes](https://github.com/charmbracelet/lipgloss/releases)
- [Commits](https://github.com/charmbracelet/lipgloss/compare/v0.5.0...v0.6.0)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/lipgloss
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-26 08:22:33 +02:00
dependabot[bot]
ea21e3b359 feat(deps): bump github.com/mattn/go-runewidth from 0.0.13 to 0.0.14
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.13 to 0.0.14.
- [Release notes](https://github.com/mattn/go-runewidth/releases)
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.13...v0.0.14)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-26 08:19:30 +02:00
dependabot[bot]
f67537dca5 chore(deps): bump actions/checkout from 2 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-26 08:16:51 +02:00
dependabot[bot]
b7a4a1cd93 chore(deps): bump actions/setup-go from 2 to 3
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 2 to 3.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-09-26 08:16:35 +02:00
Christian Muehlhaeuser
4ae575a4c5 chore: use latest gofmt 2022-09-26 08:13:03 +02:00
Christian Muehlhaeuser
bd074d2dbc ci: add separate sets of hard- and soft-enforced linters 2022-09-26 08:13:03 +02:00
Christian Muehlhaeuser
18909edafc chore: add dependabot config 2022-09-26 08:02:50 +02:00
Tom Payne
72d87e4513 chore: Miscellaneous minor fixes (#243)
* chore: remove internal use of deprecated NewModel

* chore: miscellaneous comment fixes
2022-09-15 10:51:42 -04:00
Maas Lalani
a4ed54327f chore: move cursor bubble to internal 2022-09-14 15:45:51 -04:00
Maas Lalani
a99b55cda1 Delegate Cursor Logic to cursor bubble (#181)
* refactor: remove cursor logic from textinput
* chore: Add Deprecated Fields to avoid breaking changes
* fix: Ctrl+A and typing moves to end bug
2022-09-14 10:44:10 -04:00
Raphael 'kena' Poss
062b257e8c fix(cursor): apply TextStyle inline (#230)
Co-authored-by: Maas Lalani <maas@lalani.dev>
2022-09-07 16:05:25 -04:00
Maas Lalani
8b3bfee06d docs: add table bubble to README 2022-09-06 13:28:31 -04:00
Maas Lalani
ff77858511 docs: change tea{,party} to bubbletea 2022-09-06 13:28:31 -04:00
Bastian Winkler
13f52d678d fix(table): append headers/columns to zero sized string slice 2022-09-02 16:30:15 -04:00
Raphael 'kena' Poss
278edd1c2b fix(viewport): properly truncate to size (#228)
There are many "interesting" cases:

- logical content is wider than display width (long lines).
  In this case, we truncate.
- logical content is smaller than display height (fewer lines
  than visible).
  In this case, we pad with empty lines up to the specified height.
- logical content is higher than display height (more lines than
  visible).
  In this case, we truncate.

- style specifies a width wider than the display width.
  In this case, we ignore the style width and
  fit in the display width.
- style specifies a height higher than the display height.
  Same as width, we ignore the style and fit in the display height.
- style specifies a narrower width or smaller height than the display.
  In this case we obey the style.
2022-09-02 12:50:33 -07:00
Raphael 'kena' Poss
09e1f00349 textarea: support dynamic prompts
The dynamic prompt can be different on every line.
See for example:
[![asciicast](https://asciinema.org/a/iFBPBwoDZOzcoRJOgfmPk8ObH.svg)](https://asciinema.org/a/iFBPBwoDZOzcoRJOgfmPk8ObH)
2022-09-02 14:31:26 -04:00
Raphael 'kena' Poss
c099d31570 textarea: add some comments 2022-09-02 14:31:26 -04:00
Raphael 'kena' Poss
db2a8b4e16 textarea: new bindings for "go to begin" / "go to end" 2022-09-02 13:44:52 -04:00
Nicolas Karolak
afd6f58c18 feat(table): add function to set cursor position (#219) 2022-08-30 20:54:25 -04:00
Christian Rocha
d44e242f37 fix(viewport): honor width and height settings 2022-08-29 21:05:16 -07:00
buz
776062e30c fix(table): correct keybinding for page down (#220)
Co-authored-by: Bastian Winkler <buz@serious.im>
2022-08-21 09:56:58 -04:00
kena
1c26128786 textarea: add uppercase/lowercase/capitalize word right (#210)
* Extract char navigation in separate function.

* Make wordLeft/wordRight find words on the prev/next line

... if there's no word on the current line to find any more.

* Support uppercase/lowercase/capitalize commands.

* Add transpose
2022-08-19 11:17:36 -04:00
22 changed files with 862 additions and 1417 deletions

22
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
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" GO111MODULE: "on"
steps: steps:
- name: Install Go - name: Install Go
uses: actions/setup-go@v2 uses: actions/setup-go@v3
with: with:
go-version: ${{ matrix.go-version }} go-version: ${{ matrix.go-version }}
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
- name: Download Go modules - name: Download Go modules
run: go mod download run: go mod download

View File

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

28
.github/workflows/lint-soft.yml vendored Normal file
View File

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

47
.golangci-soft.yml Normal file
View File

@@ -0,0 +1,47 @@
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,20 +15,15 @@ issues:
linters: linters:
enable: enable:
- bodyclose - bodyclose
- dupl
- exportloopref - exportloopref
- goconst
- godot
- godox
- goimports - goimports
- goprintffuncname
- gosec - gosec
- ifshort - nilerr
- misspell - predeclared
- prealloc
- revive - revive
- rowserrcheck - rowserrcheck
- sqlclosecheck - sqlclosecheck
- tparallel
- unconvert - unconvert
- unparam - unparam
- whitespace - 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. 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.” 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, basic spinner](https://github.com/charmbracelet/bubbletea/tree/master/examples/spinner/main.go)
* [Example code, various spinners](https://github.com/charmbracelet/tea/tree/master/examples/spinners/main.go) * [Example code, various spinners](https://github.com/charmbracelet/bubbletea/tree/master/examples/spinners/main.go)
## Text Input ## Text Input
@@ -38,8 +38,8 @@ 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 pasting, in-place scrolling when the value exceeds the width of the element and
the common, and many customization options. the common, and many customization options.
* [Example code, one field](https://github.com/charmbracelet/tea/tree/master/examples/textinput/main.go) * [Example code, one field](https://github.com/charmbracelet/bubbletea/tree/master/examples/textinput/main.go)
* [Example code, many fields](https://github.com/charmbracelet/tea/tree/master/examples/textinputs/main.go) * [Example code, many fields](https://github.com/charmbracelet/bubbletea/tree/master/examples/textinputs/main.go)
## Text Area ## Text Area
@@ -50,17 +50,21 @@ spans multiple lines. Supports unicode, pasting, vertical scrolling when the
value exceeds the width and height of the element, and many customization value exceeds the width and height of the element, and many customization
options. options.
* [Example code, chat input](https://github.com/charmbracelet/tea/tree/master/examples/chat/main.go) * [Example code, chat input](https://github.com/charmbracelet/bubbletea/tree/master/examples/chat/main.go)
* [Example code, story time input](https://github.com/charmbracelet/tea/tree/master/examples/textarea/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 ## Progress
<picture> <img src="https://stuff.charm.sh/bubbles-examples/progress.gif" width="800" alt="Progressbar Example">
<source media="(max-width: 800px)" srcset="https://stuff.charm.sh/bubbles-examples/progress.gif">
<source media="(min-width: 800px)" width="800" srcset="https://stuff.charm.sh/bubbles-examples/progress.gif">
<img src="https://stuff.charm.sh/bubbles-examples/progress.gif" alt="Progressbar Example">
</picture>
A simple, customizable progress meter, with optional animation via A simple, customizable progress meter, with optional animation via
[Harmonica][harmonica]. Supports solid and gradient fills. The empty and filled [Harmonica][harmonica]. Supports solid and gradient fills. The empty and filled
@@ -87,17 +91,13 @@ logic and visualize pagination however you like.
## Viewport ## Viewport
<picture> <img src="https://stuff.charm.sh/bubbles-examples/viewport.gif" width="600" alt="Viewport Example">
<source media="(max-width: 600px)" srcset="https://stuff.charm.sh/bubbles-examples/viewport.gif?0">
<source media="(min-width: 600px)" width="600" srcset="https://stuff.charm.sh/bubbles-examples/viewport.gif?0">
<img src="https://stuff.charm.sh/bubbles-examples/viewport.gif?0" alt="Viewport Example">
</picture>
A viewport for vertically scrolling content. Optionally includes standard A viewport for vertically scrolling content. Optionally includes standard
pager keybindings and mouse wheel support. A high performance mode is available pager keybindings and mouse wheel support. A high performance mode is available
for applications which make use of the alternate screen buffer. for applications which make use of the alternate screen buffer.
* [Example code](https://github.com/charmbracelet/tea/tree/master/examples/pager/main.go) * [Example code](https://github.com/charmbracelet/bubbletea/tree/master/examples/pager/main.go)
This component is well complemented with [Reflow][reflow] for ANSI-aware This component is well complemented with [Reflow][reflow] for ANSI-aware
indenting and text wrapping. indenting and text wrapping.
@@ -107,20 +107,16 @@ indenting and text wrapping.
## List ## List
<picture> <img src="https://stuff.charm.sh/bubbles-examples/list.gif" width="600" alt="List Example">
<source media="(max-width: 600px)" srcset="https://stuff.charm.sh/bubbles-examples/list.gif">
<source media="(min-width: 600px)" width="600" srcset="https://stuff.charm.sh/bubbles-examples/list.gif">
<img src="https://stuff.charm.sh/bubbles-examples/list.gif" alt="List Example">
</picture>
A customizable, batteries-included component for browsing a set of items. A customizable, batteries-included component for browsing a set of items.
Features pagination, fuzzy filtering, auto-generated help, an activity spinner, Features pagination, fuzzy filtering, auto-generated help, an activity spinner,
and status messages, all of which can be enabled and disabled as needed. and status messages, all of which can be enabled and disabled as needed.
Extrapolated from [Glow][glow]. Extrapolated from [Glow][glow].
* [Example code, default list](https://github.com/charmbracelet/tea/tree/master/examples/list-default/main.go) * [Example code, default list](https://github.com/charmbracelet/bubbletea/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, simple list](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-simple/main.go)
* [Example code, all features](https://github.com/charmbracelet/tea/tree/master/examples/list-fancy/main.go) * [Example code, all features](https://github.com/charmbracelet/bubbletea/tree/master/examples/list-fancy/main.go)
## Timer ## Timer
@@ -145,11 +141,7 @@ can be customized as you see fit.
## Help ## Help
<picture> <img src="https://stuff.charm.sh/bubbles-examples/help.gif" width="500" alt="Help Example">
<source media="(max-width: 500px)" srcset="https://stuff.charm.sh/bubbles-examples/help.gif">
<source media="(min-width: 500px)" width="500" srcset="https://stuff.charm.sh/bubbles-examples/help.gif">
<img src="https://stuff.charm.sh/bubbles-examples/help.gif" alt="Help Example">
</picture>
A customizable horizontal mini help view that automatically generates itself A customizable horizontal mini help view that automatically generates itself
from your keybindings. It features single and multi-line modes, which the user from your keybindings. It features single and multi-line modes, which the user
@@ -225,11 +217,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
If youve built a Bubble you think should be listed here, If youve built a Bubble you think should be listed here,
[let us know](mailto:vt100@charm.sh). [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 ## License
[MIT](https://github.com/charmbracelet/teaparty/raw/master/LICENSE) [MIT](https://github.com/charmbracelet/bubbletea/raw/master/LICENSE)
*** ***
@@ -238,5 +236,3 @@ 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> <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热爱开源 • Charm loves open source
[charm]: https://charm.sh/

View File

@@ -70,8 +70,8 @@ type Model struct {
blinkCtx *blinkCtx blinkCtx *blinkCtx
// The ID of the blink message we're expecting to receive. // The ID of the blink message we're expecting to receive.
blinkTag int blinkTag int
// cursorMode determines the behavior of the cursor // mode determines the behavior of the cursor
cursorMode Mode mode Mode
} }
// New creates a new model with default settings. // New creates a new model with default settings.
@@ -79,8 +79,8 @@ func New() Model {
return Model{ return Model{
BlinkSpeed: defaultBlinkSpeed, BlinkSpeed: defaultBlinkSpeed,
Blink: true, Blink: true,
cursorMode: CursorBlink, mode: CursorBlink,
blinkCtx: &blinkCtx{ blinkCtx: &blinkCtx{
ctx: context.Background(), ctx: context.Background(),
@@ -94,7 +94,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
case initialBlinkMsg: case initialBlinkMsg:
// We accept all initialBlinkMsgs generated by the Blink command. // We accept all initialBlinkMsgs generated by the Blink command.
if m.cursorMode != CursorBlink || !m.focus { if m.mode != CursorBlink || !m.focus {
return m, nil return m, nil
} }
@@ -106,7 +106,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
// only exactly when it should. // only exactly when it should.
// Is this model blink-able? // Is this model blink-able?
if m.cursorMode != CursorBlink || !m.focus { if m.mode != CursorBlink || !m.focus {
return m, nil return m, nil
} }
@@ -116,7 +116,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
var cmd tea.Cmd var cmd tea.Cmd
if m.cursorMode == CursorBlink { if m.mode == CursorBlink {
m.Blink = !m.Blink m.Blink = !m.Blink
cmd = m.BlinkCmd() cmd = m.BlinkCmd()
} }
@@ -128,18 +128,18 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
// CursorMode returns the model's cursor mode. For available cursor modes, see // Mode returns the model's cursor mode. For available cursor modes, see
// type CursorMode. // type Mode.
func (m Model) CursorMode() Mode { func (m Model) Mode() Mode {
return m.cursorMode return m.mode
} }
// SetCursorMode sets the model's cursor mode. This method returns a command. // SetMode sets the model's cursor mode. This method returns a command.
// //
// For available cursor modes, see type CursorMode. // For available cursor modes, see type CursorMode.
func (m *Model) SetCursorMode(mode Mode) tea.Cmd { func (m *Model) SetMode(mode Mode) tea.Cmd {
m.cursorMode = mode m.mode = mode
m.Blink = m.cursorMode == CursorHide || !m.focus m.Blink = m.mode == CursorHide || !m.focus
if mode == CursorBlink { if mode == CursorBlink {
return Blink return Blink
} }
@@ -148,7 +148,7 @@ func (m *Model) SetCursorMode(mode Mode) tea.Cmd {
// BlinkCmd is an command used to manage cursor blinking. // BlinkCmd is an command used to manage cursor blinking.
func (m *Model) BlinkCmd() tea.Cmd { func (m *Model) BlinkCmd() tea.Cmd {
if m.cursorMode != CursorBlink { if m.mode != CursorBlink {
return nil return nil
} }
@@ -179,9 +179,9 @@ func Blink() tea.Msg {
// Focus focuses the cursor to allow it to blink if desired. // Focus focuses the cursor to allow it to blink if desired.
func (m *Model) Focus() tea.Cmd { func (m *Model) Focus() tea.Cmd {
m.focus = true m.focus = true
m.Blink = m.cursorMode == CursorHide // show the cursor unless we've explicitly hidden it m.Blink = m.mode == CursorHide // show the cursor unless we've explicitly hidden it
if m.cursorMode == CursorBlink && m.focus { if m.mode == CursorBlink && m.focus {
return m.BlinkCmd() return m.BlinkCmd()
} }
return nil return nil
@@ -201,7 +201,7 @@ func (m *Model) SetChar(char string) {
// View displays the cursor. // View displays the cursor.
func (m Model) View() string { func (m Model) View() string {
if m.Blink { if m.Blink {
return m.TextStyle.Render(m.char) return m.TextStyle.Inline(true).Render(m.char)
} }
return m.Style.Inline(true).Reverse(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 ( require (
github.com/atotto/clipboard v0.1.4 github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbletea v0.21.0 github.com/charmbracelet/bubbletea v0.22.1
github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/harmonica v0.2.0
github.com/charmbracelet/lipgloss v0.5.0 github.com/charmbracelet/lipgloss v0.6.0
github.com/kylelemons/godebug v1.1.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-runewidth v0.0.13 github.com/mattn/go-runewidth v0.0.14
github.com/muesli/reflow v0.3.0 github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739
github.com/sahilm/fuzzy v0.1.0 github.com/sahilm/fuzzy v0.1.0

30
go.sum
View File

@@ -1,27 +1,31 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI= github.com/charmbracelet/bubbletea v0.22.1 h1:z66q0LWdJNOWEH9zadiAIXp2GN1AWrwNXU8obVY9X24=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= github.com/charmbracelet/bubbletea v0.22.1/go.mod h1:8/7hVvbPN6ZZPkczLiB8YpLkLJ0n7DMho5Wvfd2X1C0=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= 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/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk=
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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.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.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/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.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 h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/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.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 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
@@ -29,10 +33,7 @@ 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 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI=
github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 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.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 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/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 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
@@ -40,7 +41,10 @@ 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-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-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-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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 // Column
totalWidth += lipgloss.Width(col) totalWidth += lipgloss.Width(col)
if totalWidth > m.Width { if m.Width > 0 && totalWidth > m.Width {
break break
} }
@@ -212,7 +212,7 @@ func (m Model) FullHelpView(groups [][]key.Binding) string {
// Separator // Separator
if i < len(group)-1 { if i < len(group)-1 {
totalWidth += sepWidth totalWidth += sepWidth
if totalWidth > m.Width { if m.Width > 0 && totalWidth > m.Width {
break break
} }
} }

View File

@@ -2,35 +2,35 @@
// keymappings useful in Bubble Tea components. There are a few different ways // keymappings useful in Bubble Tea components. There are a few different ways
// you can define a keymapping with this package. Here's one example: // you can define a keymapping with this package. Here's one example:
// //
// type KeyMap struct { // type KeyMap struct {
// Up key.Binding // Up key.Binding
// Down key.Binding // Down key.Binding
// } // }
// //
// var DefaultKeyMap = KeyMap{ // var DefaultKeyMap = KeyMap{
// Up: key.NewBinding( // Up: key.NewBinding(
// key.WithKeys("k", "up"), // actual keybindings // key.WithKeys("k", "up"), // actual keybindings
// key.WithHelp("↑/k", "move up"), // corresponding help text // key.WithHelp("↑/k", "move up"), // corresponding help text
// ), // ),
// Down: key.NewBinding( // Down: key.NewBinding(
// key.WithKeys("j", "down"), // key.WithKeys("j", "down"),
// key.WithHelp("↓/j", "move down"), // key.WithHelp("↓/j", "move down"),
// ), // ),
// } // }
// //
// func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// switch msg := msg.(type) { // switch msg := msg.(type) {
// case tea.KeyMsg: // case tea.KeyMsg:
// switch { // switch {
// case key.Matches(msg, DefaultKeyMap.Up): // case key.Matches(msg, DefaultKeyMap.Up):
// // The user pressed up // // The user pressed up
// case key.Matches(msg, DefaultKeyMap.Down): // case key.Matches(msg, DefaultKeyMap.Down):
// // The user pressed down // // The user pressed down
// } // }
// } // }
// //
// // ... // // ...
// } // }
// //
// The help information, which is not used in the example above, can be used // The help information, which is not used in the example above, can be used
// to render help text for keystrokes in your views. // to render help text for keystrokes in your views.

View File

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

View File

@@ -7,6 +7,7 @@ package paginator
import ( import (
"fmt" "fmt"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
) )
@@ -19,20 +20,49 @@ const (
Dots 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. // Model is the Bubble Tea model for this user interface.
type Model struct { type Model struct {
Type Type // Type configures how the pagination is rendered (Arabic, Dots).
Page int Type Type
PerPage int // Page is the current page number.
TotalPages int Page int
ActiveDot string // PerPage is the number of items per page.
InactiveDot string PerPage int
ArabicFormat string // 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.
UsePgUpPgDownKeys bool UsePgUpPgDownKeys bool
UseLeftRightKeys bool // Deprecated: customize KeyMap instead.
UseUpDownKeys bool UseLeftRightKeys bool
UseHLKeys bool // Deprecated: customize KeyMap instead.
UseJKKeys bool UseUpDownKeys bool
// Deprecated: customize KeyMap instead.
UseHLKeys bool
// Deprecated: customize KeyMap instead.
UseJKKeys bool
} }
// SetTotalPages is a helper function for calculating the total number of pages // SetTotalPages is a helper function for calculating the total number of pages
@@ -65,10 +95,9 @@ func (m Model) ItemsOnPage(totalItems int) int {
// of the slice you're rendering and you'll receive the start and end bounds // of the slice you're rendering and you'll receive the start and end bounds
// corresponding the to pagination. For example: // corresponding the to pagination. For example:
// //
// bunchOfStuff := []stuff{...} // bunchOfStuff := []stuff{...}
// start, end := model.GetSliceBounds(len(bunchOfStuff)) // start, end := model.GetSliceBounds(len(bunchOfStuff))
// sliceToRender := bunchOfStuff[start:end] // sliceToRender := bunchOfStuff[start:end]
//
func (m *Model) GetSliceBounds(length int) (start int, end int) { func (m *Model) GetSliceBounds(length int) (start int, end int) {
start = m.Page * m.PerPage start = m.Page * m.PerPage
end = min(m.Page*m.PerPage+m.PerPage, length) end = min(m.Page*m.PerPage+m.PerPage, length)
@@ -99,18 +128,14 @@ func (m Model) OnLastPage() bool {
// New creates a new model with defaults. // New creates a new model with defaults.
func New() Model { func New() Model {
return Model{ return Model{
Type: Arabic, Type: Arabic,
Page: 0, Page: 0,
PerPage: 1, PerPage: 1,
TotalPages: 1, TotalPages: 1,
ActiveDot: "•", KeyMap: DefaultKeyMap,
InactiveDot: "", ActiveDot: "",
ArabicFormat: "%d/%d", InactiveDot: "○",
UsePgUpPgDownKeys: true, ArabicFormat: "%d/%d",
UseLeftRightKeys: true,
UseUpDownKeys: false,
UseHLKeys: true,
UseJKKeys: false,
} }
} }
@@ -123,45 +148,11 @@ var NewModel = New
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
if m.UsePgUpPgDownKeys { switch {
switch msg.String() { case key.Matches(msg, m.KeyMap.NextPage):
case "pgup": m.NextPage()
m.PrevPage() case key.Matches(msg, m.KeyMap.PrevPage):
case "pgdown": m.PrevPage()
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,13 +37,12 @@ const (
defaultDamping = 1.0 defaultDamping = 1.0
) )
// Option is used to set options in NewModel. For example: // Option is used to set options in New. For example:
//
// progress := NewModel(
// WithRamp("#ff0000", "#0000ff"),
// WithoutPercentage(),
// )
// //
// progress := New(
// WithRamp("#ff0000", "#0000ff"),
// WithoutPercentage(),
// )
type Option func(*Model) type Option func(*Model)
// WithDefaultGradient sets a gradient fill with default colors. // WithDefaultGradient sets a gradient fill with default colors.

View File

@@ -85,7 +85,7 @@ var (
} }
) )
// Model contains the state for the spinner. Use NewModel to create new models // Model contains the state for the spinner. Use New to create new models
// rather than using Model as a struct literal. // rather than using Model as a struct literal.
type Model struct { type Model struct {
// Spinner settings to use. See type Spinner. // Spinner settings to use. See type Spinner.
@@ -208,8 +208,7 @@ func Tick() tea.Msg {
// Option is used to set options in New. For example: // Option is used to set options in New. For example:
// //
// spinner := New(WithSpinner(Dot)) // spinner := New(WithSpinner(Dot))
//
type Option func(*Model) type Option func(*Model)
// WithSpinner is an option to set the spinner. // WithSpinner is an option to set the spinner.

View File

@@ -59,11 +59,11 @@ func DefaultKeyMap() KeyMap {
), ),
PageUp: key.NewBinding( PageUp: key.NewBinding(
key.WithKeys("b", "pgup"), key.WithKeys("b", "pgup"),
key.WithHelp("b", "page up"), key.WithHelp("b/pgup", "page up"),
), ),
PageDown: key.NewBinding( PageDown: key.NewBinding(
key.WithKeys("f", "pgup", spacebar), key.WithKeys("f", "pgdown", spacebar),
key.WithHelp("f", "page down"), key.WithHelp("f/pgdn", "page down"),
), ),
HalfPageUp: key.NewBinding( HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"), key.WithKeys("u", "ctrl+u"),
@@ -109,8 +109,7 @@ func (m *Model) SetStyles(s Styles) {
// Option is used to set options in New. For example: // Option is used to set options in New. For example:
// //
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}})) // table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
//
type Option func(*Model) type Option func(*Model)
// New creates a new model for the table widget. // New creates a new model for the table widget.
@@ -291,6 +290,12 @@ func (m Model) Cursor() int {
return m.cursor 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. // MoveUp moves the selection up by any number of row.
// It can not go above the first row. // It can not go above the first row.
func (m *Model) MoveUp(n int) { func (m *Model) MoveUp(n int) {
@@ -340,7 +345,7 @@ func (m *Model) FromValues(value, separator string) {
} }
func (m Model) headersView() string { func (m Model) headersView() string {
var s = make([]string, len(m.cols)) var s = make([]string, 0, len(m.cols))
for _, col := range m.cols { for _, col := range m.cols {
style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true) style := lipgloss.NewStyle().Width(col.Width).MaxWidth(col.Width).Inline(true)
renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…")) renderedCell := style.Render(runewidth.Truncate(col.Title, col.Width, "…"))
@@ -350,7 +355,7 @@ func (m Model) headersView() string {
} }
func (m *Model) renderRow(rowID int) string { func (m *Model) renderRow(rowID int) string {
var s = make([]string, len(m.cols)) var s = make([]string, 0, len(m.cols))
for i, value := range m.rows[rowID] { for i, value := range m.rows[rowID] {
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) 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, "…"))) renderedCell := m.styles.Cell.Render(style.Render(runewidth.Truncate(value, m.cols[i].Width, "…")))

View File

@@ -1,791 +0,0 @@
package textarea
import (
"strconv"
"strings"
"unicode"
"github.com/charmbracelet/bubbles/cursor"
tea "github.com/charmbracelet/bubbletea"
rw "github.com/mattn/go-runewidth"
)
// Mode is the possible modes of the textarea for modal editing.
type Mode string
const (
// ModeNormal is the normal mode for navigating around text.
ModeNormal Mode = "normal"
// ModeInsert is the insert mode for inserting text.
ModeInsert Mode = "insert"
)
// SetMode sets the mode of the textarea.
func (m *Model) SetMode(mode Mode) tea.Cmd {
switch mode {
case ModeInsert:
m.mode = ModeInsert
return m.Cursor.SetCursorMode(cursor.CursorBlink)
case ModeNormal:
m.mode = ModeNormal
m.col = clamp(m.col-1, 0, len(m.value[m.row]))
return m.Cursor.SetCursorMode(cursor.CursorStatic)
}
return nil
}
// Action is the type of action that will be performed when the user completes
// a key combination.
type Action int
const (
// ActionMove moves the cursor.
ActionMove Action = iota
// ActionReplace replaces text.
ActionReplace
// ActionDelete deletes text.
ActionDelete
// ActionYank yanks text.
ActionYank
// ActionChange deletes text and enters insert mode.
ActionChange
)
// Position is a (row, column) pair representing a position of the cursor or
// any character.
type Position struct {
Row int
Col int
}
// Range is a range of characters in the text area.
type Range struct {
Start Position
End Position
}
// NormalCommand is a helper for keeping track of the various relevant information
// when performing vim motions in the textarea.
type NormalCommand struct {
// Buffer is the buffer of keys that have been press for the current
// command.
Buffer string
// Count is the number of times to replay the action. This is usually
// optional and defaults to 1.
Count int
// Action is the action to be performed.
Action Action
// Range is the range of characters to perform the action on.
Range Range
// Seeking is the type of seeking that is in progress.
Seeking SeekType
}
// SeekType are the possible types of seeking that can be in progress.
type SeekType int
const (
// SeekNone is the default seeking action.
SeekNone SeekType = iota
// SeekForwardTo is the seeking action for f.
SeekForwardTo // f
// SeekBackwardTo is the seeking action for F.
SeekBackwardTo // F
// SeekForwardUntil is the seeking action for t.
SeekForwardUntil // t
// SeekBackwardUntil is the seeking action for T.
SeekBackwardUntil // T
// SeekAround is the seeking action for a.
SeekAround // a
// SeekInner is the seeking action for i.
SeekInner // i
)
// IsSeeking returns whether the command is in the middle of seeking a range.
func (n *NormalCommand) IsSeeking() bool {
return n.Seeking != SeekNone
}
// IsSeekingForward returns whether the seeking action is in the forward direction (f, t).
func (n *NormalCommand) IsSeekingForward() bool {
return n.Seeking == SeekForwardTo || n.Seeking == SeekForwardUntil
}
// IsSeekingBackward returns whether the seeking action is in the backward direction (F, T).
func (n *NormalCommand) IsSeekingBackward() bool {
return n.Seeking == SeekBackwardTo || n.Seeking == SeekBackwardUntil
}
// executeMsg executes a command.
type executeMsg NormalCommand
// executeCmd implements tea.Cmd for an executeMsg.
func executeCmd(n NormalCommand) tea.Cmd {
return func() tea.Msg {
return executeMsg(n)
}
}
func (m *Model) insertUpdate(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
return m.SetMode(ModeNormal)
case "ctrl+k":
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col >= len(m.value[m.row]) {
m.mergeLineBelow(m.row)
break
}
m.deleteAfterCursor()
case "ctrl+u":
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col <= 0 {
m.mergeLineAbove(m.row)
break
}
m.deleteBeforeCursor()
case "backspace", "ctrl+h":
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col <= 0 {
m.mergeLineAbove(m.row)
break
}
if len(m.value[m.row]) > 0 {
m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...)
if m.col > 0 {
m.SetCursor(m.col - 1)
}
}
case "delete", "ctrl+d":
if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][m.col+1:]...)
}
if m.col >= len(m.value[m.row]) {
m.mergeLineBelow(m.row)
break
}
case "alt+backspace", "ctrl+w":
if m.col <= 0 {
m.mergeLineAbove(m.row)
break
}
m.deleteWordLeft()
case "alt+delete", "alt+d":
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col >= len(m.value[m.row]) {
m.mergeLineBelow(m.row)
break
}
m.deleteWordRight()
case "enter", "ctrl+m":
if len(m.value) >= maxHeight {
return nil
}
m.col = clamp(m.col, 0, len(m.value[m.row]))
m.splitLine(m.row, m.col)
case "end", "ctrl+e":
m.CursorEnd()
case "home", "ctrl+a":
m.CursorStart()
case "right", "ctrl+f":
if m.col < len(m.value[m.row]) {
m.SetCursor(m.col + 1)
} else {
if m.row < len(m.value)-1 {
m.row++
m.CursorStart()
}
}
case "down", "ctrl+n":
m.CursorDown()
case "alt+right", "alt+f":
m.wordRight()
case "ctrl+v":
return Paste
case "left", "ctrl+b":
if m.col == 0 && m.row != 0 {
m.row--
m.CursorEnd()
break
}
if m.col > 0 {
m.SetCursor(m.col - 1)
}
case "up", "ctrl+p":
m.CursorUp()
case "alt+left", "alt+b":
m.wordLeft()
default:
if m.CharLimit > 0 && rw.StringWidth(m.Value()) >= m.CharLimit {
break
}
m.col = min(m.col, len(m.value[m.row]))
m.value[m.row] = append(m.value[m.row][:m.col], append(msg.Runes, m.value[m.row][m.col:]...)...)
m.SetCursor(m.col + len(msg.Runes))
}
case pasteMsg:
m.handlePaste(string(msg))
case pasteErrMsg:
m.Err = msg
}
return nil
}
func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.KeyMsg:
if m.command.IsSeeking() {
return m.seekingUpdate(msg)
}
if m.command.Action == ActionReplace {
for i := m.col; i < m.col+max(m.command.Count, 1); i++ {
if i >= len(m.value[m.row]) || len(msg.Runes) <= 0 {
break
}
m.value[m.row][i] = msg.Runes[0]
}
m.command = &NormalCommand{}
break
}
if !strings.ContainsAny(msg.String(), "jk") {
m.lastCharOffset = 0
}
switch msg.String() {
case "esc":
m.command = &NormalCommand{}
return m.SetMode(ModeNormal)
case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":
if m.command.Count == 0 && msg.String() == "0" {
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: 0},
}
return executeCmd(*m.command)
}
v := m.command.Buffer + msg.String()
count, err := strconv.Atoi(v)
if err != nil {
count, _ = strconv.Atoi(msg.String())
m.command.Buffer = msg.String()
m.command.Count = count
} else {
m.command.Buffer = v
m.command.Count = count
}
case "o":
m.CursorEnd()
m.splitLine(m.row, m.col)
return m.SetMode(ModeInsert)
case "O":
m.CursorStart()
m.splitLine(m.row, m.col)
m.CursorUp()
return m.SetMode(ModeInsert)
case "G":
var row int
if m.command.Count > 0 {
row = m.command.Count - 1
} else {
row = len(m.value) - 1
}
m.command.Range = Range{
Start: Position{Row: row, Col: m.col},
End: Position{Row: clamp(row, 0, len(m.value)-1), Col: m.col},
}
return executeCmd(*m.command)
case "g":
if m.command.Buffer == "g" {
m.command.Range = Range{
Start: Position{Row: 0, Col: m.col},
End: Position{Row: 0, Col: clamp(m.col, 0, len(m.value[0]))},
}
return executeCmd(*m.command)
}
m.command.Buffer = "g"
case "x":
m.command.Action = ActionDelete
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: m.col + max(m.command.Count, 1)},
}
return executeCmd(*m.command)
case "X":
m.command.Action = ActionDelete
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: m.col - max(m.command.Count, 1)},
}
return executeCmd(*m.command)
case "c":
if m.command.Action == ActionChange {
m.CursorStart()
m.deleteAfterCursor()
m.command = &NormalCommand{}
return m.SetMode(ModeInsert)
}
m.command.Action = ActionChange
case "d":
if m.command.Action == ActionDelete {
for i := 0; i < max(m.command.Count, 1); i++ {
m.value[m.row] = []rune{}
if m.row < len(m.value)-1 {
m.mergeLineBelow(m.row)
} else {
m.mergeLineAbove(m.row)
}
}
m.command = &NormalCommand{}
return nil
}
m.command.Action = ActionDelete
case "y":
m.command.Action = ActionYank
case "t", "T", "f", "F":
m.command.Buffer = msg.String()
switch msg.String() {
case "t":
m.command.Seeking = SeekForwardUntil
case "T":
m.command.Seeking = SeekBackwardUntil
case "f":
m.command.Seeking = SeekForwardTo
case "F":
m.command.Seeking = SeekBackwardTo
}
case "r":
m.command.Action = ActionReplace
case "i":
if m.command.Action != ActionMove {
m.command.Buffer = "i"
m.command.Seeking = SeekInner
return nil
}
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: m.col},
}
return tea.Sequentially(executeCmd(*m.command), m.SetMode(ModeInsert))
case "I":
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: 0},
}
return tea.Sequentially(executeCmd(*m.command), m.SetMode(ModeInsert))
case "a":
if m.command.Action != ActionMove {
m.command.Buffer = "a"
m.command.Seeking = SeekAround
return nil
}
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: m.col + 1},
}
return tea.Sequentially(executeCmd(*m.command), m.SetMode(ModeInsert))
case "A":
m.command.Range = Range{
Start: Position{Row: m.row, Col: m.col},
End: Position{Row: m.row, Col: len(m.value[m.row]) + 1},
}
return tea.Sequentially(executeCmd(*m.command), m.SetMode(ModeInsert))
case "^":
m.command.Range = Range{
Start: Position{m.row, m.col},
End: Position{m.row, 0},
}
return executeCmd(*m.command)
case "$":
m.command.Range = Range{
Start: Position{m.row, m.col},
End: Position{m.row, len(m.value[m.row])},
}
return executeCmd(*m.command)
case "e", "E":
end := m.findWordEndRight(max(m.command.Count, 1), msg.String() == "E")
if m.command.Action == ActionDelete || m.command.Action == ActionChange {
end.Col = min(end.Col+1, len(m.value[end.Row]))
}
m.command.Range = Range{
Start: Position{m.row, m.col},
End: end,
}
return executeCmd(*m.command)
case "w", "W":
m.command.Range = Range{
Start: Position{m.row, m.col},
End: m.findWordStartRight(max(m.command.Count, 1), msg.String() == "W"),
}
return executeCmd(*m.command)
case "b", "B":
m.command.Range = Range{
Start: Position{m.row, m.col},
End: m.findWordLeft(max(m.command.Count, 1), msg.String() == "B"),
}
return executeCmd(*m.command)
case "h", "l":
direction := 1
if msg.String() == "h" {
direction = -1
}
m.command.Range = Range{
Start: Position{m.row, m.col},
End: Position{m.row, clamp(m.col+(direction*max(m.command.Count, 1)), 0, len(m.value[m.row]))},
}
return executeCmd(*m.command)
case "j", "k":
direction := 1
if msg.String() == "k" {
direction = -1
}
row := clamp(m.row+(direction*max(m.command.Count, 1)), 0, len(m.value)-1)
li := m.LineInfo()
charOffset := max(m.lastCharOffset, li.CharOffset)
m.lastCharOffset = charOffset
rowContent := m.value[row]
charWidth := rw.StringWidth(string(rowContent))
col := 0
offset := 0
for offset < charOffset {
if col > len(m.value[row]) || offset >= charWidth-1 {
break
}
offset += rw.RuneWidth(m.value[row][col])
col++
}
m.command.Range = Range{
Start: Position{m.row, m.col},
End: Position{row, col},
}
return executeCmd(*m.command)
case "C":
m.deleteAfterCursor()
m.command = &NormalCommand{}
return m.SetMode(ModeInsert)
case "D":
m.deleteAfterCursor()
m.command = &NormalCommand{}
return nil
case "J":
m.CursorEnd()
m.mergeLineBelow(m.row)
m.command = &NormalCommand{}
return nil
case "p":
return Paste
}
case executeMsg:
switch m.command.Action {
case ActionDelete:
m.deleteRange(m.command.Range)
case ActionChange:
m.deleteRange(m.command.Range)
return m.SetMode(ModeInsert)
case ActionMove:
row := clamp(m.command.Range.End.Row, 0, len(m.value)-1)
m.row = row
m.col = clamp(m.command.Range.End.Col, 0, len(m.value[row]))
}
m.command = &NormalCommand{}
case pasteMsg:
m.handlePaste(string(msg))
}
return nil
}
func (m *Model) seekingUpdate(msg tea.KeyMsg) tea.Cmd {
if len(msg.Runes) <= 0 {
m.command = &NormalCommand{}
}
if msg.Runes[0] == 'w' || msg.Runes[0] == 'W' {
m.command.Range = m.findPairRange(msg.Runes[0])
return executeCmd(*m.command)
}
switch m.command.Buffer {
case "a":
m.command.Range = m.findPairRange(msg.Runes[0])
return executeCmd(*m.command)
case "i":
pr := m.findPairRange(msg.Runes[0])
pr.Start.Col++
pr.End.Col--
m.command.Range = pr
return executeCmd(*m.command)
case "f":
end := m.findCharRight(msg.Runes[0])
m.command.Range = Range{
Start: Position{m.row, m.col},
End: end,
}
case "F":
start := m.findCharLeft(msg.Runes[0])
m.command.Range = Range{
Start: start,
End: Position{m.row, m.col},
}
case "t":
end := m.findCharRight(msg.Runes[0])
end.Col--
m.command.Range = Range{
Start: Position{m.row, m.col},
End: end,
}
case "T":
start := m.findCharLeft(msg.Runes[0])
start.Col++
m.command.Range = Range{
Start: start,
End: Position{m.row, m.col},
}
}
return executeCmd(*m.command)
}
func (m *Model) findCharLeft(r rune) Position {
col := m.col
for col > 0 {
col--
if m.value[m.row][col] == r {
return Position{m.row, col}
}
}
return Position{m.row, m.col}
}
func (m *Model) findCharRight(r rune) Position {
col := m.col
for col < len(m.value[m.row]) {
if m.value[m.row][col] == r {
return Position{m.row, col}
}
col++
}
return Position{m.row, m.col}
}
func (m *Model) findPairRange(r rune) Range {
var startRune, endRune rune
switch r {
case 'w', 'W':
return Range{
Start: m.findWordLeft(0, r == 'W'),
End: m.findWordEndRight(1, true),
}
case '(', ')':
startRune, endRune = '(', ')'
case '{', '}':
startRune, endRune = '{', '}'
case '[', ']':
startRune, endRune = '[', ']'
case '<', '>':
startRune, endRune = '<', '>'
case '"', '\'':
startRune, endRune = r, r
default:
m.command = &NormalCommand{}
return Range{
Start: Position{m.row, m.col},
End: Position{m.row, m.col},
}
}
return Range{
Start: m.findCharLeft(startRune),
End: m.findCharRight(endRune),
}
}
// findWordLeft locates the start of the word on the left of the current word.
// It takes whether or not to break words on spaces or any non-alpha-numeric
// character as an argument.
func (m *Model) findWordLeft(count int, ignorePunctuation bool) Position {
wordBreak := isSoftWordBreak
if ignorePunctuation {
wordBreak = isWordBreak
}
if count == 0 {
if wordBreak(m.value[m.row][m.col-1]) {
return Position{m.row, m.col}
}
count = 1
}
row, col := m.row, m.col
for count > 0 {
if col <= 0 && row > 0 {
row--
col = len(m.value[row]) - 1
}
// Skip all spaces (and punctuation) to the left of the cursor.
for col > 0 && wordBreak(m.value[row][col-1]) {
col--
}
// Then skip all non-spaces to the left of the cursor.
for col > 0 && !wordBreak(m.value[row][col-1]) {
col--
}
count--
if row <= 0 && col <= 0 {
break
}
}
return Position{Row: row, Col: col}
}
// findWordStartRight locates the start of the next word. It takes whether or not to
// break words on spaces or any non-alpha-numeric character as an argument.
func (m *Model) findWordStartRight(count int, ignorePunctuation bool) Position {
wordBreak := isSoftWordBreak
if ignorePunctuation {
wordBreak = isWordBreak
}
row, col := m.row, m.col
for count > 0 {
if col >= len(m.value[row])-1 && row < len(m.value)-1 {
row++
col = 0
}
// Skip until the start of a word is found.
for col < len(m.value[row]) && !wordBreak(m.value[row][col]) {
col++
}
// Skip all spaces to the right of the cursor.
for col < len(m.value[row])-1 && wordBreak(m.value[row][col]) {
col++
}
count--
if row >= len(m.value)-1 && col >= len(m.value[row])-1 {
break
}
}
return Position{Row: row, Col: col}
}
// findWordEndRight locates the start of the next word. It takes whether or not to
// break words on spaces or any non-alpha-numeric character as an argument.
func (m *Model) findWordEndRight(count int, ignorePunctuation bool) Position {
wordBreak := isSoftWordBreak
if ignorePunctuation {
wordBreak = isWordBreak
}
row, col := m.row, m.col
for count > 0 {
if col > len(m.value[row]) && row < len(m.value)-1 {
row++
col = 0
}
// Skip all spaces to the right of the cursor.
for col < len(m.value[row])-1 && wordBreak(m.value[row][col+1]) {
col++
}
// Then skip all non-spaces to the right of the cursor.
for col < len(m.value[row])-1 && !wordBreak(m.value[row][col+1]) {
col++
}
count--
if row <= 0 && col <= 0 {
break
}
}
return Position{Row: row, Col: col}
}
func isWordBreak(char rune) bool {
return unicode.IsSpace(char)
}
func isSoftWordBreak(char rune) bool {
return unicode.IsSpace(char) || unicode.IsPunct(char)
}
func (m *Model) deleteRange(r Range) {
if r.Start.Row == r.End.Row && r.Start.Col == r.End.Col {
return
}
minCol, maxCol := min(r.Start.Col, r.End.Col), max(r.Start.Col, r.End.Col)
minCol = clamp(minCol, 0, len(m.value[r.Start.Row]))
maxCol = clamp(maxCol, 0, len(m.value[r.Start.Row]))
if r.Start.Row == r.End.Row {
// If the action is delete and from a seek action, we need to delete
// the range inclusively.
if m.command.IsSeeking() {
if m.command.IsSeekingForward() {
maxCol = min(maxCol+1, len(m.value[r.Start.Row]))
} else if m.command.IsSeekingBackward() {
minCol = max(minCol-1, 0)
} else if m.command.Seeking == SeekAround || m.command.Seeking == SeekInner {
maxCol = min(maxCol+1, len(m.value[r.Start.Row]))
}
}
m.value[r.Start.Row] = append(m.value[r.Start.Row][:minCol], m.value[r.Start.Row][maxCol:]...)
m.SetCursor(minCol)
return
}
minRow, maxRow := min(r.Start.Row, r.End.Row), max(r.Start.Row, r.End.Row)
if maxRow-minRow == 1 {
if r.Start.Row < r.End.Row {
m.value[r.Start.Row] = append([]rune{}, m.value[r.Start.Row][:r.Start.Col]...)
m.value[r.End.Row] = append([]rune{}, m.value[r.End.Row][r.End.Col:]...)
m.mergeLineBelow(minRow)
} else {
m.value[r.Start.Row] = append([]rune{}, m.value[r.Start.Row][r.Start.Col:]...)
m.value[r.End.Row] = append([]rune{}, m.value[r.End.Row][:r.End.Col]...)
m.mergeLineAbove(maxRow)
}
return
}
for i := max(minRow, 0); i <= min(maxRow, len(m.value)-1); i++ {
m.value[i] = []rune{}
}
m.value = append(m.value[:minRow], m.value[maxRow:]...)
m.row = clamp(0, minRow, len(m.value))
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -27,6 +28,63 @@ const (
type pasteMsg string type pasteMsg string
type pasteErrMsg struct{ error } type pasteErrMsg struct{ error }
// KeyMap is the key bindings for different actions within the textarea.
type KeyMap struct {
CharacterBackward key.Binding
CharacterForward key.Binding
DeleteAfterCursor key.Binding
DeleteBeforeCursor key.Binding
DeleteCharacterBackward key.Binding
DeleteCharacterForward key.Binding
DeleteWordBackward key.Binding
DeleteWordForward key.Binding
InsertNewline key.Binding
LineEnd key.Binding
LineNext key.Binding
LinePrevious key.Binding
LineStart key.Binding
Paste key.Binding
WordBackward key.Binding
WordForward key.Binding
InputBegin key.Binding
InputEnd key.Binding
UppercaseWordForward key.Binding
LowercaseWordForward key.Binding
CapitalizeWordForward key.Binding
TransposeCharacterBackward key.Binding
}
// DefaultKeyMap is the default set of key bindings for navigating and acting
// upon the textarea.
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")),
LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n")),
LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p")),
DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w")),
DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d")),
DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k")),
DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u")),
InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m")),
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")),
InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home")),
InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end")),
CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c")),
LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l")),
UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u")),
TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t")),
}
// LineInfo is a helper for keeping track of line information regarding // LineInfo is a helper for keeping track of line information regarding
// soft-wrapped lines. // soft-wrapped lines.
type LineInfo struct { type LineInfo struct {
@@ -75,11 +133,29 @@ type Model struct {
Err error Err error
// General settings. // General settings.
Prompt string
Placeholder string // Prompt is printed at the beginning of each line.
ShowLineNumbers bool //
// When changing the value of Prompt after the model has been
// initialized, ensure that SetWidth() gets called afterwards.
//
// See also SetPromptFunc().
Prompt string
// Placeholder is the text displayed when the user
// hasn't entered anything yet.
Placeholder string
// ShowLineNumbers, if enabled, causes line numbers to be printed
// after the prompt.
ShowLineNumbers bool
// EndOfBufferCharacter is displayed at the end of the input.
EndOfBufferCharacter rune EndOfBufferCharacter rune
// KeyMap encodes the keybindings recognized by the widget.
KeyMap KeyMap
// Styling. FocusedStyle and BlurredStyle are used to style the textarea in // Styling. FocusedStyle and BlurredStyle are used to style the textarea in
// focused and blurred states. // focused and blurred states.
FocusedStyle Style FocusedStyle Style
@@ -97,6 +173,13 @@ type Model struct {
// accept. If 0 or less, there's no limit. // accept. If 0 or less, there's no limit.
CharLimit int CharLimit int
// If promptFunc is set, it replaces Prompt as a generator for
// prompt strings at the beginning of each line.
promptFunc func(line int) string
// promptWidth is the width of the prompt.
promptWidth int
// width is the maximum number of characters that can be displayed at once. // width is the maximum number of characters that can be displayed at once.
// If 0 or less this setting is ignored. // If 0 or less this setting is ignored.
width int width int
@@ -119,12 +202,6 @@ type Model struct {
// Cursor row. // Cursor row.
row int row int
// mode is the current mode of the textarea.
mode Mode
// command is the normal command of the textarea.
command *NormalCommand
// Last character offset, used to maintain state when the cursor is moved // Last character offset, used to maintain state when the cursor is moved
// vertically such that we can maintain the same navigating position. // vertically such that we can maintain the same navigating position.
lastCharOffset int lastCharOffset int
@@ -154,14 +231,13 @@ func New() Model {
EndOfBufferCharacter: '~', EndOfBufferCharacter: '~',
ShowLineNumbers: true, ShowLineNumbers: true,
Cursor: cur, Cursor: cur,
KeyMap: DefaultKeyMap,
value: make([][]rune, minHeight, maxHeight), value: make([][]rune, minHeight, maxHeight),
mode: ModeInsert,
focus: false, focus: false,
col: 0, col: 0,
row: 0, row: 0,
lineNumberFormat: "%2v ", lineNumberFormat: "%2v ",
command: &NormalCommand{},
viewport: &vp, viewport: &vp,
} }
@@ -263,11 +339,6 @@ func (m Model) Line() int {
return m.row return m.row
} }
// NormalCommand returns the normal command.
func (m *Model) NormalCommand() *NormalCommand {
return m.command
}
// CursorDown moves the cursor down by one line. // CursorDown moves the cursor down by one line.
// Returns whether or not the cursor blink should be reset. // Returns whether or not the cursor blink should be reset.
func (m *Model) CursorDown() { func (m *Model) CursorDown() {
@@ -423,6 +494,10 @@ func (m *Model) handlePaste(v string) {
} }
} }
// Put it all back together
value := append(head, tail...)
m.SetValue(string(value))
// Reset blink state if necessary and run overflow checks // Reset blink state if necessary and run overflow checks
m.SetCursor(m.col + len(paste)) m.SetCursor(m.col + len(paste))
} }
@@ -442,6 +517,24 @@ func (m *Model) deleteAfterCursor() {
m.SetCursor(len(m.value[m.row])) m.SetCursor(len(m.value[m.row]))
} }
// transposeLeft exchanges the runes at the cursor and immediately
// before. No-op if the cursor is at the beginning of the line. If
// the cursor is not at the end of the line yet, moves the cursor to
// the right.
func (m *Model) transposeLeft() {
if m.col == 0 || len(m.value[m.row]) < 2 {
return
}
if m.col >= len(m.value[m.row]) {
m.SetCursor(m.col - 1)
}
m.value[m.row][m.col-1], m.value[m.row][m.col] =
m.value[m.row][m.col], m.value[m.row][m.col-1]
if m.col < len(m.value[m.row]) {
m.SetCursor(m.col + 1)
}
}
// deleteWordLeft deletes the word left to the cursor. Returns whether or not // deleteWordLeft deletes the word left to the cursor. Returns whether or not
// the cursor blink should be reset. // the cursor blink should be reset.
func (m *Model) deleteWordLeft() { func (m *Model) deleteWordLeft() {
@@ -516,31 +609,50 @@ func (m *Model) deleteWordRight() {
m.SetCursor(oldCol) m.SetCursor(oldCol)
} }
// characterRight moves the cursor one character to the right.
func (m *Model) characterRight() {
if m.col < len(m.value[m.row]) {
m.SetCursor(m.col + 1)
} else {
if m.row < len(m.value)-1 {
m.row++
m.CursorStart()
}
}
}
// characterLeft moves the cursor one character to the left.
// If insideLine is set, the cursor is moved to the last
// character in the previous line, instead of one past that.
func (m *Model) characterLeft(insideLine bool) {
if m.col == 0 && m.row != 0 {
m.row--
m.CursorEnd()
if !insideLine {
return
}
}
if m.col > 0 {
m.SetCursor(m.col - 1)
}
}
// wordLeft moves the cursor one word to the left. Returns whether or not the // 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 // 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. // so as not to reveal word breaks in the masked input.
func (m *Model) wordLeft() { func (m *Model) wordLeft() {
if m.col == 0 || len(m.value[m.row]) == 0 { for {
return m.characterLeft(true /* insideLine */)
} if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) {
i := m.col - 1
for i >= 0 {
if unicode.IsSpace(m.value[m.row][min(i, len(m.value[m.row])-1)]) {
m.SetCursor(m.col - 1)
i--
} else {
break break
} }
} }
for i >= 0 { for m.col > 0 {
if !unicode.IsSpace(m.value[m.row][min(i, len(m.value[m.row])-1)]) { if unicode.IsSpace(m.value[m.row][m.col-1]) {
m.SetCursor(m.col - 1)
i--
} else {
break break
} }
m.SetCursor(m.col - 1)
} }
} }
@@ -548,28 +660,54 @@ func (m *Model) wordLeft() {
// cursor blink should be reset. If the input is masked, move input to the end // 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. // so as not to reveal word breaks in the masked input.
func (m *Model) wordRight() { func (m *Model) wordRight() {
if m.col >= len(m.value[m.row]) || len(m.value[m.row]) == 0 { m.doWordRight(func(int, int) { /* nothing */ })
return }
}
i := m.col func (m *Model) doWordRight(fn func(charIdx int, pos int)) {
for i < len(m.value[m.row]) { // Skip spaces forward.
if unicode.IsSpace(m.value[m.row][i]) { for {
m.SetCursor(m.col + 1) if m.col < len(m.value[m.row]) && !unicode.IsSpace(m.value[m.row][m.col]) {
i++
} else {
break break
} }
} if m.row == len(m.value)-1 && m.col == len(m.value[m.row]) {
// End of text.
for i < len(m.value[m.row]) {
if !unicode.IsSpace(m.value[m.row][i]) {
m.SetCursor(m.col + 1)
i++
} else {
break break
} }
m.characterRight()
} }
charIdx := 0
for m.col < len(m.value[m.row]) {
if unicode.IsSpace(m.value[m.row][m.col]) {
break
}
fn(charIdx, m.col)
m.SetCursor(m.col + 1)
charIdx++
}
}
// uppercaseRight changes the word to the right to uppercase.
func (m *Model) uppercaseRight() {
m.doWordRight(func(_ int, i int) {
m.value[m.row][i] = unicode.ToUpper(m.value[m.row][i])
})
}
// lowercaseRight changes the word to the right to lowercase.
func (m *Model) lowercaseRight() {
m.doWordRight(func(_ int, i int) {
m.value[m.row][i] = unicode.ToLower(m.value[m.row][i])
})
}
// capitalizeRight changes the word to the right to title case.
func (m *Model) capitalizeRight() {
m.doWordRight(func(charIdx int, i int) {
if charIdx == 0 {
m.value[m.row][i] = unicode.ToTitle(m.value[m.row][i])
}
})
} }
// LineInfo returns the number of characters from the start of the // LineInfo returns the number of characters from the start of the
@@ -631,6 +769,18 @@ func (m Model) Width() int {
return m.width return m.width
} }
// moveToBegin moves the cursor to the beginning of the input.
func (m *Model) moveToBegin() {
m.row = 0
m.SetCursor(0)
}
// moveToEnd moves the cursor to the end of the input.
func (m *Model) moveToEnd() {
m.row = len(m.value) - 1
m.SetCursor(len(m.value[m.row]))
}
// SetWidth sets the width of the textarea to fit exactly within the given width. // SetWidth sets the width of the textarea to fit exactly within the given width.
// This means that the textarea will account for the width of the prompt and // This means that the textarea will account for the width of the prompt and
// whether or not line numbers are being shown. // whether or not line numbers are being shown.
@@ -651,10 +801,26 @@ func (m *Model) SetWidth(w int) {
// Account for base style borders and padding. // Account for base style borders and padding.
inputWidth -= m.style.Base.GetHorizontalFrameSize() inputWidth -= m.style.Base.GetHorizontalFrameSize()
inputWidth -= rw.StringWidth(m.Prompt) if m.promptFunc == nil {
m.promptWidth = rw.StringWidth(m.Prompt)
}
inputWidth -= m.promptWidth
m.width = clamp(inputWidth, minWidth, maxWidth) m.width = clamp(inputWidth, minWidth, maxWidth)
} }
// SetPromptFunc supersedes the Prompt field and sets a dynamic prompt
// instead.
// If the function returns a prompt that is shorter than the
// specified promptWidth, it will be padded to the left.
// If it returns a prompt that is longer, display artifacts
// may occur; the caller is responsible for computing an adequate
// promptWidth.
func (m *Model) SetPromptFunc(promptWidth int, fn func(lineIdx int) string) {
m.promptFunc = fn
m.promptWidth = promptWidth
}
// Height returns the current height of the textarea. // Height returns the current height of the textarea.
func (m Model) Height() int { func (m Model) Height() int {
return m.height return m.height
@@ -682,21 +848,108 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.value[m.row] = make([]rune, 0) m.value[m.row] = make([]rune, 0)
} }
var cmd tea.Cmd switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col >= len(m.value[m.row]) {
m.mergeLineBelow(m.row)
break
}
m.deleteAfterCursor()
case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col <= 0 {
m.mergeLineAbove(m.row)
break
}
m.deleteBeforeCursor()
case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col <= 0 {
m.mergeLineAbove(m.row)
break
}
if len(m.value[m.row]) > 0 {
m.value[m.row] = append(m.value[m.row][:max(0, m.col-1)], m.value[m.row][m.col:]...)
if m.col > 0 {
m.SetCursor(m.col - 1)
}
}
case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
if len(m.value[m.row]) > 0 && m.col < len(m.value[m.row]) {
m.value[m.row] = append(m.value[m.row][:m.col], m.value[m.row][m.col+1:]...)
}
if m.col >= len(m.value[m.row]) {
m.mergeLineBelow(m.row)
break
}
case key.Matches(msg, m.KeyMap.DeleteWordBackward):
if m.col <= 0 {
m.mergeLineAbove(m.row)
break
}
m.deleteWordLeft()
case key.Matches(msg, m.KeyMap.DeleteWordForward):
m.col = clamp(m.col, 0, len(m.value[m.row]))
if m.col >= len(m.value[m.row]) {
m.mergeLineBelow(m.row)
break
}
m.deleteWordRight()
case key.Matches(msg, m.KeyMap.InsertNewline):
if len(m.value) >= maxHeight {
return m, nil
}
m.col = clamp(m.col, 0, len(m.value[m.row]))
m.splitLine(m.row, m.col)
case key.Matches(msg, m.KeyMap.LineEnd):
m.CursorEnd()
case key.Matches(msg, m.KeyMap.LineStart):
m.CursorStart()
case key.Matches(msg, m.KeyMap.CharacterForward):
m.characterRight()
case key.Matches(msg, m.KeyMap.LineNext):
m.CursorDown()
case key.Matches(msg, m.KeyMap.WordForward):
m.wordRight()
case key.Matches(msg, m.KeyMap.Paste):
return m, Paste
case key.Matches(msg, m.KeyMap.CharacterBackward):
m.characterLeft(false /* insideLine */)
case key.Matches(msg, m.KeyMap.LinePrevious):
m.CursorUp()
case key.Matches(msg, m.KeyMap.WordBackward):
m.wordLeft()
case key.Matches(msg, m.KeyMap.InputBegin):
m.moveToBegin()
case key.Matches(msg, m.KeyMap.InputEnd):
m.moveToEnd()
case key.Matches(msg, m.KeyMap.LowercaseWordForward):
m.lowercaseRight()
case key.Matches(msg, m.KeyMap.UppercaseWordForward):
m.uppercaseRight()
case key.Matches(msg, m.KeyMap.CapitalizeWordForward):
m.capitalizeRight()
case key.Matches(msg, m.KeyMap.TransposeCharacterBackward):
m.transposeLeft()
switch m.mode { default:
case ModeInsert: if m.CharLimit > 0 && rw.StringWidth(m.Value()) >= m.CharLimit {
cmd = m.insertUpdate(msg) break
cmds = append(cmds, cmd) }
// Sometimes, we need to enter insert mode after executing a command.
switch msg.(type) { m.col = min(m.col, len(m.value[m.row]))
case executeMsg: m.value[m.row] = append(m.value[m.row][:m.col], append(msg.Runes, m.value[m.row][m.col:]...)...)
cmd = m.normalUpdate(msg) m.SetCursor(m.col + len(msg.Runes))
cmds = append(cmds, cmd)
} }
case ModeNormal:
cmd = m.normalUpdate(msg) case pasteMsg:
cmds = append(cmds, cmd) m.handlePaste(string(msg))
case pasteErrMsg:
m.Err = msg
} }
vp, cmd := m.viewport.Update(msg) vp, cmd := m.viewport.Update(msg)
@@ -705,7 +958,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
newRow, newCol := m.cursorLineNumber(), m.col newRow, newCol := m.cursorLineNumber(), m.col
m.Cursor, cmd = m.Cursor.Update(msg) m.Cursor, cmd = m.Cursor.Update(msg)
if m.mode == ModeInsert && (newRow != oldRow || newCol != oldCol) { if newRow != oldRow || newCol != oldCol {
m.Cursor.Blink = false m.Cursor.Blink = false
cmd = m.Cursor.BlinkCmd() cmd = m.Cursor.BlinkCmd()
} }
@@ -729,6 +982,7 @@ func (m Model) View() string {
var newLines int var newLines int
displayLine := 0
for l, line := range m.value { for l, line := range m.value {
wrappedLines := wrap(line, m.width) wrappedLines := wrap(line, m.width)
@@ -739,7 +993,10 @@ func (m Model) View() string {
} }
for wl, wrappedLine := range wrappedLines { for wl, wrappedLine := range wrappedLines {
s.WriteString(style.Render(m.style.Prompt.Render(m.Prompt))) prompt := m.getPromptString(displayLine)
prompt = m.style.Prompt.Render(prompt)
s.WriteString(style.Render(prompt))
displayLine++
if m.ShowLineNumbers { if m.ShowLineNumbers {
if wl == 0 { if wl == 0 {
@@ -788,7 +1045,10 @@ func (m Model) View() string {
// Always show at least `m.Height` lines at all times. // Always show at least `m.Height` lines at all times.
// To do this we can simply pad out a few extra new lines in the view. // To do this we can simply pad out a few extra new lines in the view.
for i := 0; i < m.height; i++ { for i := 0; i < m.height; i++ {
s.WriteString(m.style.Prompt.Render(m.Prompt)) prompt := m.getPromptString(displayLine)
prompt = m.style.Prompt.Render(prompt)
s.WriteString(prompt)
displayLine++
if m.ShowLineNumbers { if m.ShowLineNumbers {
lineNumber := m.style.EndOfBuffer.Render((fmt.Sprintf(m.lineNumberFormat, string(m.EndOfBufferCharacter)))) lineNumber := m.style.EndOfBuffer.Render((fmt.Sprintf(m.lineNumberFormat, string(m.EndOfBufferCharacter))))
@@ -801,6 +1061,19 @@ func (m Model) View() string {
return m.style.Base.Render(m.viewport.View()) return m.style.Base.Render(m.viewport.View())
} }
func (m Model) getPromptString(displayLine int) (prompt string) {
prompt = m.Prompt
if m.promptFunc == nil {
return prompt
}
prompt = m.promptFunc(displayLine)
pl := rw.StringWidth(prompt)
if pl < m.promptWidth {
prompt = fmt.Sprintf("%*s%s", m.promptWidth-pl, "", prompt)
}
return prompt
}
// placeholderView returns the prompt and placeholder view, if any. // placeholderView returns the prompt and placeholder view, if any.
func (m Model) placeholderView() string { func (m Model) placeholderView() string {
var ( var (
@@ -809,7 +1082,8 @@ func (m Model) placeholderView() string {
style = m.style.Placeholder.Inline(true) style = m.style.Placeholder.Inline(true)
) )
prompt := m.style.Prompt.Render(m.Prompt) prompt := m.getPromptString(0)
prompt = m.style.Prompt.Render(prompt)
s.WriteString(m.style.CursorLine.Render(prompt)) s.WriteString(m.style.CursorLine.Render(prompt))
if m.ShowLineNumbers { if m.ShowLineNumbers {
@@ -826,6 +1100,8 @@ func (m Model) placeholderView() string {
// The rest of the new lines // The rest of the new lines
for i := 1; i < m.height; i++ { for i := 1; i < m.height; i++ {
s.WriteRune('\n') s.WriteRune('\n')
prompt := m.getPromptString(i)
prompt = m.style.Prompt.Render(prompt)
s.WriteString(prompt) s.WriteString(prompt)
if m.ShowLineNumbers { if m.ShowLineNumbers {

View File

@@ -1,48 +1,18 @@
package textinput package textinput
import ( import (
"context"
"strings" "strings"
"sync"
"time" "time"
"unicode" "unicode"
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
rw "github.com/mattn/go-runewidth" 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. // Internal messages for clipboard operations.
type pasteMsg string type pasteMsg string
type pasteErrMsg struct{ error } type pasteErrMsg struct{ error }
@@ -65,35 +35,44 @@ const (
// EchoOnEdit. // 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. // ValidateFunc is a function that returns an error if the input is invalid.
type ValidateFunc func(string) error 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. // Model is the Bubble Tea model for this text input element.
type Model struct { type Model struct {
Err error Err error
@@ -101,9 +80,13 @@ type Model struct {
// General settings. // General settings.
Prompt string Prompt string
Placeholder string Placeholder string
BlinkSpeed time.Duration
EchoMode EchoMode EchoMode EchoMode
EchoCharacter rune 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. // Styles. These will be applied as inline styles.
// //
@@ -124,11 +107,8 @@ type Model struct {
// viewport. If 0 or less this setting is ignored. // viewport. If 0 or less this setting is ignored.
Width int Width int
// The ID of this Model as it relates to other textinput Models. // KeyMap encodes the keybindings recognized by the widget.
id int KeyMap KeyMap
// The ID of the blink message we're expecting to receive.
blinkTag int
// Underlying text value. // Underlying text value.
value []rune value []rune
@@ -137,9 +117,6 @@ type Model struct {
// component. When false, ignore keyboard input and hide the cursor. // component. When false, ignore keyboard input and hide the cursor.
focus bool focus bool
// Cursor blink state.
blink bool
// Cursor position. // Cursor position.
pos int pos int
@@ -148,12 +125,6 @@ type Model struct {
offset int offset int
offsetRight 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 // 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 // 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 // error returned by the function. If the function is not defined, all
@@ -165,21 +136,15 @@ type Model struct {
func New() Model { func New() Model {
return Model{ return Model{
Prompt: "> ", Prompt: "> ",
BlinkSpeed: defaultBlinkSpeed,
EchoCharacter: '*', EchoCharacter: '*',
CharLimit: 0, CharLimit: 0,
PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), PlaceholderStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("240")),
Cursor: cursor.New(),
KeyMap: DefaultKeyMap,
id: nextID(), value: nil,
value: nil, focus: false,
focus: false, pos: 0,
blink: true,
pos: 0,
cursorMode: CursorBlink,
blinkCtx: &blinkCtx{
ctx: context.Background(),
},
} }
} }
@@ -197,6 +162,7 @@ func (m *Model) SetValue(s string) {
} }
} }
empty := len(m.value) == 0
m.Err = nil m.Err = nil
runes := []rune(s) runes := []rune(s)
@@ -205,8 +171,8 @@ func (m *Model) SetValue(s string) {
} else { } else {
m.value = runes m.value = runes
} }
if m.pos == 0 || m.pos > len(m.value) { if (m.pos == 0 && empty) || m.pos > len(m.value) {
m.setCursor(len(m.value)) m.SetCursor(len(m.value))
} }
m.handleOverflow() m.handleOverflow()
} }
@@ -216,74 +182,26 @@ func (m Model) Value() string {
return string(m.value) return string(m.value)
} }
// Cursor returns the cursor position. // Position returns the cursor position.
func (m Model) Cursor() int { func (m Model) Position() int {
return m.pos 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 // 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. // out of bounds the cursor will be moved to the start or end accordingly.
func (m *Model) SetCursor(pos int) { 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.pos = clamp(pos, 0, len(m.value))
m.handleOverflow() 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. // CursorStart moves the cursor to the start of the input field.
func (m *Model) CursorStart() { func (m *Model) CursorStart() {
m.cursorStart() m.SetCursor(0)
}
// 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. // CursorEnd moves the cursor to the end of the input field.
func (m *Model) CursorEnd() { func (m *Model) CursorEnd() {
m.cursorEnd() m.SetCursor(len(m.value))
}
// 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. // Focused returns the focus state on the model.
@@ -292,34 +210,27 @@ func (m Model) Focused() bool {
} }
// Focus sets the focus state on the model. When the model is in focus it can // 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. // receive keyboard input and the cursor will be shown.
func (m *Model) Focus() tea.Cmd { func (m *Model) Focus() tea.Cmd {
m.focus = true m.focus = true
m.blink = m.cursorMode == CursorHide // show the cursor unless we've explicitly hidden it return m.Cursor.Focus()
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 // 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. // not receive keyboard input and the cursor will be hidden.
func (m *Model) Blur() { func (m *Model) Blur() {
m.focus = false m.focus = false
m.blink = true m.Cursor.Blur()
} }
// Reset sets the input to its default state with no input. Returns whether // Reset sets the input to its default state with no input.
// or not the cursor blink should reset. func (m *Model) Reset() {
func (m *Model) Reset() bool {
m.value = nil m.value = nil
return m.setCursor(0) m.SetCursor(0)
} }
// handle a clipboard paste event, if supported. Returns whether or not the // handle a clipboard paste event, if supported.
// cursor blink should reset. func (m *Model) handlePaste(v string) {
func (m *Model) handlePaste(v string) bool {
paste := []rune(v) paste := []rune(v)
var availSpace int var availSpace int
@@ -329,7 +240,7 @@ func (m *Model) handlePaste(v string) bool {
// If the char limit's been reached cancel // If the char limit's been reached cancel
if m.CharLimit > 0 && availSpace <= 0 { if m.CharLimit > 0 && availSpace <= 0 {
return false return
} }
// If there's not enough space to paste the whole thing cut the pasted // If there's not enough space to paste the whole thing cut the pasted
@@ -366,8 +277,7 @@ func (m *Model) handlePaste(v string) bool {
m.pos = oldPos m.pos = oldPos
} }
// Reset blink state if necessary and run overflow checks m.SetCursor(m.pos)
return m.setCursor(m.pos)
} }
// If a max width is defined, perform some logic to treat the visible area // If a max width is defined, perform some logic to treat the visible area
@@ -415,31 +325,30 @@ func (m *Model) handleOverflow() {
} }
} }
// deleteBeforeCursor deletes all text before the cursor. Returns whether or // deleteBeforeCursor deletes all text before the cursor.
// not the cursor blink should be reset. func (m *Model) deleteBeforeCursor() {
func (m *Model) deleteBeforeCursor() bool {
m.value = m.value[m.pos:] m.value = m.value[m.pos:]
m.offset = 0 m.offset = 0
return m.setCursor(0) m.SetCursor(0)
} }
// deleteAfterCursor deletes all text after the cursor. Returns whether or not // deleteAfterCursor deletes all text after the cursor. If input is masked
// the cursor blink should be reset. If input is masked delete everything after // delete everything after the cursor so as not to reveal word breaks in the
// the cursor so as not to reveal word breaks in the masked input. // masked input.
func (m *Model) deleteAfterCursor() bool { func (m *Model) deleteAfterCursor() {
m.value = m.value[:m.pos] m.value = m.value[:m.pos]
return m.setCursor(len(m.value)) m.SetCursor(len(m.value))
} }
// deleteWordLeft deletes the word left to the cursor. Returns whether or not // deleteWordBackward deletes the word left to the cursor.
// the cursor blink should be reset. func (m *Model) deleteWordBackward() {
func (m *Model) deleteWordLeft() bool {
if m.pos == 0 || len(m.value) == 0 { if m.pos == 0 || len(m.value) == 0 {
return false return
} }
if m.EchoMode != EchoNormal { if m.EchoMode != EchoNormal {
return m.deleteBeforeCursor() m.deleteBeforeCursor()
return
} }
// Linter note: it's critical that we acquire the initial cursor position // Linter note: it's critical that we acquire the initial cursor position
@@ -447,22 +356,22 @@ func (m *Model) deleteWordLeft() bool {
// call into the corresponding if clause does not apply here. // call into the corresponding if clause does not apply here.
oldPos := m.pos //nolint:ifshort oldPos := m.pos //nolint:ifshort
blink := m.setCursor(m.pos - 1) m.SetCursor(m.pos - 1)
for unicode.IsSpace(m.value[m.pos]) { for unicode.IsSpace(m.value[m.pos]) {
if m.pos <= 0 { if m.pos <= 0 {
break break
} }
// ignore series of whitespace before cursor // ignore series of whitespace before cursor
blink = m.setCursor(m.pos - 1) m.SetCursor(m.pos - 1)
} }
for m.pos > 0 { for m.pos > 0 {
if !unicode.IsSpace(m.value[m.pos]) { if !unicode.IsSpace(m.value[m.pos]) {
blink = m.setCursor(m.pos - 1) m.SetCursor(m.pos - 1)
} else { } else {
if m.pos > 0 { if m.pos > 0 {
// keep the previous space // keep the previous space
blink = m.setCursor(m.pos + 1) m.SetCursor(m.pos + 1)
} }
break break
} }
@@ -473,27 +382,26 @@ func (m *Model) deleteWordLeft() bool {
} else { } else {
m.value = append(m.value[:m.pos], m.value[oldPos:]...) m.value = append(m.value[:m.pos], m.value[oldPos:]...)
} }
return blink
} }
// deleteWordRight deletes the word right to the cursor. Returns whether or not // deleteWordForward deletes the word right to the cursor If input is masked
// the cursor blink should be reset. If input is masked delete everything after // delete everything after the cursor so as not to reveal word breaks in the
// the cursor so as not to reveal word breaks in the masked input. // masked input.
func (m *Model) deleteWordRight() bool { func (m *Model) deleteWordForward() {
if m.pos >= len(m.value) || len(m.value) == 0 { if m.pos >= len(m.value) || len(m.value) == 0 {
return false return
} }
if m.EchoMode != EchoNormal { if m.EchoMode != EchoNormal {
return m.deleteAfterCursor() m.deleteAfterCursor()
return
} }
oldPos := m.pos oldPos := m.pos
m.setCursor(m.pos + 1) m.SetCursor(m.pos + 1)
for unicode.IsSpace(m.value[m.pos]) { for unicode.IsSpace(m.value[m.pos]) {
// ignore series of whitespace after cursor // ignore series of whitespace after cursor
m.setCursor(m.pos + 1) m.SetCursor(m.pos + 1)
if m.pos >= len(m.value) { if m.pos >= len(m.value) {
break break
@@ -502,7 +410,7 @@ func (m *Model) deleteWordRight() bool {
for m.pos < len(m.value) { for m.pos < len(m.value) {
if !unicode.IsSpace(m.value[m.pos]) { if !unicode.IsSpace(m.value[m.pos]) {
m.setCursor(m.pos + 1) m.SetCursor(m.pos + 1)
} else { } else {
break break
} }
@@ -514,26 +422,25 @@ func (m *Model) deleteWordRight() bool {
m.value = append(m.value[:oldPos], m.value[m.pos:]...) m.value = append(m.value[:oldPos], m.value[m.pos:]...)
} }
return m.setCursor(oldPos) m.SetCursor(oldPos)
} }
// wordLeft moves the cursor one word to the left. Returns whether or not the // wordBackward moves the cursor one word to the left. If input is masked, move
// cursor blink should be reset. If input is masked, move input to the start // input to the start so as not to reveal word breaks in the masked input.
// so as not to reveal word breaks in the masked input. func (m *Model) wordBackward() {
func (m *Model) wordLeft() bool {
if m.pos == 0 || len(m.value) == 0 { if m.pos == 0 || len(m.value) == 0 {
return false return
} }
if m.EchoMode != EchoNormal { if m.EchoMode != EchoNormal {
return m.cursorStart() m.CursorStart()
return
} }
blink := false
i := m.pos - 1 i := m.pos - 1
for i >= 0 { for i >= 0 {
if unicode.IsSpace(m.value[i]) { if unicode.IsSpace(m.value[i]) {
blink = m.setCursor(m.pos - 1) m.SetCursor(m.pos - 1)
i-- i--
} else { } else {
break break
@@ -542,33 +449,30 @@ func (m *Model) wordLeft() bool {
for i >= 0 { for i >= 0 {
if !unicode.IsSpace(m.value[i]) { if !unicode.IsSpace(m.value[i]) {
blink = m.setCursor(m.pos - 1) m.SetCursor(m.pos - 1)
i-- i--
} else { } else {
break break
} }
} }
return blink
} }
// wordRight moves the cursor one word to the right. Returns whether or not the // wordForward moves the cursor one word to the right. If the input is masked,
// cursor blink should be reset. If the input is masked, move input to the end // move input to the end so as not to reveal word breaks in the masked input.
// so as not to reveal word breaks in the masked input. func (m *Model) wordForward() {
func (m *Model) wordRight() bool {
if m.pos >= len(m.value) || len(m.value) == 0 { if m.pos >= len(m.value) || len(m.value) == 0 {
return false return
} }
if m.EchoMode != EchoNormal { if m.EchoMode != EchoNormal {
return m.cursorEnd() m.CursorEnd()
return
} }
blink := false
i := m.pos i := m.pos
for i < len(m.value) { for i < len(m.value) {
if unicode.IsSpace(m.value[i]) { if unicode.IsSpace(m.value[i]) {
blink = m.setCursor(m.pos + 1) m.SetCursor(m.pos + 1)
i++ i++
} else { } else {
break break
@@ -577,14 +481,12 @@ func (m *Model) wordRight() bool {
for i < len(m.value) { for i < len(m.value) {
if !unicode.IsSpace(m.value[i]) { if !unicode.IsSpace(m.value[i]) {
blink = m.setCursor(m.pos + 1) m.SetCursor(m.pos + 1)
i++ i++
} else { } else {
break break
} }
} }
return blink
} }
func (m Model) echoTransform(v string) string { func (m Model) echoTransform(v string) string {
@@ -602,76 +504,58 @@ func (m Model) echoTransform(v string) string {
// Update is the Bubble Tea update loop. // Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus { if !m.focus {
m.blink = true
return m, nil return m, nil
} }
var resetBlink bool // 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
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.Type { switch {
case tea.KeyBackspace, tea.KeyCtrlH: // delete character before cursor case key.Matches(msg, m.KeyMap.DeleteWordBackward):
m.Err = nil m.Err = nil
m.deleteWordBackward()
if msg.Alt { case key.Matches(msg, m.KeyMap.DeleteCharacterBackward):
resetBlink = m.deleteWordLeft() m.Err = nil
} else { if len(m.value) > 0 {
if len(m.value) > 0 { m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...)
m.value = append(m.value[:max(0, m.pos-1)], m.value[m.pos:]...) if m.pos > 0 {
if m.pos > 0 { m.SetCursor(m.pos - 1)
resetBlink = m.setCursor(m.pos - 1)
}
} }
} }
case tea.KeyLeft, tea.KeyCtrlB: case key.Matches(msg, m.KeyMap.WordBackward):
if msg.Alt { // alt+left arrow, back one word m.wordBackward()
resetBlink = m.wordLeft() case key.Matches(msg, m.KeyMap.CharacterBackward):
break if m.pos > 0 {
m.SetCursor(m.pos - 1)
} }
if m.pos > 0 { // left arrow, ^F, back one character case key.Matches(msg, m.KeyMap.WordForward):
resetBlink = m.setCursor(m.pos - 1) m.wordForward()
case key.Matches(msg, m.KeyMap.CharacterForward):
if m.pos < len(m.value) {
m.SetCursor(m.pos + 1)
} }
case tea.KeyRight, tea.KeyCtrlF: case key.Matches(msg, m.KeyMap.DeleteWordBackward):
if msg.Alt { // alt+right arrow, forward one word m.deleteWordBackward()
resetBlink = m.wordRight() case key.Matches(msg, m.KeyMap.LineStart):
break m.CursorStart()
} case key.Matches(msg, m.KeyMap.DeleteCharacterForward):
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) { if len(m.value) > 0 && m.pos < len(m.value) {
m.value = append(m.value[:m.pos], m.value[m.pos+1:]...) m.value = append(m.value[:m.pos], m.value[m.pos+1:]...)
} }
case tea.KeyCtrlE, tea.KeyEnd: // ^E, go to end case key.Matches(msg, m.KeyMap.LineEnd):
resetBlink = m.cursorEnd() m.CursorEnd()
case tea.KeyCtrlK: // ^K, kill text after cursor case key.Matches(msg, m.KeyMap.DeleteAfterCursor):
resetBlink = m.deleteAfterCursor() m.deleteAfterCursor()
case tea.KeyCtrlU: // ^U, kill text before cursor case key.Matches(msg, m.KeyMap.DeleteBeforeCursor):
resetBlink = m.deleteBeforeCursor() m.deleteBeforeCursor()
case tea.KeyCtrlV: // ^V paste case key.Matches(msg, m.KeyMap.Paste):
return m, Paste return m, Paste
case tea.KeyRunes, tea.KeySpace: // input regular characters case key.Matches(msg, m.KeyMap.DeleteWordForward):
if msg.Alt && len(msg.Runes) == 1 { m.deleteWordForward()
if msg.Runes[0] == 'd' { // alt+d, delete word right of cursor default:
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 // Input a regular character
if m.CharLimit <= 0 || len(m.value) < m.CharLimit { if m.CharLimit <= 0 || len(m.value) < m.CharLimit {
runes := msg.Runes runes := msg.Runes
@@ -681,59 +565,31 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
value = append(value[:m.pos], append(runes, value[m.pos:]...)...) value = append(value[:m.pos], append(runes, value[m.pos:]...)...)
m.SetValue(string(value)) m.SetValue(string(value))
if m.Err == nil { if m.Err == nil {
resetBlink = m.setCursor(m.pos + len(runes)) 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: case pasteMsg:
resetBlink = m.handlePaste(string(msg)) m.handlePaste(string(msg))
case pasteErrMsg: case pasteErrMsg:
m.Err = msg m.Err = msg
} }
var cmds []tea.Cmd
var cmd tea.Cmd var cmd tea.Cmd
if resetBlink {
cmd = m.blinkCmd() 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())
} }
m.handleOverflow() m.handleOverflow()
return m, cmd return m, tea.Batch(cmds...)
} }
// View renders the textinput in its current state. // View renders the textinput in its current state.
@@ -750,10 +606,13 @@ func (m Model) View() string {
v := styleText(m.echoTransform(string(value[:pos]))) v := styleText(m.echoTransform(string(value[:pos])))
if pos < len(value) { if pos < len(value) {
v += m.cursorView(m.echoTransform(string(value[pos]))) // cursor and text under it char := m.echoTransform(string(value[pos]))
m.Cursor.SetChar(char)
v += m.Cursor.View() // cursor and text under it
v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor v += styleText(m.echoTransform(string(value[pos+1:]))) // text after cursor
} else { } else {
v += m.cursorView(" ") m.Cursor.SetChar(" ")
v += m.Cursor.View()
} }
// If a max width and background color were set fill the empty spaces with // If a max width and background color were set fill the empty spaces with
@@ -778,12 +637,9 @@ func (m Model) placeholderView() string {
style = m.PlaceholderStyle.Inline(true).Render style = m.PlaceholderStyle.Inline(true).Render
) )
// Cursor m.Cursor.TextStyle = m.PlaceholderStyle
if m.blink { m.Cursor.SetChar(p[:1])
v += m.cursorView(style(p[:1])) v += m.Cursor.View()
} else {
v += m.cursorView(p[:1])
}
// The rest of the placeholder text // The rest of the placeholder text
v += style(p[1:]) v += style(p[1:])
@@ -791,42 +647,9 @@ func (m Model) placeholderView() string {
return m.PromptStyle.Render(m.Prompt) + v 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. // Blink is a command used to initialize cursor blinking.
func Blink() tea.Msg { func Blink() tea.Msg {
return initialBlinkMsg{} return cursor.Blink()
} }
// Paste is a command for pasting from the clipboard into the text input. // Paste is a command for pasting from the clipboard into the text input.
@@ -858,3 +681,31 @@ func max(a, b int) int {
} }
return b 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

@@ -207,6 +207,16 @@ func (m *Model) LineUp(n int) (lines []string) {
return m.visibleLines() 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. // GotoTop sets the viewport to the top position.
func (m *Model) GotoTop() (lines []string) { func (m *Model) GotoTop() (lines []string) {
if m.AtTop() { if m.AtTop() {
@@ -240,9 +250,8 @@ func Sync(m Model) tea.Cmd {
// number of lines. Use Model.ViewDown to get the lines that should be rendered. // number of lines. Use Model.ViewDown to get the lines that should be rendered.
// For example: // For example:
// //
// lines := model.ViewDown(1) // lines := model.ViewDown(1)
// cmd := ViewDown(m, lines) // cmd := ViewDown(m, lines)
//
func ViewDown(m Model, lines []string) tea.Cmd { func ViewDown(m Model, lines []string) tea.Cmd {
if len(lines) == 0 { if len(lines) == 0 {
return nil return nil
@@ -350,18 +359,23 @@ func (m Model) View() string {
return strings.Repeat("\n", max(0, m.Height-1)) return strings.Repeat("\n", max(0, m.Height-1))
} }
lines := m.visibleLines() w, h := m.Width, m.Height
if sw := m.Style.GetWidth(); sw != 0 {
// Fill empty space with newlines w = min(w, sw)
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(). return m.Style.Copy().
UnsetWidth(). UnsetWidth().UnsetHeight(). // Style size already applied in contents.
UnsetHeight(). Render(contents)
Render(strings.Join(lines, "\n") + extraLines)
} }
func clamp(v, low, high int) int { func clamp(v, low, high int) int {