diff --git a/go.mod b/go.mod index 5bb6b70..43f6df2 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,10 @@ go 1.18 require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/atotto/clipboard v0.1.4 - github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1 + github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250203222705-2e91ec2235cd github.com/charmbracelet/harmonica v0.2.0 - github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607 - github.com/charmbracelet/x/ansi v0.7.0 + github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379d + github.com/charmbracelet/x/ansi v0.8.0 github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f github.com/dustin/go-humanize v1.0.1 github.com/lucasb-eyer/go-colorful v1.2.0 @@ -19,16 +19,14 @@ require ( require ( github.com/aymanbagabas/go-udiff v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.1.9 // indirect - github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 // indirect - github.com/charmbracelet/x/input v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07 // indirect + github.com/charmbracelet/x/input v0.3.1 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 // indirect github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.20.0 // indirect ) diff --git a/go.sum b/go.sum index 7a9bc21..27bba77 100644 --- a/go.sum +++ b/go.sum @@ -2,36 +2,27 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0 h1:BWjXQRSwBjoCpLeNu8zT93n+NHhZZhkQQLveXMmnkYc= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123204203-55f6f9f70bf6 h1:L2+Kl71AsucUpl32AqmbjVv/4Ha7dwlSFwqrU4sAeTE= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123204203-55f6f9f70bf6/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210727-80fa20da7d7b h1:QqN3KApDbHJl+B1lVSir6GyRbxH7EA6U1SCDoxz8xYU= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210727-80fa20da7d7b/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210853-99e3bbf892cd h1:1WsMNlPUaDXgJprIvWg+ZsXmc4GiL4KsBEFNZ3ymKeA= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210853-99e3bbf892cd/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1 h1:tktnM4YimEWSYd58iZlPDB3Xz25/r94VYZZsHK5zWL0= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8= -github.com/charmbracelet/colorprofile v0.1.9 h1:5JnfvX+I9D6rRNu8xK3pgIqknaBVTXHU9pGu1jkZxLw= -github.com/charmbracelet/colorprofile v0.1.9/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250203222705-2e91ec2235cd h1:u+kqgSbIL4pnP7huv4kaYUCmuN2L4yyDvdH81QJ4FZ0= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250203222705-2e91ec2235cd/go.mod h1:zaWGYfO6cBtu4dRgGbVN7yMYUz3j9sVIzHy2gn0jdo8= +github.com/charmbracelet/colorprofile v0.2.0 h1:iiIQlp3LSvoJPtR11KoDfIf9wqWm2mn/iU420rHOZ/A= +github.com/charmbracelet/colorprofile v0.2.0/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607 h1:lERE4ow371r5WMqQAt7Eqlg1A4tBNA8T4RLwdXnKyBo= -github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607/go.mod h1:MD7Vb+O1zFRgBo+F94JHHuME7df8XBByNKuX5k/L/qs= -github.com/charmbracelet/x/ansi v0.7.0 h1:/QfFmiXOGGwN6fRbzvQaYp7fu1pkxpZ3qFBZWBsP404= -github.com/charmbracelet/x/ansi v0.7.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= -github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 h1:P90NI2rZuBISjB1HIHdkBDE+riKtVzIOi6Xun3qjUn8= -github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72/go.mod h1:VXZSjC/QYH0t+9CG1qtcEx3XZubTDJb5ilWS6qJg4/0= +github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379d h1:wW4446FqrhqEHT96r2OVGNU0izi8siEybQVZ+qBRpJs= +github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379d/go.mod h1:ZWl23X8o1vsQu8dpju10HKXepcMMlsHO8SwLl2OhmEU= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07 h1:RFHEvURPMgGGd8epjjhi2UpXSKyFs39iRF4JTYCEdLg= +github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07/go.mod h1:dKfNBxLovpvzzxAP6/GZfs5eb7vNxHlUDnwGhRmvIdY= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/input v0.3.0 h1:lVzEz92E2u9jCU0mUwcyKeSOxkoeat+1eUkjzL0WCYI= -github.com/charmbracelet/x/input v0.3.0/go.mod h1:M8CHPIYnmmiNHA17hqXmvSfeZLO2lj9pzJFX3aWvzgw= +github.com/charmbracelet/x/input v0.3.1 h1:TE4s3fTRj+OUpJ86dKphrN99+NgBnto//EkWncMJQIg= +github.com/charmbracelet/x/input v0.3.1/go.mod h1:4w9jS/NW62WrHSdmjbpzydvnbqkd+mtyK8WOWbHCdvs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 h1:14czE6R5CgOlvONsJYa2B1uTyLvXzGXpBqw2AyZeTh4= -github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:hyua5CY63kyl7IfyIxv1SjVEqoKze/XmDkEglItuVjA= github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -40,10 +31,12 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -56,5 +49,3 @@ golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= diff --git a/viewport/highlight.go b/viewport/highlight.go new file mode 100644 index 0000000..ec0ffda --- /dev/null +++ b/viewport/highlight.go @@ -0,0 +1,141 @@ +package viewport + +import ( + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" +) + +// parseMatches converts the given matches into highlight ranges. +// +// Assumptions: +// - matches are measured in bytes, e.g. what [regex.FindAllStringIndex] would return +// - matches were made against the given content +// - matches are in order +// - matches do not overlap +// - content is line terminated with \n only +// +// We'll then convert the ranges into [highlightInfo]s, which hold the starting +// line and the grapheme positions. +func parseMatches( + content string, + matches [][]int, +) []highlightInfo { + if len(matches) == 0 { + return nil + } + + line := 0 + graphemePos := 0 + previousLinesOffset := 0 + bytePos := 0 + + highlights := make([]highlightInfo, 0, len(matches)) + gr := uniseg.NewGraphemes(ansi.Strip(content)) + + for _, match := range matches { + byteStart, byteEnd := match[0], match[1] + + // hilight for this match: + hi := highlightInfo{ + lines: map[int][2]int{}, + } + + // find the beginning of this byte range, setup current line and + // grapheme position. + for byteStart > bytePos { + if !gr.Next() { + break + } + if content[bytePos] == '\n' { + previousLinesOffset = graphemePos + 1 + line++ + } + graphemePos += max(1, gr.Width()) + bytePos += len(gr.Str()) + } + + hi.lineStart = line + hi.lineEnd = line + + graphemeStart := graphemePos + + // loop until we find the end + for byteEnd > bytePos { + if !gr.Next() { + break + } + + // if it ends with a new line, add the range, increase line, and continue + if content[bytePos] == '\n' { + colstart := max(0, graphemeStart-previousLinesOffset) + colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself + + if colend > colstart { + hi.lines[line] = [2]int{colstart, colend} + hi.lineEnd = line + } + + previousLinesOffset = graphemePos + 1 + line++ + } + + graphemePos += max(1, gr.Width()) + bytePos += len(gr.Str()) + } + + // we found it!, add highlight and continue + if bytePos == byteEnd { + colstart := max(0, graphemeStart-previousLinesOffset) + colend := max(graphemePos-previousLinesOffset, colstart) + + if colend > colstart { + hi.lines[line] = [2]int{colstart, colend} + hi.lineEnd = line + } + } + + highlights = append(highlights, hi) + } + + return highlights +} + +type highlightInfo struct { + // in which line this highlight starts and ends + lineStart, lineEnd int + + // the grapheme highlight ranges for each of these lines + lines map[int][2]int +} + +// coords returns the line x column of this highlight. +func (hi highlightInfo) coords() (int, int, int) { + for i := hi.lineStart; i <= hi.lineEnd; i++ { + hl, ok := hi.lines[i] + if !ok { + continue + } + return i, hl[0], hl[1] + } + return hi.lineStart, 0, 0 +} + +func makeHighlightRanges( + highlights []highlightInfo, + line int, + style lipgloss.Style, +) []lipgloss.Range { + result := []lipgloss.Range{} + for _, hi := range highlights { + lihi, ok := hi.lines[line] + if !ok { + continue + } + if lihi == [2]int{} { + continue + } + result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style)) + } + return result +} diff --git a/viewport/viewport.go b/viewport/viewport.go index cbabc28..5e6c68e 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -1,6 +1,7 @@ package viewport import ( + "fmt" "math" "strings" @@ -52,6 +53,13 @@ type Model struct { height int KeyMap KeyMap + // Whether or not to wrap text. If false, it'll allow horizontal scrolling + // instead. + SoftWrap bool + + // Whether or not to fill to the height of the viewport with empty lines. + FillHeight bool + // Whether or not to respond to the mouse. The mouse must be enabled in // Bubble Tea for this to work. For details, see the Bubble Tea docs. MouseWheelEnabled bool @@ -77,9 +85,54 @@ type Model struct { // useful for setting borders, margins and padding. Style lipgloss.Style + // LeftGutterFunc allows to define a [GutterFunc] that adds a column into + // the left of the viewport, which is kept when horizontal scrolling. + // This can be used for things like line numbers, selection indicators, + // show statuses, etc. + LeftGutterFunc GutterFunc + initialized bool lines []string longestLineWidth int + + // HighlightStyle highlights the ranges set with [SetHighligths]. + HighlightStyle lipgloss.Style + + // SelectedHighlightStyle highlights the highlight range focused during + // navigation. + // Use [SetHighligths] to set the highlight ranges, and [HightlightNext] + // and [HihglightPrevious] to navigate. + SelectedHighlightStyle lipgloss.Style + + highlights []highlightInfo + hiIdx int + memoizedMatchedLines []string +} + +// GutterFunc can be implemented and set into [Model.LeftGutterFunc]. +type GutterFunc func(GutterContext) string + +// LineNumberGutter return a [GutterFunc] that shows line numbers. +func LineNumberGutter(style lipgloss.Style) GutterFunc { + return func(info GutterContext) string { + if info.Soft { + return style.Render(" │ ") + } + if info.Index >= info.TotalLines { + return style.Render(" ~ │ ") + } + return style.Render(fmt.Sprintf("%4d │ ", info.Index+1)) + } +} + +// NoGutter is the default gutter used. +var NoGutter = func(GutterContext) string { return "" } + +// GutterContext provides context to a [GutterFunc]. +type GutterContext struct { + Index int + TotalLines int + Soft bool } func (m *Model) setInitialValues() { @@ -88,6 +141,7 @@ func (m *Model) setInitialValues() { m.MouseWheelDelta = 3 m.initialized = true m.horizontalStep = defaultHorizontalStep + m.LeftGutterFunc = NoGutter } // Init exists to satisfy the tea.Model interface for composability purposes. @@ -134,12 +188,13 @@ func (m Model) PastBottom() bool { // ScrollPercent returns the amount scrolled as a float between 0 and 1. func (m Model) ScrollPercent() float64 { - if m.Height() >= len(m.lines) { + count := m.lineCount() + if m.Height() >= count { return 1.0 } y := float64(m.YOffset) h := float64(m.Height()) - t := float64(len(m.lines)) + t := float64(count) v := y / (t - h) return math.Max(0.0, math.Min(1.0, v)) } @@ -158,43 +213,174 @@ func (m Model) HorizontalScrollPercent() float64 { } // SetContent set the pager's text content. +// Line endings will be normalized to '\n'. func (m *Model) SetContent(s string) { s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings m.lines = strings.Split(s, "\n") - m.longestLineWidth = findLongestLineWidth(m.lines) + // if there's no content, set content to actual nil instead of one empty + // line. + if len(m.lines) == 1 && ansi.StringWidth(m.lines[0]) == 0 { + m.lines = nil + } + m.longestLineWidth = maxLineWidth(m.lines) + m.ClearHighlights() - if m.YOffset > len(m.lines)-1 { + if m.YOffset > m.maxYOffset() { m.GotoBottom() } } +// GetContent returns the entire content as a single string. +// Line endings are normalized to '\n'. +func (m Model) GetContent() string { + return strings.Join(m.lines, "\n") +} + +// calculateLine taking soft wrapiing into account, returns the total viewable +// lines and the real-line index for the given yoffset. +func (m Model) calculateLine(yoffset int) (total, idx int) { + if !m.SoftWrap { + return len(m.lines), yoffset + } + maxWidth := m.maxWidth() + gutterSize := lipgloss.Width(m.LeftGutterFunc(GutterContext{})) + for i, line := range m.lines { + adjust := max(1, ansi.StringWidth(line)/(maxWidth-gutterSize)) + if yoffset >= total && yoffset < total+adjust { + idx = i + } + total += adjust + } + return total, idx +} + +// lineToIndex taking soft wrappign into account, return the real line index +// for the given line. +func (m Model) lineToIndex(y int) int { + _, idx := m.calculateLine(y) + return idx +} + +// lineCount taking soft wrapping into account, return the total viewable line +// count (real lines + soft wrapped line). +func (m Model) lineCount() int { + total, _ := m.calculateLine(0) + return total +} + // maxYOffset returns the maximum possible value of the y-offset based on the // viewport's content and set height. func (m Model) maxYOffset() int { - return max(0, len(m.lines)-m.Height()+m.Style.GetVerticalFrameSize()) + return max(0, m.lineCount()-m.Height()+m.Style.GetVerticalFrameSize()) +} + +// maxXOffset returns the maximum possible value of the x-offset based on the +// viewport's content and set width. +func (m Model) maxXOffset() int { + return max(0, m.longestLineWidth-m.Width()) +} + +func (m Model) maxWidth() int { + return m.Width() - + m.Style.GetHorizontalFrameSize() - + lipgloss.Width(m.LeftGutterFunc(GutterContext{})) +} + +func (m Model) maxHeight() int { + return m.Height() - m.Style.GetVerticalFrameSize() } // visibleLines returns the lines that should currently be visible in the // viewport. func (m Model) visibleLines() (lines []string) { - h := m.Height() - m.Style.GetVerticalFrameSize() - w := m.Width() - m.Style.GetHorizontalFrameSize() + maxHeight := m.maxHeight() + maxWidth := m.maxWidth() - if len(m.lines) > 0 { - top := max(0, m.YOffset) - bottom := clamp(m.YOffset+h, top, len(m.lines)) - lines = m.lines[top:bottom] + if m.lineCount() > 0 { + pos := m.lineToIndex(m.YOffset) + top := max(0, pos) + bottom := clamp(pos+maxHeight, top, len(m.lines)) + lines = make([]string, bottom-top) + copy(lines, m.lines[top:bottom]) + lines = m.highlightLines(lines, top) } - if (m.xOffset == 0 && m.longestLineWidth <= w) || w == 0 { + for m.FillHeight && len(lines) < maxHeight { + lines = append(lines, "") + } + + // if longest line fit within width, no need to do anything else. + if (m.xOffset == 0 && m.longestLineWidth <= maxWidth) || maxWidth == 0 { + return m.prependColumn(lines) + } + + if m.SoftWrap { + return m.softWrap(lines, maxWidth) + } + + for i := range lines { + lines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+maxWidth) + } + return m.prependColumn(lines) +} + +func (m Model) highlightLines(lines []string, offset int) []string { + if len(m.highlights) == 0 { return lines } - - cutLines := make([]string, len(lines)) for i := range lines { - cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+w) + if memoized := m.memoizedMatchedLines[i+offset]; memoized != "" { + lines[i] = memoized + } else { + ranges := makeHighlightRanges( + m.highlights, + i+offset, + m.HighlightStyle, + ) + lines[i] = lipgloss.StyleRanges(lines[i], ranges...) + m.memoizedMatchedLines[i+offset] = lines[i] + } + if m.hiIdx < 0 { + continue + } + sel := m.highlights[m.hiIdx] + if hi, ok := sel.lines[i+offset]; ok { + lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange( + hi[0], + hi[1], + m.SelectedHighlightStyle, + )) + } } - return cutLines + return lines +} + +func (m Model) softWrap(lines []string, maxWidth int) []string { + var wrappedLines []string + for i, line := range lines { + idx := 0 + for ansi.StringWidth(line) >= idx { + truncatedLine := ansi.Cut(line, idx, maxWidth+idx) + wrappedLines = append(wrappedLines, m.LeftGutterFunc(GutterContext{ + Index: i + m.YOffset, + TotalLines: m.TotalLineCount(), + Soft: idx > 0, + })+truncatedLine) + idx += maxWidth + } + } + return wrappedLines +} + +func (m Model) prependColumn(lines []string) []string { + result := make([]string, len(lines)) + for i, line := range lines { + result[i] = m.LeftGutterFunc(GutterContext{ + Index: i + m.YOffset, + TotalLines: m.TotalLineCount(), + }) + line + } + return result } // SetYOffset sets the Y offset. @@ -202,6 +388,31 @@ func (m *Model) SetYOffset(n int) { m.YOffset = clamp(n, 0, m.maxYOffset()) } +// SetXOffset sets the X offset. +// No-op when soft wrap is enabled. +func (m *Model) SetXOffset(n int) { + if m.SoftWrap { + return + } + m.xOffset = clamp(n, 0, m.maxXOffset()) +} + +// EnsureVisible ensures that the given line and column are in the viewport. +func (m *Model) EnsureVisible(line, colstart, colend int) { + maxWidth := m.maxWidth() + if colend <= maxWidth { + m.SetXOffset(0) + } else { + m.SetXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural + } + + if line < m.YOffset || line >= m.YOffset+m.maxHeight() { + m.SetYOffset(line) + } + + m.visibleLines() +} + // ViewDown moves the view down by the number of lines in the viewport. // Basically, "page down". func (m *Model) ViewDown() { @@ -249,6 +460,7 @@ func (m *Model) LineDown(n int) { // greater than the number of lines we actually have left before we reach // the bottom. m.SetYOffset(m.YOffset + n) + m.hiIdx = m.findNearedtMatch() } // LineUp moves the view down by the given number of lines. Returns the new @@ -261,11 +473,12 @@ func (m *Model) LineUp(n int) { // Make sure the number of lines by which we're going to scroll isn't // greater than the number of lines we are from the top. m.SetYOffset(m.YOffset - n) + m.hiIdx = m.findNearedtMatch() } // TotalLineCount returns the total number of lines (both hidden and visible) within the viewport. func (m Model) TotalLineCount() int { - return len(m.lines) + return m.lineCount() } // VisibleLineCount returns the number of the visible lines within the viewport. @@ -280,12 +493,14 @@ func (m *Model) GotoTop() (lines []string) { } m.SetYOffset(0) + m.hiIdx = m.findNearedtMatch() return m.visibleLines() } // GotoBottom sets the viewport to the bottom position. func (m *Model) GotoBottom() (lines []string) { m.SetYOffset(m.maxYOffset()) + m.hiIdx = m.findNearedtMatch() return m.visibleLines() } @@ -311,7 +526,8 @@ func (m *Model) MoveLeft(cols int) { // MoveRight moves viewport to the right by the given number of columns. func (m *Model) MoveRight(cols int) { // prevents over scrolling to the right - if m.xOffset >= m.longestLineWidth-m.Width() { + w := m.maxWidth() + if m.xOffset > m.longestLineWidth-w { return } m.xOffset += cols @@ -322,6 +538,68 @@ func (m *Model) ResetIndent() { m.xOffset = 0 } +// SetHighlights sets ranges of characters to highlight. +// For instance, `[]int{[]int{2, 10}, []int{20, 30}}` will highlight characters +// 2 to 10 and 20 to 30. +// Note that highlights are not expected to transpose each other, and are also +// expected to be in order. +// Use [Model.SetHighlights] to set the highlight ranges, and +// [Model.HighlightNext] and [Model.HighlightPrevious] to navigate. +// Use [Model.ClearHighlights] to remove all highlights. +func (m *Model) SetHighlights(matches [][]int) { + if len(matches) == 0 || len(m.lines) == 0 { + return + } + m.memoizedMatchedLines = make([]string, len(m.lines)) + m.highlights = parseMatches(m.GetContent(), matches) + m.hiIdx = m.findNearedtMatch() + m.showHighlight() +} + +// ClearHighlights clears previously set highlights. +func (m *Model) ClearHighlights() { + m.memoizedMatchedLines = nil + m.highlights = nil + m.hiIdx = -1 +} + +func (m *Model) showHighlight() { + if m.hiIdx == -1 { + return + } + line, colstart, colend := m.highlights[m.hiIdx].coords() + m.EnsureVisible(line, colstart, colend) +} + +// HighlightNext highlights the next match. +func (m *Model) HighlightNext() { + if m.highlights == nil { + return + } + + m.hiIdx = (m.hiIdx + 1) % len(m.highlights) + m.showHighlight() +} + +// HighlightPrevious highlights the previous match. +func (m *Model) HighlightPrevious() { + if m.highlights == nil { + return + } + + m.hiIdx = (m.hiIdx - 1 + len(m.highlights)) % len(m.highlights) + m.showHighlight() +} + +func (m Model) findNearedtMatch() int { + for i, match := range m.highlights { + if match.lineStart >= m.YOffset { + return i + } + } + return -1 +} + // Update handles standard message-based viewport updates. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m = m.updateAsModel(msg) @@ -423,12 +701,13 @@ func max(a, b int) int { return b } -func findLongestLineWidth(lines []string) int { - w := 0 - for _, l := range lines { - if ww := ansi.StringWidth(l); ww > w { - w = ww +func maxLineWidth(lines []string) int { + maxlen := 0 + for _, line := range lines { + llen := ansi.StringWidth(line) + if llen > maxlen { + maxlen = llen } } - return w + return maxlen } diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index 180cddb..860d0d0 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -1,8 +1,12 @@ package viewport import ( + "reflect" + "regexp" "strings" "testing" + + "github.com/charmbracelet/x/ansi" ) func TestNew(t *testing.T) { @@ -381,3 +385,180 @@ func TestRightOverscroll(t *testing.T) { } }) } + +func TestMatchesToHighlights(t *testing.T) { + content := `hello +world + +with empty rows + +wide chars: あいうえおafter + +爱开源 • Charm does open source + +Charm热爱开源 • Charm loves open source +` + + vt := New(WithWidth(100), WithHeight(100)) + vt.SetContent(content) + + t.Run("first", func(t *testing.T) { + testHighlights(t, content, regexp.MustCompile("hello"), []highlightInfo{ + { + lineStart: 0, + lineEnd: 0, + lines: map[int][2]int{ + 0: {0, 5}, + }, + }, + }) + }) + + t.Run("multiple", func(t *testing.T) { + testHighlights(t, content, regexp.MustCompile("l"), []highlightInfo{ + { + lineStart: 0, + lineEnd: 0, + lines: map[int][2]int{ + 0: {2, 3}, + }, + }, + { + lineStart: 0, + lineEnd: 0, + lines: map[int][2]int{ + 0: {3, 4}, + }, + }, + { + lineStart: 1, + lineEnd: 1, + lines: map[int][2]int{ + 1: {3, 4}, + }, + }, + { + lineStart: 9, + lineEnd: 9, + lines: map[int][2]int{ + 9: {22, 23}, + }, + }, + }) + }) + + t.Run("span lines", func(t *testing.T) { + testHighlights(t, content, regexp.MustCompile("lo\nwo"), []highlightInfo{ + { + lineStart: 0, + lineEnd: 1, + lines: map[int][2]int{ + 0: {3, 6}, + 1: {0, 2}, + }, + }, + }) + }) + + t.Run("ends with newline", func(t *testing.T) { + testHighlights(t, content, regexp.MustCompile("lo\n"), []highlightInfo{ + { + lineStart: 0, + lineEnd: 0, + lines: map[int][2]int{ + 0: {3, 6}, + }, + }, + }) + }) + + t.Run("empty lines in the text", func(t *testing.T) { + testHighlights(t, content, regexp.MustCompile("ith"), []highlightInfo{ + { + lineStart: 3, + lineEnd: 3, + lines: map[int][2]int{ + 3: {1, 4}, + }, + }, + }) + }) + + t.Run("empty lines in the text match start of new line", func(t *testing.T) { + testHighlights(t, content, regexp.MustCompile("with"), []highlightInfo{ + { + lineStart: 3, + lineEnd: 3, + lines: map[int][2]int{ + 3: {0, 4}, + }, + }, + }) + }) + + t.Run("wide characteres", func(t *testing.T) { + testHighlights(t, content, regexp.MustCompile("after"), []highlightInfo{ + { + lineStart: 5, + lineEnd: 5, + lines: map[int][2]int{ + 5: {22, 27}, + }, + }, + }) + }) + + t.Run("wide 2", func(t *testing.T) { + testHighlights(t, content, regexp.MustCompile("Charm"), []highlightInfo{ + { + lineStart: 7, + lineEnd: 7, + lines: map[int][2]int{ + 7: {9, 14}, + }, + }, + { + lineStart: 9, + lineEnd: 9, + lines: map[int][2]int{ + 9: {0, 5}, + }, + }, + { + lineStart: 9, + lineEnd: 9, + lines: map[int][2]int{ + 9: {16, 21}, + }, + }, + }) + }) +} + +func testHighlights(tb testing.TB, content string, re *regexp.Regexp, expect []highlightInfo) { + tb.Helper() + + vt := New(WithHeight(100), WithWidth(100)) + vt.SetContent(content) + + matches := re.FindAllStringIndex(vt.GetContent(), -1) + vt.SetHighlights(matches) + + if !reflect.DeepEqual(expect, vt.highlights) { + tb.Errorf("\nexpect: %+v\n got: %+v\n", expect, vt.highlights) + } + + if strings.Contains(re.String(), "\n") { + tb.Log("cannot check text when regex has span lines") + return + } + + for _, hi := range expect { + for line, hl := range hi.lines { + cut := ansi.Cut(vt.lines[line], hl[0], hl[1]) + if !re.MatchString(cut) { + tb.Errorf("exptect to match '%s', got '%s': line: %d, cut: %+v", re.String(), cut, line, hl) + } + } + } +}