diff --git a/text_area.go b/text_area.go index da5562f..9c88e98 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 0ef82be..618e1a0 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: 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", + 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", @@ -993,3 +1007,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") + } +}