Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions data/micro.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ var optionValidators = map[string]optionValidator{
"reload": validateChoice,
"scrollmargin": validateNonNegativeValue,
"scrollspeed": validateNonNegativeValue,
"softwrapcolumn": validateNonNegativeValue,
"tabsize": validatePositiveValue,
"truecolor": validateChoice,
}
Expand Down Expand Up @@ -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)",
Expand Down
74 changes: 47 additions & 27 deletions internal/display/bufwindow.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type BufWindow struct {
sline *StatusLine

bufWidth int
wrapWidth int
bufHeight int
gutterOffset int
hasMessage bool
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,...
Expand Down Expand Up @@ -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:]

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
22 changes: 11 additions & 11 deletions internal/display/softwrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func (w *BufWindow) getVLocFromLoc(loc buffer.Loc) VLoc {
return vloc
}

if w.bufWidth <= 0 {
if w.wrapWidth <= 0 {
return vloc
}

Expand All @@ -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)
Expand All @@ -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++
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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
}

Expand All @@ -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)
}
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
}
Expand Down
92 changes: 92 additions & 0 deletions internal/display/softwrap_test.go
Original file line number Diff line number Diff line change
@@ -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)))
})
}
7 changes: 7 additions & 0 deletions runtime/help/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down