From 8c3085a6cd90810887c9435b9885259379d5717c Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 13 Nov 2024 13:09:40 -0500 Subject: [PATCH 01/15] feat(textarea): use a real cursor position This sets the real cursor to its correct position when the cursor is moved. --- textarea/textarea.go | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index b5e3aa34..6291a6f8 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -272,6 +272,12 @@ type Model struct { // Cursor row. row int + // The bubble offset relative to the parent bubble. + offsetX, offsetY int + + // The last recorded real cursor position. + realCol, realRow int + // Last character offset, used to maintain state when the cursor is moved // vertically such that we can maintain the same navigating position. lastCharOffset int @@ -358,6 +364,11 @@ func DefaultDarkStyles() Styles { return DefaultStyles(true) } +// SetOffset sets the offset of the textarea relative to the parent bubble. +func (m *Model) SetOffset(x, y int) { + m.offsetX, m.offsetY = x, y +} + // SetValue sets the value of the text input. func (m *Model) SetValue(s string) { m.Reset() @@ -1101,11 +1112,23 @@ 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) && m.Cursor.Mode() == cursor.CursorBlink { + if cmd != nil { + cmds = append(cmds, cmd) + } + + if m.Cursor.Mode() == cursor.CursorBlink && (newRow != oldRow || newCol != oldCol) { m.Cursor.Blink = false - cmd = m.Cursor.BlinkCmd() + cmds = append(cmds, m.Cursor.BlinkCmd()) + } + + // Ensure the real cursor is at the correct position. + row := m.cursorLineNumber() + lineInfo := m.LineInfo() + realCol, realRow := m.offsetX+lineInfo.ColumnOffset, m.offsetY+row-m.viewport.YOffset + if realCol != m.realCol || realRow != m.realRow { + m.realCol, m.realRow = realCol, realRow + cmds = append(cmds, tea.SetCursorPosition(realCol, realRow)) } - cmds = append(cmds, cmd) m.repositionView() @@ -1183,7 +1206,9 @@ func (m Model) View() string { wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " ")) padding -= m.width - strwidth } - if m.row == l && lineInfo.RowOffset == wl { + + // We don't need to render the cursor if it's hidden. + if m.Cursor.Mode() != cursor.CursorHide && m.row == l && lineInfo.RowOffset == wl { s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset]))) if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.Cursor.SetChar(" ") From c350e7920977316521a4662d62479a4ee34edbb1 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 13 Nov 2024 13:13:55 -0500 Subject: [PATCH 02/15] fix(textarea): reset real cursor position on Reset --- textarea/textarea.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/textarea/textarea.go b/textarea/textarea.go index 6291a6f8..f0ceda13 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -629,6 +629,8 @@ func (m *Model) Reset() { m.value = make([][]rune, minHeight, startCap) m.col = 0 m.row = 0 + m.realCol = 0 + m.realRow = 0 m.viewport.GotoTop() m.SetCursor(0) } From 804c3708016e720928bca9797c69ff5a315a2d6d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 13 Nov 2024 13:28:48 -0500 Subject: [PATCH 03/15] fix(textarea): respect double-width characters in real cursor position --- textarea/textarea.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index f0ceda13..ee905de7 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1126,7 +1126,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // Ensure the real cursor is at the correct position. row := m.cursorLineNumber() lineInfo := m.LineInfo() - realCol, realRow := m.offsetX+lineInfo.ColumnOffset, m.offsetY+row-m.viewport.YOffset + realCol, realRow := m.offsetX+lineInfo.CharOffset, m.offsetY+row-m.viewport.YOffset if realCol != m.realCol || realRow != m.realRow { m.realCol, m.realRow = realCol, realRow cmds = append(cmds, tea.SetCursorPosition(realCol, realRow)) From 7073d1abc8669f3745337d2421a813ea418b8eb9 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 5 Sep 2024 15:32:11 -0300 Subject: [PATCH 04/15] wip: area Signed-off-by: Carlos Alexandro Becker --- textarea/textarea.go | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 0bd77009..58446a01 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -246,6 +246,8 @@ type Model struct { // there's no limit. MaxWidth int + SyntaxHighlighter func(string) string + // If promptFunc is set, it replaces Prompt as a generator for // prompt strings at the beginning of each line. promptFunc func(line int) string @@ -1182,7 +1184,13 @@ func (m Model) View() string { padding -= m.width - strwidth } if m.row == l && lineInfo.RowOffset == wl { - s.WriteString(style.Render(string(wrappedLine[:lineInfo.ColumnOffset]))) + ln := string(wrappedLine[:lineInfo.ColumnOffset]) + if m.SyntaxHighlighter == nil { + ln = style.Render(ln) + } else { + ln = m.SyntaxHighlighter(ln) + } + s.WriteString(ln) if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.Cursor.SetChar(" ") s.WriteString(m.Cursor.View()) @@ -1192,9 +1200,21 @@ func (m Model) View() string { s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) } } else { - s.WriteString(style.Render(string(wrappedLine))) + ln := string(wrappedLine) + if m.SyntaxHighlighter == nil { + ln = style.Render(ln) + } else { + ln = m.SyntaxHighlighter(ln) + } + s.WriteString(ln) + } + + pad := strings.Repeat(" ", max(0, padding)) + if m.SyntaxHighlighter == nil { + s.WriteString(style.Render(pad)) + } else { + s.WriteString(pad) } - s.WriteString(style.Render(strings.Repeat(" ", max(0, padding)))) s.WriteRune('\n') newLines++ } From 2ec013be2742f341d29df59919bfdfb57881e401 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 11 Sep 2024 15:29:51 -0300 Subject: [PATCH 05/15] wip Signed-off-by: Carlos Alexandro Becker --- textarea/textarea.go | 114 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 4 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 58446a01..d1a0040e 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -3,6 +3,7 @@ package textarea import ( "crypto/sha256" "fmt" + "reflect" "strconv" "strings" "unicode" @@ -64,6 +65,10 @@ type KeyMap struct { CapitalizeWordForward key.Binding TransposeCharacterBackward key.Binding + + AcceptSuggestion key.Binding + NextSuggestion key.Binding + PrevSuggestion key.Binding } // DefaultKeyMap returns the default set of key bindings for navigating and acting @@ -94,6 +99,10 @@ func DefaultKeyMap() KeyMap { UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")), TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")), + + AcceptSuggestion: key.NewBinding(key.WithKeys("tab")), + NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")), + PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")), } } @@ -248,6 +257,15 @@ type Model struct { SyntaxHighlighter func(string) string + // Should the input suggest to complete + ShowSuggestions bool + + // suggestions is a list of suggestions that may be used to complete the + // input. + suggestions [][][]rune + matchedSuggestions [][][]rune + currentSuggestionIndex 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 @@ -310,10 +328,11 @@ func New() Model { Cursor: cur, KeyMap: DefaultKeyMap(), - value: make([][]rune, minHeight, maxLines), - focus: false, - col: 0, - row: 0, + suggestions: [][][]rune{}, + value: make([][]rune, minHeight, maxLines), + focus: false, + col: 0, + row: 0, viewport: &vp, } @@ -369,6 +388,18 @@ func (m *Model) SetValue(s string) { m.InsertString(s) } +// SetSuggestions sets the suggestions for the input. +func (m *Model) SetSuggestions(suggestions []string) { + m.suggestions = make([][][]rune, len(suggestions)) + for i, s := range suggestions { + for _, line := range strings.Split(s, "\n") { + m.suggestions[i] = append(m.suggestions[i], []rune(line)) + } + } + + m.updateSuggestions() +} + // InsertString inserts a string at the cursor position. func (m *Model) InsertString(s string) { m.insertRunesFromUserInput([]rune(s)) @@ -982,6 +1013,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } + // Need to check for completion before, because key is configurable and might be double assigned + keyMsg, ok := msg.(tea.KeyMsg) + if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) { + if m.canAcceptSuggestion() { + m.value = append(m.value, m.matchedSuggestions[m.currentSuggestionIndex][len(m.value):]...) + m.CursorEnd() + } + } + // Used to determine if the cursor should blink. oldRow, oldCol := m.cursorLineNumber(), m.col @@ -1083,11 +1123,19 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m.capitalizeRight() case key.Matches(msg, m.KeyMap.TransposeCharacterBackward): m.transposeLeft() + case key.Matches(msg, m.KeyMap.NextSuggestion): + m.nextSuggestion() + case key.Matches(msg, m.KeyMap.PrevSuggestion): + m.previousSuggestion() default: m.insertRunesFromUserInput([]rune(msg.Text)) } + // Check again if can be completed + // because value might be something that does not match the completion prefix + m.updateSuggestions() + case pasteMsg: m.insertRunesFromUserInput([]rune(msg)) @@ -1194,10 +1242,12 @@ func (m Model) View() string { if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.Cursor.SetChar(" ") s.WriteString(m.Cursor.View()) + // XXX: suggestions } else { m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) s.WriteString(style.Render(m.Cursor.View())) s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) + // XXX: suggestions } } else { ln := string(wrappedLine) @@ -1425,6 +1475,62 @@ func (m *Model) splitLine(row, col int) { m.row++ } +// canAcceptSuggestion returns whether there is an acceptable suggestion to +// autocomplete the current value. +func (m *Model) canAcceptSuggestion() bool { + return len(m.matchedSuggestions) > 0 +} + +func linesToString(lines [][]rune) string { + var result []string + for _, line := range lines { + result = append(result, string(line)) + } + return strings.Join(result, "\n") +} + +// updateSuggestions refreshes the list of matching suggestions. +func (m *Model) updateSuggestions() { + if !m.ShowSuggestions { + return + } + + if len(m.value) <= 0 || len(m.suggestions) <= 0 { + m.matchedSuggestions = [][][]rune{} + return + } + + matches := [][][]rune{} + for _, s := range m.suggestions { + suggestion := linesToString(s) + + if strings.HasPrefix(strings.ToLower(suggestion), strings.ToLower(linesToString(m.value))) { + matches = append(matches, s) + } + } + if !reflect.DeepEqual(matches, m.matchedSuggestions) { + m.currentSuggestionIndex = 0 + } + + m.matchedSuggestions = matches +} + +// nextSuggestion selects the next suggestion. +func (m *Model) nextSuggestion() { + m.currentSuggestionIndex = (m.currentSuggestionIndex + 1) + if m.currentSuggestionIndex >= len(m.matchedSuggestions) { + m.currentSuggestionIndex = 0 + } +} + +// previousSuggestion selects the previous suggestion. +func (m *Model) previousSuggestion() { + m.currentSuggestionIndex = (m.currentSuggestionIndex - 1) + if m.currentSuggestionIndex < 0 { + m.currentSuggestionIndex = len(m.matchedSuggestions) - 1 + } +} + // Paste is a command for pasting from the clipboard into the text input. func Paste() tea.Msg { str, err := clipboard.ReadAll() From 8789a984d8975d0627c8534a7b67218c46f0c46e Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 11 Sep 2024 15:36:06 -0300 Subject: [PATCH 06/15] wip Signed-off-by: Carlos Alexandro Becker --- textarea/textarea.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/textarea/textarea.go b/textarea/textarea.go index d1a0040e..606a0d96 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1160,6 +1160,20 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m Model) ghostTextView(offset int) string { + if !m.canAcceptSuggestion() { + return "" + } + + value := linesToString(m.value) + suggestion := linesToString(m.matchedSuggestions[m.currentSuggestionIndex]) + if len(value) >= len(suggestion) { + return "" + } + str := suggestion[len(m.Value())+offset:] + return m.style.Placeholder.Inline(true).Render(str) +} + // 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 != "" { @@ -1242,11 +1256,13 @@ func (m Model) View() string { if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.Cursor.SetChar(" ") s.WriteString(m.Cursor.View()) + s.WriteString(m.ghostTextView(0)) // XXX: suggestions } else { m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) s.WriteString(style.Render(m.Cursor.View())) s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) + s.WriteString(m.ghostTextView(1)) // XXX: suggestions } } else { From ca9cbbc99e260fd777fcaf6072f4c708bd418d4f Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Wed, 11 Sep 2024 16:48:34 -0300 Subject: [PATCH 07/15] wip Signed-off-by: Carlos Alexandro Becker --- textarea/textarea.go | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 606a0d96..9a817bbe 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -100,7 +100,7 @@ func DefaultKeyMap() KeyMap { TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")), - AcceptSuggestion: key.NewBinding(key.WithKeys("tab")), + AcceptSuggestion: key.NewBinding(key.WithKeys("tab", "ctrl+y")), NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")), PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")), } @@ -1017,7 +1017,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { keyMsg, ok := msg.(tea.KeyMsg) if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) { if m.canAcceptSuggestion() { - m.value = append(m.value, m.matchedSuggestions[m.currentSuggestionIndex][len(m.value):]...) + m.value = m.matchedSuggestions[m.currentSuggestionIndex] m.CursorEnd() } } @@ -1160,7 +1160,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m Model) ghostTextView(offset int) string { +func (m Model) suggestionView(offset int) string { if !m.canAcceptSuggestion() { return "" } @@ -1170,8 +1170,15 @@ func (m Model) ghostTextView(offset int) string { if len(value) >= len(suggestion) { return "" } - str := suggestion[len(m.Value())+offset:] - return m.style.Placeholder.Inline(true).Render(str) + + var lines []string + for _, line := range strings.Split(suggestion[len(m.Value())+offset:], "\n") { + lines = append(lines, m.style.Placeholder.Inline(true).Render(line)) + } + if len(lines) > m.Height() { + m.SetHeight(len(lines) + 1) + } + return strings.Join(lines, "\n") } // View renders the text area in its current state. @@ -1253,16 +1260,23 @@ func (m Model) View() string { ln = m.SyntaxHighlighter(ln) } s.WriteString(ln) + if m.col >= len(line) && lineInfo.CharOffset >= m.width { m.Cursor.SetChar(" ") s.WriteString(m.Cursor.View()) - s.WriteString(m.ghostTextView(0)) - // XXX: suggestions + // XXX: suggestions? } else { m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) + if m.canAcceptSuggestion() && len(m.matchedSuggestions) > 0 { + suggestion := m.matchedSuggestions[m.currentSuggestionIndex][m.row:] + m.Cursor.TextStyle = m.style.Placeholder + if len(suggestion) > m.row && len(suggestion[m.row]) > m.col { + m.Cursor.SetChar(string(suggestion[m.row][m.col])) + } + } s.WriteString(style.Render(m.Cursor.View())) s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) - s.WriteString(m.ghostTextView(1)) + s.WriteString(m.suggestionView(1)) // XXX: suggestions } } else { @@ -1516,6 +1530,7 @@ func (m *Model) updateSuggestions() { return } + // TODO: this should be better matches := [][][]rune{} for _, s := range m.suggestions { suggestion := linesToString(s) From 4755f88132b105b972137df6159b5e2306592dd0 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 12 Sep 2024 09:26:48 -0300 Subject: [PATCH 08/15] fix: comp --- textarea/textarea.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 9a817bbe..4f69fafc 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1268,7 +1268,10 @@ func (m Model) View() string { } else { m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) if m.canAcceptSuggestion() && len(m.matchedSuggestions) > 0 { - suggestion := m.matchedSuggestions[m.currentSuggestionIndex][m.row:] + suggestion := m.matchedSuggestions[m.currentSuggestionIndex] + if len(suggestion) >= m.row { + suggestion = suggestion[m.row:] + } m.Cursor.TextStyle = m.style.Placeholder if len(suggestion) > m.row && len(suggestion[m.row]) > m.col { m.Cursor.SetChar(string(suggestion[m.row][m.col])) From 56f7197cd34b3710219fe94acd15104cc38d99d2 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 12 Sep 2024 10:36:36 -0300 Subject: [PATCH 09/15] fix: move to last line on accept --- textarea/textarea.go | 1 + 1 file changed, 1 insertion(+) diff --git a/textarea/textarea.go b/textarea/textarea.go index 4f69fafc..fe1bceb6 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1018,6 +1018,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) { if m.canAcceptSuggestion() { m.value = m.matchedSuggestions[m.currentSuggestionIndex] + m.row = len(m.value) - 1 m.CursorEnd() } } From 4c214e0848003e658d2d66f23e7d1485c5969168 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 12 Sep 2024 11:17:29 -0300 Subject: [PATCH 10/15] feat: formatter --- textarea/textarea.go | 51 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index fe1bceb6..f572c51f 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -256,6 +256,7 @@ type Model struct { MaxWidth int SyntaxHighlighter func(string) string + Formatter func(string) string // Should the input suggest to complete ShowSuggestions bool @@ -496,6 +497,7 @@ func (m *Model) insertRunesFromUserInput(runes []rune) { // Finally add the tail at the end of the last line inserted. m.value[m.row] = append(m.value[m.row], tail...) + m.format() m.SetCursor(m.col) } @@ -1018,6 +1020,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) { if m.canAcceptSuggestion() { m.value = m.matchedSuggestions[m.currentSuggestionIndex] + m.format() m.row = len(m.value) - 1 m.CursorEnd() } @@ -1101,7 +1104,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case key.Matches(msg, m.KeyMap.CharacterForward): m.characterRight() case key.Matches(msg, m.KeyMap.LineNext): - m.CursorDown() + if m.row == 0 { + m.nextSuggestion() + } else { + m.CursorDown() + } case key.Matches(msg, m.KeyMap.WordForward): m.wordRight() case key.Matches(msg, m.KeyMap.Paste): @@ -1109,7 +1116,11 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case key.Matches(msg, m.KeyMap.CharacterBackward): m.characterLeft(false /* insideLine */) case key.Matches(msg, m.KeyMap.LinePrevious): - m.CursorUp() + if m.row == 0 { + m.previousSuggestion() + } else { + m.CursorUp() + } case key.Matches(msg, m.KeyMap.WordBackward): m.wordLeft() case key.Matches(msg, m.KeyMap.InputBegin): @@ -1506,6 +1517,7 @@ func (m *Model) splitLine(row, col int) { m.value[row+1] = tail m.col = 0 + m.SetHeight(m.row + 2) m.row++ } @@ -1515,14 +1527,6 @@ func (m *Model) canAcceptSuggestion() bool { return len(m.matchedSuggestions) > 0 } -func linesToString(lines [][]rune) string { - var result []string - for _, line := range lines { - result = append(result, string(line)) - } - return strings.Join(result, "\n") -} - // updateSuggestions refreshes the list of matching suggestions. func (m *Model) updateSuggestions() { if !m.ShowSuggestions { @@ -1566,6 +1570,17 @@ func (m *Model) previousSuggestion() { } } +func (m *Model) format() { + if m.Formatter == nil { + return + } + m.value = stringToLines(m.Formatter(linesToString(m.value))) + m.row = len(m.value) - 1 + if m.col > len(m.value[m.row]) { + m.col = len(m.value[m.row]) - 1 + } +} + // Paste is a command for pasting from the clipboard into the text input. func Paste() tea.Msg { str, err := clipboard.ReadAll() @@ -1664,3 +1679,19 @@ func max(a, b int) int { } return b } + +func stringToLines(s string) [][]rune { + var r [][]rune + for _, line := range strings.Split(s, "\n") { + r = append(r, []rune(line)) + } + return r +} + +func linesToString(lines [][]rune) string { + var result []string + for _, line := range lines { + result = append(result, string(line)) + } + return strings.Join(result, "\n") +} From afbb7bc049ae53e2fa2543ac99cdfb9ffe9bec68 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 28 Oct 2024 11:55:57 -0400 Subject: [PATCH 11/15] feat(textarea): add SetOffset and CursorPosition methods --- textarea/textarea.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/textarea/textarea.go b/textarea/textarea.go index f572c51f..efc1f1fb 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -296,6 +296,9 @@ type Model struct { // Cursor row. row int + // The bubble offset in the parent model. + offsetX, offsetY int + // Last character offset, used to maintain state when the cursor is moved // vertically such that we can maintain the same navigating position. lastCharOffset int @@ -536,6 +539,11 @@ func (m Model) Line() int { return m.row } +// SetOffset sets the bubble offset in the parent model. +func (m *Model) SetOffset(x, y int) { + m.offsetX, m.offsetY = x, y +} + // CursorDown moves the cursor down by one line. // Returns whether or not the cursor blink should be reset. func (m *Model) CursorDown() { @@ -606,6 +614,11 @@ func (m *Model) CursorUp() { } } +// CursorPosition returns the current cursor position. +func (m Model) CursorPosition() (int, int) { + return m.col, m.row +} + // SetCursor moves the cursor to the given position. If the position is // out of bounds the cursor will be moved to the start or end accordingly. func (m *Model) SetCursor(col int) { @@ -1160,6 +1173,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmds = append(cmds, cmd) newRow, newCol := m.cursorLineNumber(), m.col + cmds = append(cmds, tea.SetCursorPosition(m.offsetX+newCol, m.offsetY+newRow)) + m.Cursor, cmd = m.Cursor.Update(msg) if (newRow != oldRow || newCol != oldCol) && m.Cursor.Mode() == cursor.CursorBlink { m.Cursor.Blink = false From c288adf7ab3b618b5d0400bddc768fd41020d1f5 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Fri, 1 Nov 2024 14:05:10 -0300 Subject: [PATCH 12/15] fix: fmt --- textarea/textarea.go | 51 +++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index efc1f1fb..b64a0efb 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -75,34 +75,31 @@ type KeyMap struct { // upon the textarea. func DefaultKeyMap() KeyMap { return KeyMap{ - CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")), - CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")), - WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")), - WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")), - LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")), - LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")), - DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")), - DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")), - DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")), - DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")), - InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")), - DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")), - DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")), - LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")), - LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")), - Paste: key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")), - InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")), - InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")), - - CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")), - LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")), - UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")), - + CharacterForward: key.NewBinding(key.WithKeys("right", "ctrl+f"), key.WithHelp("right", "character forward")), + CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")), + WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")), + WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")), + LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")), + LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")), + DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")), + DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")), + DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")), + DeleteBeforeCursor: key.NewBinding(key.WithKeys("ctrl+u"), key.WithHelp("ctrl+u", "delete before cursor")), + InsertNewline: key.NewBinding(key.WithKeys("enter", "ctrl+m"), key.WithHelp("enter", "insert newline")), + DeleteCharacterBackward: key.NewBinding(key.WithKeys("backspace", "ctrl+h"), key.WithHelp("backspace", "delete character backward")), + DeleteCharacterForward: key.NewBinding(key.WithKeys("delete", "ctrl+d"), key.WithHelp("delete", "delete character forward")), + LineStart: key.NewBinding(key.WithKeys("home", "ctrl+a"), key.WithHelp("home", "line start")), + LineEnd: key.NewBinding(key.WithKeys("end", "ctrl+e"), key.WithHelp("end", "line end")), + Paste: key.NewBinding(key.WithKeys("ctrl+v"), key.WithHelp("ctrl+v", "paste")), + InputBegin: key.NewBinding(key.WithKeys("alt+<", "ctrl+home"), key.WithHelp("alt+<", "input begin")), + InputEnd: key.NewBinding(key.WithKeys("alt+>", "ctrl+end"), key.WithHelp("alt+>", "input end")), + CapitalizeWordForward: key.NewBinding(key.WithKeys("alt+c"), key.WithHelp("alt+c", "capitalize word forward")), + LowercaseWordForward: key.NewBinding(key.WithKeys("alt+l"), key.WithHelp("alt+l", "lowercase word forward")), + UppercaseWordForward: key.NewBinding(key.WithKeys("alt+u"), key.WithHelp("alt+u", "uppercase word forward")), TransposeCharacterBackward: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("ctrl+t", "transpose character backward")), - - AcceptSuggestion: key.NewBinding(key.WithKeys("tab", "ctrl+y")), - NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")), - PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")), + AcceptSuggestion: key.NewBinding(key.WithKeys("tab", "ctrl+y")), + NextSuggestion: key.NewBinding(key.WithKeys("down", "ctrl+n")), + PrevSuggestion: key.NewBinding(key.WithKeys("up", "ctrl+p")), } } From 3ba296c534e46fb6c5fc0f32452701988c9c007e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 23 Jan 2025 11:49:34 -0500 Subject: [PATCH 13/15] (v2) Revert cursor position from v2-area (#709) * chore: revert "wip" This reverts commit d020289a27cd1f929bceae8a3ec88bed40ab78a6, reversing changes made to c288adf7ab3b618b5d0400bddc768fd41020d1f5. * chore: revert "feat(textarea): add SetOffset and CursorPosition methods" This reverts commit afbb7bc049ae53e2fa2543ac99cdfb9ffe9bec68. * chor: revert "fix(textarea): respect double-width characters in real cursor position" This reverts commit 804c3708016e720928bca9797c69ff5a315a2d6d. * chore: revert "feat(textarea): use a real cursor position" This reverts commit 8c3085a6cd90810887c9435b9885259379d5717c. --- textarea/textarea.go | 107 ++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 58 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 6510d850..42f063f1 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -293,12 +293,6 @@ type Model struct { // Cursor row. row int - // The bubble offset relative to the parent bubble. - offsetX, offsetY int - - // The last recorded real cursor position. - realCol, realRow int - // Last character offset, used to maintain state when the cursor is moved // vertically such that we can maintain the same navigating position. lastCharOffset int @@ -386,11 +380,6 @@ func DefaultDarkStyles() Styles { return DefaultStyles(true) } -// SetOffset sets the offset of the textarea relative to the parent bubble. -func (m *Model) SetOffset(x, y int) { - m.offsetX, m.offsetY = x, y -} - // SetValue sets the value of the text input. func (m *Model) SetValue(s string) { m.Reset() @@ -614,11 +603,6 @@ func (m *Model) CursorUp() { } } -// CursorPosition returns the current cursor position. -func (m Model) CursorPosition() (int, int) { - return m.col, m.row -} - // SetCursor moves the cursor to the given position. If the position is // out of bounds the cursor will be moved to the start or end accordingly. func (m *Model) SetCursor(col int) { @@ -664,8 +648,6 @@ func (m *Model) Reset() { m.value = make([][]rune, minHeight, maxLines) m.col = 0 m.row = 0 - m.realCol = 0 - m.realRow = 0 m.viewport.GotoTop() m.SetCursor(0) } @@ -1031,16 +1013,15 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } // Need to check for completion before, because key is configurable and might be double assigned - // keyMsg, ok := msg.(tea.KeyMsg) - // if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) { - // if m.canAcceptSuggestion() { - // m.value = m.matchedSuggestions[m.currentSuggestionIndex] - // m.format() - // m.row = len(m.value) - 1 - // m.CursorEnd() - // m.SetSuggestions(nil) - // } - // } + keyMsg, ok := msg.(tea.KeyMsg) + if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) { + if m.canAcceptSuggestion() { + m.value = m.matchedSuggestions[m.currentSuggestionIndex] + m.format() + m.row = len(m.value) - 1 + m.CursorEnd() + } + } // Used to determine if the cursor should blink. oldRow, oldCol := m.cursorLineNumber(), m.col @@ -1176,26 +1157,12 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmds = append(cmds, cmd) newRow, newCol := m.cursorLineNumber(), m.col - cmds = append(cmds, tea.SetCursorPosition(m.offsetX+newCol, m.offsetY+newRow)) - m.Cursor, cmd = m.Cursor.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - - if m.Cursor.Mode() == cursor.CursorBlink && (newRow != oldRow || newCol != oldCol) { + if (newRow != oldRow || newCol != oldCol) && m.Cursor.Mode() == cursor.CursorBlink { m.Cursor.Blink = false - cmds = append(cmds, m.Cursor.BlinkCmd()) - } - - // Ensure the real cursor is at the correct position. - row := m.cursorLineNumber() - lineInfo := m.LineInfo() - realCol, realRow := m.offsetX+lineInfo.CharOffset, m.offsetY+row-m.viewport.YOffset - if realCol != m.realCol || realRow != m.realRow { - m.realCol, m.realRow = realCol, realRow - cmds = append(cmds, tea.SetCursorPosition(realCol, realRow)) + cmd = m.Cursor.BlinkCmd() } + cmds = append(cmds, cmd) m.repositionView() @@ -1235,7 +1202,7 @@ func (m Model) View() string { style lipgloss.Style newLines int widestLineNumber int - // lineInfo = m.LineInfo() + lineInfo = m.LineInfo() ) displayLine := 0 @@ -1294,20 +1261,45 @@ func (m Model) View() string { wrappedLine = []rune(strings.TrimSuffix(string(wrappedLine), " ")) padding -= m.width - strwidth } + if m.row == l && lineInfo.RowOffset == wl { + ln := string(wrappedLine[:lineInfo.ColumnOffset]) + if m.SyntaxHighlighter == nil { + ln = style.Render(ln) + } else { + ln = m.SyntaxHighlighter(ln) + } + s.WriteString(ln) - ln = string(wrappedLine) - if m.SyntaxHighlighter == nil { - ln = style.Render(ln) + if m.col >= len(line) && lineInfo.CharOffset >= m.width { + m.Cursor.SetChar(" ") + s.WriteString(m.Cursor.View()) + // XXX: suggestions? + } else { + m.Cursor.SetChar(string(wrappedLine[lineInfo.ColumnOffset])) + if m.canAcceptSuggestion() && len(m.matchedSuggestions) > 0 { + suggestion := m.matchedSuggestions[m.currentSuggestionIndex] + if len(suggestion) >= m.row { + suggestion = suggestion[m.row:] + } + m.Cursor.TextStyle = m.activeStyle.Placeholder + if len(suggestion) > m.row && len(suggestion[m.row]) > m.col { + m.Cursor.SetChar(string(suggestion[m.row][m.col])) + } + } + s.WriteString(style.Render(m.Cursor.View())) + s.WriteString(style.Render(string(wrappedLine[lineInfo.ColumnOffset+1:]))) + s.WriteString(m.suggestionView(1)) + // XXX: suggestions + } } else { - ln = m.SyntaxHighlighter(ln) + ln := string(wrappedLine) + if m.SyntaxHighlighter == nil { + ln = style.Render(ln) + } else { + ln = m.SyntaxHighlighter(ln) + } + s.WriteString(ln) } - s.WriteString(ln) - - // if m.col < len(line) || lineInfo.CharOffset < m.width { - // if m.canAcceptSuggestion() && len(m.matchedSuggestions) > 0 { - // s.WriteString(m.suggestionView(1)) - // } - // } pad := strings.Repeat(" ", max(0, padding)) if m.SyntaxHighlighter == nil { @@ -1522,7 +1514,6 @@ func (m *Model) splitLine(row, col int) { m.value[row+1] = tail m.col = 0 - m.SetHeight(m.row + 2) m.row++ } From 644c7b33989e9896372a0702d86c0bfed524e1d7 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sat, 1 Feb 2025 07:19:54 -0500 Subject: [PATCH 14/15] chore(textarea): move completion check --- textarea/textarea.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 9baed939..45d67051 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -1092,17 +1092,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { return m, nil } - // Need to check for completion before, because key is configurable and might be double assigned - keyMsg, ok := msg.(tea.KeyMsg) - if ok && key.Matches(keyMsg, m.KeyMap.AcceptSuggestion) { - if m.canAcceptSuggestion() { - m.value = m.matchedSuggestions[m.currentSuggestionIndex] - m.format() - m.row = len(m.value) - 1 - m.CursorEnd() - } - } - // Used to determine if the cursor should blink. oldRow, oldCol := m.cursorLineNumber(), m.col @@ -1119,7 +1108,19 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { switch msg := msg.(type) { case tea.PasteMsg: m.insertRunesFromUserInput([]rune(msg)) + case tea.KeyPressMsg: + // We need to check for completion before checking other key matches, + // because the key is configurable and might be double assigned. + if key.Matches(msg, m.KeyMap.AcceptSuggestion) { + if m.canAcceptSuggestion() { + m.value = m.matchedSuggestions[m.currentSuggestionIndex] + m.format() + m.row = len(m.value) - 1 + m.CursorEnd() + } + } + switch { case key.Matches(msg, m.KeyMap.DeleteAfterCursor): m.col = clamp(m.col, 0, len(m.value[m.row])) From 1588e33bf8e56665fdea7b9ad68d4909f494be1a Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 6 Feb 2025 15:40:09 -0300 Subject: [PATCH 15/15] fix: improve suggestion navigation on multiline input Signed-off-by: Carlos Alexandro Becker --- textarea/textarea.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 762ffe85..914b1f2b 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -81,8 +81,8 @@ func DefaultKeyMap() KeyMap { CharacterBackward: key.NewBinding(key.WithKeys("left", "ctrl+b"), key.WithHelp("left", "character backward")), WordForward: key.NewBinding(key.WithKeys("alt+right", "alt+f"), key.WithHelp("alt+right", "word forward")), WordBackward: key.NewBinding(key.WithKeys("alt+left", "alt+b"), key.WithHelp("alt+left", "word backward")), - LineNext: key.NewBinding(key.WithKeys("down", "ctrl+n"), key.WithHelp("down", "next line")), - LinePrevious: key.NewBinding(key.WithKeys("up", "ctrl+p"), key.WithHelp("up", "previous line")), + LineNext: key.NewBinding(key.WithKeys("down"), key.WithHelp("down", "next line")), + LinePrevious: key.NewBinding(key.WithKeys("up"), key.WithHelp("up", "previous line")), DeleteWordBackward: key.NewBinding(key.WithKeys("alt+backspace", "ctrl+w"), key.WithHelp("alt+backspace", "delete word backward")), DeleteWordForward: key.NewBinding(key.WithKeys("alt+delete", "alt+d"), key.WithHelp("alt+delete", "delete word forward")), DeleteAfterCursor: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "delete after cursor")), @@ -1182,7 +1182,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case key.Matches(msg, m.KeyMap.CharacterForward): m.characterRight() case key.Matches(msg, m.KeyMap.LineNext): - if m.row == 0 { + if m.row == 0 && len(m.value) == 1 { m.nextSuggestion() } else { m.CursorDown() @@ -1194,7 +1194,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { case key.Matches(msg, m.KeyMap.CharacterBackward): m.characterLeft(false /* insideLine */) case key.Matches(msg, m.KeyMap.LinePrevious): - if m.row == 0 { + if m.row == 0 && len(m.value) == 1 { m.previousSuggestion() } else { m.CursorUp()