From 0e6698f7868362153dc6656ee3816a79a0792696 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 20 Sep 2025 12:34:52 +0200 Subject: [PATCH] Don't break line after footnote symbol in auto-wrapped TextArea This is useful for including footnotes with long URLs (e.g. [1]) in commit messages in lazygit. [1]: https://www.example.com/this-is-a-really-long-url-that-lazy-git-automatically-wraps-and-is-an-issue --- text_area.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++- text_area_test.go | 21 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/text_area.go b/text_area.go index 7291b870..1194b272 100644 --- a/text_area.go +++ b/text_area.go @@ -1,6 +1,7 @@ package gocui import ( + "regexp" "strings" "github.com/mattn/go-runewidth" @@ -33,14 +34,16 @@ func AutoWrapContent(content []rune, autoWrapWidth int) ([]rune, []CursorMapping wrappedContent := make([]rune, 0, len(content)+estimatedNumberOfSoftLineBreaks) startOfLine := 0 indexOfLastWhitespace := -1 + var footNoteMatcher footNoteMatcher for currentPos, r := range content { if r == '\n' { wrappedContent = append(wrappedContent, content[startOfLine:currentPos+1]...) startOfLine = currentPos + 1 indexOfLastWhitespace = -1 + footNoteMatcher.reset() } else { - if r == ' ' { + if r == ' ' && !footNoteMatcher.isFootNote() { indexOfLastWhitespace = currentPos + 1 } else if currentPos-startOfLine >= autoWrapWidth && indexOfLastWhitespace >= 0 { wrapAt := indexOfLastWhitespace @@ -49,7 +52,9 @@ func AutoWrapContent(content []rune, autoWrapWidth int) ([]rune, []CursorMapping cursorMapping = append(cursorMapping, CursorMapping{wrapAt, len(wrappedContent)}) startOfLine = wrapAt indexOfLastWhitespace = -1 + footNoteMatcher.reset() } + footNoteMatcher.addRune(r) } } @@ -58,6 +63,50 @@ func AutoWrapContent(content []rune, autoWrapWidth int) ([]rune, []CursorMapping return wrappedContent, cursorMapping } +var footNoteRe = regexp.MustCompile(`^\[\d+\]:\s*$`) + +type footNoteMatcher struct { + lineStr strings.Builder + didFailToMatch bool +} + +func (self *footNoteMatcher) addRune(r rune) { + if self.didFailToMatch { + // don't bother tracking the rune if we know it can't possibly match any more + return + } + + if self.lineStr.Len() == 0 && r != '[' { + // fail early if the first rune of a line isn't a '['; this is mainly to avoid a (possibly + // expensive) regex match + self.didFailToMatch = true + return + } + + self.lineStr.WriteRune(r) +} + +func (self *footNoteMatcher) isFootNote() bool { + if self.didFailToMatch { + return false + } + + if footNoteRe.MatchString(self.lineStr.String()) { + // it's a footnote, so treat spaces as non-breaking. It's important not to reset the matcher + // here, because there could be multiple spaces after a footnote. + return true + } + + // no need to check again for this line + self.didFailToMatch = true + return false +} + +func (self *footNoteMatcher) reset() { + self.lineStr.Reset() + self.didFailToMatch = false +} + func (self *TextArea) autoWrapContent() { if self.AutoWrap { self.wrappedContent, self.cursorMapping = AutoWrapContent(self.content, self.AutoWrapWidth) diff --git a/text_area_test.go b/text_area_test.go index 8403c64e..289dffef 100644 --- a/text_area_test.go +++ b/text_area_test.go @@ -777,6 +777,27 @@ func Test_AutoWrapContent(t *testing.T) { expectedWrappedContent: "abc \ndefghijklmn \nopq", expectedCursorMapping: []CursorMapping{{4, 5}, {16, 18}}, }, + { + name: "don't break at space after footnote symbol", + content: "abc\n[1]: https://long/link\ndef", + autoWrapWidth: 7, + expectedWrappedContent: "abc\n[1]: https://long/link\ndef", + expectedCursorMapping: []CursorMapping{}, + }, + { + name: "don't break at space after footnote symbol at soft line start", + content: "abc def [1]: https://long/link\nghi", + autoWrapWidth: 7, + expectedWrappedContent: "abc def \n[1]: https://long/link\nghi", + expectedCursorMapping: []CursorMapping{{8, 9}}, + }, + { + name: "do break at subsequent space after footnote symbol", + content: "abc\n[1]: normal text follows\ndef", + autoWrapWidth: 7, + expectedWrappedContent: "abc\n[1]: normal \ntext \nfollows\ndef", + expectedCursorMapping: []CursorMapping{{16, 17}, {21, 23}}, + }, { name: "hard line breaks", content: "abc\ndef\n",