From 2e45c7e883b2c3f8b9687579edf0b2d166717369 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 24 Jan 2026 14:27:14 +0100 Subject: [PATCH 1/3] Add benchmark for typing characters into a TextArea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is calling contentToCells for every key press. As we are going to make changes to that function in this branch, we want to make sure that we don't cause a terrible performance regression with our changes. On my machine, one benchmark iteration takes 150μs, which is already a lot less than I had expected. --- text_area_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/text_area_test.go b/text_area_test.go index 0ef82bea..d09eb37b 100644 --- a/text_area_test.go +++ b/text_area_test.go @@ -993,3 +993,37 @@ func Test_AutoWrapContent(t *testing.T) { }) } } + +var testContent string = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Quisque vehicula mi at elit pellentesque, eu pulvinar ligula molestie. +In vitae orci vitae elit fermentum lobortis sed in nisi. +Nam non odio nisi. +Donec vitae elit enim. +Pellentesque faucibus dolor at metus elementum sollicitudin. +Mauris eu orci vel odio ornare feugiat eget ac nisl. +Nam at dolor erat. +Integer sit amet rutrum lectus, mollis pretium sapien. +Maecenas ligula ipsum, congue vitae rhoncus eget, volutpat at quam. +Donec ac ultricies tortor, sit amet sollicitudin urna. +Integer porta ornare diam a imperdiet. +Praesent vulputate mi turpis, in porttitor diam commodo a. +Donec ut enim ligula. + +[Thïs-is-not-à-fôôtnöte]: https://example.com/footnote + +Sïgned-öff-by: This is not a trailer + +[1]: This is a footnote + +Signed-off-by: John Doe +` + +func BenchmarkTypeCharacter(b *testing.B) { + textArea := &TextArea{content: testContent, AutoWrapWidth: 72, AutoWrap: true} + textArea.SetCursor2D(0, 0) + + b.ResetTimer() + for b.Loop() { + textArea.TypeCharacter("a") + } +} From d1fc791576ed6b6458866407e43af6e916392e8c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 24 Jan 2026 14:47:54 +0100 Subject: [PATCH 2/3] Add "failing" test showing how trailers currently get line-wrapped --- text_area_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/text_area_test.go b/text_area_test.go index d09eb37b..5fa56b6f 100644 --- a/text_area_test.go +++ b/text_area_test.go @@ -944,6 +944,20 @@ func Test_AutoWrapContent(t *testing.T) { expectedWrappedContent: "abc\n[1]: normal \ntext \nfollows\ndef", expectedSoftLineBreaks: []int{16, 21}, }, + { + name: "don't break at space after trailer", + content: "abc\nSigned-off-by: John Doe \nCo-authored-by: Jane Smith \n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\nSigned-off-by: \nJohn Doe \n\nCo-authored-by: \nJane Smith \n\n", + expectedSoftLineBreaks: []int{19, 28, 59, 70}, + }, + { + name: "do break at space after trailer if there is no space after the colon", + content: "abc\nSigned-off-by:John Doe \n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\nSigned-off-by:John \nDoe \n\n", + expectedSoftLineBreaks: []int{23, 27}, + }, { name: "hard line breaks", content: "abc\ndef\n", From 8029be2c5eb08158fd9d04749528a19c458ea91c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 24 Jan 2026 14:48:00 +0100 Subject: [PATCH 3/3] Exclude commit trailers from line wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line-wrapping a commit trailer that contains a very long email address results in a broken trailer (trailers do support being split into several lines, but this requires continuation lines to start with a space, like in RFC-822 message headers). To avoid this, simply never wrap commit trailers. It's a bit questionable that we hard-code this behavior in TextArea, which is meant to be a general-purpose widget; but we know we only use it in lazygit's commit message panel, so don't bother making this configurable for now. Benchmark time increases from 150μs to 165μs on my machine, which is still more than acceptable. --- text_area.go | 76 ++++++++++++++++++++++++++++++++++++++++++++++- text_area_test.go | 4 +-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/text_area.go b/text_area.go index da5562f8..9c88e983 100644 --- a/text_area.go +++ b/text_area.go @@ -71,6 +71,7 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) { currentLineWidth := 0 indexOfLastWhitespace := -1 var footNoteMatcher footNoteMatcher + var trailerMatcher trailerMatcher cells := stringToTextAreaCells(content) y := 0 @@ -94,9 +95,10 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) { indexOfLastWhitespace = -1 currentLineWidth = 0 footNoteMatcher.reset() + trailerMatcher.reset() } else { currentLineWidth += c.width - if c.char == " " && !footNoteMatcher.isFootNote() { + if c.char == " " && !footNoteMatcher.isFootNote() && !trailerMatcher.isTrailer() { indexOfLastWhitespace = currentPos + 1 } else if autoWrapWidth > 0 && currentLineWidth > autoWrapWidth && indexOfLastWhitespace >= 0 { wrapAt := indexOfLastWhitespace @@ -112,9 +114,11 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) { currentLineWidth += c1.width } footNoteMatcher.reset() + trailerMatcher.reset() } footNoteMatcher.addCharacter(c.char) + trailerMatcher.addCharacter(c.char) } } @@ -167,6 +171,76 @@ func (self *footNoteMatcher) reset() { self.didFailToMatch = false } +var supportedTrailers = []string{ + "Signed-off-by:", + "Co-authored-by:", +} + +type trailerMatcher struct { + lineStr strings.Builder + didFailToMatch bool + didMatch bool +} + +func (self *trailerMatcher) addCharacter(chr string) { + if self.didFailToMatch || self.didMatch { + return + } + + if len(chr) != 1 { + // Trailers are all ASCII, so if we get a non-ASCII UTF-8 character (or even a multi-rune + // grapheme cluster), we can fail early. + self.didFailToMatch = true + return + } + + if self.lineStr.Len() == 0 { + // If this is the first character, see if it could possibly match any supported trailer; if + // not, we can fail early and stop tracking further characters for this line. + if !anyOf(supportedTrailers, func(trailer string) bool { return trailer[0] == chr[0] }) { + self.didFailToMatch = true + return + } + } + + self.lineStr.WriteString(chr) +} + +func (self *trailerMatcher) isTrailer() bool { + if self.didFailToMatch { + return false + } + + if self.didMatch { + return true + } + + line := self.lineStr.String() + if anyOf(supportedTrailers, func(trailer string) bool { return line == trailer }) { + self.didMatch = true + return true + } + + self.didFailToMatch = true + return false +} + +func (self *trailerMatcher) reset() { + self.lineStr.Reset() + self.didFailToMatch = false + self.didMatch = false +} + +func anyOf(strings []string, predicate func(s string) bool) bool { + for _, s := range strings { + if predicate(s) { + return true + } + } + + return false +} + func (self *TextArea) updateCells() { width := self.AutoWrapWidth if !self.AutoWrap { diff --git a/text_area_test.go b/text_area_test.go index 5fa56b6f..618e1a02 100644 --- a/text_area_test.go +++ b/text_area_test.go @@ -948,8 +948,8 @@ func Test_AutoWrapContent(t *testing.T) { name: "don't break at space after trailer", content: "abc\nSigned-off-by: John Doe \nCo-authored-by: Jane Smith \n", autoWrapWidth: 10, - expectedWrappedContent: "abc\nSigned-off-by: \nJohn Doe \n\nCo-authored-by: \nJane Smith \n\n", - expectedSoftLineBreaks: []int{19, 28, 59, 70}, + expectedWrappedContent: "abc\nSigned-off-by: John Doe \nCo-authored-by: Jane Smith \n", + expectedSoftLineBreaks: []int{}, }, { name: "do break at space after trailer if there is no space after the colon",