From 48dffdd97e4ff8cb3fb16e6414bc8b6d8ae86a4c Mon Sep 17 00:00:00 2001 From: Michael Lorant Date: Thu, 14 Mar 2024 15:15:59 +1100 Subject: [PATCH] refactor(textarea): Improve setting width When setting the width of the textarea there were some issues preventing this from working correctly. These problems included: - If the maximum width needed to be used, the width of the textarea did not take into account the prompt and line number width. - The viewport width did not take into account the style width. The entire function was confusing to understand and a refactor was warranted. As part of this refactor, the bugs mentioned above were fixed and the code was simplified. To verify that the logic works as expected, unit tests were expanded to validate that setting the width works as expected. Signed-off-by: Michael Lorant --- textarea/textarea.go | 53 ++-- textarea/textarea_test.go | 601 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 631 insertions(+), 23 deletions(-) diff --git a/textarea/textarea.go b/textarea/textarea.go index 1d3aa97..6421681 100644 --- a/textarea/textarea.go +++ b/textarea/textarea.go @@ -20,7 +20,6 @@ import ( const ( minHeight = 1 - minWidth = 2 defaultHeight = 6 defaultWidth = 40 defaultCharLimit = 400 @@ -862,32 +861,40 @@ func (m *Model) moveToEnd() { // It is important that the width of the textarea be exactly the given width // and no more. func (m *Model) SetWidth(w int) { - if m.MaxWidth > 0 { - m.viewport.Width = clamp(w, minWidth, m.MaxWidth) - } else { - m.viewport.Width = max(w, minWidth) - } - - // Since the width of the textarea input is dependent on the width of the - // prompt and line numbers, we need to calculate it by subtracting. - inputWidth := w - if m.ShowLineNumbers { - inputWidth -= uniseg.StringWidth(fmt.Sprintf(m.lineNumberFormat, 0)) - } - - // Account for base style borders and padding. - inputWidth -= m.style.Base.GetHorizontalFrameSize() - + // Update prompt width only if there is no prompt function as SetPromptFunc + // updates the prompt width when it is called. if m.promptFunc == nil { m.promptWidth = uniseg.StringWidth(m.Prompt) } - inputWidth -= m.promptWidth - if m.MaxWidth > 0 { - m.width = clamp(inputWidth, minWidth, m.MaxWidth) - } else { - m.width = max(inputWidth, minWidth) + // Add base style borders and padding to reserved outer width. + reservedOuter := m.style.Base.GetHorizontalFrameSize() + + // Add prompt width to reserved inner width. + reservedInner := m.promptWidth + + // Add line number width to reserved inner width. + if m.ShowLineNumbers { + const lnWidth = 4 // Up to 3 digits for line number plus 1 margin. + reservedInner += lnWidth } + + // Input width must be at least one more than the reserved inner and outer + // width. This gives us a minimum input width of 1. + minWidth := reservedInner + reservedOuter + 1 + inputWidth := max(w, minWidth) + + // Input width must be no more than maximum width. + if m.MaxWidth > 0 { + inputWidth = min(inputWidth, m.MaxWidth) + } + + // Since the width of the viewport and input area is dependent on the width of + // borders, prompt and line numbers, we need to calculate it by subtracting + // the reserved width from them. + + m.viewport.Width = inputWidth - reservedOuter + m.width = inputWidth - reservedOuter - reservedInner } // SetPromptFunc supersedes the Prompt field and sets a dynamic prompt @@ -1163,7 +1170,7 @@ func (m Model) getPromptString(displayLine int) (prompt string) { func (m Model) placeholderView() string { var ( s strings.Builder - p = rw.Truncate(m.Placeholder, m.width, "...") + p = rw.Truncate(rw.Truncate(m.Placeholder, m.width, "..."), m.width, "") style = m.style.Placeholder.Inline(true) ) diff --git a/textarea/textarea_test.go b/textarea/textarea_test.go index e02ecf8..d0381a7 100644 --- a/textarea/textarea_test.go +++ b/textarea/textarea_test.go @@ -8,6 +8,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/acarl005/stripansi" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) func TestVerticalScrolling(t *testing.T) { @@ -672,6 +673,606 @@ func TestView(t *testing.T) { cursorCol: 7, }, }, + { + name: "set width", + modelFunc: func(m Model) Model { + m.SetWidth(10) + + input := "12" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 12 + > ~ + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 0, + cursorCol: 2, + }, + }, + { + name: "set width max length text minus one", + modelFunc: func(m Model) Model { + m.SetWidth(10) + + input := "123" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 123 + > ~ + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 0, + cursorCol: 3, + }, + }, + { + name: "set width max length text", + modelFunc: func(m Model) Model { + m.SetWidth(10) + + input := "1234" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 1234 + > + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 1, + cursorCol: 0, + }, + }, + { + name: "set width max length text plus one", + modelFunc: func(m Model) Model { + m.SetWidth(10) + + input := "12345" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 1234 + > 5 + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 1, + cursorCol: 1, + }, + }, + { + name: "set width set max width minus one", + modelFunc: func(m Model) Model { + m.MaxWidth = 10 + m.SetWidth(11) + + input := "123" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 123 + > ~ + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 0, + cursorCol: 3, + }, + }, + { + name: "set width set max width", + modelFunc: func(m Model) Model { + m.MaxWidth = 10 + m.SetWidth(11) + + input := "1234" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 1234 + > + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 1, + cursorCol: 0, + }, + }, + { + name: "set width set max width plus one", + modelFunc: func(m Model) Model { + m.MaxWidth = 10 + m.SetWidth(11) + + input := "12345" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 1234 + > 5 + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 1, + cursorCol: 1, + }, + }, + { + name: "set width min width minus one", + modelFunc: func(m Model) Model { + m.SetWidth(6) + + input := "123" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 1 + > 2 + > 3 + > + > ~ + > ~ + `), + cursorRow: 3, + cursorCol: 0, + }, + }, + { + name: "set width min width", + modelFunc: func(m Model) Model { + m.SetWidth(7) + + input := "123" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 1 + > 2 + > 3 + > + > ~ + > ~ + `), + cursorRow: 3, + cursorCol: 0, + }, + }, + { + name: "set width min width no line numbers", + modelFunc: func(m Model) Model { + m.ShowLineNumbers = false + m.SetWidth(0) + + input := "123" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 + > 2 + > 3 + > + > ~ + > ~ + `), + cursorRow: 3, + cursorCol: 0, + }, + }, + { + name: "set width min width no line numbers no prompt", + modelFunc: func(m Model) Model { + m.ShowLineNumbers = false + m.Prompt = "" + m.SetWidth(0) + + input := "123" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + 1 + 2 + 3 + + ~ + ~ + `), + cursorRow: 3, + cursorCol: 0, + }, + }, + { + name: "set width min width plus one", + modelFunc: func(m Model) Model { + m.SetWidth(8) + + input := "123" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 12 + > 3 + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 1, + cursorCol: 1, + }, + }, + { + name: "set width without line numbers max length text minus one", + modelFunc: func(m Model) Model { + m.ShowLineNumbers = false + m.SetWidth(6) + + input := "123" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 123 + > ~ + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 0, + cursorCol: 3, + }, + }, + { + name: "set width without line numbers max length text", + modelFunc: func(m Model) Model { + m.ShowLineNumbers = false + m.SetWidth(6) + + input := "1234" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1234 + > + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 1, + cursorCol: 0, + }, + }, + { + name: "set width without line numbers max length text plus one", + modelFunc: func(m Model) Model { + m.ShowLineNumbers = false + m.SetWidth(6) + + input := "12345" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1234 + > 5 + > ~ + > ~ + > ~ + > ~ + `), + cursorRow: 1, + cursorCol: 1, + }, + }, + { + name: "set width with style", + modelFunc: func(m Model) Model { + m.FocusedStyle.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.Focus() + + m.SetWidth(12) + + input := "1" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + ┌──────────┐ + │> 1 1 │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + └──────────┘ + `), + cursorRow: 0, + cursorCol: 1, + }, + }, + { + name: "set width with style max width minus one", + modelFunc: func(m Model) Model { + m.FocusedStyle.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.Focus() + + m.SetWidth(12) + + input := "123" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + ┌──────────┐ + │> 1 123 │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + └──────────┘ + `), + cursorRow: 0, + cursorCol: 3, + }, + }, + { + name: "set width with style max width", + modelFunc: func(m Model) Model { + m.FocusedStyle.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.Focus() + + m.SetWidth(12) + + input := "1234" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + ┌──────────┐ + │> 1 1234│ + │> │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + └──────────┘ + `), + cursorRow: 1, + cursorCol: 0, + }, + }, + { + name: "set width with style max width plus one", + modelFunc: func(m Model) Model { + m.FocusedStyle.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.Focus() + + m.SetWidth(12) + + input := "12345" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + ┌──────────┐ + │> 1 1234│ + │> 5 │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + └──────────┘ + `), + cursorRow: 1, + cursorCol: 1, + }, + }, + { + name: "set width without line numbers with style", + modelFunc: func(m Model) Model { + m.FocusedStyle.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.Focus() + + m.ShowLineNumbers = false + m.SetWidth(12) + + input := "123456" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + ┌──────────┐ + │> 123456 │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + └──────────┘ + `), + cursorRow: 0, + cursorCol: 6, + }, + }, + { + name: "set width without line numbers with style max width minus one", + modelFunc: func(m Model) Model { + m.FocusedStyle.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.Focus() + + m.ShowLineNumbers = false + m.SetWidth(12) + + input := "1234567" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + ┌──────────┐ + │> 1234567 │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + └──────────┘ + `), + cursorRow: 0, + cursorCol: 7, + }, + }, + { + name: "set width without line numbers with style max width", + modelFunc: func(m Model) Model { + m.FocusedStyle.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.Focus() + + m.ShowLineNumbers = false + m.SetWidth(12) + + input := "12345678" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + ┌──────────┐ + │> 12345678│ + │> │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + └──────────┘ + `), + cursorRow: 1, + cursorCol: 0, + }, + }, + { + name: "set width without line numbers with style max width plus one", + modelFunc: func(m Model) Model { + m.FocusedStyle.Base = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.Focus() + + m.ShowLineNumbers = false + m.SetWidth(12) + + input := "123456789" + m = sendString(m, input) + + return m + }, + want: want{ + view: heredoc.Doc(` + ┌──────────┐ + │> 12345678│ + │> 9 │ + │> ~ │ + │> ~ │ + │> ~ │ + │> ~ │ + └──────────┘ + `), + cursorRow: 1, + cursorCol: 1, + }, + }, + { + name: "placeholder min width", + modelFunc: func(m Model) Model { + m.SetWidth(0) + + return m + }, + want: want{ + view: heredoc.Doc(` + > 1 . + > ~ + > ~ + > ~ + > ~ + > ~ + `), + }, + }, } for _, tt := range tests {