Skip to content

Commit cfd9cb0

Browse files
committed
feat: introduce vertical scrollbar
1 parent 64a67d1 commit cfd9cb0

File tree

6 files changed

+266
-0
lines changed

6 files changed

+266
-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

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

scrollbar/vertical.go

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

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)