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.