mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-05 04:36:07 +00:00
Merge branch 'v2-exp' into v2-area
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+303
-24
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user