diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index dbdc985e8..bbeb07a45 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -3,12 +3,11 @@ package helpers import ( goContext "context" "fmt" - "strings" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" - "github.com/mattn/go-runewidth" + "github.com/jesseduffield/lazygit/pkg/utils" ) type ConfirmationHelper struct { @@ -57,59 +56,9 @@ func (self *ConfirmationHelper) DeactivateConfirmationPrompt() { self.clearConfirmationViewKeyBindings() } -// Temporary hack: we're just duplicating the logic in `gocui.lineWrap` func getMessageHeight(wrap bool, message string, width int) int { - return len(wrapMessageToWidth(wrap, message, width)) -} - -func wrapMessageToWidth(wrap bool, message string, width int) []string { - lines := strings.Split(message, "\n") - if !wrap { - return lines - } - - wrappedLines := make([]string, 0, len(lines)) - - for _, line := range lines { - n := 0 - offset := 0 - lastWhitespaceIndex := -1 - for i, currChr := range line { - rw := runewidth.RuneWidth(currChr) - n += rw - - if n > width { - if currChr == ' ' { - wrappedLines = append(wrappedLines, line[offset:i]) - offset = i + 1 - n = 0 - } else if currChr == '-' { - wrappedLines = append(wrappedLines, line[offset:i]) - offset = i - n = rw - } else if lastWhitespaceIndex != -1 { - if line[lastWhitespaceIndex] == '-' { - wrappedLines = append(wrappedLines, line[offset:lastWhitespaceIndex+1]) - } else { - wrappedLines = append(wrappedLines, line[offset:lastWhitespaceIndex]) - } - offset = lastWhitespaceIndex + 1 - n = runewidth.StringWidth(line[offset : i+1]) - } else { - wrappedLines = append(wrappedLines, line[offset:i]) - offset = i - n = rw - } - lastWhitespaceIndex = -1 - } else if currChr == ' ' || currChr == '-' { - lastWhitespaceIndex = i - } - } - - wrappedLines = append(wrappedLines, line[offset:]) - } - - return wrappedLines + wrappedLines := utils.WrapViewLinesToWidth(wrap, message, width) + return len(wrappedLines) } func (self *ConfirmationHelper) getPopupPanelDimensionsForContentHeight(panelWidth, contentHeight int, parentPopupContext types.Context) (int, int, int, int) { @@ -327,7 +276,7 @@ func (self *ConfirmationHelper) layoutMenuPrompt(contentWidth int) int { var promptLines []string prompt := self.c.Contexts().Menu.GetPrompt() if len(prompt) > 0 { - promptLines = wrapMessageToWidth(true, prompt, contentWidth) + promptLines = utils.WrapViewLinesToWidth(true, prompt, contentWidth) promptLines = append(promptLines, "") } self.c.Contexts().Menu.SetPromptLines(promptLines) diff --git a/pkg/utils/lines.go b/pkg/utils/lines.go index c70d02ffc..740d4a14c 100644 --- a/pkg/utils/lines.go +++ b/pkg/utils/lines.go @@ -3,6 +3,8 @@ package utils import ( "bytes" "strings" + + "github.com/mattn/go-runewidth" ) // SplitLines takes a multiline string and splits it on newlines @@ -100,3 +102,56 @@ func ScanLinesAndTruncateWhenLongerThanBuffer(maxBufferSize int) func(data []byt return 0, nil, nil } } + +// Wrap lines to a given width. +// If wrap is false, the text is returned as is. +// This code needs to behave the same as `gocui.lineWrap` does. +func WrapViewLinesToWidth(wrap bool, text string, width int) []string { + lines := strings.Split(text, "\n") + if !wrap { + return lines + } + + wrappedLines := make([]string, 0, len(lines)) + + for _, line := range lines { + n := 0 + offset := 0 + lastWhitespaceIndex := -1 + for i, currChr := range line { + rw := runewidth.RuneWidth(currChr) + n += rw + + if n > width { + if currChr == ' ' { + wrappedLines = append(wrappedLines, line[offset:i]) + offset = i + 1 + n = 0 + } else if currChr == '-' { + wrappedLines = append(wrappedLines, line[offset:i]) + offset = i + n = rw + } else if lastWhitespaceIndex != -1 { + if line[lastWhitespaceIndex] == '-' { + wrappedLines = append(wrappedLines, line[offset:lastWhitespaceIndex+1]) + } else { + wrappedLines = append(wrappedLines, line[offset:lastWhitespaceIndex]) + } + offset = lastWhitespaceIndex + 1 + n = runewidth.StringWidth(line[offset : i+1]) + } else { + wrappedLines = append(wrappedLines, line[offset:i]) + offset = i + n = rw + } + lastWhitespaceIndex = -1 + } else if currChr == ' ' || currChr == '-' { + lastWhitespaceIndex = i + } + } + + wrappedLines = append(wrappedLines, line[offset:]) + } + + return wrappedLines +} diff --git a/pkg/utils/lines_test.go b/pkg/utils/lines_test.go index 2192a3780..eb319d619 100644 --- a/pkg/utils/lines_test.go +++ b/pkg/utils/lines_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/jesseduffield/gocui" "github.com/stretchr/testify/assert" ) @@ -164,3 +165,187 @@ func TestScanLinesAndTruncateWhenLongerThanBuffer(t *testing.T) { assert.EqualValues(t, s.expectedLines, result) } } + +func TestWrapViewLinesToWidth(t *testing.T) { + tests := []struct { + name string + wrap bool + text string + width int + expectedWrappedLines []string + }{ + { + name: "Wrap on space", + wrap: true, + text: "Hello World", + width: 5, + expectedWrappedLines: []string{ + "Hello", + "World", + }, + }, + { + name: "Wrap on hyphen", + wrap: true, + text: "Hello-World", + width: 6, + expectedWrappedLines: []string{ + "Hello-", + "World", + }, + }, + { + name: "Wrap on hyphen 2", + wrap: true, + text: "Blah Hello-World", + width: 12, + expectedWrappedLines: []string{ + "Blah Hello-", + "World", + }, + }, + { + name: "Wrap on hyphen 3", + wrap: true, + text: "Blah Hello-World", + width: 11, + expectedWrappedLines: []string{ + "Blah Hello-", + "World", + }, + }, + { + name: "Wrap on hyphen 4", + wrap: true, + text: "Blah Hello-World", + width: 10, + expectedWrappedLines: []string{ + "Blah Hello", + "-World", + }, + }, + { + name: "Wrap on space 2", + wrap: true, + text: "Blah Hello World", + width: 10, + expectedWrappedLines: []string{ + "Blah Hello", + "World", + }, + }, + { + name: "Wrap on space with more words", + wrap: true, + text: "Longer word here", + width: 10, + expectedWrappedLines: []string{ + "Longer", + "word here", + }, + }, + { + name: "Split word that's too long", + wrap: true, + text: "ThisWordIsWayTooLong", + width: 10, + expectedWrappedLines: []string{ + "ThisWordIs", + "WayTooLong", + }, + }, + { + name: "Split word that's too long over multiple lines", + wrap: true, + text: "ThisWordIsWayTooLong", + width: 5, + expectedWrappedLines: []string{ + "ThisW", + "ordIs", + "WayTo", + "oLong", + }, + }, + { + name: "Lots of hyphens", + wrap: true, + text: "one-two-three-four-five", + width: 8, + expectedWrappedLines: []string{ + "one-two-", + "three-", + "four-", + "five", + }, + }, + { + name: "Several lines using all the available width", + wrap: true, + text: "aaa bb cc ddd-ee ff", + width: 5, + expectedWrappedLines: []string{ + "aaa", + "bb cc", + "ddd-", + "ee ff", + }, + }, + { + name: "Several lines using all the available width, with multi-cell runes", + wrap: true, + text: "ðŸĪðŸĪðŸĪ 🐝🐝 🙉🙉 ðŸĶŠðŸĶŠðŸĶŠ-🐎🐎 ðŸĶĒðŸĶĒ", + width: 9, + expectedWrappedLines: []string{ + "ðŸĪðŸĪðŸĪ", + "🐝🐝 🙉🙉", + "ðŸĶŠðŸĶŠðŸĶŠ-", + "🐎🐎 ðŸĶĒðŸĶĒ", + }, + }, + { + name: "Space in last column", + wrap: true, + text: "hello world", + width: 6, + expectedWrappedLines: []string{ + "hello", + "world", + }, + }, + { + name: "Hyphen in last column", + wrap: true, + text: "hello-world", + width: 6, + expectedWrappedLines: []string{ + "hello-", + "world", + }, + }, + { + name: "English text", + wrap: true, + text: "+The sea reach of the Thames stretched before us like the bedinnind of an interminable waterway. In the offind the sea and the sky were welded todether without a joint, and in the luminous space the tanned sails of the bardes drifting blah blah", + width: 81, + expectedWrappedLines: []string{ + "+The sea reach of the Thames stretched before us like the bedinnind of an", + "interminable waterway. In the offind the sea and the sky were welded todether", + "without a joint, and in the luminous space the tanned sails of the bardes", + "drifting blah blah", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wrappedLines := WrapViewLinesToWidth(tt.wrap, tt.text, tt.width) + assert.Equal(t, tt.expectedWrappedLines, wrappedLines) + + // As a sanity check, also test that gocui's line wrapping behaves the same way + view := gocui.NewView("", 0, 0, tt.width+1, 1000, gocui.OutputNormal) + assert.Equal(t, tt.width, view.InnerWidth()) + view.Wrap = tt.wrap + view.SetContent(tt.text) + assert.Equal(t, wrappedLines, view.ViewBufferLines()) + }) + } +}