From feb64ae2091357601a74628f20a54c716ed94ef4 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:45:04 -0700 Subject: [PATCH] fix: keep moved-line selections in view MoveLinesUp applies its own newline-boundary compensation to the selection after the buffer edit event updates coordinates. When the cursor sits on the compensated endpoint, Cursor.Loc is left one line away from the endpoint it should track, so Relocate scrolls to a stale position and the moved selection can end up above the viewport. Record which selection endpoint the cursor is on before the move and restore Cursor.Loc to that endpoint after compensation, in both MoveLinesUp and MoveLinesDown. Fixes #4134 --- internal/action/actions.go | 18 +++ internal/action/movelines_test.go | 221 ++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 internal/action/movelines_test.go diff --git a/internal/action/actions.go b/internal/action/actions.go index 491199f76f..4567abe01f 100644 --- a/internal/action/actions.go +++ b/internal/action/actions.go @@ -1509,6 +1509,12 @@ func (h *BufPane) MoveLinesUp() bool { InfoBar.Message("Cannot move further up") return false } + active := -1 + if h.Cursor.Loc == h.Cursor.CurSelection[0] { + active = 0 + } else if h.Cursor.Loc == h.Cursor.CurSelection[1] { + active = 1 + } start := h.Cursor.CurSelection[0].Y end := h.Cursor.CurSelection[1].Y sel := 1 @@ -1531,6 +1537,9 @@ func (h *BufPane) MoveLinesUp() bool { if compensate { h.Cursor.CurSelection[sel].Y -= 1 } + if active >= 0 { + h.Cursor.Loc = h.Cursor.CurSelection[active] + } } else { if h.Cursor.Loc.Y == 0 { InfoBar.Message("Cannot move further up") @@ -1553,6 +1562,12 @@ func (h *BufPane) MoveLinesDown() bool { InfoBar.Message("Cannot move further down") return false } + active := -1 + if h.Cursor.Loc == h.Cursor.CurSelection[0] { + active = 0 + } else if h.Cursor.Loc == h.Cursor.CurSelection[1] { + active = 1 + } start := h.Cursor.CurSelection[0].Y end := h.Cursor.CurSelection[1].Y sel := 1 @@ -1569,6 +1584,9 @@ func (h *BufPane) MoveLinesDown() bool { start, end, ) + if active >= 0 { + h.Cursor.Loc = h.Cursor.CurSelection[active] + } } else { if h.Cursor.Loc.Y >= h.Buf.LinesNum()-1 { InfoBar.Message("Cannot move further down") diff --git a/internal/action/movelines_test.go b/internal/action/movelines_test.go new file mode 100644 index 0000000000..88b0425a79 --- /dev/null +++ b/internal/action/movelines_test.go @@ -0,0 +1,221 @@ +package action + +import ( + "fmt" + "strings" + "testing" + + "github.com/micro-editor/micro/v2/internal/buffer" + "github.com/micro-editor/micro/v2/internal/config" + "github.com/micro-editor/micro/v2/internal/display" + "github.com/micro-editor/micro/v2/internal/info" + ulua "github.com/micro-editor/micro/v2/internal/lua" + lua "github.com/yuin/gopher-lua" +) + +func init() { + ulua.L = lua.NewState() + config.InitRuntimeFiles(false) + config.InitGlobalSettings() + config.GlobalSettings["backup"] = false + config.GlobalSettings["fastdirty"] = true +} + +func newMoveLinesPane(t *testing.T, nlines, viewHeight int) *BufPane { + t.Helper() + + lines := make([]string, nlines) + for i := range lines { + lines[i] = fmt.Sprintf("line %d", i) + } + buf := buffer.NewBufferFromString(strings.Join(lines, "\n"), "", buffer.BTDefault) + win := display.NewBufWindow(0, 0, 20, viewHeight+1, buf) + win.Resize(20, viewHeight+1) + return newBufPane(buf, win, nil) +} + +func selectTo(pane *BufPane, anchor, active buffer.Loc) { + pane.Cursor.Loc = anchor + pane.Cursor.OrigSelection[0] = anchor + pane.Cursor.Loc = active + pane.Cursor.SelectTo(active) +} + +func assertCursorAtActiveEndpoint(t *testing.T, pane *BufPane, endpoint int) { + t.Helper() + + if pane.Cursor.Loc != pane.Cursor.CurSelection[endpoint] { + t.Fatalf("cursor = %v, active selection endpoint = %v", pane.Cursor.Loc, pane.Cursor.CurSelection[endpoint]) + } +} + +func assertCursorVisible(t *testing.T, pane *BufPane) { + t.Helper() + + cursor := pane.SLocFromLoc(pane.Cursor.Loc) + viewStart := pane.GetView().StartLine + viewEnd := pane.Scroll(viewStart, pane.BufView().Height-1) + if cursor.LessThan(viewStart) || cursor.GreaterThan(viewEnd) { + t.Fatalf("cursor %v is outside view %v through %v", cursor, viewStart, viewEnd) + } +} + +func assertLines(t *testing.T, pane *BufPane, want []int) { + t.Helper() + + for y, line := range want { + got := string(pane.Buf.LineBytes(y)) + want := fmt.Sprintf("line %d", line) + if got != want { + t.Fatalf("line %d = %q, want %q", y, got, want) + } + } +} + +func TestMoveLinesUpFollowsSelection(t *testing.T) { + tests := []struct { + name string + anchor buffer.Loc + active buffer.Loc + activeEndpoint int + moves int + wantSelection [2]buffer.Loc + }{ + { + name: "top-down selection ending at column zero", + anchor: buffer.Loc{X: 0, Y: 6}, + active: buffer.Loc{X: 0, Y: 9}, + activeEndpoint: 1, + moves: 4, + wantSelection: [2]buffer.Loc{{X: 0, Y: 2}, {X: 0, Y: 5}}, + }, + { + name: "bottom-up selection ending within a line", + anchor: buffer.Loc{X: 2, Y: 8}, + active: buffer.Loc{X: 0, Y: 6}, + activeEndpoint: 0, + moves: 4, + wantSelection: [2]buffer.Loc{{X: 0, Y: 2}, {X: 2, Y: 4}}, + }, + { + name: "top-down selection ending within a line", + anchor: buffer.Loc{X: 0, Y: 6}, + active: buffer.Loc{X: 2, Y: 8}, + activeEndpoint: 1, + moves: 4, + wantSelection: [2]buffer.Loc{{X: 0, Y: 2}, {X: 2, Y: 4}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pane := newMoveLinesPane(t, 12, 4) + selectTo(pane, tt.anchor, tt.active) + pane.GetView().StartLine = display.SLoc{Line: 6} + + for i := 0; i < tt.moves; i++ { + if !pane.MoveLinesUp() { + t.Fatalf("move %d failed", i+1) + } + assertCursorAtActiveEndpoint(t, pane, tt.activeEndpoint) + assertCursorVisible(t, pane) + } + + if pane.Cursor.CurSelection != tt.wantSelection { + t.Fatalf("selection = %v, want %v", pane.Cursor.CurSelection, tt.wantSelection) + } + assertLines(t, pane, []int{0, 1, 6, 7, 8, 2, 3, 4, 5, 9, 10, 11}) + }) + } +} + +func TestMoveLinesDownFollowsSelection(t *testing.T) { + pane := newMoveLinesPane(t, 12, 4) + selectTo(pane, buffer.Loc{X: 0, Y: 2}, buffer.Loc{X: 0, Y: 5}) + pane.GetView().StartLine = display.SLoc{Line: 2} + + for i := 0; i < 4; i++ { + if !pane.MoveLinesDown() { + t.Fatalf("move %d failed", i+1) + } + assertCursorAtActiveEndpoint(t, pane, 1) + assertCursorVisible(t, pane) + } + + wantSelection := [2]buffer.Loc{{X: 0, Y: 6}, {X: 0, Y: 9}} + if pane.Cursor.CurSelection != wantSelection { + t.Fatalf("selection = %v, want %v", pane.Cursor.CurSelection, wantSelection) + } + assertLines(t, pane, []int{0, 1, 5, 6, 7, 8, 2, 3, 4, 9, 10, 11}) +} + +func TestMoveLinesPreservesCursorInsideSelection(t *testing.T) { + tests := []struct { + name string + move func(*BufPane) bool + wantCursor buffer.Loc + }{ + { + name: "up", + move: (*BufPane).MoveLinesUp, + wantCursor: buffer.Loc{X: 6, Y: 3}, + }, + { + name: "down", + move: (*BufPane).MoveLinesDown, + wantCursor: buffer.Loc{X: 6, Y: 5}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pane := newMoveLinesPane(t, 8, 4) + pane.Cursor.Loc = buffer.Loc{X: 6, Y: 4} + pane.Cursor.SelectLine() + + if !tt.move(pane) { + t.Fatal("move failed") + } + if pane.Cursor.Loc != tt.wantCursor { + t.Fatalf("cursor = %v, want %v", pane.Cursor.Loc, tt.wantCursor) + } + }) + } +} + +func TestMoveLinesBoundaries(t *testing.T) { + oldInfoBar := InfoBar + InfoBar = &InfoPane{InfoBuf: &info.InfoBuf{}} + t.Cleanup(func() { + InfoBar = oldInfoBar + }) + + pane := newMoveLinesPane(t, 4, 3) + if pane.MoveLinesUp() { + t.Fatal("MoveLinesUp succeeded on the first line") + } + if InfoBar.Msg != "Cannot move further up" { + t.Fatalf("message = %q, want %q", InfoBar.Msg, "Cannot move further up") + } + + pane.Cursor.Loc = pane.Buf.End() + if pane.MoveLinesDown() { + t.Fatal("MoveLinesDown succeeded on the last line") + } + if InfoBar.Msg != "Cannot move further down" { + t.Fatalf("message = %q, want %q", InfoBar.Msg, "Cannot move further down") + } +} + +func TestMoveLinesUpTallSelectionFollowsActiveEndpoint(t *testing.T) { + pane := newMoveLinesPane(t, 10, 4) + selectTo(pane, buffer.Loc{X: 0, Y: 2}, buffer.Loc{X: 0, Y: 7}) + pane.GetView().StartLine = display.SLoc{Line: 5} + + if !pane.MoveLinesUp() { + t.Fatal("MoveLinesUp failed") + } + + assertCursorAtActiveEndpoint(t, pane, 1) + assertCursorVisible(t, pane) +}