From d4686045b2b6c258601c22d869b6a542ad11c2d2 Mon Sep 17 00:00:00 2001 From: David Allouche Date: Sun, 5 Jul 2026 00:07:28 +0200 Subject: [PATCH] Add `softwrapcolumn` option to soft-wrap at a fixed column When softwrap is enabled, lines wrap at the edge of the pane. This adds a `softwrapcolumn` option that, when non-zero, wraps at that column instead, clamped to the pane width. It defaults to 0, preserving the current behavior of wrapping at the pane edge, and only takes effect when softwrap is enabled. The wrap width is tracked separately from the pane width (`wrapWidth` vs `bufWidth`), so the region between the wrap column and the pane edge is still cleared on every visual row and keeps colorcolumn and cursor-line highlighting spanning the full pane. Addresses #2374. --- data/micro.json | 6 ++ internal/config/settings.go | 2 + internal/display/bufwindow.go | 74 ++++++++++++++++--------- internal/display/softwrap.go | 22 ++++---- internal/display/softwrap_test.go | 92 +++++++++++++++++++++++++++++++ runtime/help/options.md | 7 +++ 6 files changed, 165 insertions(+), 38 deletions(-) create mode 100644 internal/display/softwrap_test.go diff --git a/data/micro.json b/data/micro.json index a50accb37a..dc5aa10333 100644 --- a/data/micro.json +++ b/data/micro.json @@ -283,6 +283,12 @@ "type": "boolean", "default": false }, + "softwrapcolumn": { + "description": "The column at which to soft-wrap long lines, instead of at the screen edge\nhttps://github.com/micro-editor/micro/blob/master/runtime/help/options.md#options", + "type": "integer", + "minimum": 0, + "default": 0 + }, "splitbottom": { "description": "Whether to create a new horizontal split below the current one\nhttps://github.com/micro-editor/micro/blob/master/runtime/help/options.md#options", "type": "boolean", diff --git a/internal/config/settings.go b/internal/config/settings.go index 45f6b3aad6..3b5e9d8e52 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -35,6 +35,7 @@ var optionValidators = map[string]optionValidator{ "reload": validateChoice, "scrollmargin": validateNonNegativeValue, "scrollspeed": validateNonNegativeValue, + "softwrapcolumn": validateNonNegativeValue, "tabsize": validatePositiveValue, "truecolor": validateChoice, } @@ -93,6 +94,7 @@ var defaultCommonSettings = map[string]any{ "showchars": "", "smartpaste": true, "softwrap": false, + "softwrapcolumn": float64(0), "splitbottom": true, "splitright": true, "statusformatl": "$(filename) $(modified)$(overwrite)($(line),$(col)) $(status.paste)| ft:$(opt:filetype) | $(opt:fileformat) | $(opt:encoding)", diff --git a/internal/display/bufwindow.go b/internal/display/bufwindow.go index ddbb044c7b..a5b1f79b0f 100644 --- a/internal/display/bufwindow.go +++ b/internal/display/bufwindow.go @@ -24,6 +24,7 @@ type BufWindow struct { sline *StatusLine bufWidth int + wrapWidth int bufHeight int gutterOffset int hasMessage bool @@ -64,7 +65,7 @@ func (w *BufWindow) SetBuffer(b *buffer.Buffer) { } if option == "diffgutter" || option == "ruler" || option == "scrollbar" || - option == "statusline" { + option == "statusline" || option == "softwrapcolumn" { w.updateDisplayInfo() w.Relocate() } @@ -162,10 +163,18 @@ func (w *BufWindow) updateDisplayInfo() { w.gutterOffset = w.Width - scrollbarWidth } - prevBufWidth := w.bufWidth + prevWrapWidth := w.wrapWidth w.bufWidth = w.Width - w.gutterOffset - scrollbarWidth - if w.bufWidth != prevBufWidth && w.Buf.Settings["softwrap"].(bool) { + // softwrapcolumn narrows the wrap width below the pane width. + w.wrapWidth = w.bufWidth + if b.Settings["softwrap"].(bool) { + if softwrapcolumn := util.IntOpt(b.Settings["softwrapcolumn"]); softwrapcolumn > 0 { + w.wrapWidth = util.Min(w.bufWidth, softwrapcolumn) + } + } + + if w.wrapWidth != prevWrapWidth && w.Buf.Settings["softwrap"].(bool) { for _, c := range w.Buf.GetCursors() { c.LastWrappedVisualX = c.GetVisualX(true) } @@ -388,6 +397,8 @@ func (w *BufWindow) displayBuffer() { } maxWidth := w.gutterOffset + w.bufWidth + // wrapMaxWidth is where lines wrap; softwrapcolumn may narrow it below maxWidth. + wrapMaxWidth := w.gutterOffset + w.wrapWidth if b.ModifiedThisFrame { if b.Settings["diffgutter"].(bool) { @@ -450,6 +461,27 @@ func (w *BufWindow) displayBuffer() { cursors := b.GetCursors() + // fillStyle returns the background for a blank cell at visual column x, so + // cursor-line and color-column highlighting span the full pane width. + fillStyle := func(x int) tcell.Style { + style := config.DefStyle + for _, c := range cursors { + if b.Settings["cursorline"].(bool) && w.active && !c.HasSelection() && c.Y == bloc.Y { + if s, ok := config.Colorscheme["cursor-line"]; ok { + fg, _, _ := s.Decompose() + style = style.Background(fg) + } + } + } + if colorcolumn != 0 && x-w.gutterOffset+w.StartCol == colorcolumn { + if s, ok := config.Colorscheme["color-column"]; ok { + fg, _, _ := s.Decompose() + style = style.Background(fg) + } + } + return style + } + curStyle := config.DefStyle // Parse showchars which is in the format of key1=val1,key2=val2,... @@ -722,7 +754,7 @@ func (w *BufWindow) displayBuffer() { wordwidth := 0 totalwidth := w.StartCol - nColsBeforeStart - for len(line) > 0 && vloc.X < maxWidth { + for len(line) > 0 && vloc.X < wrapMaxWidth { r, combc, size := util.DecodeCharacter(line) line = line[size:] @@ -735,7 +767,7 @@ func (w *BufWindow) displayBuffer() { switch r { case '\t': ts := tabsize - (totalwidth % tabsize) - width = util.Min(ts, maxWidth-vloc.X) + width = util.Min(ts, wrapMaxWidth-vloc.X) totalwidth += ts default: width = runewidth.RuneWidth(r) @@ -748,15 +780,15 @@ func (w *BufWindow) displayBuffer() { // Collect a complete word to know its width. // If wordwrap is off, every single character is a complete "word". if wordwrap { - if !util.IsWhitespace(r) && len(line) > 0 && wordwidth < w.bufWidth { + if !util.IsWhitespace(r) && len(line) > 0 && wordwidth < w.wrapWidth { continue } } // If a word (or just a wide rune) does not fit in the window - if vloc.X+wordwidth > maxWidth && vloc.X > w.gutterOffset { + if vloc.X+wordwidth > wrapMaxWidth && vloc.X > w.gutterOffset { for vloc.X < maxWidth { - draw(' ', nil, config.DefStyle, false, false, true) + draw(' ', nil, fillStyle(vloc.X), false, false, true) } // We either stop or we wrap to draw the word in the next line @@ -791,10 +823,15 @@ func (w *BufWindow) displayBuffer() { wordwidth = 0 // If we reach the end of the window then we either stop or we wrap for softwrap - if vloc.X >= maxWidth { + if vloc.X >= wrapMaxWidth { if !softwrap { break } else { + // Fill the dead zone right of the wrap column so colorcolumn + // and cursor-line span the full pane on wrapped rows. + for vloc.X < maxWidth { + draw(' ', nil, fillStyle(vloc.X), false, false, true) + } vloc.Y++ if vloc.Y >= w.bufHeight { break @@ -804,25 +841,8 @@ func (w *BufWindow) displayBuffer() { } } - style := config.DefStyle - for _, c := range cursors { - if b.Settings["cursorline"].(bool) && w.active && - !c.HasSelection() && c.Y == bloc.Y { - if s, ok := config.Colorscheme["cursor-line"]; ok { - fg, _, _ := s.Decompose() - style = style.Background(fg) - } - } - } for i := vloc.X; i < maxWidth; i++ { - curStyle := style - if s, ok := config.Colorscheme["color-column"]; ok { - if colorcolumn != 0 && i-w.gutterOffset+w.StartCol == colorcolumn { - fg, _, _ := s.Decompose() - curStyle = style.Background(fg) - } - } - screen.SetContent(i+w.X, vloc.Y+w.Y, ' ', nil, curStyle) + screen.SetContent(i+w.X, vloc.Y+w.Y, ' ', nil, fillStyle(i)) } if vloc.X != maxWidth { diff --git a/internal/display/softwrap.go b/internal/display/softwrap.go index 7c36a3bb10..6184fd260a 100644 --- a/internal/display/softwrap.go +++ b/internal/display/softwrap.go @@ -74,7 +74,7 @@ func (w *BufWindow) getVLocFromLoc(loc buffer.Loc) VLoc { return vloc } - if w.bufWidth <= 0 { + if w.wrapWidth <= 0 { return vloc } @@ -96,7 +96,7 @@ func (w *BufWindow) getVLocFromLoc(loc buffer.Loc) VLoc { switch r { case '\t': ts := tabsize - (totalwidth % tabsize) - width = util.Min(ts, w.bufWidth-vloc.VisualX) + width = util.Min(ts, w.wrapWidth-vloc.VisualX) totalwidth += ts default: width = runewidth.RuneWidth(r) @@ -108,7 +108,7 @@ func (w *BufWindow) getVLocFromLoc(loc buffer.Loc) VLoc { // Collect a complete word to know its width. // If wordwrap is off, every single character is a complete "word". if wordwrap { - if !util.IsWhitespace(r) && len(line) > 0 && wordwidth < w.bufWidth { + if !util.IsWhitespace(r) && len(line) > 0 && wordwidth < w.wrapWidth { if x < loc.X { wordoffset += width x++ @@ -118,7 +118,7 @@ func (w *BufWindow) getVLocFromLoc(loc buffer.Loc) VLoc { } // If a word (or just a wide rune) does not fit in the window - if vloc.VisualX+wordwidth > w.bufWidth && vloc.VisualX > 0 { + if vloc.VisualX+wordwidth > w.wrapWidth && vloc.VisualX > 0 { vloc.Row++ vloc.VisualX = 0 } @@ -134,7 +134,7 @@ func (w *BufWindow) getVLocFromLoc(loc buffer.Loc) VLoc { wordwidth = 0 wordoffset = 0 - if vloc.VisualX >= w.bufWidth { + if vloc.VisualX >= w.wrapWidth { vloc.Row++ vloc.VisualX = 0 } @@ -145,7 +145,7 @@ func (w *BufWindow) getVLocFromLoc(loc buffer.Loc) VLoc { func (w *BufWindow) getLocFromVLoc(svloc VLoc) buffer.Loc { loc := buffer.Loc{X: 0, Y: svloc.Line} - if w.bufWidth <= 0 { + if w.wrapWidth <= 0 { return loc } @@ -159,7 +159,7 @@ func (w *BufWindow) getLocFromVLoc(svloc VLoc) buffer.Loc { var widths []int if wordwrap { - widths = make([]int, 0, w.bufWidth) + widths = make([]int, 0, w.wrapWidth) } else { widths = make([]int, 0, 1) } @@ -173,7 +173,7 @@ func (w *BufWindow) getLocFromVLoc(svloc VLoc) buffer.Loc { switch r { case '\t': ts := tabsize - (totalwidth % tabsize) - width = util.Min(ts, w.bufWidth-vloc.VisualX) + width = util.Min(ts, w.wrapWidth-vloc.VisualX) totalwidth += ts default: width = runewidth.RuneWidth(r) @@ -186,13 +186,13 @@ func (w *BufWindow) getLocFromVLoc(svloc VLoc) buffer.Loc { // Collect a complete word to know its width. // If wordwrap is off, every single character is a complete "word". if wordwrap { - if !util.IsWhitespace(r) && len(line) > 0 && wordwidth < w.bufWidth { + if !util.IsWhitespace(r) && len(line) > 0 && wordwidth < w.wrapWidth { continue } } // If a word (or just a wide rune) does not fit in the window - if vloc.VisualX+wordwidth > w.bufWidth && vloc.VisualX > 0 { + if vloc.VisualX+wordwidth > w.wrapWidth && vloc.VisualX > 0 { if vloc.Row == svloc.Row { if wordwrap { // it's a word, not a wide rune @@ -215,7 +215,7 @@ func (w *BufWindow) getLocFromVLoc(svloc VLoc) buffer.Loc { widths = widths[:0] wordwidth = 0 - if vloc.VisualX >= w.bufWidth { + if vloc.VisualX >= w.wrapWidth { vloc.Row++ vloc.VisualX = 0 } diff --git a/internal/display/softwrap_test.go b/internal/display/softwrap_test.go new file mode 100644 index 0000000000..c474351464 --- /dev/null +++ b/internal/display/softwrap_test.go @@ -0,0 +1,92 @@ +package display + +import ( + "strings" + "testing" + + "github.com/micro-editor/micro/v2/internal/buffer" + "github.com/micro-editor/micro/v2/internal/config" + ulua "github.com/micro-editor/micro/v2/internal/lua" + "github.com/micro-editor/micro/v2/internal/util" + "github.com/stretchr/testify/assert" + lua "github.com/yuin/gopher-lua" +) + +func init() { + ulua.L = lua.NewState() + config.InitRuntimeFiles(false) + config.InitGlobalSettings() +} + +// newWrapTestWindow builds a BufWindow whose bufWidth exactly equals paneWidth +// (ruler, diffgutter, scrollbar all off, so gutterOffset is 0), with softwrap +// on and the given wordwrap/tabsize/softwrapcolumn settings. Using the default +// `statusline: true` setting means updateDisplayInfo never touches +// screen.Screen, so this works without any terminal, real or simulated. +func newWrapTestWindow(text string, paneWidth int, wordwrap bool, tabsize int, softwrapcolumn int) *BufWindow { + buf := buffer.NewBufferFromString(text, "", buffer.BTDefault) + buf.Settings["softwrap"] = true + buf.Settings["wordwrap"] = wordwrap + buf.Settings["tabsize"] = float64(tabsize) + buf.Settings["softwrapcolumn"] = float64(softwrapcolumn) + buf.Settings["ruler"] = false + buf.Settings["diffgutter"] = false + buf.Settings["scrollbar"] = false + + w := NewBufWindow(0, 0, paneWidth, 10, buf) + w.updateDisplayInfo() + return w +} + +// assertRoundTrip checks that mapping every buffer column on line 0 to a +// visual location and back returns the original column — the invariant that +// cursor up/down, mouse clicks, and selection all rely on. This is the +// property that broke for the wrapindent PR (#3107) when it injected phantom +// leading columns on continuation rows; softwrapcolumn does not, so it must +// hold here too. +func assertRoundTrip(t *testing.T, w *BufWindow, lineLen int) { + t.Helper() + for x := 0; x <= lineLen; x++ { + loc := buffer.Loc{X: x, Y: 0} + vloc := w.VLocFromLoc(loc) + got := w.LocFromVLoc(vloc) + assert.Equal(t, x, got.X, "round-trip mismatch at column %d (vloc=%+v)", x, vloc) + } +} + +func TestSoftWrapColumnRoundTrip(t *testing.T) { + line := strings.Repeat("A", 60) + + t.Run("off (0) wraps at the full pane width", func(t *testing.T) { + w := newWrapTestWindow(line, 40, false, 4, 0) + assertRoundTrip(t, w, len(line)) + assert.Equal(t, 0, w.VLocFromLoc(buffer.Loc{X: 39, Y: 0}).Row) + assert.Equal(t, 1, w.VLocFromLoc(buffer.Loc{X: 40, Y: 0}).Row) + }) + + t.Run("narrower than the pane wraps at the configured column", func(t *testing.T) { + w := newWrapTestWindow(line, 58, false, 4, 30) + assertRoundTrip(t, w, len(line)) + assert.Equal(t, 0, w.VLocFromLoc(buffer.Loc{X: 29, Y: 0}).Row) + assert.Equal(t, 1, w.VLocFromLoc(buffer.Loc{X: 30, Y: 0}).Row) + }) + + t.Run("wider than the pane clamps to the pane width", func(t *testing.T) { + w := newWrapTestWindow(line, 18, false, 4, 30) + assertRoundTrip(t, w, len(line)) + assert.Equal(t, 0, w.VLocFromLoc(buffer.Loc{X: 17, Y: 0}).Row) + assert.Equal(t, 1, w.VLocFromLoc(buffer.Loc{X: 18, Y: 0}).Row) + }) + + t.Run("wordwrap on with a word longer than the column", func(t *testing.T) { + longWord := "short " + strings.Repeat("B", 50) + w := newWrapTestWindow(longWord, 58, true, 4, 10) + assertRoundTrip(t, w, len(longWord)) + }) + + t.Run("tabs straddling the wrap column", func(t *testing.T) { + tabby := "a\tb\tc\td\te\tf\tg\th" + w := newWrapTestWindow(tabby, 58, false, 4, 10) + assertRoundTrip(t, w, util.CharacterCount([]byte(tabby))) + }) +} diff --git a/runtime/help/options.md b/runtime/help/options.md index 542089425b..24fbf2f560 100644 --- a/runtime/help/options.md +++ b/runtime/help/options.md @@ -423,6 +423,13 @@ Here are the available options: default value: `false` +* `softwrapcolumn`: if this is not set to 0, lines will soft-wrap at the + specified column instead of at the edge of the screen, once softwrap is + enabled. If the specified column is wider than the screen, it wraps at + the screen edge instead. + + default value: `0` + * `splitbottom`: when a horizontal split is created, create it below the current split.