diff --git a/textarea/modal.go b/textarea/modal.go index 8edf8c4..87bab20 100644 --- a/textarea/modal.go +++ b/textarea/modal.go @@ -44,6 +44,8 @@ const ( 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 diff --git a/textarea/normal.go b/textarea/normal.go index a9fec1f..cdba62c 100644 --- a/textarea/normal.go +++ b/textarea/normal.go @@ -2,12 +2,15 @@ package textarea import ( "strconv" + "strings" + "unicode" tea "github.com/charmbracelet/bubbletea" ) func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd { var cmd tea.Cmd + var execute bool switch msg := msg.(type) { case tea.KeyMsg: @@ -15,13 +18,24 @@ func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd { case "esc": m.command = &NormalCommand{} 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}, + } + execute = true + break + } + v := m.command.Buffer + msg.String() count, err := strconv.Atoi(v) if err != nil { count, _ = strconv.Atoi(msg.String()) - m.command = &NormalCommand{Buffer: msg.String(), Count: count} + m.command.Buffer = msg.String() + m.command.Count = count } else { - m.command = &NormalCommand{Buffer: v, Count: count} + m.command.Buffer = v + m.command.Count = count } case "G": var row int @@ -40,15 +54,38 @@ func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd { m.command = &NormalCommand{Buffer: "g"} } return nil + 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)}, + } + 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)}, + } + 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.CursorStart() - m.deleteAfterCursor() - m.mergeLineBelow(m.row) + m.value[m.row] = []rune{} + if m.row < len(m.value)-1 { + m.mergeLineBelow(m.row) + } else { + m.mergeLineAbove(m.row) + } } m.command = &NormalCommand{} - break + return nil } m.command.Action = ActionDelete case "y": @@ -56,16 +93,29 @@ func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd { case "r": m.command.Action = ActionReplace case "i": - return m.SetMode(ModeInsert) + m.command.Range = Range{ + Start: Position{Row: m.row, Col: m.col}, + End: Position{Row: m.row, Col: m.col}, + } + cmd = m.SetMode(ModeInsert) case "I": - m.CursorStart() - return m.SetMode(ModeInsert) + m.command.Range = Range{ + Start: Position{Row: m.row, Col: m.col}, + End: Position{Row: m.row, Col: 0}, + } + cmd = m.SetMode(ModeInsert) case "a": - m.SetCursor(m.col + 1) - return m.SetMode(ModeInsert) + m.command.Range = Range{ + Start: Position{Row: m.row, Col: m.col}, + End: Position{Row: m.row, Col: m.col + 1}, + } + cmd = m.SetMode(ModeInsert) case "A": - m.CursorEnd() - return m.SetMode(ModeInsert) + m.command.Range = Range{ + Start: Position{Row: m.row, Col: m.col}, + End: Position{Row: m.row, Col: len(m.value[m.row]) + 1}, + } + cmd = m.SetMode(ModeInsert) case "^": m.command.Range = Range{ Start: Position{m.row, m.col}, @@ -74,23 +124,22 @@ func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd { case "$": m.command.Range = Range{ Start: Position{m.row, m.col}, - End: Position{m.row, len(m.value[m.row]) - 1}, + End: Position{m.row, len(m.value[m.row])}, } case "e", "E": m.command.Range = Range{ Start: Position{m.row, m.col}, - End: m.findWordEnd(m.command.Count, msg.String() == "E"), + End: m.findWordRight(max(m.command.Count, 1), msg.String() == "E"), } case "w", "W": m.command.Range = Range{ Start: Position{m.row, m.col}, - End: m.findWordStart(m.command.Count, msg.String() == "W"), + End: m.findWordRight(max(m.command.Count, 1), msg.String() == "W"), } case "b", "B": - direction := -1 m.command.Range = Range{ Start: Position{m.row, m.col}, - End: m.findWordStart(direction*m.command.Count, msg.String() == "B"), + End: m.findWordLeft(max(m.command.Count, 1), msg.String() == "B"), } case "h": m.command.Range = Range{ @@ -100,18 +149,26 @@ func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd { case "j": m.command.Range = Range{ Start: Position{m.row, m.col}, - End: Position{m.row + max(m.command.Count, 1), m.col}, + End: Position{min(m.row+max(m.command.Count, 1), len(m.value)-1), m.col}, } case "k": m.command.Range = Range{ Start: Position{m.row, m.col}, - End: Position{m.row - max(m.command.Count, 1), m.col}, + End: Position{max(m.row-max(m.command.Count, 1), 0), m.col}, } case "l": m.command.Range = Range{ Start: Position{m.row, m.col}, End: Position{m.row, m.col + max(m.command.Count, 1)}, } + 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) @@ -120,24 +177,16 @@ func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd { cmd = Paste } - switch msg.String() { - case "i", "I", "a", "A", "e", "w", "W", "b", "B", "h", "j", "k", "l", "p", "$", "^": + if strings.ContainsAny(msg.String(), "iIaAewWbBhjklp$^xX") || execute { switch m.command.Action { case ActionDelete: m.deleteRange(m.command.Range) + case ActionChange: + m.deleteRange(m.command.Range) + cmd = m.SetMode(ModeInsert) case ActionMove: - rowDelta := m.command.Range.End.Row - m.command.Range.Start.Row - if rowDelta > 0 { - for i := 0; i < rowDelta; i++ { - m.CursorDown() - } - } else if rowDelta < 0 { - for i := 0; i < -rowDelta; i++ { - m.CursorUp() - } - } else { - m.SetCursor(m.command.Range.End.Col) - } + m.row = clamp(m.command.Range.End.Row, 0, len(m.value)-1) + m.col = clamp(m.command.Range.End.Col, 0, len(m.value[m.row])) } m.command = &NormalCommand{} } @@ -148,3 +197,92 @@ func (m *Model) normalUpdate(msg tea.Msg) tea.Cmd { return cmd } + +// findWordRight locates the end of the current word or the end of the next word +// if already at the end 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) findWordRight(count int, _ bool) Position { + row, col := m.row, m.col + + for count > 0 { + if col < len(m.value[row])-1 && unicode.IsSpace(m.value[row][col+1]) { + col++ + } else if col >= len(m.value[row])-1 && row < len(m.value)-1 { + row++ + col = 0 + } + + for col < len(m.value[row])-1 { + if !unicode.IsSpace(m.value[row][col+1]) { + col++ + } else { + count-- + break + } + } + + if row >= len(m.value)-1 && col >= len(m.value[row])-1 { + break + } + } + + return Position{Row: row, Col: col} +} + +// findWordLeft 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) findWordLeft(count int, onlySpaces bool) Position { + _ = onlySpaces + row, col := m.row, m.col + + for count > 0 { + if col > 0 && unicode.IsSpace(m.value[row][col-1]) { + col-- + } else if col <= 0 && row > 0 { + row-- + col = len(m.value[row]) - 1 + } + + for col > 0 { + if !unicode.IsSpace(m.value[row][col-1]) { + col-- + } else { + count-- + break + } + } + + if row <= 0 && col <= 0 { + break + } + } + + return Position{Row: row, Col: col} +} + +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 { + 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) + + 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)-1) +} diff --git a/textarea/textarea.go b/textarea/textarea.go index 5667f58..682728d 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -572,31 +572,6 @@ func (m *Model) wordRight() { } } -// findWordEnd locates the end of the current word or the end of the next word -// if already at the end 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) findWordEnd(count int, onlySpaces bool) Position { - _ = count - _ = onlySpaces - row, col := m.row, m.col - - for col < len(m.value[row]) { - if !unicode.IsSpace(m.value[row][col]) { - col++ - } else { - break - } - } - - return Position{Row: row, Col: col} -} - -// findWordStart 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) findWordStart(count int, onlySpaces bool) Position { - return m.findWordEnd(count, onlySpaces) -} - // LineInfo returns the number of characters from the start of the // (soft-wrapped) line and the (soft-wrapped) line width. func (m Model) LineInfo() LineInfo { @@ -733,9 +708,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *Model) deleteRange(r Range) { -} - // View renders the text area in its current state. func (m Model) View() string { if m.Value() == "" && m.row == 0 && m.col == 0 && m.Placeholder != "" {