@@ -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+
130136func 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
791814func (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+ }
0 commit comments