From 137dc553dc5212abdfffe9ea6fe7889cb9e4c006 Mon Sep 17 00:00:00 2001 From: qiannian <40210590@qq.com> Date: Sun, 19 Jul 2026 12:09:04 +0800 Subject: [PATCH 1/5] perf: improve large document deletion performance --- event.go | 19 ++- event_test.go | 65 +++++++++ internal/layout/text_layout.go | 211 +++++++++++++++++++++------- internal/layout/text_layout_test.go | 74 +++++++++- 4 files changed, 313 insertions(+), 56 deletions(-) diff --git a/event.go b/event.go index 0d85547..0f7a109 100644 --- a/event.go +++ b/event.go @@ -41,11 +41,10 @@ func (e *Editor) processEvents(gtx layout.Context) (ev EditorEvent, ok bool) { } } - switch ev.(type) { - case ChangeEvent: + if isChangeEvent(ev) { e.wordHighlighter.MarkActive(false) e.updateCompletor() - case SelectEvent: + } else if _, ok := ev.(SelectEvent); ok { e.updateCompletor() } }() @@ -215,6 +214,11 @@ func (e *Editor) processKey(gtx layout.Context) (EditorEvent, bool) { } if evt := e.processCommands(gtx); evt != nil { + // The command already reported this change; consume the buffer flag so + // the next Update call does not report it again. + if isChangeEvent(evt) { + e.text.Changed() + } return evt, true } @@ -225,6 +229,15 @@ func (e *Editor) processKey(gtx layout.Context) (EditorEvent, bool) { return nil, false } +func isChangeEvent(evt EditorEvent) bool { + switch evt.(type) { + case ChangeEvent, *ChangeEvent: + return true + default: + return false + } +} + func (e *Editor) processEditEvents(gtx layout.Context) EditorEvent { filters := []event.Filter{ key.FocusFilter{Target: e}, diff --git a/event_test.go b/event_test.go index e92bfac..4076b40 100644 --- a/event_test.go +++ b/event_test.go @@ -1,14 +1,79 @@ package gvcode import ( + "image" + "strings" "testing" + "gioui.org/io/input" + "gioui.org/io/key" "gioui.org/layout" + "gioui.org/op" "gioui.org/text" "gioui.org/unit" "github.com/oligo/gvcode/textview" ) +func BenchmarkDeleteBackwardLargeDocument(b *testing.B) { + doc := strings.Repeat("func main() { println(\"hello\") }\n", 5000) + editor := &Editor{} + editor.WithOptions(WithTextSize(unit.Sp(14))) + editor.SetText(doc) + gtx := layout.Context{Constraints: layout.Exact(image.Pt(1200, 800))} + editor.text.Layout(gtx, text.NewShaper()) + editor.SetCaret(editor.Len(), editor.Len()) + + b.ResetTimer() + for range b.N { + editor.Delete(-1) + } +} + +func TestDeleteBackwardEmitsOneChangeEvent(t *testing.T) { + editor := &Editor{} + editor.SetText("abc") + editor.SetCaret(editor.Len(), editor.Len()) + + router := new(input.Router) + gtx := layout.Context{Ops: new(op.Ops), Source: router.Source()} + editor.Update(gtx) + router.Frame(new(op.Ops)) + + router.Source().Execute(key.FocusCmd{Tag: editor}) + router.Queue(key.Event{Name: key.NameDeleteBackward, State: key.Press}) + + gtx.Source = router.Source() + changes := 0 + for { + evt, ok := editor.Update(gtx) + if !ok { + break + } + if _, ok := evt.(ChangeEvent); ok { + changes++ + } + } + + if changes != 1 { + t.Fatalf("DeleteBackward emitted %d ChangeEvents, want 1", changes) + } + if got := editor.Len(); got != 2 { + t.Fatalf("DeleteBackward left %d runes, want 2", got) + } +} + +func TestIsChangeEventAcceptsValueAndPointer(t *testing.T) { + if !isChangeEvent(ChangeEvent{}) { + t.Fatal("value ChangeEvent was not recognized") + } + if !isChangeEvent(&ChangeEvent{}) { + t.Fatal("pointer ChangeEvent was not recognized") + } + if isChangeEvent(SelectEvent{}) { + t.Fatal("SelectEvent was recognized as a ChangeEvent") + } +} + func TestOnDeleteBackward_Indentation(t *testing.T) { setup := func(input string, cursorPos, tabWidth int) *Editor { vw := textview.NewTextView() diff --git a/internal/layout/text_layout.go b/internal/layout/text_layout.go index 9f0e900..06f8d53 100644 --- a/internal/layout/text_layout.go +++ b/internal/layout/text_layout.go @@ -6,6 +6,7 @@ import ( "image" "io" "math" + "slices" "sort" "strings" @@ -16,6 +17,14 @@ import ( "golang.org/x/image/math/fixed" ) +type paragraphLayout struct { + text string + last bool + runes int + graphemes []int + lines []Line +} + type TextLayout struct { src buffer.TextSource reader *bufio.Reader @@ -23,6 +32,13 @@ type TextLayout struct { spaceGlyph text.Glyph wrapper lineWrapper seg segmenter.Segmenter + shaper *text.Shaper + glyphCache map[string][]text.Glyph + nextCache map[string][]text.Glyph + paragraphs []paragraphLayout + nextParas []paragraphLayout + tabWidth int + wrapLine bool // Positions contain all possible caret positions, sorted by rune index. Positions []CombinedPos @@ -41,8 +57,10 @@ type TextLayout struct { func NewTextLayout(src buffer.TextSource) TextLayout { return TextLayout{ - src: src, - reader: bufio.NewReader(buffer.NewReader(src)), + src: src, + reader: bufio.NewReader(buffer.NewReader(src)), + glyphCache: make(map[string][]text.Glyph), + nextCache: make(map[string][]text.Glyph), } } @@ -74,76 +92,152 @@ func (tl *TextLayout) reset() { } func (tl *TextLayout) Layout(shaper *text.Shaper, params *text.Parameters, tabWidth int, wrapLine bool) layout.Dimensions { + cacheValid := tl.shaper == shaper && tl.params == *params && tl.tabWidth == tabWidth && tl.wrapLine == wrapLine + if !cacheValid { + clear(tl.glyphCache) + tl.paragraphs = tl.paragraphs[:0] + } + tl.shaper = shaper + tl.tabWidth = tabWidth + tl.wrapLine = wrapLine + clear(tl.nextCache) + tl.nextParas = tl.nextParas[:0] + oldPositions := tl.Positions + oldParagraphs := tl.Paragraphs tl.reset() tl.params = *params - paragraphCount := tl.src.Lines() if shaper == nil { tl.fakeLayout() } else { tl.spaceGlyph, _ = tl.shapeRune(shaper, tl.params, '\u0020') - if paragraphCount > 0 { + cleanPrefix := cacheValid + cleanParagraphs := 0 + cleanLines := 0 + paragraph, _ := tl.reader.ReadString('\n') + if len(paragraph) > 0 { runeOffset := 0 currentIdx := 0 - for { - text, readErr := tl.reader.ReadString('\n') - // the last line returned by ReadBytes returns EOF and may have remaining bytes to process. - if len(text) > 0 { - tl.layoutNextParagraph(shaper, text, paragraphCount-1 == currentIdx, tabWidth, wrapLine) - - paragraphRunes := []rune(text) - tl.indexGraphemeClusters(paragraphRunes, runeOffset) - runeOffset += len(paragraphRunes) - currentIdx++ + for len(paragraph) > 0 { + nextParagraph, nextErr := tl.reader.ReadString('\n') + isLastParagraph := len(nextParagraph) == 0 && nextErr != nil + runes, reused := tl.layoutNextParagraph(shaper, paragraph, currentIdx, isLastParagraph, runeOffset, tabWidth, wrapLine) + runeOffset += runes + currentIdx++ + + if cleanPrefix && reused { + cleanParagraphs++ + cleanLines += len(tl.nextParas[len(tl.nextParas)-1].lines) + } else { + cleanPrefix = false } - if readErr != nil { + if isLastParagraph { break } + paragraph = nextParagraph } } else { - tl.layoutNextParagraph(shaper, "", true, tabWidth, wrapLine) + _, reused := tl.layoutNextParagraph(shaper, "", 0, true, 0, tabWidth, wrapLine) + if cleanPrefix && reused { + cleanParagraphs = 1 + cleanLines = len(tl.nextParas[0].lines) + } } - - tl.calculateXOffsets() - tl.calculateYOffsets() - - // build position index - for idx, line := range tl.Lines { + tl.calculateXOffsets(cleanLines) + tl.calculateYOffsets(cleanLines) + + positionPrefix := sort.Search(len(oldPositions), func(i int) bool { + return oldPositions[i].LineCol.Line >= cleanLines + }) + tl.Positions = oldPositions[:positionPrefix] + for idx := cleanLines; idx < len(tl.Lines); idx++ { + line := tl.Lines[idx] tl.indexGlyphs(idx, line) + } + + for _, line := range tl.Lines { tl.updateBounds(line) - // log.Printf("line[%d]: %s", idx, line) + } + cleanTrackedParagraphs := cleanParagraphs + if cleanLines == len(tl.Lines) { + cleanTrackedParagraphs = len(oldParagraphs) + } + tl.Paragraphs = oldParagraphs[:cleanTrackedParagraphs] + if cleanLines < len(tl.Lines) { + tl.trackLines(tl.Lines[cleanLines:]) } - tl.trackLines(tl.Lines) + lineIdx := cleanLines + for idx := cleanParagraphs; idx < len(tl.nextParas); idx++ { + nextLine := lineIdx + len(tl.nextParas[idx].lines) + copy(tl.nextParas[idx].lines, tl.Lines[lineIdx:nextLine]) + lineIdx = nextLine + } } + tl.glyphCache, tl.nextCache = tl.nextCache, tl.glyphCache + tl.paragraphs, tl.nextParas = tl.nextParas, tl.paragraphs + clear(tl.nextCache) + clear(tl.nextParas) + tl.nextParas = tl.nextParas[:0] + clear(tl.paragraphs[len(tl.paragraphs):cap(tl.paragraphs)]) + clear(tl.Lines[len(tl.Lines):cap(tl.Lines)]) dims := layout.Dimensions{Size: tl.bounds.Size()} dims.Baseline = dims.Size.Y - tl.baseline return dims } -func (tl *TextLayout) layoutNextParagraph(shaper *text.Shaper, paragraph string, isLastParagrah bool, tabWidth int, wrapLine bool) { +func (tl *TextLayout) layoutNextParagraph(shaper *text.Shaper, paragraph string, paragraphIdx int, isLastParagrah bool, runeOffset int, tabWidth int, wrapLine bool) (int, bool) { + if paragraphIdx < len(tl.paragraphs) { + cached := tl.paragraphs[paragraphIdx] + if cached.text == paragraph && cached.last == isLastParagrah { + tl.Lines = append(tl.Lines, cached.lines...) + tl.appendGraphemes(cached.graphemes, runeOffset) + tl.nextParas = append(tl.nextParas, cached) + return cached.runes, true + } + } + + paragraphRunes := []rune(paragraph) + graphemes := tl.graphemeOffsets(paragraphRunes) + tl.appendGraphemes(graphemes, runeOffset) + params := tl.params maxWidth := params.MaxWidth params.MaxWidth = 1e6 if !wrapLine { maxWidth = params.MaxWidth } - shaper.LayoutString(params, paragraph) + glyphs, ok := tl.glyphCache[paragraph] + if !ok { + shaper.LayoutString(params, paragraph) + for { + glyph, ok := shaper.NextGlyph() + if !ok { + break + } + glyphs = append(glyphs, glyph) + } + } + tl.nextCache[paragraph] = glyphs - lines := tl.wrapParagraph(glyphIter{shaper: shaper}, []rune(paragraph), maxWidth, tabWidth, &tl.spaceGlyph) + lines := tl.wrapper.WrapParagraph(slices.Values(glyphs), paragraphRunes, maxWidth, tabWidth, &tl.spaceGlyph) if strings.HasSuffix(paragraph, "\n") && len(lines) > 0 && !isLastParagrah { lines = lines[:len(lines)-1] } tl.Lines = append(tl.Lines, lines...) -} - -func (tl *TextLayout) wrapParagraph(glyphs glyphIter, paragraph []rune, maxWidth int, tabWidth int, spaceGlyph *text.Glyph) []Line { - return tl.wrapper.WrapParagraph(glyphs.All(), paragraph, maxWidth, tabWidth, spaceGlyph) + tl.nextParas = append(tl.nextParas, paragraphLayout{ + text: paragraph, + last: isLastParagrah, + runes: len(paragraphRunes), + graphemes: graphemes, + lines: lines, + }) + return len(paragraphRunes), false } func (tl *TextLayout) fakeLayout() { @@ -168,26 +262,35 @@ func (tl *TextLayout) fakeLayout() { } } -func (tl *TextLayout) calculateYOffsets() { - if len(tl.Lines) <= 0 { +func (tl *TextLayout) calculateYOffsets(startLine int) { + if startLine >= len(tl.Lines) { return } lineHeight := tl.calcLineHeight(&tl.params) - // Ceil the first value to ensure that we don't baseline it too close to the top of the - // viewport and cut off the top pixel. - currentY := tl.Lines[0].Ascent.Ceil() - for i := range tl.Lines { - if i > 0 { + currentY := 0 + if startLine == 0 { + // Keep the first baseline far enough from the top to avoid clipping. + currentY = tl.Lines[0].Ascent.Ceil() + } else { + currentY = tl.Lines[startLine-1].YOff + lineHeight.Round() + } + for i := startLine; i < len(tl.Lines); i++ { + if i > startLine { currentY += lineHeight.Round() } tl.Lines[i].adjustYOff(currentY) } } -func (tl *TextLayout) calculateXOffsets() { +func (tl *TextLayout) calculateXOffsets(startLine int) { runeOff := 0 - for i, line := range tl.Lines { + if startLine > 0 { + previous := tl.Lines[startLine-1] + runeOff = previous.RuneOff + previous.Runes + } + for i := startLine; i < len(tl.Lines); i++ { + line := tl.Lines[i] alignOff := tl.params.Alignment.Align(tl.params.Locale.Direction, line.Width, tl.params.MaxWidth) tl.Lines[i].recompute(alignOff, runeOff) runeOff += line.Runes @@ -204,24 +307,28 @@ func (tl *TextLayout) shapeRune(shaper *text.Shaper, params text.Parameters, r r return glyph, nil } -func (tl *TextLayout) indexGraphemeClusters(paragraph []rune, runeOffset int) { - tl.seg.Init(paragraph) - iter := tl.seg.GraphemeIterator() - if len(tl.Graphemes) == 0 { - if iter.Next() { - grapheme := iter.Grapheme() - tl.Graphemes = append(tl.Graphemes, - runeOffset+grapheme.Offset, - runeOffset+grapheme.Offset+len(grapheme.Text), - ) - } +func (tl *TextLayout) graphemeOffsets(paragraph []rune) []int { + if len(paragraph) == 0 { + return nil } + offsets := []int{0} + tl.seg.Init(paragraph) + iter := tl.seg.GraphemeIterator() for iter.Next() { grapheme := iter.Grapheme() - tl.Graphemes = append(tl.Graphemes, runeOffset+grapheme.Offset+len(grapheme.Text)) + offsets = append(offsets, grapheme.Offset+len(grapheme.Text)) } + return offsets +} +func (tl *TextLayout) appendGraphemes(offsets []int, runeOffset int) { + for _, offset := range offsets { + offset += runeOffset + if len(tl.Graphemes) == 0 || tl.Graphemes[len(tl.Graphemes)-1] != offset { + tl.Graphemes = append(tl.Graphemes, offset) + } + } } func (tl *TextLayout) updateBounds(line Line) { diff --git a/internal/layout/text_layout_test.go b/internal/layout/text_layout_test.go index 8c6aa64..82cac80 100644 --- a/internal/layout/text_layout_test.go +++ b/internal/layout/text_layout_test.go @@ -1,12 +1,85 @@ package layout import ( + "reflect" + "strings" "testing" "gioui.org/text" "github.com/oligo/gvcode/internal/buffer" ) +func TestCachedLayoutMatchesFreshLayoutAfterEdit(t *testing.T) { + buf := buffer.NewTextSource() + buf.SetText([]byte("alpha\nmiddle\nalpha\n")) + shaper := text.NewShaper() + params := text.Parameters{PxPerEm: 14, MaxWidth: 400} + cached := NewTextLayout(buf) + cached.Layout(shaper, ¶ms, 4, true) + + check := func() { + t.Helper() + gotDims := cached.Layout(shaper, ¶ms, 4, true) + fresh := NewTextLayout(buffer.NewPieceTable(buffer.NewReader(buf).ReadAll(nil))) + wantDims := fresh.Layout(shaper, ¶ms, 4, true) + + if gotDims != wantDims { + t.Fatalf("dimensions differ: got %+v, want %+v", gotDims, wantDims) + } + if !reflect.DeepEqual(cached.Lines, fresh.Lines) { + t.Fatalf("lines differ: got %+v, want %+v", cached.Lines, fresh.Lines) + } + if !reflect.DeepEqual(cached.Paragraphs, fresh.Paragraphs) { + t.Fatalf("paragraphs differ: got %+v, want %+v", cached.Paragraphs, fresh.Paragraphs) + } + if !reflect.DeepEqual(cached.Positions, fresh.Positions) { + t.Fatalf("positions differ: got %+v, want %+v", cached.Positions, fresh.Positions) + } + if !reflect.DeepEqual(cached.Graphemes, fresh.Graphemes) { + t.Fatalf("graphemes differ: got %+v, want %+v", cached.Graphemes, fresh.Graphemes) + } + } + + check() + buf.Replace(8, 9, "X") + check() + buf.Replace(5, 6, "") + check() + buf.Replace(buf.Len()-1, buf.Len(), "") + check() + params.MaxWidth = 40 + check() +} + +func TestCachedLayoutClearsDiscardedStorage(t *testing.T) { + buf := buffer.NewTextSource() + buf.SetText([]byte(strings.Repeat("line\n", 2000))) + shaper := text.NewShaper() + params := text.Parameters{PxPerEm: 14, MaxWidth: 400} + tl := NewTextLayout(buf) + tl.Layout(shaper, ¶ms, 4, true) + + buf.SetText([]byte("short")) + tl.Layout(shaper, ¶ms, 4, true) + + if len(tl.nextParas) != 0 { + t.Fatalf("inactive paragraph cache length = %d, want 0", len(tl.nextParas)) + } + if len(tl.nextCache) != 0 { + t.Fatalf("inactive glyph cache length = %d, want 0", len(tl.nextCache)) + } + for _, paragraph := range tl.paragraphs[len(tl.paragraphs):cap(tl.paragraphs)] { + if paragraph.text != "" || len(paragraph.lines) != 0 || len(paragraph.graphemes) != 0 { + t.Fatal("discarded paragraph cache still holds data") + } + } + for _, line := range tl.Lines[len(tl.Lines):cap(tl.Lines)] { + if len(line.Glyphs) != 0 { + t.Fatal("discarded line cache still holds glyphs") + } + } +} + func BenchmarkLayout(b *testing.B) { buf := buffer.NewTextSource() buf.SetText([]byte("a fox jumps over the lazy dog")) @@ -19,4 +92,3 @@ func BenchmarkLayout(b *testing.B) { } } - From 4be9830b865723c83f39fce084de21ffd661a55c Mon Sep 17 00:00:00 2001 From: qiannian <40210590@qq.com> Date: Sun, 19 Jul 2026 12:09:23 +0800 Subject: [PATCH 2/5] perf: defer example syntax and diff updates --- example/main.go | 42 +++++++++++++++++++++++++++--------------- example/main_test.go | 13 +++++++++++++ 2 files changed, 40 insertions(+), 15 deletions(-) create mode 100644 example/main_test.go diff --git a/example/main.go b/example/main.go index 52a36b6..26bf7c4 100644 --- a/example/main.go +++ b/example/main.go @@ -12,6 +12,8 @@ import ( "os" "regexp" "strings" + "time" + "unicode/utf8" "gioui.org/app" "gioui.org/io/key" @@ -47,11 +49,11 @@ type EditorApp struct { diffProvider *providers.VCSDiffProvider diffPopup *diff.DiffPopup differ *diff.GitDiff + refreshAt time.Time + refreshDirty bool } -const ( - syntaxPattern = "package|import|type|func|struct|for|var|switch|case|if|else" -) +var syntaxPattern = regexp.MustCompile("package|import|type|func|struct|for|var|switch|case|if|else") func (ed *EditorApp) run() error { @@ -79,13 +81,8 @@ func (ed *EditorApp) layout(gtx C, th *material.Theme) D { switch evt.(type) { case gvcode.ChangeEvent: - tokens := HightlightTextByPattern(ed.state.Text(), syntaxPattern) - ed.state.SetSyntaxTokens(tokens...) - - // Parse git diff for the current file and update the diff provider - if hunks := ed.differ.ParseDiff([]byte(ed.state.Text())); len(hunks) > 0 { - ed.diffProvider.UpdateDiff(hunks) - } + ed.refreshDirty = true + ed.refreshAt = gtx.Now.Add(150 * time.Millisecond) case gvcode.GutterEventWrapper: wrapper := evt.(gvcode.GutterEventWrapper) @@ -100,6 +97,16 @@ func (ed *EditorApp) layout(gtx C, th *material.Theme) D { } } + if ed.refreshDirty { + if gtx.Now.Before(ed.refreshAt) { + gtx.Execute(op.InvalidateCmd{At: ed.refreshAt}) + } else { + text := ed.state.Text() + ed.state.SetSyntaxTokens(HightlightTextByPattern(text, syntaxPattern)...) + ed.diffProvider.UpdateDiff(ed.differ.ParseDiff([]byte(text))) + ed.refreshDirty = false + } + } xScrollDist := ed.xScroll.ScrollDistance() yScrollDist := ed.yScroll.ScrollDistance() @@ -275,17 +282,22 @@ func main() { } -func HightlightTextByPattern(text string, pattern string) []syntax.Token { +func HightlightTextByPattern(text string, pattern *regexp.Regexp) []syntax.Token { var tokens []syntax.Token - re := regexp.MustCompile(pattern) - matches := re.FindAllIndex([]byte(text), -1) + matches := pattern.FindAllStringIndex(text, -1) + byteOffset := 0 + runeOffset := 0 for _, match := range matches { + runeOffset += utf8.RuneCountInString(text[byteOffset:match[0]]) + start := runeOffset + runeOffset += utf8.RuneCountInString(text[match[0]:match[1]]) tokens = append(tokens, syntax.Token{ - Start: match[0], - End: match[1], + Start: start, + End: runeOffset, Scope: "keyword", }) + byteOffset = match[1] } return tokens diff --git a/example/main_test.go b/example/main_test.go new file mode 100644 index 0000000..27d3c30 --- /dev/null +++ b/example/main_test.go @@ -0,0 +1,13 @@ +//go:build ignore +// +build ignore + +package main + +import "testing" + +func TestHighlightTextByPatternUsesRuneOffsets(t *testing.T) { + tokens := HightlightTextByPattern("你好 func", syntaxPattern) + if len(tokens) != 1 || tokens[0].Start != 3 || tokens[0].End != 7 { + t.Fatalf("unexpected tokens: %+v", tokens) + } +} From 7fdcf54ca73030af1190d91530b9774d8f71e7af Mon Sep 17 00:00:00 2001 From: qiannian <40210590@qq.com> Date: Sun, 19 Jul 2026 12:47:31 +0800 Subject: [PATCH 3/5] fix: keep text input state consistent --- editor.go | 24 ++++++- event.go | 10 ++- event_test.go | 118 +++++++++++++++++++++++++++++++++ internal/buffer/reader.go | 4 ++ internal/buffer/reader_test.go | 7 ++ 5 files changed, 155 insertions(+), 8 deletions(-) diff --git a/editor.go b/editor.go index af4c8ff..800771a 100644 --- a/editor.go +++ b/editor.go @@ -370,6 +370,9 @@ func (e *Editor) GetReader() io.ReadSeeker { // before populate the internal text buffer. func (e *Editor) SetText(s string) { e.initBuffer() + e.resetIME() + clear(e.autoInsertions) + e.lastInput = nil indent, _, size := GuessIndentation(s) e.text.SoftTab = indent == Spaces @@ -378,12 +381,17 @@ func (e *Editor) SetText(s string) { e.lineEnding = DetectLineEnding(s) e.text.SetText(StripLineEnding(s)) - e.ime.start = 0 - e.ime.end = 0 // Reset xoff and move the caret to the beginning. e.SetCaret(0, 0) } +func (e *Editor) resetIME() { + if e.ime.isComposing { + e.buffer.UnGroupOp() + } + e.ime.imeState = imeState{} +} + // CaretPos returns the line & column numbers of the caret. func (e *Editor) CaretPos() (line, col int) { e.initBuffer() @@ -576,6 +584,18 @@ func (e *Editor) replace(start, end int, s string) int { sc := e.text.Replace(start, end, s) newEnd := start + sc + if len(e.autoInsertions) > 0 && (start != end || sc != 0) { + updated := make(map[int]rune, len(e.autoInsertions)) + for pos, r := range e.autoInsertions { + switch { + case pos < start: + updated[pos] = r + case pos >= end: + updated[pos+newEnd-end] = r + } + } + e.autoInsertions = updated + } adjust := func(pos int) int { switch { case newEnd < pos && pos <= end: diff --git a/event.go b/event.go index 0f7a109..b3ccf63 100644 --- a/event.go +++ b/event.go @@ -254,8 +254,7 @@ func (e *Editor) processEditEvents(gtx layout.Context) EditorEvent { switch ke := evt.(type) { case key.FocusEvent: - // Reset IME state. - e.ime.imeState = imeState{} + e.resetIME() if ke.Focus && e.mode != ModeReadOnly { gtx.Execute(key.SoftKeyboardCmd{Show: true}) } @@ -263,6 +262,9 @@ func (e *Editor) processEditEvents(gtx layout.Context) EditorEvent { e.updateSnippet(gtx, ke.Start, ke.End) case key.EditEvent: e.onTextInput(ke) + if e.text.Changed() { + return ChangeEvent{} + } case key.CompositionEvent: // Since v0.10.1, gio delivers IME composition event. During composition stage, // range marks the current text range of composition. When composition confirmed/canceled, @@ -494,9 +496,6 @@ func (e *Editor) onTextInput(ke key.EditEvent) { e.text.MoveCaret(-1, -1) start, _ := e.text.Selection() // start and end should be the same e.autoInsertions[start] = counterpart - } else { - // If only the opening char was inserted, ensure it's not tracked - delete(e.autoInsertions, ke.Range.Start) } } else if counterpart > 0 { @@ -513,7 +512,6 @@ func (e *Editor) onTextInput(ke key.EditEvent) { e.replace(ke.Range.Start, ke.Range.End, ke.Text) } } else { - delete(e.autoInsertions, ke.Range.Start) e.replace(ke.Range.Start, ke.Range.End, ke.Text) } diff --git a/event_test.go b/event_test.go index 4076b40..9ebee28 100644 --- a/event_test.go +++ b/event_test.go @@ -29,6 +29,52 @@ func BenchmarkDeleteBackwardLargeDocument(b *testing.B) { } } +func BenchmarkTextInputLargeDocument(b *testing.B) { + doc := strings.Repeat("func main() { println(\"hello\") }\n", 5000) + editor := &Editor{} + editor.WithOptions(WithTextSize(unit.Sp(14))) + editor.SetText(doc) + textGtx := layout.Context{Constraints: layout.Exact(image.Pt(1200, 800))} + shaper := text.NewShaper() + editor.text.Layout(textGtx, shaper) + editor.SetCaret(editor.Len(), editor.Len()) + + router := new(input.Router) + gtx := layout.Context{Ops: new(op.Ops), Source: router.Source()} + editor.Update(gtx) + router.Frame(new(op.Ops)) + router.Source().Execute(key.FocusCmd{Tag: editor}) + for { + if _, ok := editor.Update(gtx); !ok { + break + } + } + + b.ResetTimer() + for range b.N { + b.StopTimer() + pos := editor.Len() + router.Queue(key.EditEvent{Range: key.Range{Start: pos, End: pos}, Text: "a"}) + b.StartTimer() + evt, ok := editor.Update(gtx) + b.StopTimer() + if !ok || !isChangeEvent(evt) { + b.Fatalf("Update() = (%T, %v), want ChangeEvent", evt, ok) + } + + for { + if _, ok := editor.Update(gtx); !ok { + break + } + } + if _, ok := editor.undo(); !ok { + b.Fatal("undo failed") + } + editor.text.Changed() + editor.text.Layout(textGtx, shaper) + } +} + func TestDeleteBackwardEmitsOneChangeEvent(t *testing.T) { editor := &Editor{} editor.SetText("abc") @@ -74,6 +120,78 @@ func TestIsChangeEventAcceptsValueAndPointer(t *testing.T) { } } +func TestEditEventsEmitSeparateChangeEvents(t *testing.T) { + editor := &Editor{} + editor.SetText("") + + router := new(input.Router) + gtx := layout.Context{Ops: new(op.Ops), Source: router.Source()} + editor.Update(gtx) + router.Frame(new(op.Ops)) + + router.Source().Execute(key.FocusCmd{Tag: editor}) + router.Queue( + key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "a"}, + key.EditEvent{Range: key.Range{Start: 1, End: 1}, Text: "b"}, + ) + + gtx.Source = router.Source() + changes := 0 + for { + evt, ok := editor.Update(gtx) + if !ok { + break + } + if _, ok := evt.(ChangeEvent); ok { + changes++ + } + } + + if changes != 2 { + t.Fatalf("two EditEvents emitted %d ChangeEvents, want 2", changes) + } + if got := editor.Text(); got != "ab" { + t.Fatalf("text = %q, want %q", got, "ab") + } +} + +func TestResetIMEClosesUndoGroup(t *testing.T) { + editor := &Editor{} + editor.SetText("") + editor.ime.isComposing = true + editor.buffer.GroupOp() + editor.replace(0, 0, "中") + + editor.resetIME() + editor.replace(1, 1, " ") + + if _, ok := editor.undo(); !ok { + t.Fatal("undo failed") + } + if got := editor.Text(); got != "中" { + t.Fatalf("undo after IME reset left %q, want %q", got, "中") + } +} + +func TestAutoInsertionTracksEditsBeforeClosingRune(t *testing.T) { + editor := &Editor{} + editor.WithOptions(WithTextSize(unit.Sp(14))) + editor.SetText("") + editor.text.Layout(layout.Context{Constraints: layout.Exact(image.Pt(800, 600))}, text.NewShaper()) + + editor.onTextInput(key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "("}) + editor.onTextInput(key.EditEvent{Range: key.Range{Start: 1, End: 1}, Text: "x"}) + editor.onTextInput(key.EditEvent{Range: key.Range{Start: 2, End: 2}, Text: ")"}) + + if got := editor.Text(); got != "(x)" { + t.Fatalf("text = %q, want %q", got, "(x)") + } + start, end := editor.Selection() + if start != 3 || end != 3 { + t.Fatalf("selection = (%d, %d), want (3, 3)", start, end) + } +} + func TestOnDeleteBackward_Indentation(t *testing.T) { setup := func(input string, cursorPos, tabWidth int) *Editor { vw := textview.NewTextView() diff --git a/internal/buffer/reader.go b/internal/buffer/reader.go index 01de44c..68bf914 100644 --- a/internal/buffer/reader.go +++ b/internal/buffer/reader.go @@ -31,6 +31,10 @@ func (pt *PieceTable) ReadRuneAt(runeOff int) (rune, error) { pt.mu.RLock() defer pt.mu.RUnlock() + if runeOff < 0 || runeOff >= pt.seqLength { + return 0, io.EOF + } + n, off, _ := pt.pieces.FindPiece(runeOff) if n == nil { return 0, io.EOF diff --git a/internal/buffer/reader_test.go b/internal/buffer/reader_test.go index e6d95db..137618f 100644 --- a/internal/buffer/reader_test.go +++ b/internal/buffer/reader_test.go @@ -1,6 +1,8 @@ package buffer import ( + "errors" + "io" "testing" "unicode/utf8" ) @@ -68,4 +70,9 @@ func TestReadRuneAt(t *testing.T) { t.Fail() } + for _, off := range []int{-1, src.Len()} { + if _, err := src.ReadRuneAt(off); !errors.Is(err, io.EOF) { + t.Errorf("ReadRuneAt(%d) error = %v, want io.EOF", off, err) + } + } } From 67c2e4f5496609262b97e1e9cb4d802263bc786a Mon Sep 17 00:00:00 2001 From: qiannian <40210590@qq.com> Date: Sun, 19 Jul 2026 12:47:41 +0800 Subject: [PATCH 4/5] perf: reduce text layout allocations --- internal/layout/text_layout.go | 82 +++++++++++++++++++---------- internal/layout/text_layout_test.go | 18 +++++++ 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/internal/layout/text_layout.go b/internal/layout/text_layout.go index 06f8d53..6577f4a 100644 --- a/internal/layout/text_layout.go +++ b/internal/layout/text_layout.go @@ -8,7 +8,6 @@ import ( "math" "slices" "sort" - "strings" "gioui.org/layout" "gioui.org/text" @@ -26,19 +25,20 @@ type paragraphLayout struct { } type TextLayout struct { - src buffer.TextSource - reader *bufio.Reader - params text.Parameters - spaceGlyph text.Glyph - wrapper lineWrapper - seg segmenter.Segmenter - shaper *text.Shaper - glyphCache map[string][]text.Glyph - nextCache map[string][]text.Glyph - paragraphs []paragraphLayout - nextParas []paragraphLayout - tabWidth int - wrapLine bool + src buffer.TextSource + reader *bufio.Reader + params text.Parameters + spaceGlyph text.Glyph + wrapper lineWrapper + seg segmenter.Segmenter + shaper *text.Shaper + glyphCache map[string][]text.Glyph + nextCache map[string][]text.Glyph + paragraphs []paragraphLayout + nextParas []paragraphLayout + paragraphBuf []byte + tabWidth int + wrapLine bool // Positions contain all possible caret positions, sorted by rune index. Positions []CombinedPos @@ -91,6 +91,25 @@ func (tl *TextLayout) reset() { tl.baseline = 0 } +func (tl *TextLayout) readParagraph() ([]byte, error) { + var paragraph []byte + for { + chunk, err := tl.reader.ReadSlice('\n') + if len(paragraph) > 0 || err == bufio.ErrBufferFull { + if len(paragraph) == 0 { + tl.paragraphBuf = tl.paragraphBuf[:0] + } + tl.paragraphBuf = append(tl.paragraphBuf, chunk...) + paragraph = tl.paragraphBuf + } else { + return chunk, err + } + if err != bufio.ErrBufferFull { + return paragraph, err + } + } +} + func (tl *TextLayout) Layout(shaper *text.Shaper, params *text.Parameters, tabWidth int, wrapLine bool) layout.Dimensions { cacheValid := tl.shaper == shaper && tl.params == *params && tl.tabWidth == tabWidth && tl.wrapLine == wrapLine if !cacheValid { @@ -114,14 +133,19 @@ func (tl *TextLayout) Layout(shaper *text.Shaper, params *text.Parameters, tabWi cleanPrefix := cacheValid cleanParagraphs := 0 cleanLines := 0 - paragraph, _ := tl.reader.ReadString('\n') - if len(paragraph) > 0 { + totalBytes := tl.src.Size() + if totalBytes > 0 { runeOffset := 0 currentIdx := 0 + bytesRead := 0 - for len(paragraph) > 0 { - nextParagraph, nextErr := tl.reader.ReadString('\n') - isLastParagraph := len(nextParagraph) == 0 && nextErr != nil + for bytesRead < totalBytes { + paragraph, err := tl.readParagraph() + if len(paragraph) == 0 { + break + } + bytesRead += len(paragraph) + isLastParagraph := bytesRead >= totalBytes || err != nil runes, reused := tl.layoutNextParagraph(shaper, paragraph, currentIdx, isLastParagraph, runeOffset, tabWidth, wrapLine) runeOffset += runes currentIdx++ @@ -136,10 +160,9 @@ func (tl *TextLayout) Layout(shaper *text.Shaper, params *text.Parameters, tabWi if isLastParagraph { break } - paragraph = nextParagraph } } else { - _, reused := tl.layoutNextParagraph(shaper, "", 0, true, 0, tabWidth, wrapLine) + _, reused := tl.layoutNextParagraph(shaper, nil, 0, true, 0, tabWidth, wrapLine) if cleanPrefix && reused { cleanParagraphs = 1 cleanLines = len(tl.nextParas[0].lines) @@ -190,10 +213,10 @@ func (tl *TextLayout) Layout(shaper *text.Shaper, params *text.Parameters, tabWi return dims } -func (tl *TextLayout) layoutNextParagraph(shaper *text.Shaper, paragraph string, paragraphIdx int, isLastParagrah bool, runeOffset int, tabWidth int, wrapLine bool) (int, bool) { +func (tl *TextLayout) layoutNextParagraph(shaper *text.Shaper, paragraph []byte, paragraphIdx int, isLastParagrah bool, runeOffset int, tabWidth int, wrapLine bool) (int, bool) { if paragraphIdx < len(tl.paragraphs) { cached := tl.paragraphs[paragraphIdx] - if cached.text == paragraph && cached.last == isLastParagrah { + if cached.text == string(paragraph) && cached.last == isLastParagrah { tl.Lines = append(tl.Lines, cached.lines...) tl.appendGraphemes(cached.graphemes, runeOffset) tl.nextParas = append(tl.nextParas, cached) @@ -201,7 +224,8 @@ func (tl *TextLayout) layoutNextParagraph(shaper *text.Shaper, paragraph string, } } - paragraphRunes := []rune(paragraph) + paragraphText := string(paragraph) + paragraphRunes := []rune(paragraphText) graphemes := tl.graphemeOffsets(paragraphRunes) tl.appendGraphemes(graphemes, runeOffset) @@ -211,9 +235,9 @@ func (tl *TextLayout) layoutNextParagraph(shaper *text.Shaper, paragraph string, if !wrapLine { maxWidth = params.MaxWidth } - glyphs, ok := tl.glyphCache[paragraph] + glyphs, ok := tl.glyphCache[paragraphText] if !ok { - shaper.LayoutString(params, paragraph) + shaper.LayoutString(params, paragraphText) for { glyph, ok := shaper.NextGlyph() if !ok { @@ -222,16 +246,16 @@ func (tl *TextLayout) layoutNextParagraph(shaper *text.Shaper, paragraph string, glyphs = append(glyphs, glyph) } } - tl.nextCache[paragraph] = glyphs + tl.nextCache[paragraphText] = glyphs lines := tl.wrapper.WrapParagraph(slices.Values(glyphs), paragraphRunes, maxWidth, tabWidth, &tl.spaceGlyph) - if strings.HasSuffix(paragraph, "\n") && len(lines) > 0 && !isLastParagrah { + if len(paragraph) > 0 && paragraph[len(paragraph)-1] == '\n' && len(lines) > 0 && !isLastParagrah { lines = lines[:len(lines)-1] } tl.Lines = append(tl.Lines, lines...) tl.nextParas = append(tl.nextParas, paragraphLayout{ - text: paragraph, + text: paragraphText, last: isLastParagrah, runes: len(paragraphRunes), graphemes: graphemes, diff --git a/internal/layout/text_layout_test.go b/internal/layout/text_layout_test.go index 82cac80..118e7bb 100644 --- a/internal/layout/text_layout_test.go +++ b/internal/layout/text_layout_test.go @@ -80,6 +80,24 @@ func TestCachedLayoutClearsDiscardedStorage(t *testing.T) { } } +func TestLayoutReadsParagraphLargerThanBuffer(t *testing.T) { + longLine := strings.Repeat("x", 5000) + buf := buffer.NewPieceTable([]byte(longLine + "\ntail")) + tl := NewTextLayout(buf) + params := text.Parameters{PxPerEm: 14, MaxWidth: 400} + tl.Layout(text.NewShaper(), ¶ms, 4, false) + + if len(tl.paragraphs) != 2 { + t.Fatalf("paragraph count = %d, want 2", len(tl.paragraphs)) + } + if got := tl.paragraphs[0].text; got != longLine+"\n" { + t.Fatalf("first paragraph length = %d, want %d", len(got), len(longLine)+1) + } + if got := tl.paragraphs[1].text; got != "tail" { + t.Fatalf("last paragraph = %q, want %q", got, "tail") + } +} + func BenchmarkLayout(b *testing.B) { buf := buffer.NewTextSource() buf.SetText([]byte("a fox jumps over the lazy dog")) From b65ee5838e427a3cc97c9e5a0daadf4144f5a61d Mon Sep 17 00:00:00 2001 From: qiannian <40210590@qq.com> Date: Mon, 20 Jul 2026 00:20:20 +0800 Subject: [PATCH 5/5] fix: preserve edit event batching --- event.go | 3 --- event_test.go | 23 ++++++++++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/event.go b/event.go index b3ccf63..e1af445 100644 --- a/event.go +++ b/event.go @@ -262,9 +262,6 @@ func (e *Editor) processEditEvents(gtx layout.Context) EditorEvent { e.updateSnippet(gtx, ke.Start, ke.End) case key.EditEvent: e.onTextInput(ke) - if e.text.Changed() { - return ChangeEvent{} - } case key.CompositionEvent: // Since v0.10.1, gio delivers IME composition event. During composition stage, // range marks the current text range of composition. When composition confirmed/canceled, diff --git a/event_test.go b/event_test.go index 9ebee28..bb80e0e 100644 --- a/event_test.go +++ b/event_test.go @@ -120,7 +120,7 @@ func TestIsChangeEventAcceptsValueAndPointer(t *testing.T) { } } -func TestEditEventsEmitSeparateChangeEvents(t *testing.T) { +func TestEditEventsEmitOneChangeEventPerFrame(t *testing.T) { editor := &Editor{} editor.SetText("") @@ -147,8 +147,8 @@ func TestEditEventsEmitSeparateChangeEvents(t *testing.T) { } } - if changes != 2 { - t.Fatalf("two EditEvents emitted %d ChangeEvents, want 2", changes) + if changes != 1 { + t.Fatalf("two EditEvents emitted %d ChangeEvents, want 1", changes) } if got := editor.Text(); got != "ab" { t.Fatalf("text = %q, want %q", got, "ab") @@ -192,6 +192,23 @@ func TestAutoInsertionTracksEditsBeforeClosingRune(t *testing.T) { } } +func TestAutoInsertionTracksPairAfterEarlierEdit(t *testing.T) { + editor := &Editor{} + editor.WithOptions(WithTextSize(unit.Sp(14))) + editor.SetText("") + editor.text.Layout(layout.Context{Constraints: layout.Exact(image.Pt(800, 600))}, text.NewShaper()) + + editor.onTextInput(key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "("}) + editor.onTextInput(key.EditEvent{Range: key.Range{Start: 0, End: 0}, Text: "x"}) + if deleted := editor.Delete(-1); deleted != 2 { + t.Fatalf("deleted runes = %d, want 2", deleted) + } + + if got := editor.Text(); got != "x" { + t.Fatalf("text = %q, want %q", got, "x") + } +} + func TestOnDeleteBackward_Indentation(t *testing.T) { setup := func(input string, cursorPos, tabWidth int) *Editor { vw := textview.NewTextView()