fix: editor whitespaces

This commit is contained in:
2026-08-03 12:06:30 +02:00
parent 42e9571834
commit 63b6a645e0
3 changed files with 112 additions and 13 deletions

View File

@@ -761,7 +761,7 @@ func normalizeLineEndings(value string) string {
type editorVisualLine struct { type editorVisualLine struct {
text string text string
start, end int start, displayStart, end int
logicalStart, logicalEnd int logicalStart, logicalEnd int
} }
@@ -826,9 +826,11 @@ func moveEditorCursorLine(value string, cursor, delta, wrapWidth int, normal boo
if targetIndex == index { if targetIndex == index {
return cursor 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] target := lines[targetIndex]
position, usedWidth := target.start, 0 position, usedWidth := max(target.start, target.displayStart), 0
for position < target.end { for position < target.end {
runeWidth := lipgloss.Width(string(runes[position])) runeWidth := lipgloss.Width(string(runes[position]))
if usedWidth+runeWidth > column { if usedWidth+runeWidth > column {
@@ -887,23 +889,39 @@ func editorCursorVisualPosition(editor textEditor, width int) (int, int) {
visual := editorVisualLines(editor.Text, width) visual := editorVisualLines(editor.Text, width)
index := editorVisualLineIndex(visual, cursor) index := editorVisualLineIndex(visual, cursor)
line := visual[index] 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 return index, column
} }
func wrapEditorLogicalLine(runes []rune, start, end, width int) []editorVisualLine { func wrapEditorLogicalLine(runes []rune, start, end, width int) []editorVisualLine {
if start == end { if start == end {
return []editorVisualLine{{ return []editorVisualLine{{
start: start, end: end, logicalStart: start, logicalEnd: end, start: start, displayStart: start, end: end,
logicalStart: start, logicalEnd: end,
}} }}
} }
var lines []editorVisualLine var lines []editorVisualLine
for offset := start; offset < end; { 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 lineWidth := 0
for next < end { for next < end {
runeWidth := lipgloss.Width(string(runes[next])) runeWidth := lipgloss.Width(string(runes[next]))
if next > offset && lineWidth+runeWidth > width { if next > displayStart && lineWidth+runeWidth > width {
break break
} }
lineWidth += runeWidth lineWidth += runeWidth
@@ -912,14 +930,32 @@ func wrapEditorLogicalLine(runes []rune, start, end, width int) []editorVisualLi
break break
} }
} }
if next == offset { if next == displayStart {
next++ 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{ 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, logicalStart: start, logicalEnd: end,
}) })
offset = next offset = lineEnd
} }
return lines return lines
} }
@@ -941,12 +977,16 @@ func renderEditorVisualLine(
underlineEnd = "\x1b[24m" underlineEnd = "\x1b[24m"
) )
runes := []rune(line.text) runes := []rune(line.text)
displayCursor := cursor
if displayCursor < line.displayStart {
displayCursor = line.displayStart
}
var rendered strings.Builder var rendered strings.Builder
selected := false selected := false
protectedColor := "" protectedColor := ""
markdownStyle := editorMarkdownPlain markdownStyle := editorMarkdownPlain
for offset, value := range runes { for offset, value := range runes {
position := line.start + offset position := line.displayStart + offset
nextProtectedColor := "" nextProtectedColor := ""
if position < protectedPrefix { if position < protectedPrefix {
nextProtectedColor = editorMarkdownTheme.Dim nextProtectedColor = editorMarkdownTheme.Dim
@@ -991,7 +1031,7 @@ func renderEditorVisualLine(
} }
selected = nowSelected selected = nowSelected
} }
if showCursor && position == cursor { if showCursor && position == displayCursor {
switch mode { switch mode {
case textEditorInsert: case textEditorInsert:
if hardwareCursor { if hardwareCursor {

View File

@@ -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) { func TestVimEditorTreatsSoftWrapsAsVisualLinesWithoutChangingText(t *testing.T) {
const value = "abcdefghijklmnopqrstuv" const value = "abcdefghijklmnopqrstuv"
editor := newTextEditor(value, true) editor := newTextEditor(value, true)

View File

@@ -1,3 +1,3 @@
package main package main
const dipleVersion = "0.3.3" const dipleVersion = "0.3.4"