From 356c6087d72c66e22c2ad536909a5ec95a54b093 Mon Sep 17 00:00:00 2001 From: h8d13 Date: Sat, 4 Jul 2026 02:59:33 +0200 Subject: [PATCH 1/9] feat(hlchunk) --- internal/config/settings.go | 1 + internal/display/bufwindow.go | 58 ++++++++- internal/display/chunkguide.go | 191 ++++++++++++++++++++++++++++ internal/display/chunkguide_test.go | 113 ++++++++++++++++ runtime/help/colors.md | 1 + runtime/help/options.md | 11 ++ 6 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 internal/display/chunkguide.go create mode 100644 internal/display/chunkguide_test.go diff --git a/internal/config/settings.go b/internal/config/settings.go index 45f6b3aad6..bb0a2263d5 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -67,6 +67,7 @@ var defaultCommonSettings = map[string]any{ "fastdirty": false, "fileformat": defaultFileFormat(), "filetype": "unknown", + "hlchunk": false, "hlsearch": false, "hltaberrors": false, "hltrailingws": false, diff --git a/internal/display/bufwindow.go b/internal/display/bufwindow.go index ddbb044c7b..b5e4f7c018 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,35 @@ func (w *BufWindow) displayBuffer() { } } + hlchunk := b.Settings["hlchunk"].(bool) && w.active + var chunk chunkGuide + if hlchunk { + chunk, hlchunk = findChunk(b.LineBytes, b.LinesNum(), b.GetActiveCursor().Y, tabsize) + } + 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 +576,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 +861,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.gcol { + 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.gcol { + 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..ac3fb3fdad --- /dev/null +++ b/internal/display/chunkguide.go @@ -0,0 +1,191 @@ +package display + +// The hlchunk option highlights the indent chunk around the active cursor, +// in the spirit of hlchunk.nvim. 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). 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" + +const ( + chunkCornerTop = '╭' + chunkCornerBot = '╰' + chunkVertical = '│' + chunkHorizontal = '─' + chunkArrow = '>' + + chunkAnimDuration = 200 * time.Millisecond + chunkAnimFrame = 16 * time.Millisecond +) + +type chunkGuide struct { + start, end int // boundary line numbers (corner rows) + startIndent, endIndent int // visual indent width of the boundary lines + gcol int // guide column, in visual columns +} + +// 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 +} + +// findChunk locates the indent chunk around line cury. It reports false +// when the cursor is at top level or a boundary is missing. +func findChunk(getLine func(int) []byte, nlines, cury, tabsize int) (chunkGuide, bool) { + var cg chunkGuide + + curIndent, blank := visualIndent(getLine(cury), tabsize) + if blank { + // on a blank line take the deeper of the two neighboring + // indents, so the guide survives typing inside a block + curIndent = 0 + for y := cury - 1; y >= 0; y-- { + if w, b := visualIndent(getLine(y), tabsize); !b { + curIndent = w + break + } + } + for y := cury + 1; y < nlines; y++ { + if w, b := visualIndent(getLine(y), tabsize); !b { + if w > curIndent { + curIndent = w + } + break + } + } + } + if curIndent == 0 { + return cg, false + } + + cg.start = -1 + for y := cury - 1; y >= 0; y-- { + if w, b := visualIndent(getLine(y), tabsize); !b && w < curIndent { + cg.start, cg.startIndent = y, w + break + } + } + cg.end = -1 + for y := cury + 1; y < nlines; 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 + } + + cg.gcol = cg.startIndent + if cg.endIndent < cg.gcol { + cg.gcol = cg.endIndent + } + cg.gcol -= tabsize + if cg.gcol < 0 { + cg.gcol = 0 + } + return cg, true +} + +// 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, restarting +// the clock when the chunk changed, and whether more frames are needed. +func (a *chunkAnim) visible(cg chunkGuide) (int, bool) { + if cg != a.shown { + a.shown = cg + a.start = time.Now() + } + total := cg.cells() + elapsed := time.Since(a.start) + 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.gcol { + n += cg.startIndent - cg.gcol + } + if cg.endIndent > cg.gcol { + n += cg.endIndent - cg.gcol + } + 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.gcol { + topLen = cg.startIndent - cg.gcol + } + 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.gcol + } +} + +// 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.gcol { + return chunkVertical + } + case y == cg.start && cg.startIndent > cg.gcol: + switch { + case vcol == cg.gcol: + return chunkCornerTop + case vcol > cg.gcol && vcol < cg.startIndent: + return chunkHorizontal + } + case y == cg.end && cg.endIndent > cg.gcol: + switch { + case vcol == cg.gcol: + return chunkCornerBot + case vcol == cg.endIndent-1: + return chunkArrow + case vcol > cg.gcol && 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..6803df0b36 --- /dev/null +++ b/internal/display/chunkguide_test.go @@ -0,0 +1,113 @@ +package display + +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 TestFindChunkBraces(t *testing.T) { + getLine, n := linesOf( + "func main() {\n" + + "\tif x {\n" + + "\t\ta()\n" + + "\n" + + "\t\tb()\n" + + "\t}\n" + + "}") + + // cursor on a() (indent 16): boundaries are `if x {` and `\t}` + cg, ok := findChunk(getLine, n, 2, 8) + if !ok { + t.Fatal("expected chunk at line 2") + } + if cg.start != 1 || cg.end != 5 { + t.Errorf("boundaries = %d,%d, want 1,5", cg.start, cg.end) + } + if cg.gcol != 0 { + t.Errorf("gcol = %d, want 0 (min(8,8)-8)", cg.gcol) + } + + // cursor on the blank line inside the block keeps the same chunk + cg, ok = findChunk(getLine, n, 3, 8) + if !ok || cg.start != 1 || cg.end != 5 { + t.Errorf("blank line: ok=%v boundaries=%d,%d, want 1,5", ok, cg.start, cg.end) + } + + // cursor on `if x {` (indent 8): chunk is the func body + cg, ok = findChunk(getLine, n, 1, 8) + if !ok || cg.start != 0 || cg.end != 6 || cg.gcol != 0 { + t.Errorf("outer: ok=%v boundaries=%d,%d gcol=%d, want 0,6,0", ok, cg.start, cg.end, cg.gcol) + } + + // cursor at top level: no chunk + if _, ok = findChunk(getLine, n, 0, 8); ok { + t.Error("top level should have no chunk") + } +} + +func TestFindChunkNoClosing(t *testing.T) { + getLine, n := linesOf("if x {\n\ta()\n\tb()") + if _, ok := findChunk(getLine, n, 1, 8); ok { + t.Error("chunk without lower boundary should not be reported") + } +} + +func TestChunkRuneAt(t *testing.T) { + // python-ish, tabsize 4, spaces: + // 0 def f(): + // 1 if x: + // 2 a() + // 3 return + getLine, n := linesOf("def f():\n if x:\n a()\n return") + cg, ok := findChunk(getLine, n, 2, 4) + if !ok { + t.Fatal("expected chunk") + } + // boundaries `if x:` (indent 4) and `return` (indent 4), gcol 0 + if cg.start != 1 || cg.end != 3 || cg.gcol != 0 { + t.Fatalf("boundaries=%d,%d gcol=%d, want 1,3,0", cg.start, cg.end, cg.gcol) + } + + expect := map[[2]int]rune{ + {1, 0}: chunkCornerTop, {1, 1}: chunkHorizontal, {1, 3}: chunkHorizontal, + {2, 0}: chunkVertical, + {3, 0}: chunkCornerBot, {3, 1}: chunkHorizontal, {3, 3}: chunkArrow, + } + for pos, want := range expect { + if got := cg.runeAt(pos[0], pos[1]); got != want { + t.Errorf("runeAt(%d,%d) = %q, want %q", pos[0], pos[1], got, want) + } + } + // never outside the guide: on text columns or unrelated rows + for _, pos := range [][2]int{{1, 4}, {3, 4}, {2, 1}, {0, 0}, {2, 4}} { + if got := cg.runeAt(pos[0], pos[1]); got != 0 { + t.Errorf("runeAt(%d,%d) = %q, want none", pos[0], pos[1], got) + } + } +} + +func TestVisualIndent(t *testing.T) { + for _, c := range []struct { + line string + tab int + width int + blank bool + }{ + {"\tx", 8, 8, false}, + {" x", 8, 4, false}, + {" \ty", 8, 8, false}, // tab snaps to next stop + {"", 8, 0, true}, + {" \t ", 8, 9, true}, + {"x", 8, 0, false}, + } { + w, b := visualIndent([]byte(c.line), c.tab) + if w != c.width || b != c.blank { + t.Errorf("visualIndent(%q) = %d,%v, want %d,%v", c.line, w, b, c.width, c.blank) + } + } +} 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..c689cf655f 100644 --- a/runtime/help/options.md +++ b/runtime/help/options.md @@ -175,6 +175,16 @@ Here are the available options: default value: `hsplit` +* `hlchunk`: highlight the indent chunk surrounding the cursor with a guide + drawn one indent level left of the chunk's boundary lines: a `╭──` corner + on the line opening the chunk, `│` bars along it and a `╰─>` corner on the + line closing it. Chunk boundaries are the nearest lines above and below + the cursor with smaller indentation. The guide only ever replaces + whitespace, text is never covered. Its color is taken from the `hlchunk` + colorscheme group, falling back to the default text color. + + default value: `false` + * `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 +593,7 @@ so that you can see what the formatting should look like. "filetype": "unknown", "ftoptions": true, "helpsplit": "hsplit", + "hlchunk": false, "hlsearch": false, "hltaberrors": false, "hltrailingws": false, From 5a2480b305d3280aa2d5f10137680c9763551e7d Mon Sep 17 00:00:00 2001 From: h8d13 Date: Sat, 4 Jul 2026 03:09:46 +0200 Subject: [PATCH 2/9] shorten test, add max --- internal/display/chunkguide.go | 25 ++++-- internal/display/chunkguide_test.go | 121 +++++++++------------------- 2 files changed, 59 insertions(+), 87 deletions(-) diff --git a/internal/display/chunkguide.go b/internal/display/chunkguide.go index ac3fb3fdad..09fec1ebf8 100644 --- a/internal/display/chunkguide.go +++ b/internal/display/chunkguide.go @@ -27,6 +27,11 @@ const ( chunkAnimDuration = 200 * time.Millisecond chunkAnimFrame = 16 * time.Millisecond + + // findChunk runs on every redraw (every 16ms while animating), so + // boundary scans bail beyond this distance instead of walking a + // huge uniformly-indented file end to end + chunkScanLimit = 5000 ) type chunkGuide struct { @@ -53,22 +58,32 @@ func visualIndent(line []byte, tabsize int) (int, bool) { } // findChunk locates the indent chunk around line cury. It reports false -// when the cursor is at top level or a boundary is missing. +// when the cursor is at top level or a boundary is missing, or when a +// boundary lies further than chunkScanLimit lines away. func findChunk(getLine func(int) []byte, nlines, cury, tabsize int) (chunkGuide, bool) { var cg chunkGuide + ymin := cury - chunkScanLimit + if ymin < 0 { + ymin = 0 + } + ymax := cury + chunkScanLimit + if ymax > nlines-1 { + ymax = nlines - 1 + } + curIndent, blank := visualIndent(getLine(cury), tabsize) if blank { // on a blank line take the deeper of the two neighboring // indents, so the guide survives typing inside a block curIndent = 0 - for y := cury - 1; y >= 0; y-- { + for y := cury - 1; y >= ymin; y-- { if w, b := visualIndent(getLine(y), tabsize); !b { curIndent = w break } } - for y := cury + 1; y < nlines; y++ { + for y := cury + 1; y <= ymax; y++ { if w, b := visualIndent(getLine(y), tabsize); !b { if w > curIndent { curIndent = w @@ -82,14 +97,14 @@ func findChunk(getLine func(int) []byte, nlines, cury, tabsize int) (chunkGuide, } cg.start = -1 - for y := cury - 1; y >= 0; y-- { + for y := cury - 1; y >= ymin; y-- { if w, b := visualIndent(getLine(y), tabsize); !b && w < curIndent { cg.start, cg.startIndent = y, w break } } cg.end = -1 - for y := cury + 1; y < nlines; y++ { + for y := cury + 1; y <= ymax; y++ { if w, b := visualIndent(getLine(y), tabsize); !b && w < curIndent { cg.end, cg.endIndent = y, w break diff --git a/internal/display/chunkguide_test.go b/internal/display/chunkguide_test.go index 6803df0b36..99e6ddffa5 100644 --- a/internal/display/chunkguide_test.go +++ b/internal/display/chunkguide_test.go @@ -10,104 +10,61 @@ func linesOf(src string) (func(int) []byte, int) { return func(i int) []byte { return []byte(lines[i]) }, len(lines) } -func TestFindChunkBraces(t *testing.T) { - getLine, n := linesOf( - "func main() {\n" + - "\tif x {\n" + - "\t\ta()\n" + - "\n" + - "\t\tb()\n" + - "\t}\n" + - "}") +func TestFindChunk(t *testing.T) { + getLine, n := linesOf("func main() {\n\tif x {\n\t\ta()\n\n\t\tb()\n\t}\n}") - // cursor on a() (indent 16): boundaries are `if x {` and `\t}` - cg, ok := findChunk(getLine, n, 2, 8) - if !ok { - t.Fatal("expected chunk at line 2") - } - if cg.start != 1 || cg.end != 5 { - t.Errorf("boundaries = %d,%d, want 1,5", cg.start, cg.end) - } - if cg.gcol != 0 { - t.Errorf("gcol = %d, want 0 (min(8,8)-8)", cg.gcol) + for _, c := range []struct { + cury, start, end, gcol int + ok bool + }{ + {2, 1, 5, 0, true}, // inside if block + {3, 1, 5, 0, true}, // blank line keeps the chunk + {1, 0, 6, 0, true}, // if line belongs to func body + {0, 0, 0, 0, false}, // top level + } { + cg, ok := findChunk(getLine, n, c.cury, 8) + if ok != c.ok || ok && (cg.start != c.start || cg.end != c.end || cg.gcol != c.gcol) { + t.Errorf("findChunk(cury=%d) = %+v,%v, want %+v", c.cury, cg, ok, c) + } } - // cursor on the blank line inside the block keeps the same chunk - cg, ok = findChunk(getLine, n, 3, 8) - if !ok || cg.start != 1 || cg.end != 5 { - t.Errorf("blank line: ok=%v boundaries=%d,%d, want 1,5", ok, cg.start, cg.end) + // missing lower boundary + getLine, n = linesOf("if x {\n\ta()") + if _, ok := findChunk(getLine, n, 1, 8); ok { + t.Error("unclosed chunk reported") } - // cursor on `if x {` (indent 8): chunk is the func body - cg, ok = findChunk(getLine, n, 1, 8) - if !ok || cg.start != 0 || cg.end != 6 || cg.gcol != 0 { - t.Errorf("outer: ok=%v boundaries=%d,%d gcol=%d, want 0,6,0", ok, cg.start, cg.end, cg.gcol) + // boundaries beyond the scan cap + huge := func(i int) []byte { + if i == 0 || i == 2*chunkScanLimit+2 { + return []byte("x") + } + return []byte("\tx") } - - // cursor at top level: no chunk - if _, ok = findChunk(getLine, n, 0, 8); ok { - t.Error("top level should have no chunk") + if _, ok := findChunk(huge, 2*chunkScanLimit+3, chunkScanLimit+1, 8); ok { + t.Error("chunk beyond scan limit reported") } } -func TestFindChunkNoClosing(t *testing.T) { - getLine, n := linesOf("if x {\n\ta()\n\tb()") - if _, ok := findChunk(getLine, n, 1, 8); ok { - t.Error("chunk without lower boundary should not be reported") - } -} - -func TestChunkRuneAt(t *testing.T) { - // python-ish, tabsize 4, spaces: - // 0 def f(): - // 1 if x: - // 2 a() - // 3 return +func TestChunkGeometry(t *testing.T) { + // tabsize 4, space indent: guide on `if x:`(4) .. `return`(4), gcol 0 getLine, n := linesOf("def f():\n if x:\n a()\n return") cg, ok := findChunk(getLine, n, 2, 4) - if !ok { - t.Fatal("expected chunk") - } - // boundaries `if x:` (indent 4) and `return` (indent 4), gcol 0 - if cg.start != 1 || cg.end != 3 || cg.gcol != 0 { - t.Fatalf("boundaries=%d,%d gcol=%d, want 1,3,0", cg.start, cg.end, cg.gcol) - } - - expect := map[[2]int]rune{ - {1, 0}: chunkCornerTop, {1, 1}: chunkHorizontal, {1, 3}: chunkHorizontal, - {2, 0}: chunkVertical, - {3, 0}: chunkCornerBot, {3, 1}: chunkHorizontal, {3, 3}: chunkArrow, + if !ok || cg.start != 1 || cg.end != 3 || cg.gcol != 0 { + t.Fatalf("findChunk = %+v,%v, want start 1 end 3 gcol 0", cg, ok) } - for pos, want := range expect { - if got := cg.runeAt(pos[0], pos[1]); got != want { - t.Errorf("runeAt(%d,%d) = %q, want %q", pos[0], pos[1], got, want) - } - } - // never outside the guide: on text columns or unrelated rows - for _, pos := range [][2]int{{1, 4}, {3, 4}, {2, 1}, {0, 0}, {2, 4}} { - if got := cg.runeAt(pos[0], pos[1]); got != 0 { - t.Errorf("runeAt(%d,%d) = %q, want none", pos[0], pos[1], got) - } - } -} -func TestVisualIndent(t *testing.T) { for _, c := range []struct { - line string - tab int - width int - blank bool + y, vcol int + want rune }{ - {"\tx", 8, 8, false}, - {" x", 8, 4, false}, - {" \ty", 8, 8, false}, // tab snaps to next stop - {"", 8, 0, true}, - {" \t ", 8, 9, true}, - {"x", 8, 0, false}, + {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 } { - w, b := visualIndent([]byte(c.line), c.tab) - if w != c.width || b != c.blank { - t.Errorf("visualIndent(%q) = %d,%v, want %d,%v", c.line, w, b, c.width, c.blank) + 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) } } } From 003939724260e66dc6ca3ba15fd21d0233b0cf0c Mon Sep 17 00:00:00 2001 From: h8d13 Date: Sat, 4 Jul 2026 03:14:51 +0200 Subject: [PATCH 3/9] match docs style --- runtime/help/options.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/runtime/help/options.md b/runtime/help/options.md index c689cf655f..61b1959a01 100644 --- a/runtime/help/options.md +++ b/runtime/help/options.md @@ -175,13 +175,10 @@ Here are the available options: default value: `hsplit` -* `hlchunk`: highlight the indent chunk surrounding the cursor with a guide - drawn one indent level left of the chunk's boundary lines: a `╭──` corner - on the line opening the chunk, `│` bars along it and a `╰─>` corner on the - line closing it. Chunk boundaries are the nearest lines above and below - the cursor with smaller indentation. The guide only ever replaces - whitespace, text is never covered. Its color is taken from the `hlchunk` - colorscheme group, falling back to the default text color. +* `hlchunk`: highlight the indent chunk containing the cursor, i.e. the lines + between the nearest lines above and below it with smaller indentation. + The guide is drawn in the leading whitespace and colored by the `hlchunk` + colorscheme group. default value: `false` From 7c61cac5cd3225311abc04e928ea3808c7d8d838 Mon Sep 17 00:00:00 2001 From: h8d13 Date: Sat, 4 Jul 2026 03:20:35 +0200 Subject: [PATCH 4/9] add to `simple` example --- runtime/colorschemes/simple.micro | 1 + 1 file changed, 1 insertion(+) 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" From c16be05eaf5ceb773fcefc0f60b60a9924c4b19d Mon Sep 17 00:00:00 2001 From: h8d13 Date: Sat, 4 Jul 2026 03:34:34 +0200 Subject: [PATCH 5/9] small delay --- internal/display/chunkguide.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/display/chunkguide.go b/internal/display/chunkguide.go index 09fec1ebf8..cd4266caf7 100644 --- a/internal/display/chunkguide.go +++ b/internal/display/chunkguide.go @@ -28,6 +28,10 @@ const ( 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 + // findChunk runs on every redraw (every 16ms while animating), so // boundary scans bail beyond this distance instead of walking a // huge uniformly-indented file end to end @@ -131,15 +135,19 @@ type chunkAnim struct { start time.Time } -// visible returns how many guide cells are revealed right now, restarting -// the clock when the chunk changed, and whether more frames are needed. +// 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() + a.start = time.Now().Add(chunkSettleDelay) } - total := cg.cells() elapsed := time.Since(a.start) + if elapsed < 0 { + return 0, true + } + total := cg.cells() if elapsed >= chunkAnimDuration { return total, false } From 2d488389ca6cef4cdd7e421a37e7a4812702dd74 Mon Sep 17 00:00:00 2001 From: h8d13 Date: Sat, 4 Jul 2026 03:59:03 +0200 Subject: [PATCH 6/9] simplify and change logic a bit --- internal/display/chunkguide.go | 84 +++++++++++++++++++---------- internal/display/chunkguide_test.go | 20 +++++-- 2 files changed, 74 insertions(+), 30 deletions(-) diff --git a/internal/display/chunkguide.go b/internal/display/chunkguide.go index cd4266caf7..fe8a6fae1b 100644 --- a/internal/display/chunkguide.go +++ b/internal/display/chunkguide.go @@ -3,8 +3,9 @@ package display // The hlchunk option highlights the indent chunk around the active cursor, // in the spirit of hlchunk.nvim. 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). The -// guide is drawn one indent level left of the boundary indent: +// statement and the closing token, or the next sibling statement); a +// cursor on a block-opening header anchors the chunk that header opens +// instead. The guide is drawn one indent level left of the boundary indent: // // if x { <- start row: ╭── // a() <- middle row: │ @@ -78,45 +79,63 @@ func findChunk(getLine func(int) []byte, nlines, cury, tabsize int) (chunkGuide, curIndent, blank := visualIndent(getLine(cury), tabsize) if blank { - // on a blank line take the deeper of the two neighboring - // indents, so the guide survives typing inside a block - curIndent = 0 + return cg, false + } + // a line opening a deeper block anchors the chunk it opens, not + // the block enclosing it (what treesitter gives hlchunk.nvim): + // 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 { - curIndent = w + 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 { - if w > curIndent { - curIndent = w - } + if w, b := visualIndent(getLine(y), tabsize); !b && w < curIndent { + cg.end, cg.endIndent = y, w break } } } - if curIndent == 0 { + if cg.start < 0 || cg.end < 0 { return cg, false } - cg.start = -1 - for y := cury - 1; y >= ymin; y-- { - if w, b := visualIndent(getLine(y), tabsize); !b && w < curIndent { - cg.start, cg.startIndent = y, w - break - } - } - cg.end = -1 - for y := cury + 1; y <= ymax; y++ { - if w, b := visualIndent(getLine(y), tabsize); !b && w < curIndent { - cg.end, cg.endIndent = y, w - break + // a dedent straight to column zero has no whitespace to hold the + // bottom corner, leaving the bars dangling, so anchor the corner + // on the chunk's last code line instead (hlchunk's treesitter + // ranges end there too: such blocks have no closing token) + if cg.endIndent == 0 { + for y := cg.end - 1; y > cg.start; y-- { + if w, b := visualIndent(getLine(y), tabsize); !b { + cg.end, cg.endIndent = y, w + break + } } } - if cg.start < 0 || cg.end < 0 { - return cg, false - } cg.gcol = cg.startIndent if cg.endIndent < cg.gcol { @@ -126,6 +145,17 @@ func findChunk(getLine func(int) []byte, nlines, cury, tabsize int) (chunkGuide, if cg.gcol < 0 { cg.gcol = 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++ + } + } return cg, true } diff --git a/internal/display/chunkguide_test.go b/internal/display/chunkguide_test.go index 99e6ddffa5..52dbe47564 100644 --- a/internal/display/chunkguide_test.go +++ b/internal/display/chunkguide_test.go @@ -18,9 +18,10 @@ func TestFindChunk(t *testing.T) { ok bool }{ {2, 1, 5, 0, true}, // inside if block - {3, 1, 5, 0, true}, // blank line keeps the chunk - {1, 0, 6, 0, true}, // if line belongs to func body - {0, 0, 0, 0, false}, // top level + {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 := findChunk(getLine, n, c.cury, 8) if ok != c.ok || ok && (cg.start != c.start || cg.end != c.end || cg.gcol != c.gcol) { @@ -34,6 +35,19 @@ func TestFindChunk(t *testing.T) { 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 := findChunk(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 := findChunk(getLine, n, 1, 8); !ok || cg.start != 1 || cg.end != 2 || cg.gcol != 0 { + t.Errorf("header dedent: 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 { From a9eac76b26b097185e9ad7a25af358a0899b1945 Mon Sep 17 00:00:00 2001 From: h8d13 Date: Sat, 4 Jul 2026 11:17:36 +0200 Subject: [PATCH 7/9] chore(hlchunk): seperate into 2 modes * default: `indent` mode vs `bracket` mode for C styled langs Signed-off-by: h8d13 --- data/micro.json | 14 ++ internal/buffer/chunk.go | 296 ++++++++++++++++++++++++++++ internal/buffer/chunk_test.go | 132 +++++++++++++ internal/config/settings.go | 3 + internal/display/bufwindow.go | 12 +- internal/display/chunkguide.go | 183 ++++------------- internal/display/chunkguide_test.go | 85 +++----- runtime/help/options.md | 20 +- 8 files changed, 526 insertions(+), 219 deletions(-) create mode 100644 internal/buffer/chunk.go create mode 100644 internal/buffer/chunk_test.go 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..97b39725f2 --- /dev/null +++ b/internal/buffer/chunk.go @@ -0,0 +1,296 @@ +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 multi-line bracket pair around cur. +// It reports false when no pair encloses the cursor within the scan +// limit. +func (b *Buffer) BraceChunk(cur Loc) (Chunk, bool) { + tabsize := util.IntOpt(b.Settings["tabsize"]) + return findBraceChunk(b.LineBytes, b.LinesNum(), cur, tabsize) +} + +// 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 and retarget +// corners that a column-zero boundary leaves with no whitespace to draw +// into. +func (cg *Chunk) finalize(getLine func(int) []byte, cury, tabsize int) { + // a boundary at column zero has no whitespace to hold the bottom + // corner, leaving the bars dangling, so anchor the corner on the + // chunk's last code line instead (such blocks read as ending there: + // the closing token, if any, sits at top level) + if cg.EndIndent == 0 { + 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.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 + } + + cg.finalize(getLine, cury, tabsize) + return cg, true +} + +// 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] +} + +// braceMatchForward finds the closer matching the opener at start, +// scanning no further than line ymax +func braceMatchForward(getLine func(int) []byte, ymax int, start Loc, pair [2]rune) (Loc, bool) { + depth := 0 + for y := start.Y; y <= ymax; y++ { + l := []rune(string(getLine(y))) + x0 := 0 + if y == start.Y { + x0 = start.X + } + for x := x0; x < len(l); x++ { + switch l[x] { + case pair[0]: + depth++ + case pair[1]: + depth-- + if depth == 0 { + return Loc{x, y}, true + } + } + } + } + return start, false +} + +// 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 +} + +// findBraceChunk locates the innermost bracket pair around cur that +// spans more than one line. +func findBraceChunk(getLine func(int) []byte, nlines int, cur Loc, tabsize int) (Chunk, bool) { + var cg Chunk + ymin, ymax := chunkScanBounds(cur.Y, nlines) + + 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(getLine(cur.Y))) + if x := lastOpenBrace(line); x >= 0 { + pair, _ := braceDir(line[x]) + if cl, ok := braceMatchForward(getLine, ymax, Loc{x, cur.Y}, pair); 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(getLine, ymin, pos) + if !ok { + return cg, false + } + if cl, ok := braceMatchForward(getLine, ymax, op, pair); ok && cl.Y > op.Y { + cg.Start, cg.End = op.Y, cl.Y + break + } + pos = op + } + + cg.StartIndent, _ = visualIndent(getLine(cg.Start), tabsize) + cg.EndIndent, _ = visualIndent(getLine(cg.End), tabsize) + cg.finalize(getLine, cur.Y, tabsize) + return cg, true +} diff --git a/internal/buffer/chunk_test.go b/internal/buffer/chunk_test.go new file mode 100644 index 0000000000..f2124110e5 --- /dev/null +++ b/internal/buffer/chunk_test.go @@ -0,0 +1,132 @@ +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) + } + + // 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 TestFindBraceChunk(t *testing.T) { + // 0: func main() { + // 1: if x > + // 2: 0 { + // 3: a(b) + // 4: } + // 5: c( + // 6: d, + // 7: ) + // 8: } + getLine, n := linesOf("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 := findBraceChunk(getLine, n, c.cur, 8) + if ok != c.ok || ok && (cg.Start != c.start || cg.End != c.end || cg.GuideCol != c.gcol) { + t.Errorf("findBraceChunk(%v) = %+v,%v, want %+v", c.cur, cg, ok, c) + } + } + + // func chunk: bottom corner retargets off the col-0 `}` to the last + // code line + if cg, ok := findBraceChunk(getLine, n, Loc{0, 1}, 8); !ok || cg.Start != 0 || cg.End != 7 || cg.EndIndent != 8 { + t.Errorf("func chunk: got %+v,%v, want start 0 end 7 endIndent 8", cg, ok) + } + + // no enclosing pair at top level + getLine, n = linesOf("x := 1\ny := 2") + if _, ok := findBraceChunk(getLine, n, Loc{0, 1}, 8); ok { + t.Error("chunk reported at top level") + } + + // unclosed opener is not a chunk + getLine, n = linesOf("if x {\n\ta(") + if _, ok := findBraceChunk(getLine, n, Loc{3, 1}, 8); ok { + t.Error("unclosed chunk reported") + } + + // blank line inside a block still resolves (indent mode cannot) + getLine, n = linesOf("if x {\n\ta()\n\n\tb()\n}") + if cg, ok := findBraceChunk(getLine, n, Loc{0, 2}, 8); !ok || cg.Start != 0 || cg.End != 3 { + t.Errorf("blank line: got %+v,%v, want start 0 end 3", cg, ok) + } + + // boundaries beyond the scan cap + huge := func(i int) []byte { + if i == 0 { + return []byte("f(") + } + if i == 2*chunkScanLimit+2 { + return []byte(")") + } + return []byte("\tx,") + } + if _, ok := findBraceChunk(huge, 2*chunkScanLimit+3, Loc{0, chunkScanLimit + 1}, 8); ok { + t.Error("chunk beyond scan limit reported") + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index bb0a2263d5..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"}, @@ -68,6 +70,7 @@ var defaultCommonSettings = map[string]any{ "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 b5e4f7c018..7be8be1a51 100644 --- a/internal/display/bufwindow.go +++ b/internal/display/bufwindow.go @@ -482,7 +482,13 @@ func (w *BufWindow) displayBuffer() { hlchunk := b.Settings["hlchunk"].(bool) && w.active var chunk chunkGuide if hlchunk { - chunk, hlchunk = findChunk(b.LineBytes, b.LinesNum(), b.GetActiveCursor().Y, tabsize) + 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 { @@ -865,7 +871,7 @@ func (w *BufWindow) displayBuffer() { // 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.gcol { + if hlchunk && totalwidth <= chunk.GuideCol { if cr := chunkRuneAt(bloc.Y, i-w.gutterOffset+w.StartCol); cr != 0 { fillRune = cr curStyle = curStyle.Foreground(chunkFg) @@ -878,7 +884,7 @@ func (w *BufWindow) displayBuffer() { // 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.gcol { + if hlchunk && totalwidth <= chunk.GuideCol { if cr := chunkRuneAt(bloc.Y, totalwidth); cr != 0 { drawrune = cr drawstyle = drawstyle.Foreground(chunkFg) diff --git a/internal/display/chunkguide.go b/internal/display/chunkguide.go index fe8a6fae1b..bf1099ae3e 100644 --- a/internal/display/chunkguide.go +++ b/internal/display/chunkguide.go @@ -1,11 +1,10 @@ package display -// The hlchunk option highlights the indent chunk around the active cursor, -// in the spirit of hlchunk.nvim. 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. The guide is drawn one indent level left of the boundary indent: +// 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: │ @@ -17,7 +16,11 @@ package display // chunkAnimDuration, sweeping from the opening line's text around to the // closing line's text, like the original plugin. -import "time" +import ( + "time" + + "github.com/micro-editor/micro/v2/internal/buffer" +) const ( chunkCornerTop = '╭' @@ -32,131 +35,11 @@ const ( // 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 - - // findChunk runs on every redraw (every 16ms while animating), so - // boundary scans bail beyond this distance instead of walking a - // huge uniformly-indented file end to end - chunkScanLimit = 5000 ) +// chunkGuide adds guide geometry to a detected chunk type chunkGuide struct { - start, end int // boundary line numbers (corner rows) - startIndent, endIndent int // visual indent width of the boundary lines - gcol int // guide column, in visual columns -} - -// 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 -} - -// findChunk 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 chunkScanLimit lines away. -func findChunk(getLine func(int) []byte, nlines, cury, tabsize int) (chunkGuide, bool) { - var cg chunkGuide - - ymin := cury - chunkScanLimit - if ymin < 0 { - ymin = 0 - } - ymax := cury + chunkScanLimit - if ymax > nlines-1 { - ymax = nlines - 1 - } - - 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 (what treesitter gives hlchunk.nvim): - // 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 - } - - // a dedent straight to column zero has no whitespace to hold the - // bottom corner, leaving the bars dangling, so anchor the corner - // on the chunk's last code line instead (hlchunk's treesitter - // ranges end there too: such blocks have no closing token) - if cg.endIndent == 0 { - 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.gcol = cg.startIndent - if cg.endIndent < cg.gcol { - cg.gcol = cg.endIndent - } - cg.gcol -= tabsize - if cg.gcol < 0 { - cg.gcol = 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++ - } - } - return cg, true + buffer.Chunk } // chunkAnim animates a guide by revealing its cells over chunkAnimDuration @@ -186,12 +69,12 @@ func (a *chunkAnim) visible(cg chunkGuide) (int, bool) { // 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.gcol { - n += cg.startIndent - cg.gcol + n := cg.End - cg.Start - 1 + if cg.StartIndent > cg.GuideCol { + n += cg.StartIndent - cg.GuideCol } - if cg.endIndent > cg.gcol { - n += cg.endIndent - cg.gcol + if cg.EndIndent > cg.GuideCol { + n += cg.EndIndent - cg.GuideCol } return n } @@ -201,16 +84,16 @@ func (cg *chunkGuide) cells() int { // bars run downwards, the bottom corner rightwards to the arrow. func (cg *chunkGuide) cellIndex(y, vcol int) int { topLen := 0 - if cg.startIndent > cg.gcol { - topLen = cg.startIndent - cg.gcol + 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 + 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.gcol + return topLen + cg.End - cg.Start - 1 + vcol - cg.GuideCol } } @@ -219,24 +102,24 @@ func (cg *chunkGuide) cellIndex(y, vcol int) int { // 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.gcol { + case y > cg.Start && y < cg.End: + if vcol == cg.GuideCol { return chunkVertical } - case y == cg.start && cg.startIndent > cg.gcol: + case y == cg.Start && cg.StartIndent > cg.GuideCol: switch { - case vcol == cg.gcol: + case vcol == cg.GuideCol: return chunkCornerTop - case vcol > cg.gcol && vcol < cg.startIndent: + case vcol > cg.GuideCol && vcol < cg.StartIndent: return chunkHorizontal } - case y == cg.end && cg.endIndent > cg.gcol: + case y == cg.End && cg.EndIndent > cg.GuideCol: switch { - case vcol == cg.gcol: + case vcol == cg.GuideCol: return chunkCornerBot - case vcol == cg.endIndent-1: + case vcol == cg.EndIndent-1: return chunkArrow - case vcol > cg.gcol && vcol < cg.endIndent: + case vcol > cg.GuideCol && vcol < cg.EndIndent: return chunkHorizontal } } diff --git a/internal/display/chunkguide_test.go b/internal/display/chunkguide_test.go index 52dbe47564..61739ec15a 100644 --- a/internal/display/chunkguide_test.go +++ b/internal/display/chunkguide_test.go @@ -1,72 +1,20 @@ package display 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 TestFindChunk(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 := findChunk(getLine, n, c.cury, 8) - if ok != c.ok || ok && (cg.start != c.start || cg.end != c.end || cg.gcol != c.gcol) { - t.Errorf("findChunk(cury=%d) = %+v,%v, want %+v", c.cury, cg, ok, c) - } - } - - // missing lower boundary - getLine, n = linesOf("if x {\n\ta()") - if _, ok := findChunk(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 := findChunk(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 := findChunk(getLine, n, 1, 8); !ok || cg.start != 1 || cg.end != 2 || cg.gcol != 0 { - t.Errorf("header dedent: 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 := findChunk(huge, 2*chunkScanLimit+3, chunkScanLimit+1, 8); ok { - t.Error("chunk beyond scan limit reported") - } -} + "github.com/micro-editor/micro/v2/internal/buffer" +) func TestChunkGeometry(t *testing.T) { - // tabsize 4, space indent: guide on `if x:`(4) .. `return`(4), gcol 0 - getLine, n := linesOf("def f():\n if x:\n a()\n return") - cg, ok := findChunk(getLine, n, 2, 4) - if !ok || cg.start != 1 || cg.end != 3 || cg.gcol != 0 { - t.Fatalf("findChunk = %+v,%v, want start 1 end 3 gcol 0", cg, ok) - } + // 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 @@ -81,4 +29,17 @@ func TestChunkGeometry(t *testing.T) { 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/help/options.md b/runtime/help/options.md index 61b1959a01..bf5776a735 100644 --- a/runtime/help/options.md +++ b/runtime/help/options.md @@ -175,13 +175,24 @@ Here are the available options: default value: `hsplit` -* `hlchunk`: highlight the indent chunk containing the cursor, i.e. the lines - between the nearest lines above and below it with smaller indentation. - The guide is drawn in the leading whitespace and colored by the `hlchunk` - colorscheme group. +* `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`. + + 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 @@ -591,6 +602,7 @@ so that you can see what the formatting should look like. "ftoptions": true, "helpsplit": "hsplit", "hlchunk": false, + "hlchunkmode": "indent", "hlsearch": false, "hltaberrors": false, "hltrailingws": false, From 56536234f3329545ac5313ce90bcb7cd0816211c Mon Sep 17 00:00:00 2001 From: h8d13 Date: Sat, 4 Jul 2026 12:03:06 +0200 Subject: [PATCH 8/9] simplify Signed-off-by: h8d13 --- internal/buffer/chunk.go | 133 +++++++++++++--------------------- internal/buffer/chunk_test.go | 52 ++++++------- runtime/help/options.md | 4 +- 3 files changed, 81 insertions(+), 108 deletions(-) diff --git a/internal/buffer/chunk.go b/internal/buffer/chunk.go index 97b39725f2..c985201326 100644 --- a/internal/buffer/chunk.go +++ b/internal/buffer/chunk.go @@ -40,12 +40,43 @@ func (b *Buffer) IndentChunk(cury int) (Chunk, bool) { return findIndentChunk(b.LineBytes, b.LinesNum(), cury, tabsize) } -// BraceChunk locates the innermost multi-line bracket pair around cur. -// It reports false when no pair encloses the cursor within the scan -// limit. +// 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"]) - return findBraceChunk(b.LineBytes, b.LinesNum(), cur, 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 @@ -79,23 +110,10 @@ func chunkScanBounds(y, nlines int) (int, int) { } // finalize turns raw boundaries into a drawable guide: place the guide -// column one indent level left of the boundary indent and retarget -// corners that a column-zero boundary leaves with no whitespace to draw -// into. +// 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) { - // a boundary at column zero has no whitespace to hold the bottom - // corner, leaving the bars dangling, so anchor the corner on the - // chunk's last code line instead (such blocks read as ending there: - // the closing token, if any, sits at top level) - if cg.EndIndent == 0 { - 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.GuideCol = cg.StartIndent if cg.EndIndent < cg.GuideCol { cg.GuideCol = cg.EndIndent @@ -170,6 +188,19 @@ func findIndentChunk(getLine func(int) []byte, nlines, cury, tabsize int) (Chunk return cg, false } + // a dedent straight to column zero means the boundary is a sibling + // statement, not part of the chunk (unlike bracket mode, where the + // closer is the chunk's own last line), so anchor the corner on the + // chunk's last code line instead of dangling the bars over it + if cg.EndIndent == 0 { + 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 } @@ -207,31 +238,6 @@ func lastOpenBrace(line []rune) int { return open[len(open)-1] } -// braceMatchForward finds the closer matching the opener at start, -// scanning no further than line ymax -func braceMatchForward(getLine func(int) []byte, ymax int, start Loc, pair [2]rune) (Loc, bool) { - depth := 0 - for y := start.Y; y <= ymax; y++ { - l := []rune(string(getLine(y))) - x0 := 0 - if y == start.Y { - x0 = start.X - } - for x := x0; x < len(l); x++ { - switch l[x] { - case pair[0]: - depth++ - case pair[1]: - depth-- - if depth == 0 { - return Loc{x, y}, true - } - } - } - } - return start, false -} - // 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. @@ -257,40 +263,3 @@ func enclosingBrace(getLine func(int) []byte, ymin int, cur Loc) (Loc, [2]rune, } return cur, [2]rune{}, false } - -// findBraceChunk locates the innermost bracket pair around cur that -// spans more than one line. -func findBraceChunk(getLine func(int) []byte, nlines int, cur Loc, tabsize int) (Chunk, bool) { - var cg Chunk - ymin, ymax := chunkScanBounds(cur.Y, nlines) - - 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(getLine(cur.Y))) - if x := lastOpenBrace(line); x >= 0 { - pair, _ := braceDir(line[x]) - if cl, ok := braceMatchForward(getLine, ymax, Loc{x, cur.Y}, pair); 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(getLine, ymin, pos) - if !ok { - return cg, false - } - if cl, ok := braceMatchForward(getLine, ymax, op, pair); ok && cl.Y > op.Y { - cg.Start, cg.End = op.Y, cl.Y - break - } - pos = op - } - - cg.StartIndent, _ = visualIndent(getLine(cg.Start), tabsize) - cg.EndIndent, _ = visualIndent(getLine(cg.End), tabsize) - cg.finalize(getLine, cur.Y, tabsize) - return cg, true -} diff --git a/internal/buffer/chunk_test.go b/internal/buffer/chunk_test.go index f2124110e5..b20cb2f728 100644 --- a/internal/buffer/chunk_test.go +++ b/internal/buffer/chunk_test.go @@ -60,7 +60,13 @@ func TestFindIndentChunk(t *testing.T) { } } -func TestFindBraceChunk(t *testing.T) { +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 { @@ -70,7 +76,7 @@ func TestFindBraceChunk(t *testing.T) { // 6: d, // 7: ) // 8: } - getLine, n := linesOf("func main() {\n\tif x >\n\t\t0 {\n\t\ta(b)\n\t}\n\tc(\n\t\td,\n\t)\n}") + 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 @@ -86,47 +92,43 @@ func TestFindBraceChunk(t *testing.T) { // col-0 `}` retargets nothing here (endIndent 8 > 0) {Loc{4, 3}, 2, 4, 0, true}, } { - cg, ok := findBraceChunk(getLine, n, c.cur, 8) + 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("findBraceChunk(%v) = %+v,%v, want %+v", c.cur, cg, ok, c) + t.Errorf("BraceChunk(%v) = %+v,%v, want %+v", c.cur, cg, ok, c) } } - // func chunk: bottom corner retargets off the col-0 `}` to the last - // code line - if cg, ok := findBraceChunk(getLine, n, Loc{0, 1}, 8); !ok || cg.Start != 0 || cg.End != 7 || cg.EndIndent != 8 { - t.Errorf("func chunk: got %+v,%v, want start 0 end 7 endIndent 8", cg, ok) + // 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 - getLine, n = linesOf("x := 1\ny := 2") - if _, ok := findBraceChunk(getLine, n, Loc{0, 1}, 8); ok { + 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 - getLine, n = linesOf("if x {\n\ta(") - if _, ok := findBraceChunk(getLine, n, Loc{3, 1}, 8); ok { + 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) - getLine, n = linesOf("if x {\n\ta()\n\n\tb()\n}") - if cg, ok := findBraceChunk(getLine, n, Loc{0, 2}, 8); !ok || cg.Start != 0 || cg.End != 3 { - t.Errorf("blank line: got %+v,%v, want start 0 end 3", cg, ok) + 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 := func(i int) []byte { - if i == 0 { - return []byte("f(") - } - if i == 2*chunkScanLimit+2 { - return []byte(")") - } - return []byte("\tx,") - } - if _, ok := findBraceChunk(huge, 2*chunkScanLimit+3, Loc{0, chunkScanLimit + 1}, 8); ok { + 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/runtime/help/options.md b/runtime/help/options.md index bf5776a735..53eba6f775 100644 --- a/runtime/help/options.md +++ b/runtime/help/options.md @@ -189,7 +189,9 @@ Here are the available options: * `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`. + 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` From c42d1370ae38b828e8fea4b092de20401bc23833 Mon Sep 17 00:00:00 2001 From: h8d13 Date: Thu, 9 Jul 2026 14:57:25 +0200 Subject: [PATCH 9/9] fix indent mode Signed-off-by: h8d13 --- internal/buffer/chunk.go | 26 +++++++++++++++++++++----- internal/buffer/chunk_test.go | 7 +++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/internal/buffer/chunk.go b/internal/buffer/chunk.go index c985201326..ae96a68c37 100644 --- a/internal/buffer/chunk.go +++ b/internal/buffer/chunk.go @@ -188,11 +188,13 @@ func findIndentChunk(getLine func(int) []byte, nlines, cury, tabsize int) (Chunk return cg, false } - // a dedent straight to column zero means the boundary is a sibling - // statement, not part of the chunk (unlike bracket mode, where the - // closer is the chunk's own last line), so anchor the corner on the - // chunk's last code line instead of dangling the bars over it - if cg.EndIndent == 0 { + // 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 @@ -205,6 +207,20 @@ func findIndentChunk(getLine func(int) []byte, nlines, cury, tabsize int) (Chunk 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) { diff --git a/internal/buffer/chunk_test.go b/internal/buffer/chunk_test.go index b20cb2f728..1c0a758083 100644 --- a/internal/buffer/chunk_test.go +++ b/internal/buffer/chunk_test.go @@ -48,6 +48,13 @@ func TestFindIndentChunk(t *testing.T) { 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 {