Skip to content

Commit 363738d

Browse files
fix(ui): resolve wrap mode selection issues and panic
- Fix inaccurate text selection in wrap mode by implementing
1 parent 30349d6 commit 363738d

3 files changed

Lines changed: 291 additions & 11 deletions

File tree

internal/ui/bench_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,18 @@ func BenchmarkDirectSlice(b *testing.B) {
4747
_ = lines[start:end]
4848
}
4949
}
50+
51+
func BenchmarkResolvePos(b *testing.B) {
52+
// Setup model with long lines
53+
longLine := strings.Repeat("A long line with words and spaces to trigger lipgloss wrapping several times over. ", 50) // ~3000 chars
54+
lines := []string{longLine}
55+
m := InitialModel("bench.log", lines, nil)
56+
m.screenWidth = 80
57+
m.wrap = true
58+
59+
b.ResetTimer()
60+
for i := 0; i < b.N; i++ {
61+
// Resolve a click near the end
62+
_, _ = m.resolvePos(40, 10)
63+
}
64+
}

internal/ui/model.go

Lines changed: 192 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"github.com/charmbracelet/lipgloss"
1515
"github.com/atotto/clipboard"
1616
"github.com/fsnotify/fsnotify"
17+
"github.com/mattn/go-runewidth"
18+
"unicode/utf8"
1719
"os"
1820
"sort"
1921
"math"
@@ -125,8 +127,12 @@ type Model struct {
125127

126128
// Streamer
127129
streamer *Streamer
130+
131+
// Cache
132+
layoutCache map[int][]string
128133
}
129134

135+
130136
func InitialModel(filename string, lines []string, reader io.Reader) Model {
131137
var streamer *Streamer
132138
if reader != nil {
@@ -184,6 +190,7 @@ func InitialModel(filename string, lines []string, reader io.Reader) Model {
184190
bookmarks: make(map[int]struct{}),
185191
showHelp: false,
186192
streamer: streamer,
193+
layoutCache: make(map[int][]string),
187194
}
188195
m.applyFilters(true)
189196
return m
@@ -277,6 +284,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
277284
m.viewport.Height = msg.Height - verticalMarginHeight
278285
}
279286

287+
// Invalidate cache on resize
288+
m.layoutCache = make(map[int][]string)
289+
280290
// Return early to avoid m.viewport.Update(msg) resetting Width to msg.Width
281291
return m, nil
282292
}
@@ -303,13 +313,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
303313
totalLines := len(m.filteredLines)
304314
if lineIndex >= 0 && lineIndex < totalLines {
305315
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
306-
m.selecting = true
307-
m.selectionStart = &Point{X: logicalX, Y: lineIndex}
308-
m.selectionEnd = &Point{X: logicalX, Y: lineIndex}
309-
// View update happens automatically on re-render
316+
targetLine, targetX := m.resolvePos(msg.X, msg.Y-m.headerHeight)
317+
if targetLine >= 0 && targetLine < totalLines {
318+
m.selecting = true
319+
m.selectionStart = &Point{X: targetX, Y: targetLine}
320+
m.selectionEnd = &Point{X: targetX, Y: targetLine}
321+
}
310322
} else if msg.Action == tea.MouseActionMotion && msg.Button == tea.MouseButtonLeft && m.selecting {
311-
m.selectionEnd = &Point{X: logicalX, Y: lineIndex}
312-
// View update happens automatically on re-render
323+
targetLine, targetX := m.resolvePos(msg.X, msg.Y-m.headerHeight)
324+
if targetLine >= 0 && targetLine < totalLines {
325+
m.selectionEnd = &Point{X: targetX, Y: targetLine}
326+
}
313327
} else if msg.Action == tea.MouseActionRelease && msg.Button == tea.MouseButtonLeft {
314328
m.selecting = false
315329
}
@@ -716,6 +730,7 @@ func (m *Model) applyFilters(resetView bool) {
716730
}
717731
}
718732

733+
719734
// 3. Text/Regex Filtering
720735
if m.filterText != "" {
721736
if m.regexMode {
@@ -729,6 +744,9 @@ func (m *Model) applyFilters(resetView bool) {
729744
}
730745
}
731746
}
747+
748+
// Tab Normalization (Fixes offset drift in selection)
749+
line = strings.ReplaceAll(line, "\t", " ")
732750

733751
filtered = append(filtered, line)
734752
}
@@ -786,6 +804,11 @@ func (m *Model) applyFilters(resetView bool) {
786804
}
787805
// Always clear viewport content as View() reconstructs it
788806
m.viewport.SetContent("")
807+
808+
// Clear height cache on filter change
809+
if resetView {
810+
m.layoutCache = make(map[int][]string)
811+
}
789812
}
790813

791814
func (m Model) View() string {
@@ -834,11 +857,7 @@ func (m Model) View() string {
834857
if m.wrap {
835858
// WRAP MODE
836859
// 0. Highlight Matches (Priority)
837-
line = highlightMatches(line, m.regex)
838-
line = highlightLine(line)
839-
if isBookmarked {
840-
line = "🔖 " + line
841-
}
860+
line = m.getDecoratedLine(realLineIndex, line)
842861

843862
// 1. Apply Selection
844863
if m.selectionStart != nil && m.selectionEnd != nil {
@@ -1353,3 +1372,165 @@ func (m *Model) copySelection() {
13531372
m.selectionEnd = nil
13541373
}
13551374
}
1375+
1376+
func (m Model) getDecoratedLine(i int, line string) string {
1377+
line = highlightMatches(line, m.regex)
1378+
line = highlightLine(line)
1379+
if _, ok := m.bookmarks[i]; ok {
1380+
line = "🔖 " + line
1381+
}
1382+
return line
1383+
}
1384+
1385+
func (m Model) resolvePos(visualX, visualY int) (int, int) {
1386+
if !m.wrap {
1387+
// Default behavior (No Wrap)
1388+
logicalLine := m.yOffset + visualY
1389+
gutterOffset := 3
1390+
logicalX := m.xOffset + visualX - gutterOffset
1391+
if logicalX < 0 { logicalX = 0 }
1392+
return logicalLine, logicalX
1393+
}
1394+
1395+
width := m.screenWidth
1396+
if width <= 0 { width = 80 }
1397+
1398+
currentVisualY := 0
1399+
targetLineIndex := -1
1400+
targetCharIndex := 0
1401+
1402+
// Iterate through lines starting from scroll offset
1403+
// Check until we reach the visualY we clicked on
1404+
for i := 0; i+m.yOffset < len(m.filteredLines); i++ {
1405+
idx := m.yOffset + i
1406+
line := m.filteredLines[idx]
1407+
1408+
// Use Cache to skip expensive wrapping
1409+
parts, cached := m.layoutCache[idx]
1410+
1411+
var plain string
1412+
1413+
if !cached {
1414+
// OPTIMIZATION: operate on plain string.
1415+
// Decoration adds ANSI (zero width) + potentially Bookmark (2 chars).
1416+
// Timestamps/JSON coloring are just ANSI.
1417+
// So wrapping 'plain' should match wrapping 'decorated'.
1418+
1419+
plain = stripAnsi(line)
1420+
if _, ok := m.bookmarks[idx]; ok {
1421+
plain = "🔖 " + plain
1422+
}
1423+
1424+
// Wrap plain text
1425+
wrapped := lipgloss.NewStyle().Width(width).Render(plain)
1426+
parts = strings.Split(wrapped, "\n")
1427+
m.layoutCache[idx] = parts
1428+
}
1429+
1430+
h := len(parts)
1431+
if visualY < currentVisualY + h {
1432+
// Found the line!
1433+
targetLineIndex = idx
1434+
1435+
// Need plain string now
1436+
if plain == "" {
1437+
plain = stripAnsi(line)
1438+
if _, ok := m.bookmarks[idx]; ok {
1439+
plain = "🔖 " + plain
1440+
}
1441+
}
1442+
1443+
// Reconstruct offset by matching parts against original plain line
1444+
originalClean := plain // This is what we built above
1445+
currentByteOffset := 0
1446+
currentRuneOffset := 0
1447+
1448+
localRow := visualY - currentVisualY
1449+
1450+
// Iterate up to localRow to find start index of current line segment
1451+
for k := 0; k < localRow; k++ {
1452+
part := parts[k]
1453+
1454+
// Safety check: if we already exceeded length, stop
1455+
if currentByteOffset >= len(originalClean) {
1456+
break
1457+
}
1458+
1459+
matchIdx := strings.Index(originalClean[currentByteOffset:], part)
1460+
if matchIdx == -1 {
1461+
// Fallbck: assume it was just skipped or expanded?
1462+
// Just advance by part length to be safe-ish
1463+
currentByteOffset += len(part)
1464+
currentRuneOffset += utf8.RuneCountInString(part)
1465+
} else {
1466+
// 1. Add skipped characters (e.g. spaces eaten by wrap)
1467+
if matchIdx > 0 {
1468+
skippedBytes := matchIdx
1469+
skippedPart := originalClean[currentByteOffset : currentByteOffset+skippedBytes]
1470+
currentRuneOffset += utf8.RuneCountInString(skippedPart)
1471+
currentByteOffset += skippedBytes
1472+
}
1473+
1474+
// 2. Add the part itself
1475+
currentByteOffset += len(part)
1476+
currentRuneOffset += utf8.RuneCountInString(part)
1477+
}
1478+
}
1479+
1480+
// Find start of current line (target line)
1481+
currentPart := parts[localRow]
1482+
startOfLineRuneIdx := currentRuneOffset
1483+
1484+
if currentByteOffset < len(originalClean) {
1485+
matchIdx := strings.Index(originalClean[currentByteOffset:], currentPart)
1486+
if matchIdx != -1 {
1487+
// Add any skipped chars before this line starts
1488+
skippedPart := originalClean[currentByteOffset : currentByteOffset+matchIdx]
1489+
startOfLineRuneIdx += utf8.RuneCountInString(skippedPart)
1490+
}
1491+
}
1492+
1493+
// Add current visual X logic (runewidth)
1494+
currentSegRunes := []rune(currentPart)
1495+
1496+
// Iterate runes to find which one covers visualX
1497+
cw := 0
1498+
foundIdx := len(currentSegRunes)
1499+
1500+
for rIdx, r := range currentSegRunes {
1501+
w := runewidth.RuneWidth(r)
1502+
if cw + w > visualX {
1503+
foundIdx = rIdx
1504+
break
1505+
}
1506+
cw += w
1507+
}
1508+
1509+
// If bookmarked, the first 2 chars are "🔖 " (idx 0, 1? rune length 2?)
1510+
// Bookmark is "🔖 " -> Rune count: 2 (Bookmark char + space).
1511+
// We want index into the LOG LINE (without bookmark).
1512+
1513+
finalIdx := startOfLineRuneIdx + foundIdx
1514+
1515+
if _, ok := m.bookmarks[idx]; ok {
1516+
// Original plain was "🔖 " + content
1517+
// We want index into content.
1518+
// "🔖 " is 2 runes?
1519+
bookmarkPrefixLen := utf8.RuneCountInString("🔖 ")
1520+
finalIdx -= bookmarkPrefixLen
1521+
if finalIdx < 0 { finalIdx = 0 }
1522+
}
1523+
1524+
targetCharIndex = finalIdx
1525+
return targetLineIndex, targetCharIndex
1526+
}
1527+
1528+
currentVisualY += h
1529+
1530+
if currentVisualY > visualY {
1531+
break
1532+
}
1533+
}
1534+
1535+
return -1, -1
1536+
}

internal/ui/model_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,87 @@ func TestApplyFilters(t *testing.T) {
7878
t.Errorf("Expected 3 lines (no INFO), got %d", len(m.filteredLines))
7979
}
8080
}
81+
82+
func TestResolvePos(t *testing.T) {
83+
// Setup a model with forced width
84+
lines := []string{
85+
"1234567890ABCDE", // 15 chars
86+
}
87+
m := InitialModel("test.log", lines, nil)
88+
m.screenWidth = 10
89+
m.wrap = true
90+
// Simulate View() logic indirectly by knowing how it should wrap
91+
// Row 0: "1234567890" (10 chars)
92+
// Row 1: "ABCDE" (5 chars)
93+
94+
tests := []struct{
95+
vX, vY int
96+
wantLine int
97+
wantIdx int
98+
}{
99+
{0, 0, 0, 0}, // Click '1'
100+
{9, 0, 0, 9}, // Click '0'
101+
{0, 1, 0, 10}, // Click 'A' (start of next row)
102+
{4, 1, 0, 14}, // Click 'E'
103+
{5, 1, 0, 15}, // Click after 'E'
104+
// Test bounds
105+
{20, 0, 0, 10}, // Click way right on first line
106+
}
107+
108+
for _, tt := range tests {
109+
l, idx := m.resolvePos(tt.vX, tt.vY)
110+
if l != tt.wantLine {
111+
t.Errorf("resolvePos(%d, %d) Line: got %d, want %d", tt.vX, tt.vY, l, tt.wantLine)
112+
}
113+
if idx != tt.wantIdx {
114+
t.Errorf("resolvePos(%d, %d) Idx: got %d, want %d", tt.vX, tt.vY, idx, tt.wantIdx)
115+
}
116+
}
117+
118+
// Space Consumption Hypothesis Check
119+
// "A B C" width 1 -> wraps to A / B / C.
120+
m2 := InitialModel("test2", []string{"A B C"}, nil)
121+
m2.screenWidth = 1
122+
m2.wrap = true
123+
124+
// Expect:
125+
// Row 0: "A"
126+
// Row 1: "B" (Space eaten?)
127+
// Row 2: "C"
128+
129+
// If we click "C" (visual X=0, Y=2)
130+
// Correct index in "A B C" is 4.
131+
// If spaces are eaten, prefix len sum might be 2.
132+
133+
l, idx := m2.resolvePos(0, 2)
134+
if l != 0 {
135+
t.Errorf("Space Check: Expected Line 0, got %d", l)
136+
}
137+
if idx != 4 {
138+
t.Errorf("Space Check: Expected Idx 4 (C), got %d. Drift detected!", idx)
139+
}
140+
}
141+
142+
func TestResolvePosPanic(t *testing.T) {
143+
// Regression test for "slice bounds out of range" panic
144+
// Occurs when byte/rune offsets are mixed
145+
line := "INFO 🚀 Startup complete" // Contains emoji (multibyte)
146+
m := InitialModel("panic.log", []string{line}, nil)
147+
m.screenWidth = 10
148+
m.wrap = true
149+
150+
// Wrapped likely:
151+
// "INFO 🚀 " (width 7? Emoji is 2 cells. I N F O _ 🚀 _) -> 5+2 = 7?
152+
// "Startup "
153+
// "complete"
154+
155+
// Simulate clicking deeply into the content
156+
// We mainly care that it DOES NOT PANIC.
157+
158+
// Testing many points
159+
for y := 0; y < 5; y++ {
160+
for x := 0; x < 20; x++ {
161+
m.resolvePos(x, y)
162+
}
163+
}
164+
}

0 commit comments

Comments
 (0)