diff --git a/data/micro.json b/data/micro.json index a50accb37a..725e898514 100644 --- a/data/micro.json +++ b/data/micro.json @@ -133,6 +133,20 @@ "type": "string", "default": "unknown" }, + "hlchunk": { + "description": "Whether to highlight the chunk containing the cursor\nhttps://github.com/micro-editor/micro/blob/master/runtime/help/options.md#options", + "type": "boolean", + "default": false + }, + "hlchunkmode": { + "description": "How hlchunk detects the chunk around the cursor\nhttps://github.com/micro-editor/micro/blob/master/runtime/help/options.md#options", + "type": "string", + "enum": [ + "indent", + "bracket" + ], + "default": "indent" + }, "hlsearch": { "description": "Whether to highlight all instances of a searched text after a successful search\nhttps://github.com/micro-editor/micro/blob/master/runtime/help/options.md#options", "type": "boolean", diff --git a/internal/buffer/chunk.go b/internal/buffer/chunk.go new file mode 100644 index 0000000000..ae96a68c37 --- /dev/null +++ b/internal/buffer/chunk.go @@ -0,0 +1,281 @@ +package buffer + +// Chunk detection for the hlchunk option, in the spirit of hlchunk.nvim: +// locate the block around the active cursor so the display layer can draw +// a guide along its edge. Two detectors, selected by the hlchunkmode +// setting: +// +// - indent: a chunk is delimited by the nearest lines above and below +// the cursor with smaller visual indent (the opening statement and +// the closing token, or the next sibling statement); a cursor on a +// block-opening header anchors the chunk that header opens instead. +// Works for any language but trusts the indentation. +// - bracket: a chunk is the innermost (), [] or {} pair spanning more +// than one line around the cursor. Exact block extents for brace +// languages (multi-line conditions, mixed indent), but counts +// brackets inside strings and comments, like matchbrace does. +// +// Both fill the same draw-ready Chunk, so the display layer never knows +// which mode fired. + +import "github.com/micro-editor/micro/v2/internal/util" + +// detection runs on every redraw (every 16ms while animating), so +// boundary scans bail beyond this distance instead of walking a huge +// file end to end +const chunkScanLimit = 5000 + +// Chunk describes the block around the cursor and where its guide sits +type Chunk struct { + Start, End int // boundary line numbers (corner rows) + StartIndent, EndIndent int // visual indent width of the boundary lines + GuideCol int // guide column, in visual columns +} + +// IndentChunk locates the indent chunk around line cury. It reports +// false when the cursor is at top level or a boundary is missing, or +// when a boundary lies further than the scan limit. +func (b *Buffer) IndentChunk(cury int) (Chunk, bool) { + tabsize := util.IntOpt(b.Settings["tabsize"]) + return findIndentChunk(b.LineBytes, b.LinesNum(), cury, tabsize) +} + +// BraceChunk locates the innermost bracket pair around cur that spans +// more than one line. It reports false when no pair encloses the cursor +// within the scan limit. +func (b *Buffer) BraceChunk(cur Loc) (Chunk, bool) { + var cg Chunk + tabsize := util.IntOpt(b.Settings["tabsize"]) + ymin, _ := chunkScanBounds(cur.Y, b.LinesNum()) + + cg.Start = -1 + // a line that leaves a bracket open anchors the chunk that bracket + // opens, mirroring the indent mode's header rule + line := []rune(string(b.LineBytes(cur.Y))) + if x := lastOpenBrace(line); x >= 0 { + pair, _ := braceDir(line[x]) + if cl, ok := b.findMatchingBrace(pair, Loc{x, cur.Y}, pair[0]); ok && cl.Y > cur.Y { + cg.Start, cg.End = cur.Y, cl.Y + } + } + // enclosing pairs living on a single line are not chunks: consume + // them and keep scanning outward + pos := cur + for cg.Start < 0 { + op, pair, ok := enclosingBrace(b.LineBytes, ymin, pos) + if !ok { + return cg, false + } + if cl, ok := b.findMatchingBrace(pair, op, pair[0]); ok && cl.Y > op.Y { + cg.Start, cg.End = op.Y, cl.Y + break + } + pos = op + } + + cg.StartIndent, _ = visualIndent(b.LineBytes(cg.Start), tabsize) + cg.EndIndent, _ = visualIndent(b.LineBytes(cg.End), tabsize) + cg.finalize(b.LineBytes, cur.Y, tabsize) + return cg, true +} + +// visualIndent returns the display width of the line's leading whitespace +// and whether the line contains nothing else. +func visualIndent(line []byte, tabsize int) (int, bool) { + w := 0 + for _, c := range line { + switch c { + case ' ': + w++ + case '\t': + w += tabsize - (w % tabsize) + default: + return w, false + } + } + return w, true +} + +// chunkScanBounds clamps the boundary scan window around line y +func chunkScanBounds(y, nlines int) (int, int) { + ymin := y - chunkScanLimit + if ymin < 0 { + ymin = 0 + } + ymax := y + chunkScanLimit + if ymax > nlines-1 { + ymax = nlines - 1 + } + return ymin, ymax +} + +// finalize turns raw boundaries into a drawable guide: place the guide +// column one indent level left of the boundary indent. Column-zero +// boundary lines have no whitespace to hold their corner; the display +// layer skips such corner rows and the bars just span the body. +func (cg *Chunk) finalize(getLine func(int) []byte, cury, tabsize int) { + cg.GuideCol = cg.StartIndent + if cg.EndIndent < cg.GuideCol { + cg.GuideCol = cg.EndIndent + } + cg.GuideCol -= tabsize + if cg.GuideCol < 0 { + cg.GuideCol = 0 + } + + // a column-zero opener likewise has no top corner: keep bars off + // blank lines at the chunk's head (never past the cursor's line) + if cg.StartIndent == 0 { + for cg.Start+1 < cury { + if _, b := visualIndent(getLine(cg.Start+1), tabsize); !b { + break + } + cg.Start++ + } + } +} + +// findIndentChunk locates the indent chunk around line cury: the lines +// between the nearest lines above and below it with smaller visual +// indent. +func findIndentChunk(getLine func(int) []byte, nlines, cury, tabsize int) (Chunk, bool) { + var cg Chunk + ymin, ymax := chunkScanBounds(cury, nlines) + + curIndent, blank := visualIndent(getLine(cury), tabsize) + if blank { + return cg, false + } + // a line opening a deeper block anchors the chunk it opens, not + // the block enclosing it: the header is the top corner row and the + // chunk runs to the first line back at the header's indent or less + header := false + for y := cury + 1; y <= ymax; y++ { + if w, b := visualIndent(getLine(y), tabsize); !b { + header = w > curIndent + break + } + } + if curIndent == 0 && !header { + return cg, false + } + + cg.Start = -1 + cg.End = -1 + if header { + cg.Start, cg.StartIndent = cury, curIndent + for y := cury + 1; y <= ymax; y++ { + if w, b := visualIndent(getLine(y), tabsize); !b && w <= curIndent { + cg.End, cg.EndIndent = y, w + break + } + } + } else { + for y := cury - 1; y >= ymin; y-- { + if w, b := visualIndent(getLine(y), tabsize); !b && w < curIndent { + cg.Start, cg.StartIndent = y, w + break + } + } + for y := cury + 1; y <= ymax; y++ { + if w, b := visualIndent(getLine(y), tabsize); !b && w < curIndent { + cg.End, cg.EndIndent = y, w + break + } + } + } + if cg.Start < 0 || cg.End < 0 { + return cg, false + } + + // the lower boundary is the chunk's own last line only when it is + // a closing token; a sibling statement (python's except:, the next + // def) is not part of the chunk, so anchor the corner on the + // chunk's last code line instead of dangling the bars over it. + // col-0 closers retarget too: no whitespace there to hold a corner + // (unlike bracket mode, where the closer stays the boundary) + if cg.EndIndent == 0 || !closesChunk(getLine(cg.End)) { + for y := cg.End - 1; y > cg.Start; y-- { + if w, b := visualIndent(getLine(y), tabsize); !b { + cg.End, cg.EndIndent = y, w + break + } + } + } + + cg.finalize(getLine, cury, tabsize) + return cg, true +} + +// closesChunk reports whether a boundary line is a block's own closing +// token (first code rune closes a bracket) rather than a sibling +// statement. +func closesChunk(line []byte) bool { + for _, r := range string(line) { + if r == ' ' || r == '\t' { + continue + } + _, dir := braceDir(r) + return dir < 0 + } + return false +} + +// braceDir reports which pair a bracket rune belongs to; dir is +1 for +// an opener, -1 for a closer, 0 for any other rune. +func braceDir(r rune) (pair [2]rune, dir int) { + for _, bp := range BracePairs { + if r == bp[0] { + return bp, 1 + } + if r == bp[1] { + return bp, -1 + } + } + return pair, 0 +} + +// lastOpenBrace returns the position of the innermost bracket the line +// opens without closing, or -1. Pairing is type-blind: valid code nests +// brackets properly, and code that does not (brackets in strings) +// miscounts the same way matchbrace does. +func lastOpenBrace(line []rune) int { + var open []int + for x, r := range line { + if _, dir := braceDir(r); dir > 0 { + open = append(open, x) + } else if dir < 0 && len(open) > 0 { + open = open[:len(open)-1] + } + } + if len(open) == 0 { + return -1 + } + return open[len(open)-1] +} + +// enclosingBrace scans backwards from cur (exclusive) for the nearest +// bracket left open at the cursor, scanning no further than line ymin. +// Type-blind pairing, as in lastOpenBrace. +func enclosingBrace(getLine func(int) []byte, ymin int, cur Loc) (Loc, [2]rune, bool) { + depth := 0 + for y := cur.Y; y >= ymin; y-- { + l := []rune(string(getLine(y))) + x0 := len(l) - 1 + if y == cur.Y && cur.X-1 < x0 { + x0 = cur.X - 1 + } + for x := x0; x >= 0; x-- { + pair, dir := braceDir(l[x]) + if dir < 0 { + depth++ + } else if dir > 0 { + if depth == 0 { + return Loc{x, y}, pair, true + } + depth-- + } + } + } + return cur, [2]rune{}, false +} diff --git a/internal/buffer/chunk_test.go b/internal/buffer/chunk_test.go new file mode 100644 index 0000000000..1c0a758083 --- /dev/null +++ b/internal/buffer/chunk_test.go @@ -0,0 +1,141 @@ +package buffer + +import ( + "strings" + "testing" +) + +func linesOf(src string) (func(int) []byte, int) { + lines := strings.Split(src, "\n") + return func(i int) []byte { return []byte(lines[i]) }, len(lines) +} + +func TestFindIndentChunk(t *testing.T) { + getLine, n := linesOf("func main() {\n\tif x {\n\t\ta()\n\n\t\tb()\n\t}\n}") + + for _, c := range []struct { + cury, start, end, gcol int + ok bool + }{ + {2, 1, 5, 0, true}, // inside if block + {3, 0, 0, 0, false}, // blank line draws nothing + {1, 1, 5, 0, true}, // header line anchors the block it opens + {0, 0, 5, 0, true}, // func chunk: corner retargets off the col-0 `}` + {6, 0, 0, 0, false}, // closing brace opens nothing + } { + cg, ok := findIndentChunk(getLine, n, c.cury, 8) + if ok != c.ok || ok && (cg.Start != c.start || cg.End != c.end || cg.GuideCol != c.gcol) { + t.Errorf("findIndentChunk(cury=%d) = %+v,%v, want %+v", c.cury, cg, ok, c) + } + } + + // missing lower boundary + getLine, n = linesOf("if x {\n\ta()") + if _, ok := findIndentChunk(getLine, n, 1, 8); ok { + t.Error("unclosed chunk reported") + } + + // a dedent to column zero anchors the corner on the last code line + getLine, n = linesOf("def f():\n\ta()\n\n\ndef g():") + if cg, ok := findIndentChunk(getLine, n, 1, 8); !ok || cg.End != 1 || cg.EndIndent != 8 { + t.Errorf("dedent to zero: end,endIndent,ok = %d,%d,%v, want 1,8,true", cg.End, cg.EndIndent, ok) + } + + // header whose block dedents to zero: corner lands on the body's + // last line, not the next top-level statement + getLine, n = linesOf("def f():\n\ttry:\n\t\ta()\n\n\ndef g():") + if cg, ok := findIndentChunk(getLine, n, 1, 8); !ok || cg.Start != 1 || cg.End != 2 || cg.GuideCol != 0 { + t.Errorf("header dedent: got %+v,%v, want start 1 end 2 gcol 0", cg, ok) + } + + // a boundary at the header's own indent that is not a closing + // token is a sibling clause (except:), not the chunk's last line + getLine, n = linesOf("def f():\n\ttry:\n\t\ta()\n\texcept E:\n\t\tpass") + if cg, ok := findIndentChunk(getLine, n, 1, 8); !ok || cg.Start != 1 || cg.End != 2 || cg.GuideCol != 0 { + t.Errorf("sibling clause: got %+v,%v, want start 1 end 2 gcol 0", cg, ok) + } + + // boundaries beyond the scan cap + huge := func(i int) []byte { + if i == 0 || i == 2*chunkScanLimit+2 { + return []byte("x") + } + return []byte("\tx") + } + if _, ok := findIndentChunk(huge, 2*chunkScanLimit+3, chunkScanLimit+1, 8); ok { + t.Error("chunk beyond scan limit reported") + } +} + +func braceBuf(src string) *Buffer { + b := NewBufferFromString(src, "", BTDefault) + b.Settings["tabsize"] = float64(8) + return b +} + +func TestBraceChunk(t *testing.T) { + // 0: func main() { + // 1: if x > + // 2: 0 { + // 3: a(b) + // 4: } + // 5: c( + // 6: d, + // 7: ) + // 8: } + b := braceBuf("func main() {\n\tif x >\n\t\t0 {\n\t\ta(b)\n\t}\n\tc(\n\t\td,\n\t)\n}") + + for _, c := range []struct { + cur Loc + start, end, gcol int + ok bool + }{ + {Loc{2, 3}, 2, 4, 0, true}, // inside if body (multi-line condition) + {Loc{0, 6}, 5, 7, 0, true}, // inside paren args + {Loc{0, 4}, 2, 4, 0, true}, // on `}` before the brace: the block it closes + {Loc{4, 2}, 2, 4, 0, true}, // header rule: `0 {` anchors the block it opens + {Loc{3, 5}, 5, 7, 0, true}, // header rule: `c(` anchors the paren chunk + // same-line pair `(b)` is consumed, enclosing if block wins; + // col-0 `}` retargets nothing here (endIndent 8 > 0) + {Loc{4, 3}, 2, 4, 0, true}, + } { + cg, ok := b.BraceChunk(c.cur) + if ok != c.ok || ok && (cg.Start != c.start || cg.End != c.end || cg.GuideCol != c.gcol) { + t.Errorf("BraceChunk(%v) = %+v,%v, want %+v", c.cur, cg, ok, c) + } + } + + // func chunk: the col-0 `}` stays the boundary (no corner drawn + // there, bars span the body) + if cg, ok := b.BraceChunk(Loc{0, 1}); !ok || cg.Start != 0 || cg.End != 8 || cg.EndIndent != 0 { + t.Errorf("func chunk: got %+v,%v, want start 0 end 8 endIndent 0", cg, ok) + } + + // Allman braces: the guide runs to the function's own closer, not + // the inner for-loop's `}` above it + b = braceBuf("f()\n{\n\ta();\n\tfor (;;) {\n\t\tb();\n\t}\n}") + if cg, ok := b.BraceChunk(Loc{0, 1}); !ok || cg.Start != 1 || cg.End != 6 || cg.EndIndent != 0 { + t.Errorf("allman: got %+v,%v, want start 1 end 6 endIndent 0", cg, ok) + } + + // no enclosing pair at top level + if _, ok := braceBuf("x := 1\ny := 2").BraceChunk(Loc{0, 1}); ok { + t.Error("chunk reported at top level") + } + + // unclosed opener is not a chunk + if _, ok := braceBuf("if x {\n\ta(").BraceChunk(Loc{3, 1}); ok { + t.Error("unclosed chunk reported") + } + + // blank line inside a block still resolves (indent mode cannot) + if cg, ok := braceBuf("if x {\n\ta()\n\n\tb()\n}").BraceChunk(Loc{0, 2}); !ok || cg.Start != 0 || cg.End != 4 { + t.Errorf("blank line: got %+v,%v, want start 0 end 4", cg, ok) + } + + // boundaries beyond the scan cap + huge := "f(\n" + strings.Repeat("\tx,\n", 2*chunkScanLimit+1) + ")" + if _, ok := braceBuf(huge).BraceChunk(Loc{0, chunkScanLimit + 1}); ok { + t.Error("chunk beyond scan limit reported") + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index 45f6b3aad6..25cbbb7d5b 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -29,6 +29,7 @@ var optionValidators = map[string]optionValidator{ "encoding": validateEncoding, "fileformat": validateChoice, "helpsplit": validateChoice, + "hlchunkmode": validateChoice, "matchbracestyle": validateChoice, "multiopen": validateChoice, "pageoverlap": validateNonNegativeValue, @@ -44,6 +45,7 @@ var OptionChoices = map[string][]string{ "clipboard": {"internal", "external", "terminal"}, "fileformat": {"unix", "dos"}, "helpsplit": {"hsplit", "vsplit"}, + "hlchunkmode": {"indent", "bracket"}, "matchbracestyle": {"underline", "highlight"}, "multiopen": {"tab", "hsplit", "vsplit"}, "reload": {"prompt", "auto", "disabled"}, @@ -67,6 +69,8 @@ var defaultCommonSettings = map[string]any{ "fastdirty": false, "fileformat": defaultFileFormat(), "filetype": "unknown", + "hlchunk": false, + "hlchunkmode": "indent", "hlsearch": false, "hltaberrors": false, "hltrailingws": false, diff --git a/internal/display/bufwindow.go b/internal/display/bufwindow.go index ddbb044c7b..7be8be1a51 100644 --- a/internal/display/bufwindow.go +++ b/internal/display/bufwindow.go @@ -3,6 +3,7 @@ package display import ( "strconv" "strings" + "time" runewidth "github.com/mattn/go-runewidth" "github.com/micro-editor/micro/v2/internal/buffer" @@ -29,6 +30,9 @@ type BufWindow struct { hasMessage bool maxLineNumLength int drawDivider bool + + // animation state for the hlchunk guide + chunkAnim chunkAnim } // NewBufWindow creates a new window at a location in the screen with a width and height @@ -475,6 +479,41 @@ func (w *BufWindow) displayBuffer() { } } + hlchunk := b.Settings["hlchunk"].(bool) && w.active + var chunk chunkGuide + if hlchunk { + var c buffer.Chunk + if b.Settings["hlchunkmode"].(string) == "bracket" { + c, hlchunk = b.BraceChunk(b.GetActiveCursor().Loc) + } else { + c, hlchunk = b.IndentChunk(b.GetActiveCursor().Y) + } + chunk = chunkGuide{c} + } + chunkVisible := 0 + if hlchunk { + var more bool + chunkVisible, more = w.chunkAnim.visible(chunk) + if more { + time.AfterFunc(chunkAnimFrame, screen.Redraw) + } + } else { + w.chunkAnim = chunkAnim{} + } + // chunkRuneAt hides cells the animation has not yet reached + chunkRuneAt := func(y, vcol int) rune { + cr := chunk.runeAt(y, vcol) + if cr != 0 && chunk.cellIndex(y, vcol) >= chunkVisible { + return 0 + } + return cr + } + chunkStyle := config.DefStyle + if s, ok := config.Colorscheme["hlchunk"]; ok { + chunkStyle = s + } + chunkFg, _, _ := chunkStyle.Decompose() + for ; vloc.Y < w.bufHeight; vloc.Y++ { vloc.X = 0 @@ -543,6 +582,12 @@ func (w *BufWindow) displayBuffer() { return r, style, false } + if hlchunk && bloc.X < leadingwsEnd { + if cr := chunkRuneAt(bloc.Y, linex); cr != 0 { + return cr, style.Foreground(chunkFg), false + } + } + var indentrunes []rune switch r { case '\t': @@ -822,12 +867,29 @@ func (w *BufWindow) displayBuffer() { curStyle = style.Background(fg) } } - screen.SetContent(i+w.X, vloc.Y+w.Y, ' ', nil, curStyle) + fillRune := ' ' + // the guide continues through lines ending left of it + // (blank lines mostly); with softwrap a wrapped line is + // always wider than gcol, so the mapping below is safe + if hlchunk && totalwidth <= chunk.GuideCol { + if cr := chunkRuneAt(bloc.Y, i-w.gutterOffset+w.StartCol); cr != 0 { + fillRune = cr + curStyle = curStyle.Foreground(chunkFg) + } + } + screen.SetContent(i+w.X, vloc.Y+w.Y, fillRune, nil, curStyle) } if vloc.X != maxWidth { // Display newline within a selection drawrune, drawstyle, preservebg := getRuneStyle(' ', config.DefStyle, 0, totalwidth, true) + // the newline cell may sit exactly on the guide column + if hlchunk && totalwidth <= chunk.GuideCol { + if cr := chunkRuneAt(bloc.Y, totalwidth); cr != 0 { + drawrune = cr + drawstyle = drawstyle.Foreground(chunkFg) + } + } draw(drawrune, nil, drawstyle, true, true, preservebg) } diff --git a/internal/display/chunkguide.go b/internal/display/chunkguide.go new file mode 100644 index 0000000000..bf1099ae3e --- /dev/null +++ b/internal/display/chunkguide.go @@ -0,0 +1,127 @@ +package display + +// The hlchunk option draws a guide around the chunk containing the +// active cursor, in the spirit of hlchunk.nvim. Detection lives in +// internal/buffer (chunk.go); this file owns guide geometry and +// animation. The guide is drawn one indent level left of the boundary +// indent: +// +// if x { <- start row: ╭── +// a() <- middle row: │ +// } <- end row: ╰─> +// +// Guide runes only ever replace whitespace cells, so text is never covered. +// +// A fresh chunk is animated: its cells appear one by one over +// chunkAnimDuration, sweeping from the opening line's text around to the +// closing line's text, like the original plugin. + +import ( + "time" + + "github.com/micro-editor/micro/v2/internal/buffer" +) + +const ( + chunkCornerTop = '╭' + chunkCornerBot = '╰' + chunkVertical = '│' + chunkHorizontal = '─' + chunkArrow = '>' + + chunkAnimDuration = 200 * time.Millisecond + chunkAnimFrame = 16 * time.Millisecond + + // a new chunk stays hidden until the cursor settles on it, so + // holding up/down does not fire a sweep at every step + chunkSettleDelay = 200 * time.Millisecond +) + +// chunkGuide adds guide geometry to a detected chunk +type chunkGuide struct { + buffer.Chunk +} + +// chunkAnim animates a guide by revealing its cells over chunkAnimDuration +type chunkAnim struct { + shown chunkGuide + start time.Time +} + +// visible returns how many guide cells are revealed right now, and whether +// more frames are needed. A changed chunk restarts the clock, set in the +// future so the sweep only begins once the cursor has settled. +func (a *chunkAnim) visible(cg chunkGuide) (int, bool) { + if cg != a.shown { + a.shown = cg + a.start = time.Now().Add(chunkSettleDelay) + } + elapsed := time.Since(a.start) + if elapsed < 0 { + return 0, true + } + total := cg.cells() + if elapsed >= chunkAnimDuration { + return total, false + } + return int(elapsed * time.Duration(total) / chunkAnimDuration), true +} + +// cells returns the number of cells the guide occupies (animation steps) +func (cg *chunkGuide) cells() int { + n := cg.End - cg.Start - 1 + if cg.StartIndent > cg.GuideCol { + n += cg.StartIndent - cg.GuideCol + } + if cg.EndIndent > cg.GuideCol { + n += cg.EndIndent - cg.GuideCol + } + return n +} + +// cellIndex returns the draw-order position of a cell the guide covers: +// the top corner row fills leftwards from the opening line's text, the +// bars run downwards, the bottom corner rightwards to the arrow. +func (cg *chunkGuide) cellIndex(y, vcol int) int { + topLen := 0 + if cg.StartIndent > cg.GuideCol { + topLen = cg.StartIndent - cg.GuideCol + } + switch { + case y == cg.Start: + return cg.StartIndent - 1 - vcol + case y < cg.End: + return topLen + y - cg.Start - 1 + default: + return topLen + cg.End - cg.Start - 1 + vcol - cg.GuideCol + } +} + +// runeAt returns the guide rune for visual column vcol of line y, or 0 if +// the guide does not cover that cell. Corner rows are skipped when their +// boundary line has no leading whitespace to draw into. +func (cg *chunkGuide) runeAt(y, vcol int) rune { + switch { + case y > cg.Start && y < cg.End: + if vcol == cg.GuideCol { + return chunkVertical + } + case y == cg.Start && cg.StartIndent > cg.GuideCol: + switch { + case vcol == cg.GuideCol: + return chunkCornerTop + case vcol > cg.GuideCol && vcol < cg.StartIndent: + return chunkHorizontal + } + case y == cg.End && cg.EndIndent > cg.GuideCol: + switch { + case vcol == cg.GuideCol: + return chunkCornerBot + case vcol == cg.EndIndent-1: + return chunkArrow + case vcol > cg.GuideCol && vcol < cg.EndIndent: + return chunkHorizontal + } + } + return 0 +} diff --git a/internal/display/chunkguide_test.go b/internal/display/chunkguide_test.go new file mode 100644 index 0000000000..61739ec15a --- /dev/null +++ b/internal/display/chunkguide_test.go @@ -0,0 +1,45 @@ +package display + +import ( + "testing" + + "github.com/micro-editor/micro/v2/internal/buffer" +) + +func TestChunkGeometry(t *testing.T) { + // tabsize 4, space indent: + // def f(): + // if x: <- start, indent 4 + // a() + // return <- end, indent 4 + cg := chunkGuide{buffer.Chunk{ + Start: 1, End: 3, StartIndent: 4, EndIndent: 4, GuideCol: 0, + }} + + for _, c := range []struct { + y, vcol int + want rune + }{ + {1, 0, chunkCornerTop}, {1, 3, chunkHorizontal}, + {2, 0, chunkVertical}, + {3, 0, chunkCornerBot}, {3, 3, chunkArrow}, + {1, 4, 0}, {3, 4, 0}, {2, 1, 0}, {0, 0, 0}, // text cells and outside rows stay bare + } { + if got := cg.runeAt(c.y, c.vcol); got != c.want { + t.Errorf("runeAt(%d,%d) = %q, want %q", c.y, c.vcol, got, c.want) + } + } + + if n := cg.cells(); n != 9 { + t.Errorf("cells() = %d, want 9", n) + } + // sweep order: top corner fills leftwards, bars down, bottom corner + // rightwards to the arrow + for i, c := range []struct{ y, vcol int }{ + {1, 3}, {1, 2}, {1, 1}, {1, 0}, {2, 0}, {3, 0}, {3, 1}, {3, 2}, {3, 3}, + } { + if got := cg.cellIndex(c.y, c.vcol); got != i { + t.Errorf("cellIndex(%d,%d) = %d, want %d", c.y, c.vcol, got, i) + } + } +} diff --git a/runtime/colorschemes/simple.micro b/runtime/colorschemes/simple.micro index 707c04cb27..f3de7ce99a 100644 --- a/runtime/colorschemes/simple.micro +++ b/runtime/colorschemes/simple.micro @@ -31,3 +31,4 @@ color-link preproc.shebang "comment" color-link match-brace ",magenta" color-link tab-error "brightred" color-link trailingws "brightred" +color-link hlchunk "blue" diff --git a/runtime/help/colors.md b/runtime/help/colors.md index 7437108731..cc7a9e9779 100644 --- a/runtime/help/colors.md +++ b/runtime/help/colors.md @@ -200,6 +200,7 @@ Here is a list of the colorscheme groups that you can use: * hlsearch (Color of highlighted search results when `hlsearch` is enabled) * tab-error (Color of tab vs space errors when `hltaberrors` is enabled) * trailingws (Color of trailing whitespaces when `hltrailingws` is enabled) +* hlchunk (Color of the indent chunk guide when `hlchunk` is enabled) Colorschemes must be placed in the `~/.config/micro/colorschemes` directory to be used. diff --git a/runtime/help/options.md b/runtime/help/options.md index 542089425b..53eba6f775 100644 --- a/runtime/help/options.md +++ b/runtime/help/options.md @@ -175,6 +175,26 @@ Here are the available options: default value: `hsplit` +* `hlchunk`: highlight the chunk containing the cursor with a guide drawn + in the leading whitespace, colored by the `hlchunk` colorscheme group. + How the chunk is detected is set by `hlchunkmode`. + + default value: `false` + +* `hlchunkmode`: how `hlchunk` detects the chunk around the cursor. + Possible values: + * `indent`: the chunk is delimited by the nearest lines above and below + the cursor with smaller indentation. Works for any language but + trusts the indentation. + * `bracket`: the chunk is the innermost `()`, `[]` or `{}` pair spanning + more than one line around the cursor. Exact block extents for brace + languages (multi-line conditions, mixed indentation), but brackets + inside strings and comments miscount, as with `matchbrace`. The + guide only draws into whitespace, so a bracket in column zero gets + no corner row; the bars stop at the body's edge. + + default value: `indent` + * `hlsearch`: highlight all instances of the searched text after a successful search. This highlighting can be temporarily turned off via the `UnhighlightSearch` action (triggered by the Esc key by default) or toggled @@ -583,6 +603,8 @@ so that you can see what the formatting should look like. "filetype": "unknown", "ftoptions": true, "helpsplit": "hsplit", + "hlchunk": false, + "hlchunkmode": "indent", "hlsearch": false, "hltaberrors": false, "hltrailingws": false,