Skip to content

Conversation

lrstanley
Copy link
Contributor

@lrstanley lrstanley commented Sep 22, 2025

Describe your changes

Initial exploratory work for a scrollbar implementation.

  • New scrollbar component, 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 scrollbar component 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

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.

@meowgorithm
Copy link
Member

This looking absolutely outstanding, Liam. We’ll prioritize #844. What else can we do to help you with this one?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants