From 63b6a645e0e1c515da1244a2868774f19d042ae8 Mon Sep 17 00:00:00 2001 From: pablu Date: Mon, 3 Aug 2026 12:06:30 +0200 Subject: [PATCH] fix: editor whitespaces --- text_editor.go | 64 ++++++++++++++++++++++++++++++++++++--------- text_editor_test.go | 59 +++++++++++++++++++++++++++++++++++++++++ version.go | 2 +- 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/text_editor.go b/text_editor.go index 6c4e9ad..764f053 100644 --- a/text_editor.go +++ b/text_editor.go @@ -761,7 +761,7 @@ func normalizeLineEndings(value string) string { type editorVisualLine struct { text string - start, end int + start, displayStart, end int logicalStart, logicalEnd int } @@ -826,9 +826,11 @@ func moveEditorCursorLine(value string, cursor, delta, wrapWidth int, normal boo if targetIndex == index { return cursor } - column := lipgloss.Width(string(runes[lines[index].start:clamp(cursor, lines[index].start, lines[index].end)])) + current := lines[index] + columnStart := min(current.end, max(current.start, current.displayStart)) + column := lipgloss.Width(string(runes[columnStart:clamp(cursor, columnStart, current.end)])) target := lines[targetIndex] - position, usedWidth := target.start, 0 + position, usedWidth := max(target.start, target.displayStart), 0 for position < target.end { runeWidth := lipgloss.Width(string(runes[position])) if usedWidth+runeWidth > column { @@ -887,23 +889,39 @@ func editorCursorVisualPosition(editor textEditor, width int) (int, int) { visual := editorVisualLines(editor.Text, width) index := editorVisualLineIndex(visual, cursor) line := visual[index] - column := lipgloss.Width(string(runes[line.start:clamp(cursor, line.start, line.end)])) + columnStart := min(line.end, max(line.start, line.displayStart)) + column := lipgloss.Width(string(runes[columnStart:clamp(cursor, columnStart, line.end)])) return index, column } func wrapEditorLogicalLine(runes []rune, start, end, width int) []editorVisualLine { if start == end { return []editorVisualLine{{ - start: start, end: end, logicalStart: start, logicalEnd: end, + start: start, displayStart: start, end: end, + logicalStart: start, logicalEnd: end, }} } var lines []editorVisualLine for offset := start; offset < end; { - next := offset + displayStart := offset + if offset > start { + for displayStart < end && unicode.IsSpace(runes[displayStart]) { + displayStart++ + } + } + if displayStart == end { + lines = append(lines, editorVisualLine{ + start: offset, displayStart: displayStart, end: end, + logicalStart: start, logicalEnd: end, + }) + break + } + + next := displayStart lineWidth := 0 for next < end { runeWidth := lipgloss.Width(string(runes[next])) - if next > offset && lineWidth+runeWidth > width { + if next > displayStart && lineWidth+runeWidth > width { break } lineWidth += runeWidth @@ -912,14 +930,32 @@ func wrapEditorLogicalLine(runes []rune, start, end, width int) []editorVisualLi break } } - if next == offset { + if next == displayStart { next++ } + lineEnd := next + if next < end { + breakAt := -1 + haveWord := false + for index := displayStart; index < next; index++ { + if unicode.IsSpace(runes[index]) { + if haveWord { + breakAt = index + } + } else { + haveWord = true + } + } + if breakAt > displayStart { + lineEnd = breakAt + } + } lines = append(lines, editorVisualLine{ - text: string(runes[offset:next]), start: offset, end: next, + text: string(runes[displayStart:lineEnd]), + start: offset, displayStart: displayStart, end: lineEnd, logicalStart: start, logicalEnd: end, }) - offset = next + offset = lineEnd } return lines } @@ -941,12 +977,16 @@ func renderEditorVisualLine( underlineEnd = "\x1b[24m" ) runes := []rune(line.text) + displayCursor := cursor + if displayCursor < line.displayStart { + displayCursor = line.displayStart + } var rendered strings.Builder selected := false protectedColor := "" markdownStyle := editorMarkdownPlain for offset, value := range runes { - position := line.start + offset + position := line.displayStart + offset nextProtectedColor := "" if position < protectedPrefix { nextProtectedColor = editorMarkdownTheme.Dim @@ -991,7 +1031,7 @@ func renderEditorVisualLine( } selected = nowSelected } - if showCursor && position == cursor { + if showCursor && position == displayCursor { switch mode { case textEditorInsert: if hardwareCursor { diff --git a/text_editor_test.go b/text_editor_test.go index ed2e5b2..a38d14c 100644 --- a/text_editor_test.go +++ b/text_editor_test.go @@ -308,6 +308,65 @@ func TestEditorKeepsWrappedRowsAndContextRailsVisible(t *testing.T) { } } +func TestEditorWordWrapHidesSoftWrapSpacesWithoutChangingText(t *testing.T) { + const value = "abcdefghij hello" + visual := editorVisualLines(value, 10) + if len(visual) != 2 || visual[0].text != "abcdefghij" || visual[1].text != "hello" { + t.Fatalf("word-wrapped lines = %#v", visual) + } + if visual[1].start != 10 || visual[1].displayStart != 11 { + t.Fatalf("wrapped separator offsets = %#v", visual[1]) + } + + editor := newTextEditor(value, true) + editor.Cursor = len([]rune(value)) + rendered := renderTextEditor(editor, 10, false) + if got := ansi.Strip(rendered[1].text); got != "hello" { + t.Fatalf("wrapped row begins with separator space: %q", got) + } + if editor.Text != value { + t.Fatalf("word wrapping changed stored text: %q", editor.Text) + } + + visual = editorVisualLines("hello world", 10) + if len(visual) != 2 || visual[0].text != "hello" || visual[1].text != "world" { + t.Fatalf("overflowing word was split instead of moved: %#v", visual) + } +} + +func TestEditorBoundarySpaceCreatesEmptyVisualRow(t *testing.T) { + const value = "abcdefghij " + visual := editorVisualLines(value, 10) + if len(visual) != 2 || visual[0].text != "abcdefghij" || visual[1].text != "" { + t.Fatalf("boundary-space lines = %#v", visual) + } + if visual[1].start != 10 || visual[1].displayStart != 11 || visual[1].end != 11 { + t.Fatalf("boundary-space offsets = %#v", visual[1]) + } + editor := newTextEditor(value, true) + editor.Cursor = len([]rune(value)) + if line, column := editorCursorVisualPosition(editor, 10); line != 1 || column != 0 { + t.Fatalf("boundary-space cursor = row %d column %d, want row 1 column 0", line, column) + } +} + +func TestEditorWordWrapHardWrapsWordsWiderThanViewport(t *testing.T) { + const value = "hi abcdefghijklmnopqrstuv" + visual := editorVisualLines(value, 10) + want := []string{"hi", "abcdefghij", "klmnopqrst", "uv"} + if len(visual) != len(want) { + t.Fatalf("long-word rows = %#v, want %q", visual, want) + } + for index, line := range visual { + if line.text != want[index] { + t.Fatalf("long-word row %d = %q, want %q", index, line.text, want[index]) + } + if ansi.StringWidth(line.text) > 10 { + t.Fatalf("long-word row %d exceeds viewport: %q", index, line.text) + } + } +} + func TestVimEditorTreatsSoftWrapsAsVisualLinesWithoutChangingText(t *testing.T) { const value = "abcdefghijklmnopqrstuv" editor := newTextEditor(value, true) diff --git a/version.go b/version.go index 1719ec9..5417881 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const dipleVersion = "0.3.3" +const dipleVersion = "0.3.4"