From aab59cf60c0dc7c531a4b80a09267476f59cb289 Mon Sep 17 00:00:00 2001 From: Massimo Mund Date: Wed, 6 Aug 2025 11:07:39 +0200 Subject: [PATCH 1/4] Do not recalculate locations, just store them. The code in `DoTextEvent` is quite hard to understand. Why do we clamp an existing event here? Because we recalculate the end location which should have been assigned before in `ExecuteTextEvent`. The end locations for all `TextEventInsert` are incorrectly being stored in the undo stack and are hot-fixed while being processed. This also fixes potential issues with `TextEventReplace` in regards to multi-line strings. --- internal/buffer/buffer.go | 9 +++++---- internal/buffer/eventhandler.go | 17 +++++------------ internal/buffer/line_array.go | 5 +++-- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/internal/buffer/buffer.go b/internal/buffer/buffer.go index 5280a71f24..bbe489ca67 100644 --- a/internal/buffer/buffer.go +++ b/internal/buffer/buffer.go @@ -125,14 +125,15 @@ type SharedBuffer struct { origHash [md5.Size]byte } -func (b *SharedBuffer) insert(pos Loc, value []byte) { +func (b *SharedBuffer) insert(pos Loc, value []byte) Loc { b.isModified = true b.HasSuggestions = false - b.LineArray.insert(pos, value) + endPos := b.LineArray.insert(pos, value) - inslines := bytes.Count(value, []byte{'\n'}) - b.MarkModified(pos.Y, pos.Y+inslines) + b.MarkModified(pos.Y, endPos.Y) + return endPos } + func (b *SharedBuffer) remove(start, end Loc) []byte { b.isModified = true b.HasSuggestions = false diff --git a/internal/buffer/eventhandler.go b/internal/buffer/eventhandler.go index e739f25011..52523b19bb 100644 --- a/internal/buffer/eventhandler.go +++ b/internal/buffer/eventhandler.go @@ -43,7 +43,6 @@ type Delta struct { // DoTextEvent runs a text event func (eh *EventHandler) DoTextEvent(t *TextEvent, useUndo bool) { - oldl := eh.buf.LinesNum() if useUndo { eh.Execute(t) @@ -57,23 +56,18 @@ func (eh *EventHandler) DoTextEvent(t *TextEvent, useUndo bool) { text := t.Deltas[0].Text start := t.Deltas[0].Start + end := t.Deltas[0].End lastnl := -1 - var endX int var textX int if t.EventType == TextEventInsert { - linecount := eh.buf.LinesNum() - oldl textcount := util.CharacterCount(text) lastnl = bytes.LastIndex(text, []byte{'\n'}) if lastnl >= 0 { - endX = util.CharacterCount(text[lastnl+1:]) - textX = endX + textX = util.CharacterCount(text[lastnl+1:]) } else { - endX = start.X + textcount textX = textcount } - t.Deltas[0].End = clamp(Loc{endX, start.Y + linecount}, eh.buf.LineArray) } - end := t.Deltas[0].End for _, c := range eh.cursors { move := func(loc Loc) Loc { @@ -115,8 +109,8 @@ func (eh *EventHandler) DoTextEvent(t *TextEvent, useUndo bool) { // ExecuteTextEvent runs a text event func ExecuteTextEvent(t *TextEvent, buf *SharedBuffer) { if t.EventType == TextEventInsert { - for _, d := range t.Deltas { - buf.insert(d.Start, d.Text) + for i, d := range t.Deltas { + t.Deltas[i].End = buf.insert(d.Start, d.Text) } } else if t.EventType == TextEventRemove { for i, d := range t.Deltas { @@ -125,9 +119,8 @@ func ExecuteTextEvent(t *TextEvent, buf *SharedBuffer) { } else if t.EventType == TextEventReplace { for i, d := range t.Deltas { t.Deltas[i].Text = buf.remove(d.Start, d.End) - buf.insert(d.Start, d.Text) t.Deltas[i].Start = d.Start - t.Deltas[i].End = Loc{d.Start.X + util.CharacterCount(d.Text), d.Start.Y} + t.Deltas[i].End = buf.insert(d.Start, d.Text) } for i, j := 0, len(t.Deltas)-1; i < j; i, j = i+1, j-1 { t.Deltas[i], t.Deltas[j] = t.Deltas[j], t.Deltas[i] diff --git a/internal/buffer/line_array.go b/internal/buffer/line_array.go index b65213b805..c4e3ceb13b 100644 --- a/internal/buffer/line_array.go +++ b/internal/buffer/line_array.go @@ -200,8 +200,8 @@ func (la *LineArray) newlineBelow(y int) { } } -// Inserts a byte array at a given location -func (la *LineArray) insert(pos Loc, value []byte) { +// Inserts a byte array at a given location and returns the location where the insertion ends +func (la *LineArray) insert(pos Loc, value []byte) Loc { la.lock.Lock() defer la.lock.Unlock() @@ -221,6 +221,7 @@ func (la *LineArray) insert(pos Loc, value []byte) { la.insertByte(Loc{x, y}, value[i]) x++ } + return Loc{x, y} } // InsertByte inserts a byte at a given location From 5fcc09ca3c763cb33ca92141d58bda537e80cc45 Mon Sep 17 00:00:00 2001 From: Massimo Mund Date: Wed, 6 Aug 2025 11:22:40 +0200 Subject: [PATCH 2/4] Refactor `DoTextEvent` to use `util.GetTextLengthAfterLastLinebreak` Getting the remaining characters after line breaks is a common task when dealing with TextEvents. So make it a utility function and expose it to Lua. This is very useful when processing these events in Lua plugins. --- cmd/micro/initlua.go | 1 + internal/buffer/eventhandler.go | 12 +++--------- internal/util/util.go | 12 ++++++++++++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/cmd/micro/initlua.go b/cmd/micro/initlua.go index 7eac563763..513007c031 100644 --- a/cmd/micro/initlua.go +++ b/cmd/micro/initlua.go @@ -157,6 +157,7 @@ func luaImportMicroUtil() *lua.LTable { ulua.L.SetField(pkg, "SemVersion", luar.New(ulua.L, util.SemVersion)) ulua.L.SetField(pkg, "HttpRequest", luar.New(ulua.L, util.HttpRequest)) ulua.L.SetField(pkg, "CharacterCountInString", luar.New(ulua.L, util.CharacterCountInString)) + ulua.L.SetField(pkg, "GetTextLengthAfterLastLinebreak", luar.New(ulua.L, util.GetTextLengthAfterLastLinebreak)) ulua.L.SetField(pkg, "RuneStr", luar.New(ulua.L, func(r rune) string { return string(r) })) diff --git a/internal/buffer/eventhandler.go b/internal/buffer/eventhandler.go index 52523b19bb..45746952ab 100644 --- a/internal/buffer/eventhandler.go +++ b/internal/buffer/eventhandler.go @@ -57,16 +57,10 @@ func (eh *EventHandler) DoTextEvent(t *TextEvent, useUndo bool) { text := t.Deltas[0].Text start := t.Deltas[0].Start end := t.Deltas[0].End - lastnl := -1 var textX int + var isMultiLine bool if t.EventType == TextEventInsert { - textcount := util.CharacterCount(text) - lastnl = bytes.LastIndex(text, []byte{'\n'}) - if lastnl >= 0 { - textX = util.CharacterCount(text[lastnl+1:]) - } else { - textX = textcount - } + textX, isMultiLine = util.GetTextLengthAfterLastLinebreak(text) } for _, c := range eh.cursors { @@ -76,7 +70,7 @@ func (eh *EventHandler) DoTextEvent(t *TextEvent, useUndo bool) { loc.Y += end.Y - start.Y } else if loc.Y == start.Y && loc.GreaterEqual(start) { loc.Y += end.Y - start.Y - if lastnl >= 0 { + if isMultiLine { loc.X += textX - start.X } else { loc.X += textX diff --git a/internal/util/util.go b/internal/util/util.go index f2cb2a99d9..7370c97d9c 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -521,6 +521,18 @@ func HasTrailingWhitespace(b []byte) bool { return IsWhitespace(r) } +// GetTextLengthAfterLastLinebreak returns the length of the remaining +// characters after the last line break in the given byte array and whether it +// contains line breaks. +// If no line breaks were found, it returns the length of all characters in the byte array. +func GetTextLengthAfterLastLinebreak(text []byte) (int, bool) { + lastnl := bytes.LastIndex(text, []byte{'\n'}) + if lastnl >= 0 { + return CharacterCount(text[lastnl+1:]), true + } + return CharacterCount(text), false +} + // IntOpt turns a float64 setting to an int func IntOpt(opt interface{}) int { return int(opt.(float64)) From 66e8aef0fe92032191d893f211735c699a7fc2e1 Mon Sep 17 00:00:00 2001 From: Massimo Mund Date: Wed, 6 Aug 2025 12:36:20 +0200 Subject: [PATCH 3/4] Emit `TextEventReplace` instead of two consecutive events (`TextEventRemove`, `TextEventInsert`) When pasting from the clipboard with multiple cursors, cursors that are directly next to each other are filtered out. The reason for this is that `EventHandler.Replace` triggers two separate events to mimic the replacement. These two events are processed sequentially in `DoTextEvent`, which means that these two consecutive cursors have the same position after `TextEventRemove`. The second event `TextEventInsert`, can no longer distinguish between them, and Buffer.MergeCursors filters out one cursor later. --- internal/action/actions.go | 6 +-- internal/action/bufpane.go | 14 ++++-- internal/buffer/buffer.go | 11 +++++ internal/buffer/eventhandler.go | 81 +++++++++++++++++++++------------ 4 files changed, 76 insertions(+), 36 deletions(-) diff --git a/internal/action/actions.go b/internal/action/actions.go index 94fe3cf769..43b39c1c2b 100644 --- a/internal/action/actions.go +++ b/internal/action/actions.go @@ -1618,11 +1618,11 @@ func (h *BufPane) paste(clip string) { } if h.Cursor.HasSelection() { - h.Cursor.DeleteSelection() - h.Cursor.ResetSelection() + h.Buf.Replace(h.Cursor.CurSelection[0], h.Cursor.CurSelection[1], clip) + } else { + h.Buf.Insert(h.Cursor.Loc, clip) } - h.Buf.Insert(h.Cursor.Loc, clip) // h.Cursor.Loc = h.Cursor.Loc.Move(Count(clip), h.Buf) h.freshClip = false InfoBar.Message("Pasted clipboard") diff --git a/internal/action/bufpane.go b/internal/action/bufpane.go index b4030a956f..89aea39802 100644 --- a/internal/action/bufpane.go +++ b/internal/action/bufpane.go @@ -627,17 +627,21 @@ func (h *BufPane) DoRuneInsert(r rune) { if !h.PluginCB("preRune", string(r)) { continue } - if c.HasSelection() { - c.DeleteSelection() - c.ResetSelection() - } if h.Buf.OverwriteMode { + if c.HasSelection() { + c.DeleteSelection() + c.ResetSelection() + } next := c.Loc next.X++ h.Buf.Replace(c.Loc, next, string(r)) } else { - h.Buf.Insert(c.Loc, string(r)) + if c.HasSelection() { + h.Buf.Replace(c.CurSelection[0], c.CurSelection[1], string(r)) + } else { + h.Buf.Insert(c.Loc, string(r)) + } } if recordingMacro { curmacro = append(curmacro, r) diff --git a/internal/buffer/buffer.go b/internal/buffer/buffer.go index bbe489ca67..4fd285f293 100644 --- a/internal/buffer/buffer.go +++ b/internal/buffer/buffer.go @@ -511,6 +511,17 @@ func (b *Buffer) Remove(start, end Loc) { } } +// Replace replaces the characters between the start and end locations with the given text +func (b *Buffer) Replace(start, end Loc, text string) { + if !b.Type.Readonly { + b.EventHandler.cursors = b.cursors + b.EventHandler.active = b.curCursor + b.EventHandler.Replace(start, end, text) + + b.RequestBackup() + } +} + // FileType returns the buffer's filetype func (b *Buffer) FileType() string { return b.Settings["filetype"].(string) diff --git a/internal/buffer/eventhandler.go b/internal/buffer/eventhandler.go index 45746952ab..3d0d7dec2f 100644 --- a/internal/buffer/eventhandler.go +++ b/internal/buffer/eventhandler.go @@ -43,6 +43,8 @@ type Delta struct { // DoTextEvent runs a text event func (eh *EventHandler) DoTextEvent(t *TextEvent, useUndo bool) { + oldend := t.Deltas[0].End + oldtext := t.Deltas[0].Text if useUndo { eh.Execute(t) @@ -54,6 +56,29 @@ func (eh *EventHandler) DoTextEvent(t *TextEvent, useUndo bool) { return } + moveCursorInsert := func(loc, start, end Loc, textX int, isMultiLine bool) Loc { + if start.Y != loc.Y && loc.GreaterThan(start) { + loc.Y += end.Y - start.Y + } else if loc.Y == start.Y && loc.GreaterEqual(start) { + loc.Y += end.Y - start.Y + if isMultiLine { + loc.X += textX - start.X + } else { + loc.X += textX + } + } + return loc + } + + moveCursorRemove := func(loc, start, end Loc) Loc { + if loc.Y != end.Y && loc.GreaterThan(end) { + loc.Y -= end.Y - start.Y + } else if loc.Y == end.Y && loc.GreaterEqual(end) { + loc = loc.MoveLA(-DiffLA(start, end, eh.buf.LineArray), eh.buf.LineArray) + } + return loc + } + text := t.Deltas[0].Text start := t.Deltas[0].Start end := t.Deltas[0].End @@ -61,36 +86,30 @@ func (eh *EventHandler) DoTextEvent(t *TextEvent, useUndo bool) { var isMultiLine bool if t.EventType == TextEventInsert { textX, isMultiLine = util.GetTextLengthAfterLastLinebreak(text) + } else if t.EventType == TextEventReplace { + textX, isMultiLine = util.GetTextLengthAfterLastLinebreak(oldtext) + } + + moveCursor := func(loc Loc) Loc { + if t.EventType == TextEventInsert { + return moveCursorInsert(loc, start, end, textX, isMultiLine) + } else if t.EventType == TextEventRemove { + return moveCursorRemove(loc, start, end) + } else { + loc = moveCursorRemove(loc, start, oldend) + return moveCursorInsert(loc, start, end, textX, isMultiLine) + } } for _, c := range eh.cursors { - move := func(loc Loc) Loc { - if t.EventType == TextEventInsert { - if start.Y != loc.Y && loc.GreaterThan(start) { - loc.Y += end.Y - start.Y - } else if loc.Y == start.Y && loc.GreaterEqual(start) { - loc.Y += end.Y - start.Y - if isMultiLine { - loc.X += textX - start.X - } else { - loc.X += textX - } - } - return loc - } else { - if loc.Y != end.Y && loc.GreaterThan(end) { - loc.Y -= end.Y - start.Y - } else if loc.Y == end.Y && loc.GreaterEqual(end) { - loc = loc.MoveLA(-DiffLA(start, end, eh.buf.LineArray), eh.buf.LineArray) - } - return loc - } + if c.Num < t.C.Num { + continue } - c.Loc = move(c.Loc) - c.CurSelection[0] = move(c.CurSelection[0]) - c.CurSelection[1] = move(c.CurSelection[1]) - c.OrigSelection[0] = move(c.OrigSelection[0]) - c.OrigSelection[1] = move(c.OrigSelection[1]) + c.Loc = moveCursor(c.Loc) + c.CurSelection[0] = moveCursor(c.CurSelection[0]) + c.CurSelection[1] = moveCursor(c.CurSelection[1]) + c.OrigSelection[0] = moveCursor(c.OrigSelection[0]) + c.OrigSelection[1] = moveCursor(c.OrigSelection[1]) c.Relocate() c.StoreVisualX() } @@ -217,8 +236,14 @@ func (eh *EventHandler) MultipleReplace(deltas []Delta) { // Replace deletes from start to end and replaces it with the given string func (eh *EventHandler) Replace(start, end Loc, replace string) { - eh.Remove(start, end) - eh.Insert(start, replace) + text := []byte(replace) + e := &TextEvent{ + C: *eh.cursors[eh.active], + EventType: TextEventReplace, + Deltas: []Delta{{text, start, end}}, + Time: time.Now(), + } + eh.DoTextEvent(e, true) } // Execute a textevent and add it to the undo stack From 8b3250873fd49199954fd0eda32751683a2ad147 Mon Sep 17 00:00:00 2001 From: Massimo Mund Date: Wed, 6 Aug 2025 13:02:38 +0200 Subject: [PATCH 4/4] Always stop `Undo()`, `Redo()` after `TextEventReplace` After replacing text, it is (imho) helpful to undo/redo changes individually. With a single cursor, nothing changes, but with multiple cursors, this gives the user a greater sense of control. --- internal/buffer/eventhandler.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/buffer/eventhandler.go b/internal/buffer/eventhandler.go index 3d0d7dec2f..20ef1625af 100644 --- a/internal/buffer/eventhandler.go +++ b/internal/buffer/eventhandler.go @@ -286,6 +286,11 @@ func (eh *EventHandler) Undo() bool { } eh.UndoOneEvent() + + t = eh.UndoStack.Peek() + if t == nil || t.EventType == TextEventReplace { + break + } } return true } @@ -333,6 +338,11 @@ func (eh *EventHandler) Redo() bool { } eh.RedoOneEvent() + + t = eh.UndoStack.Peek() + if t == nil || t.EventType == TextEventReplace { + break + } } return true }