feat(scrollbar): initial implementation of scrollbars
Describe your changes
Initial exploratory work for a scrollbar implementation.
- New
scrollbarcomponent, which is similar to paginator, where you could just use it for logic, or for rendering.- Supports multiple out of the box bar types, which work with both horizontal and vertical scrollbars.
- Each
scrollbarcomponent itself only supports 1 direction, so for higher-level components, they would have 2 scrollbar components, 1 for horizontal and 1 for vertical. - Scrollbars support thumb start, middle, and end characters, as well as a track character. This allows for having arrows or similar characters on the thumbs, though it's challenging to find character sets that line perfectly together, in addition to matching sets between vertical and horizontal pairs.
- Scrollbars can only use 1-width characters for thumbs/tracks.
- Added scrollbar support directly into viewport. It's technically more challenging to implement scrollbars internally for a component, vs wrapping the component with scrollbars, mostly because it is heavily dependent on multiple internal states for tracking wrapping and similar. More on that below.
I've attempted to add scrollbars (just vertical) to textarea, however, the textarea component is kind of a mess when it comes to state tracking for scroll state due to it using viewport under the hood (and doing so in kind of a hacky way), I'd like to remove viewport altogether to simplify things a bit, however, I'd like to wait until #844 is merged. It isn't too difficult to wrap textarea in a scrollbar right now, however, LineCount() on textarea doesn't include virtual lines, so would have to expose VirtualLineCount() or similar to get accurate reporting. Could also just use viewports scrollbars, however, I still have to explore the implications of that, given how textarea <-> viewport interact is kind of odd.
I've also explored creating a scrollbar container that wraps any generic model, however, using generics (to not obfuscate the model we're wrapping, so you can still call methods like normal) is challenging due to the mix of pointer and non-pointer methods on other models. I may still explore that if there is enough desire to make it easier to wrap models, rather than directly implementing sidebar logic in every single model.
Related issue/discussion
n/a
Examples & Screenshots
Most of the examples below will use viewport. Worth noting that I think the horizontal scrollbar usually doesn't look as nice due to how large the characters are and the space it takes up. So, with viewport, most users probably want soft wrapping + vertical (which it will automatically turn off horizontal bars when softwrap is enabled)
Viewport with slim bars for both X & Y:

Also automatically turns off scrollbars when not required (& doesn't use extra space):

viewport.New(
viewport.WithScrollbars(scrollbar.SlimBar(), true, true),
)
Viewport with slim circles bar

Viewport with slim dotted bar

Viewport with dotted bar

Viewport with block bar

Viewport with ASCII bar

Wrapping a component in a custom scrollbar
Wrapping viewport, but not using viewports internal scrollbars. Uses soft wrapping, so just a vertical scrollbar is required.

code
package main
import (
"log/slog"
"os"
"strings"
"github.com/charmbracelet/bubbles/v2/scrollbar"
"github.com/charmbracelet/bubbles/v2/viewport"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/exp/charmtone"
)
const randomText = `
1 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
2 Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
3 Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
4 Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
5 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
6 Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
7 Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
8 Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
9 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
10 Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
11 Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
12 Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
13 Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
14 Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
15 Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
16 Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
`
type Model struct {
width int
height int
needsScrollbar bool
viewport viewport.Model
scrollbar scrollbar.Model
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.scrollbar.SetHeight(msg.Height - 2)
m.needsScrollbar = m.viewport.TotalLineCount() > m.viewport.VisibleLineCount()
if m.needsScrollbar {
m.viewport.SetDimensions(msg.Width-2-m.scrollbar.Width(), msg.Height-2)
} else {
m.viewport.SetDimensions(msg.Width-2, msg.Height-2)
}
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
}
}
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
m.scrollbar.SetContentState(
m.viewport.TotalLineCount(),
m.viewport.VisibleLineCount(),
m.viewport.YOffset(),
)
return m, tea.Batch(cmds...)
}
func (m Model) View() string {
if m.needsScrollbar {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Render(
lipgloss.JoinHorizontal(
lipgloss.Top,
m.viewport.View(),
m.scrollbar.View(),
),
)
}
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Width(m.width).
Height(m.height).
Render(m.viewport.View())
}
func main() {
m := Model{
viewport: viewport.New(),
scrollbar: scrollbar.New(
scrollbar.WithPosition(scrollbar.Vertical),
scrollbar.WithType(scrollbar.SlimBar()),
),
}
sbs := scrollbar.DefaultDarkStyles()
sbs.ThumbStart = sbs.ThumbStart.Foreground(charmtone.Violet)
sbs.ThumbMiddle = sbs.ThumbMiddle.Foreground(charmtone.Violet)
sbs.ThumbEnd = sbs.ThumbEnd.Foreground(charmtone.Violet)
sbs.Track = sbs.Track.Foreground(charmtone.Butter)
m.scrollbar.SetStyles(sbs)
m.viewport.SoftWrap = true
m.viewport.SetContent(strings.TrimSpace(randomText))
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
slog.Error("error running program", "error", err)
os.Exit(1)
}
}
Checklist before requesting a review
- [x] I have read
CONTRIBUTING.md - [x] I have performed a self-review of my code
Reviewer Notes
- Still some bugs with sizing in some scenarios with viewport where it overflows. Haven't debugged too much yet.
- Still need to make some improvements to the API.
This looking absolutely outstanding, Liam. We’ll prioritize #844. What else can we do to help you with this one?