13 Commits

Author SHA1 Message Date
Maas Lalani
9eea57c1e8 feat: insert new lines with o/O 2022-08-15 10:38:16 -04:00
Maas Lalani
e691e35a6d fix(textarea): reset lastCharOffset if not moving vertically 2022-08-15 10:38:15 -04:00
Maas Lalani
0f1a912f12 fix: edge cases with {d,c}iw and {d,c}aw 2022-08-15 10:38:15 -04:00
Maas Lalani
715108b08b fix(textarea): better SeekInner and SeekAround 2022-08-15 10:38:15 -04:00
Maas Lalani
3e1a14200c fix(textarea): ActionChange + e should also delete end of word 2022-08-15 10:38:15 -04:00
Maas Lalani
907f5e659d feat(textarea): motions for seeking to characters (f/t/T/F/a/i) 2022-08-15 10:38:15 -04:00
Maas Lalani
2cffee5f40 fix(textarea): deleteRange multiple lines 2022-08-15 10:38:15 -04:00
Maas Lalani
8c9798c7f6 feat(textarea): findWordRight / findWordLeft 2022-08-15 10:38:15 -04:00
Maas Lalani
fdfe776a07 feat: delete/change ranges 2022-08-15 10:38:14 -04:00
Maas Lalani
fb44abddf8 refactor: split files into {modal,normal,insert}.go 2022-08-15 10:38:14 -04:00
Maas Lalani
447ff2da6a feat: ranges and motions 2022-08-15 10:38:14 -04:00
Maas Lalani
57a01e62e3 fix: cursor updating
cursor remains static in Normal mode and blinks in insert mode.
2022-08-15 10:38:14 -04:00
Maas Lalani
f5b74d002c feat(textarea): basic vim emulation
Normal & Insert Mode
Normal Mode:
* i/I/a/A insert at cursor, after cursor, start, end
* e/E/w/W word forward
* b/B word backward
* h/j/k/l cursor left/down/up/right
* p to paste
2022-08-15 10:38:06 -04:00
2 changed files with 818 additions and 146 deletions

791
textarea/modal.go Normal file
View File

@@ -0,0 +1,791 @@
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,7 +7,6 @@ import (
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/cursor"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -28,47 +27,6 @@ const (
type pasteMsg string
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
}
// 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")),
}
// LineInfo is a helper for keeping track of line information regarding
// soft-wrapped lines.
type LineInfo struct {
@@ -121,7 +79,6 @@ type Model struct {
Placeholder string
ShowLineNumbers bool
EndOfBufferCharacter rune
KeyMap KeyMap
// Styling. FocusedStyle and BlurredStyle are used to style the textarea in
// focused and blurred states.
@@ -162,6 +119,12 @@ type Model struct {
// Cursor row.
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
// vertically such that we can maintain the same navigating position.
lastCharOffset int
@@ -191,13 +154,14 @@ func New() Model {
EndOfBufferCharacter: '~',
ShowLineNumbers: true,
Cursor: cur,
KeyMap: DefaultKeyMap,
value: make([][]rune, minHeight, maxHeight),
mode: ModeInsert,
focus: false,
col: 0,
row: 0,
lineNumberFormat: "%2v ",
command: &NormalCommand{},
viewport: &vp,
}
@@ -299,6 +263,11 @@ func (m Model) Line() int {
return m.row
}
// NormalCommand returns the normal command.
func (m *Model) NormalCommand() *NormalCommand {
return m.command
}
// CursorDown moves the cursor down by one line.
// Returns whether or not the cursor blink should be reset.
func (m *Model) CursorDown() {
@@ -713,109 +682,21 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.value[m.row] = make([]rune, 0)
}
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):
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 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):
if m.col == 0 && m.row != 0 {
m.row--
m.CursorEnd()
break
}
if m.col > 0 {
m.SetCursor(m.col - 1)
}
case key.Matches(msg, m.KeyMap.LinePrevious):
m.CursorUp()
case key.Matches(msg, m.KeyMap.WordBackward):
m.wordLeft()
default:
if m.CharLimit > 0 && rw.StringWidth(m.Value()) >= m.CharLimit {
break
}
var cmd tea.Cmd
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))
switch m.mode {
case ModeInsert:
cmd = m.insertUpdate(msg)
cmds = append(cmds, cmd)
// Sometimes, we need to enter insert mode after executing a command.
switch msg.(type) {
case executeMsg:
cmd = m.normalUpdate(msg)
cmds = append(cmds, cmd)
}
case pasteMsg:
m.handlePaste(string(msg))
case pasteErrMsg:
m.Err = msg
case ModeNormal:
cmd = m.normalUpdate(msg)
cmds = append(cmds, cmd)
}
vp, cmd := m.viewport.Update(msg)
@@ -824,7 +705,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
newRow, newCol := m.cursorLineNumber(), m.col
m.Cursor, cmd = m.Cursor.Update(msg)
if newRow != oldRow || newCol != oldCol {
if m.mode == ModeInsert && (newRow != oldRow || newCol != oldCol) {
m.Cursor.Blink = false
cmd = m.Cursor.BlinkCmd()
}