Skip to content

Commit edbb81c

Browse files
caarlos0tty2meowgorithm
authored
feat(viewport)!: gutter column, soft wrap, search highlight (#697)
* horizontal scroll * rebase branch * add tests * add tests with 2 cells symbols * trimLeft, move to charmbracelete/x/ansi lib * up ansi package * Update viewport/viewport.go Co-authored-by: Carlos Alexandro Becker <[email protected]> * fix: do not navigate out to the right * fix: cache line width on setcontent * fix tests * fix viewport tests * add test for preventing right overscroll * chore(viewport): increase horizontal step to 6 * chore(viewport): make horizontal scroll API better match vertical scroll API * fix: nolint * fix: use ansi.Cut * perf: do not cut anything if not needed * feat: expose HorizontalScrollPercent * fix: do not scroll if width is 0 Signed-off-by: Carlos Alexandro Becker <[email protected]> * fix: visible lines take frame into account * feat(viewport): column sign * feat: gutter, soft wrap * wip: search * wip: search * wip: search * fix: perf Signed-off-by: Carlos Alexandro Becker <[email protected]> * fix: rename * wip * wip * refactor: viewport highlight ranges * fix: ligloss update * doc: godoc * feat: fill height optional * fix: handle no content * fix: empty lines * wip * wip * Revert "wip" This reverts commit 933f181. * Reapply "wip" This reverts commit 0e3e31b. * fix: wide * fix: wide, find * still not quite there * fix: grapheme width * fix: cleanups * fix: refactors, improves highlight visibility * docs: godoc * chore: lipgloss update Signed-off-by: Carlos Alexandro Becker <[email protected]> * chore: x/ansi update Signed-off-by: Carlos Alexandro Becker <[email protected]> * fix: typos, godocs Signed-off-by: Carlos Alexandro Becker <[email protected]> * fix: rename Signed-off-by: Carlos Alexandro Becker <[email protected]> * fix: typo * fix: scroll when soft-wrapping * fix: soft wrap adjustments * fix: update Signed-off-by: Carlos Alexandro Becker <[email protected]> * fix: deps Signed-off-by: Carlos Alexandro Becker <[email protected]> --------- Signed-off-by: Carlos Alexandro Becker <[email protected]> Co-authored-by: Roman Suvorov <[email protected]> Co-authored-by: Roman Suvorov <[email protected]> Co-authored-by: Christian Rocha <[email protected]>
1 parent b2e3cc5 commit edbb81c

File tree

5 files changed

+646
-56
lines changed

5 files changed

+646
-56
lines changed

go.mod

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ go 1.18
55
require (
66
github.com/MakeNowJust/heredoc v1.0.0
77
github.com/atotto/clipboard v0.1.4
8-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1
8+
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250203222705-2e91ec2235cd
99
github.com/charmbracelet/harmonica v0.2.0
10-
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607
11-
github.com/charmbracelet/x/ansi v0.7.0
10+
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379d
11+
github.com/charmbracelet/x/ansi v0.8.0
1212
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f
1313
github.com/dustin/go-humanize v1.0.1
1414
github.com/lucasb-eyer/go-colorful v1.2.0
@@ -19,16 +19,14 @@ require (
1919

2020
require (
2121
github.com/aymanbagabas/go-udiff v0.2.0 // indirect
22-
github.com/charmbracelet/colorprofile v0.1.9 // indirect
23-
github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 // indirect
24-
github.com/charmbracelet/x/input v0.3.0 // indirect
22+
github.com/charmbracelet/colorprofile v0.2.0 // indirect
23+
github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07 // indirect
24+
github.com/charmbracelet/x/input v0.3.1 // indirect
2525
github.com/charmbracelet/x/term v0.2.1 // indirect
26-
github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 // indirect
2726
github.com/charmbracelet/x/windows v0.2.0 // indirect
2827
github.com/kylelemons/godebug v1.1.0 // indirect
2928
github.com/muesli/cancelreader v0.2.2 // indirect
3029
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
3130
golang.org/x/sync v0.10.0 // indirect
3231
golang.org/x/sys v0.29.0 // indirect
33-
golang.org/x/text v0.20.0 // indirect
3432
)

go.sum

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,36 +2,27 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
22
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
33
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
44
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
5+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
56
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
67
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
7-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0 h1:BWjXQRSwBjoCpLeNu8zT93n+NHhZZhkQQLveXMmnkYc=
8-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
9-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123204203-55f6f9f70bf6 h1:L2+Kl71AsucUpl32AqmbjVv/4Ha7dwlSFwqrU4sAeTE=
10-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123204203-55f6f9f70bf6/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
11-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210727-80fa20da7d7b h1:QqN3KApDbHJl+B1lVSir6GyRbxH7EA6U1SCDoxz8xYU=
12-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210727-80fa20da7d7b/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
13-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210853-99e3bbf892cd h1:1WsMNlPUaDXgJprIvWg+ZsXmc4GiL4KsBEFNZ3ymKeA=
14-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123210853-99e3bbf892cd/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
15-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1 h1:tktnM4YimEWSYd58iZlPDB3Xz25/r94VYZZsHK5zWL0=
16-
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250123211610-443afa6fa0c1/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8=
17-
github.com/charmbracelet/colorprofile v0.1.9 h1:5JnfvX+I9D6rRNu8xK3pgIqknaBVTXHU9pGu1jkZxLw=
18-
github.com/charmbracelet/colorprofile v0.1.9/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60=
8+
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250203222705-2e91ec2235cd h1:u+kqgSbIL4pnP7huv4kaYUCmuN2L4yyDvdH81QJ4FZ0=
9+
github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250203222705-2e91ec2235cd/go.mod h1:zaWGYfO6cBtu4dRgGbVN7yMYUz3j9sVIzHy2gn0jdo8=
10+
github.com/charmbracelet/colorprofile v0.2.0 h1:iiIQlp3LSvoJPtR11KoDfIf9wqWm2mn/iU420rHOZ/A=
11+
github.com/charmbracelet/colorprofile v0.2.0/go.mod h1:6wPrSSR4QtwYtOY3h0bLRw5YOUAIKWlZIJ02CTAsZsk=
1912
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
2013
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
21-
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607 h1:lERE4ow371r5WMqQAt7Eqlg1A4tBNA8T4RLwdXnKyBo=
22-
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607/go.mod h1:MD7Vb+O1zFRgBo+F94JHHuME7df8XBByNKuX5k/L/qs=
23-
github.com/charmbracelet/x/ansi v0.7.0 h1:/QfFmiXOGGwN6fRbzvQaYp7fu1pkxpZ3qFBZWBsP404=
24-
github.com/charmbracelet/x/ansi v0.7.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
25-
github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 h1:P90NI2rZuBISjB1HIHdkBDE+riKtVzIOi6Xun3qjUn8=
26-
github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72/go.mod h1:VXZSjC/QYH0t+9CG1qtcEx3XZubTDJb5ilWS6qJg4/0=
14+
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379d h1:wW4446FqrhqEHT96r2OVGNU0izi8siEybQVZ+qBRpJs=
15+
github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250204145343-96725424379d/go.mod h1:ZWl23X8o1vsQu8dpju10HKXepcMMlsHO8SwLl2OhmEU=
16+
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
17+
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
18+
github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07 h1:RFHEvURPMgGGd8epjjhi2UpXSKyFs39iRF4JTYCEdLg=
19+
github.com/charmbracelet/x/cellbuf v0.0.9-0.20250203222631-bea22a7f0a07/go.mod h1:dKfNBxLovpvzzxAP6/GZfs5eb7vNxHlUDnwGhRmvIdY=
2720
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w=
2821
github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
29-
github.com/charmbracelet/x/input v0.3.0 h1:lVzEz92E2u9jCU0mUwcyKeSOxkoeat+1eUkjzL0WCYI=
30-
github.com/charmbracelet/x/input v0.3.0/go.mod h1:M8CHPIYnmmiNHA17hqXmvSfeZLO2lj9pzJFX3aWvzgw=
22+
github.com/charmbracelet/x/input v0.3.1 h1:TE4s3fTRj+OUpJ86dKphrN99+NgBnto//EkWncMJQIg=
23+
github.com/charmbracelet/x/input v0.3.1/go.mod h1:4w9jS/NW62WrHSdmjbpzydvnbqkd+mtyK8WOWbHCdvs=
3124
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
3225
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
33-
github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 h1:14czE6R5CgOlvONsJYa2B1uTyLvXzGXpBqw2AyZeTh4=
34-
github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:hyua5CY63kyl7IfyIxv1SjVEqoKze/XmDkEglItuVjA=
3526
github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw=
3627
github.com/charmbracelet/x/windows v0.2.0/go.mod h1:ZibNFR49ZFqCXgP76sYanisxRyC+EYrBE7TTknD8s1s=
3728
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -40,10 +31,12 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
4031
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
4132
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
4233
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
34+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
4335
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
4436
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
4537
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
4638
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
39+
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
4740
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
4841
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
4942
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=
5649
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
5750
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
5851
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
59-
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
60-
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=

viewport/highlight.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package viewport
2+
3+
import (
4+
"github.com/charmbracelet/lipgloss/v2"
5+
"github.com/charmbracelet/x/ansi"
6+
"github.com/rivo/uniseg"
7+
)
8+
9+
// parseMatches converts the given matches into highlight ranges.
10+
//
11+
// Assumptions:
12+
// - matches are measured in bytes, e.g. what [regex.FindAllStringIndex] would return
13+
// - matches were made against the given content
14+
// - matches are in order
15+
// - matches do not overlap
16+
// - content is line terminated with \n only
17+
//
18+
// We'll then convert the ranges into [highlightInfo]s, which hold the starting
19+
// line and the grapheme positions.
20+
func parseMatches(
21+
content string,
22+
matches [][]int,
23+
) []highlightInfo {
24+
if len(matches) == 0 {
25+
return nil
26+
}
27+
28+
line := 0
29+
graphemePos := 0
30+
previousLinesOffset := 0
31+
bytePos := 0
32+
33+
highlights := make([]highlightInfo, 0, len(matches))
34+
gr := uniseg.NewGraphemes(ansi.Strip(content))
35+
36+
for _, match := range matches {
37+
byteStart, byteEnd := match[0], match[1]
38+
39+
// hilight for this match:
40+
hi := highlightInfo{
41+
lines: map[int][2]int{},
42+
}
43+
44+
// find the beginning of this byte range, setup current line and
45+
// grapheme position.
46+
for byteStart > bytePos {
47+
if !gr.Next() {
48+
break
49+
}
50+
if content[bytePos] == '\n' {
51+
previousLinesOffset = graphemePos + 1
52+
line++
53+
}
54+
graphemePos += max(1, gr.Width())
55+
bytePos += len(gr.Str())
56+
}
57+
58+
hi.lineStart = line
59+
hi.lineEnd = line
60+
61+
graphemeStart := graphemePos
62+
63+
// loop until we find the end
64+
for byteEnd > bytePos {
65+
if !gr.Next() {
66+
break
67+
}
68+
69+
// if it ends with a new line, add the range, increase line, and continue
70+
if content[bytePos] == '\n' {
71+
colstart := max(0, graphemeStart-previousLinesOffset)
72+
colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself
73+
74+
if colend > colstart {
75+
hi.lines[line] = [2]int{colstart, colend}
76+
hi.lineEnd = line
77+
}
78+
79+
previousLinesOffset = graphemePos + 1
80+
line++
81+
}
82+
83+
graphemePos += max(1, gr.Width())
84+
bytePos += len(gr.Str())
85+
}
86+
87+
// we found it!, add highlight and continue
88+
if bytePos == byteEnd {
89+
colstart := max(0, graphemeStart-previousLinesOffset)
90+
colend := max(graphemePos-previousLinesOffset, colstart)
91+
92+
if colend > colstart {
93+
hi.lines[line] = [2]int{colstart, colend}
94+
hi.lineEnd = line
95+
}
96+
}
97+
98+
highlights = append(highlights, hi)
99+
}
100+
101+
return highlights
102+
}
103+
104+
type highlightInfo struct {
105+
// in which line this highlight starts and ends
106+
lineStart, lineEnd int
107+
108+
// the grapheme highlight ranges for each of these lines
109+
lines map[int][2]int
110+
}
111+
112+
// coords returns the line x column of this highlight.
113+
func (hi highlightInfo) coords() (int, int, int) {
114+
for i := hi.lineStart; i <= hi.lineEnd; i++ {
115+
hl, ok := hi.lines[i]
116+
if !ok {
117+
continue
118+
}
119+
return i, hl[0], hl[1]
120+
}
121+
return hi.lineStart, 0, 0
122+
}
123+
124+
func makeHighlightRanges(
125+
highlights []highlightInfo,
126+
line int,
127+
style lipgloss.Style,
128+
) []lipgloss.Range {
129+
result := []lipgloss.Range{}
130+
for _, hi := range highlights {
131+
lihi, ok := hi.lines[line]
132+
if !ok {
133+
continue
134+
}
135+
if lihi == [2]int{} {
136+
continue
137+
}
138+
result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style))
139+
}
140+
return result
141+
}

0 commit comments

Comments
 (0)