Skip to content

Commit 08fed8a

Browse files
committed
feat: introduce vertical scrollbar
1 parent c3e07c9 commit 08fed8a

File tree

6 files changed

+258
-0
lines changed

6 files changed

+258
-0
lines changed

scrollbar/example/example.gif

1.17 MB
Loading

scrollbar/example/example.tape

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
Output example.gif
2+
3+
Require go
4+
5+
Hide
6+
7+
Type@0 "go run main.go" Enter
8+
Sleep 3s
9+
10+
Show
11+
12+
Space@100ms 100
13+
Up@100ms 50
14+
Down@100ms 30
15+
16+
Sleep 3s

scrollbar/example/main.go

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/charmbracelet/bubbles/scrollbar"
8+
"github.com/charmbracelet/bubbles/viewport"
9+
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/charmbracelet/lipgloss"
11+
)
12+
13+
func newModel() model {
14+
// Viewport
15+
vp := viewport.New(0, 0)
16+
17+
// Scrollbar
18+
sb := scrollbar.NewVertical()
19+
sb.Style = sb.Style.Border(lipgloss.RoundedBorder(), true)
20+
21+
return model{
22+
viewport: vp,
23+
scrollbar: sb,
24+
}
25+
}
26+
27+
type model struct {
28+
content string
29+
viewport viewport.Model
30+
scrollbar tea.Model
31+
}
32+
33+
func (m model) Init() tea.Cmd {
34+
return nil
35+
}
36+
37+
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
38+
var (
39+
cmd tea.Cmd
40+
cmds []tea.Cmd
41+
)
42+
43+
switch msg := msg.(type) {
44+
case tea.KeyMsg:
45+
switch msg.String() {
46+
case "ctrl+c", "q", "esc":
47+
return m, tea.Quit
48+
case " ":
49+
if m.content != "" {
50+
m.content += "\n"
51+
}
52+
m.content += fmt.Sprintf("%02d: Lorem ipsum dolor sit amet, consectetur adipiscing elit.", lipgloss.Height(m.content)-1)
53+
}
54+
case tea.WindowSizeMsg:
55+
// Update viewport size
56+
m.viewport.Width = msg.Width - 3
57+
m.viewport.Height = msg.Height
58+
59+
// Update scrollbar height
60+
m.scrollbar, cmd = m.scrollbar.Update(scrollbar.HeightMsg(msg.Height))
61+
cmds = append(cmds, cmd)
62+
}
63+
64+
m.viewport.SetContent(m.content)
65+
m.viewport, cmd = m.viewport.Update(msg)
66+
cmds = append(cmds, cmd)
67+
68+
// Update scrollbar viewport
69+
m.scrollbar, cmd = m.scrollbar.Update(m.viewport)
70+
cmds = append(cmds, cmd)
71+
72+
return m, tea.Batch(cmds...)
73+
}
74+
75+
func (m model) View() string {
76+
if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() {
77+
return lipgloss.JoinHorizontal(lipgloss.Left,
78+
m.viewport.View(),
79+
m.scrollbar.View(),
80+
)
81+
}
82+
83+
return m.viewport.View()
84+
}
85+
86+
func main() {
87+
p := tea.NewProgram(
88+
newModel(),
89+
tea.WithAltScreen(),
90+
tea.WithMouseCellMotion(),
91+
)
92+
93+
if _, err := p.Run(); err != nil {
94+
fmt.Println("could not run program:", err)
95+
os.Exit(1)
96+
}
97+
}

scrollbar/scrollbar.go

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package scrollbar
2+
3+
type Msg struct {
4+
Total int
5+
Visible int
6+
Offset int
7+
}
8+
9+
type HeightMsg int
10+
11+
func min(a, b int) int {
12+
if a < b {
13+
return a
14+
}
15+
return b
16+
}
17+
18+
func max(a, b int) int {
19+
if a > b {
20+
return a
21+
}
22+
return b
23+
}

scrollbar/vertical.go

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package scrollbar
2+
3+
import (
4+
"github.com/charmbracelet/bubbles/viewport"
5+
tea "github.com/charmbracelet/bubbletea"
6+
"github.com/charmbracelet/lipgloss"
7+
"math"
8+
"strings"
9+
)
10+
11+
func NewVertical() Vertical {
12+
return Vertical{
13+
Style: lipgloss.NewStyle().Width(1),
14+
ThumbStyle: lipgloss.NewStyle().SetString("█"),
15+
TrackStyle: lipgloss.NewStyle().SetString("░"),
16+
}
17+
}
18+
19+
type Vertical struct {
20+
Style lipgloss.Style
21+
ThumbStyle lipgloss.Style
22+
TrackStyle lipgloss.Style
23+
height int
24+
thumbHeight int
25+
thumbOffset int
26+
}
27+
28+
func (m Vertical) Init() tea.Cmd {
29+
return nil
30+
}
31+
32+
func (m Vertical) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33+
switch msg := msg.(type) {
34+
case Msg:
35+
m.thumbHeight, m.thumbOffset = m.computeThumb(msg.Total, msg.Visible, msg.Offset)
36+
case HeightMsg:
37+
m.height = m.computeHeight(int(msg))
38+
case viewport.Model:
39+
m.thumbHeight, m.thumbOffset = m.computeThumb(msg.TotalLineCount(), msg.VisibleLineCount(), msg.YOffset)
40+
}
41+
42+
return m, nil
43+
}
44+
45+
func (m Vertical) computeHeight(height int) int {
46+
return height - m.Style.GetVerticalFrameSize()
47+
}
48+
49+
func (m Vertical) computeThumb(total, visible, offset int) (int, int) {
50+
ratio := float64(m.height) / float64(total)
51+
52+
thumbHeight := max(1, int(math.Round(float64(visible)*ratio)))
53+
thumbOffset := max(0, min(m.height-thumbHeight, int(math.Round(float64(offset)*ratio))))
54+
55+
return thumbHeight, thumbOffset
56+
}
57+
58+
func (m Vertical) View() string {
59+
bar := strings.TrimRight(
60+
strings.Repeat(m.TrackStyle.String()+"\n", m.thumbOffset)+
61+
strings.Repeat(m.ThumbStyle.String()+"\n", m.thumbHeight)+
62+
strings.Repeat(m.TrackStyle.String()+"\n", max(0, m.height-m.thumbOffset-m.thumbHeight)),
63+
"\n",
64+
)
65+
66+
return m.Style.Render(bar)
67+
}

scrollbar/vertical_test.go

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package scrollbar
2+
3+
import (
4+
tea "github.com/charmbracelet/bubbletea"
5+
"testing"
6+
)
7+
8+
func TestVerticalView(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
total int
12+
visible int
13+
offset int
14+
view string
15+
}{
16+
{
17+
name: "ThirdTop",
18+
total: 9,
19+
visible: 3,
20+
offset: 0,
21+
view: "█\n\n░",
22+
},
23+
{
24+
name: "ThirdMiddle",
25+
total: 9,
26+
visible: 3,
27+
offset: 3,
28+
view: "░\n\n░",
29+
},
30+
{
31+
name: "ThirdBottom",
32+
total: 9,
33+
visible: 3,
34+
offset: 6,
35+
view: "░\n\n█",
36+
},
37+
}
38+
for _, test := range tests {
39+
t.Run(test.name, func(t *testing.T) {
40+
var scrollbar tea.Model
41+
scrollbar = NewVertical()
42+
scrollbar, _ = scrollbar.Update(HeightMsg(test.visible))
43+
scrollbar, _ = scrollbar.Update(Msg{
44+
Total: test.total,
45+
Visible: test.visible,
46+
Offset: test.offset,
47+
})
48+
view := scrollbar.View()
49+
50+
if view != test.view {
51+
t.Errorf("expected:\n%s\ngot:\n%s", test.view, view)
52+
}
53+
})
54+
}
55+
}

0 commit comments

Comments
 (0)