From 45ce65bc39d710d6adeaa5c7c58faa94af1a9cda Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Sun, 21 Sep 2025 16:40:43 -0600 Subject: [PATCH] feat(scrollbar): initial implementation of scrollbars Signed-off-by: Liam Stanley --- scrollbar/scrollbar.go | 287 ++++++++++++++++++ scrollbar/scrollbar_test.go | 109 +++++++ scrollbar/styles.go | 167 ++++++++++ .../block-horizontal-10perc-end.golden | 1 + .../block-horizontal-10perc-middle.golden | 1 + .../block-horizontal-10perc-start.golden | 1 + .../block-horizontal-33perc-end.golden | 1 + .../block-horizontal-33perc-middle.golden | 1 + .../block-horizontal-33perc-start.golden | 1 + .../block-vertical-10perc-end.golden | 10 + .../block-vertical-10perc-middle.golden | 10 + .../block-vertical-10perc-start.golden | 10 + .../block-vertical-33perc-end.golden | 9 + .../block-vertical-33perc-middle.golden | 9 + .../block-vertical-33perc-start.golden | 9 + .../slim-circles-horizontal-10perc-end.golden | 1 + ...im-circles-horizontal-10perc-middle.golden | 1 + ...lim-circles-horizontal-10perc-start.golden | 1 + .../slim-circles-horizontal-33perc-end.golden | 1 + ...im-circles-horizontal-33perc-middle.golden | 1 + ...lim-circles-horizontal-33perc-start.golden | 1 + .../slim-circles-vertical-10perc-end.golden | 10 + ...slim-circles-vertical-10perc-middle.golden | 10 + .../slim-circles-vertical-10perc-start.golden | 10 + .../slim-circles-vertical-33perc-end.golden | 9 + ...slim-circles-vertical-33perc-middle.golden | 9 + .../slim-circles-vertical-33perc-start.golden | 9 + viewport/scrollbar.go | 121 ++++++++ viewport/viewport.go | 112 +++++-- 29 files changed, 893 insertions(+), 29 deletions(-) create mode 100644 scrollbar/scrollbar.go create mode 100644 scrollbar/scrollbar_test.go create mode 100644 scrollbar/styles.go create mode 100644 scrollbar/testdata/TestScrollbar/block-horizontal-10perc-end.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-horizontal-10perc-middle.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-horizontal-10perc-start.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-horizontal-33perc-end.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-horizontal-33perc-middle.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-horizontal-33perc-start.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-vertical-10perc-end.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-vertical-10perc-middle.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-vertical-10perc-start.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-vertical-33perc-end.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-vertical-33perc-middle.golden create mode 100644 scrollbar/testdata/TestScrollbar/block-vertical-33perc-start.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-end.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-middle.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-start.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-end.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-middle.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-start.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-end.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-middle.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-start.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-end.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-middle.golden create mode 100644 scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-start.golden create mode 100644 viewport/scrollbar.go diff --git a/scrollbar/scrollbar.go b/scrollbar/scrollbar.go new file mode 100644 index 00000000..e40cf80a --- /dev/null +++ b/scrollbar/scrollbar.go @@ -0,0 +1,287 @@ +// Package scrollbar provides a scrollbar component for Bubble Tea applications. +package scrollbar + +import ( + "cmp" + "math" + "strings" +) + +// ScrollState is the state of the scrollbar. +type ScrollState struct { + TotalLength int // Total length of the scrollbar. + ThumbOffset int // Offset of the thumb. + ThumbLength int // Length of the thumb, including the start and end characters. +} + +// ContentState is the state of the content being tracked. +type ContentState struct { + Length int // Length of the content. + VisibleLength int // Visible length of the content. + Offset int // Offset of the content. +} + +// Position is the rendered position of the scrollbar. +type Position int + +// Available positions for the scrollbar. +const ( + Vertical Position = iota + Horizontal +) + +// Option is used to set options in New. For example: +// +// scrollbar := New(WithPosition(Vertical)) +type Option func(*Model) + +// WithPosition sets the position of the scrollbar. +func WithPosition(position Position) Option { + return func(m *Model) { + m.position = position + + switch position { + case Vertical: + m.width = 1 + case Horizontal: + m.height = 1 + } + } +} + +// WithType sets the type of the scrollbar. +func WithType(t Type) Option { + return func(m *Model) { + m.barType = t + } +} + +// Model is the Bubble Tea model for this user interface. +type Model struct { + position Position + barType Type + styles Styles + width int + height int + + // Content-specific fields that are set by the caller. + contentLength int + contentVisibleLength int + contentOffset int +} + +// New creates a new model with default settings. +func New(opts ...Option) Model { + m := Model{ + width: 0, + height: 0, + styles: DefaultDarkStyles(), + position: Vertical, + barType: SlimBar(), + } + + for _, opt := range opts { + if opt == nil { + continue + } + opt(&m) + } + + return m +} + +// Styles returns the current set of styles. +func (m Model) Styles() Styles { + return m.styles +} + +// SetStyles sets the styles for the scrollbar. +func (m *Model) SetStyles(s Styles) { + m.styles = s +} + +// Position returns the position of the scrollbar. +func (m Model) Position() Position { + return m.position +} + +// Width returns the width of the scrollbar. +func (m Model) Width() int { + if m.position == Horizontal && m.width == 0 { + return m.contentVisibleLength + } + return m.width +} + +// SetWidth sets the width of the scrollbar. If the scrollbar is vertical, this +// is a no-op, as it is dependent on the bar type used. +func (m *Model) SetWidth(w int) { + if m.position == Vertical { + return + } + m.width = max(0, w) + if m.contentVisibleLength == 0 { + m.contentVisibleLength = m.width + } +} + +// Height returns the height of the scrollbar. +func (m Model) Height() int { + if m.position == Vertical && m.height == 0 { + return m.contentVisibleLength + } + return m.height +} + +// SetHeight sets the height of the scrollbar. If the scrollbar is horizontal, +// this is a no-op, as it is dependent on the bar type used. +func (m *Model) SetHeight(h int) { + if m.position == Horizontal { + return + } + m.height = max(0, h) + if m.contentVisibleLength == 0 { + m.contentVisibleLength = m.height + } +} + +// SetContentState sets the state of the scrollbar used for tracking the content +// dimensions. +// - length: the total length of the content (height for vertical, width for +// horizontal) +// - visible: the visible length of the content (height for vertical, width for +// horizontal) +// - offset: the offset of the view within the content (typically the y-offset +// for a vertical scrollbar or the x-offset for a horizontal scrollbar) +func (m *Model) SetContentState(length, visible, offset int) { + m.contentLength = max(0, length) + m.contentVisibleLength = clamp(visible, 0, length) + m.contentOffset = clamp(offset, 0, length-visible) +} + +// ContentState returns the current content state of the scrollbar. +func (m Model) ContentState() ContentState { + return ContentState{ + Length: m.contentLength, + VisibleLength: m.contentVisibleLength, + Offset: m.contentOffset, + } +} + +// ScrollState returns the current scroll state of the scrollbar. Returns nil +// if the scrollbar is not required based on the content information provided, +// or the size of the scrollbar is too small to render correctly. +func (m Model) ScrollState() *ScrollState { + if (m.position == Vertical && m.height < 3) || + (m.position == Horizontal && m.width < 3) || + m.contentLength == 0 || m.contentVisibleLength == 0 || + m.contentLength <= m.contentVisibleLength { + return nil + } + + var length int + + switch m.position { + case Vertical: + length = m.height + case Horizontal: + length = m.width + } + + ratio := float64(length) / float64(m.contentLength) + + thumbLength := max( + m.barType.MinThumbLength(m.position), + int(math.Round(float64(m.contentVisibleLength)*ratio)), + ) + thumbOffset := max( + 0, + min(length-thumbLength, int(math.Round(float64(m.contentOffset)*ratio))), + ) + + return &ScrollState{ + TotalLength: length, + ThumbOffset: thumbOffset, + ThumbLength: thumbLength, + } +} + +// View renders the scrollbar to a string. +func (m Model) View() string { + if m.width == 0 || m.height == 0 { + return "" + } + + state := m.ScrollState() + + if state == nil { + switch m.position { + case Vertical: + return strings.TrimRight( + strings.Repeat(m.styles.Track.Render(" ")+"\n", m.height), + "\n", + ) + case Horizontal: + return strings.TrimRight( + strings.Repeat(m.styles.Track.Render(" "), m.width), + " ", + ) + } + return "" + } + + var thumbStart, thumbMiddle, thumbEnd, track string + switch m.position { + case Vertical: + thumbStart = m.styles.ThumbStart. + Render(string(m.barType.VerticalThumbStart)) + thumbMiddle = m.styles.ThumbMiddle. + Render(string(m.barType.VerticalThumbMiddle)) + thumbEnd = m.styles.ThumbEnd. + Render(string(m.barType.VerticalThumbEnd)) + track = m.styles.Track. + Render(string(m.barType.VerticalTrack)) + case Horizontal: + thumbStart = m.styles.ThumbStart. + Render(string(m.barType.HorizontalThumbStart)) + thumbMiddle = m.styles.ThumbMiddle. + Render(string(m.barType.HorizontalThumbMiddle)) + thumbEnd = m.styles.ThumbEnd. + Render(string(m.barType.HorizontalThumbEnd)) + track = m.styles.Track. + Render(string(m.barType.HorizontalTrack)) + } + + var suffix string + if m.position == Vertical { + suffix = "\n" + } + + var s strings.Builder + + s.WriteString(strings.Repeat(track+suffix, max(0, state.ThumbOffset))) + + if m.barType.MinThumbLength(m.position) == 1 { + s.WriteString(strings.Repeat(thumbMiddle+suffix, max(0, state.ThumbLength))) + } else { + s.WriteString(thumbStart + suffix) + s.WriteString(strings.Repeat(thumbMiddle+suffix, max(0, state.ThumbLength-2))) + s.WriteString(thumbEnd + suffix) + } + + s.WriteString(strings.Repeat(track+suffix, max(0, state.TotalLength-state.ThumbOffset-state.ThumbLength))) + + return strings.TrimRight(s.String(), suffix) +} + +// Percent returns the scroll percentage of the content. +func (m Model) Percent() float64 { + return clamp(float64(m.contentOffset)/float64(m.contentLength-m.contentVisibleLength), 0, 1) +} + +func clamp[T cmp.Ordered](v, low, high T) T { + if high < low { + low, high = high, low + } + return min(high, max(low, v)) +} diff --git a/scrollbar/scrollbar_test.go b/scrollbar/scrollbar_test.go new file mode 100644 index 00000000..669ebe1b --- /dev/null +++ b/scrollbar/scrollbar_test.go @@ -0,0 +1,109 @@ +package scrollbar + +import ( + "testing" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" +) + +func TestScrollbar(t *testing.T) { + tests := []struct { + name string + options []Option + length int + visible int + offset int + }{ + { + name: "vertical-10perc-start", + options: []Option{WithPosition(Vertical)}, + length: 100, visible: 10, offset: 0, + }, + { + name: "vertical-10perc-middle", + options: []Option{WithPosition(Vertical)}, + length: 100, visible: 10, offset: 49, + }, + { + name: "vertical-10perc-end", + options: []Option{WithPosition(Vertical)}, + length: 100, visible: 10, offset: 91, + }, + { + name: "horizontal-10perc-start", + options: []Option{WithPosition(Horizontal)}, + length: 100, visible: 10, offset: 0, + }, + { + name: "horizontal-10perc-middle", + options: []Option{WithPosition(Horizontal)}, + length: 100, visible: 10, offset: 49, + }, + { + name: "horizontal-10perc-end", + options: []Option{WithPosition(Horizontal)}, + length: 100, visible: 10, offset: 91, + }, + { + name: "vertical-33perc-start", + options: []Option{WithPosition(Vertical)}, + length: 30, visible: 9, offset: 0, + }, + { + name: "vertical-33perc-middle", + options: []Option{WithPosition(Vertical)}, + length: 30, visible: 9, offset: 9, + }, + { + name: "vertical-33perc-end", + options: []Option{WithPosition(Vertical)}, + length: 30, visible: 9, offset: 21, + }, + { + name: "horizontal-33perc-start", + options: []Option{WithPosition(Horizontal)}, + length: 30, visible: 9, offset: 0, + }, + { + name: "horizontal-33perc-middle", + options: []Option{WithPosition(Horizontal)}, + length: 30, visible: 9, offset: 9, + }, + { + name: "horizontal-33perc-end", + options: []Option{WithPosition(Horizontal)}, + length: 30, visible: 9, offset: 21, + }, + } + + for _, tc := range tests { + // basic block bar. + t.Run("block-"+tc.name, func(t *testing.T) { + model := New(append(tc.options, WithType(BlockBar()))...) + switch model.Position() { + case Vertical: + model.SetHeight(tc.visible) + case Horizontal: + model.SetWidth(tc.visible) + } + + model.SetContentState(tc.length, tc.visible, tc.offset) + golden.RequireEqual(t, ansi.Strip(model.View())) + }) + + // slim circles bar. + t.Run("slim-circles-"+tc.name, func(t *testing.T) { + model := New(append(tc.options, WithType(SlimCirclesBar()))...) + switch model.Position() { + case Vertical: + model.SetHeight(tc.visible) + case Horizontal: + model.SetWidth(tc.visible) + } + + model.SetContentState(tc.length, tc.visible, tc.offset) + golden.RequireEqual(t, ansi.Strip(model.View())) + }) + } +} diff --git a/scrollbar/styles.go b/scrollbar/styles.go new file mode 100644 index 00000000..39583150 --- /dev/null +++ b/scrollbar/styles.go @@ -0,0 +1,167 @@ +package scrollbar + +import "github.com/charmbracelet/lipgloss/v2" + +// Styles are the styles for the scrollbar. For vertical scrollbars, the start +// thumb is the top. For horizontal scrollbars, the start thumb is the left. +type Styles struct { + ThumbStart lipgloss.Style + ThumbMiddle lipgloss.Style + ThumbEnd lipgloss.Style + Track lipgloss.Style +} + +// DefaultStyles returns the default styles for the scrollbar. +func DefaultStyles(isDark bool) Styles { + lightDark := lipgloss.LightDark(isDark) + + var s Styles + + s.ThumbStart = lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("16"), lipgloss.Color("252"))) + s.ThumbMiddle = s.ThumbStart + s.ThumbEnd = s.ThumbStart + s.Track = lipgloss.NewStyle(). + Foreground(lightDark(lipgloss.Color("248"), lipgloss.Color("240"))) + + return s +} + +// DefaultLightStyles returns the default styles for a light background. +func DefaultLightStyles() Styles { + return DefaultStyles(false) +} + +// DefaultDarkStyles returns the default styles for a dark background. +func DefaultDarkStyles() Styles { + return DefaultStyles(true) +} + +// Type represents a scrollbars appearance as characters. All characters must +// be single-width. +type Type struct { + VerticalThumbStart rune + VerticalThumbMiddle rune + VerticalThumbEnd rune + VerticalTrack rune + + HorizontalThumbStart rune + HorizontalThumbMiddle rune + HorizontalThumbEnd rune + HorizontalTrack rune +} + +// MinThumbLength returns the minimum length of the thumb for the given position. +// This will dynamically change based on if the start/end characters are the same. +func (t Type) MinThumbLength(pos Position) int { + switch pos { + case Vertical: + if t.VerticalThumbStart == t.VerticalThumbEnd && + t.VerticalThumbStart == t.VerticalThumbMiddle { + return 1 + } + case Horizontal: + if t.HorizontalThumbStart == t.HorizontalThumbEnd && + t.HorizontalThumbStart == t.HorizontalThumbMiddle { + return 1 + } + } + + return 3 // Thumb start + thumb middle + thumb end. +} + +// SlimBar returns a scrollbar that uses slim/thin bars for both vertical and +// horizontal scrollbars. +func SlimBar() Type { + return Type{ + VerticalThumbStart: '┃', + VerticalThumbMiddle: '┃', + VerticalThumbEnd: '┃', + VerticalTrack: '┃', + + HorizontalThumbStart: '▁', + HorizontalThumbMiddle: '▁', + HorizontalThumbEnd: '▁', + HorizontalTrack: '▁', + } +} + +// SlimDottedBar returns a scrollbar that uses slim bars for thumbs, and dotted +// bars for the track. +func SlimDottedBar() Type { + return Type{ + VerticalThumbStart: '┃', + VerticalThumbMiddle: '┃', + VerticalThumbEnd: '┃', + VerticalTrack: '┇', + + HorizontalThumbStart: '▬', + HorizontalThumbMiddle: '▬', + HorizontalThumbEnd: '▬', + HorizontalTrack: '▬', + } +} + +// SlimCirclesBar returns a scrollbar that uses slim bars for thumbs and tracks, +// but uses circles as the start and end thumb characters. +func SlimCirclesBar() Type { + return Type{ + VerticalThumbStart: '◉', + VerticalThumbMiddle: '┃', + VerticalThumbEnd: '◉', + VerticalTrack: '┃', + + HorizontalThumbStart: '◉', + HorizontalThumbMiddle: '━', + HorizontalThumbEnd: '◉', + HorizontalTrack: '━', + } +} + +// BlockBar returns a scrollbar that uses full block bars for both vertical and +// horizontal scrollbars. +func BlockBar() Type { + return Type{ + VerticalThumbStart: '█', + VerticalThumbMiddle: '█', + VerticalThumbEnd: '█', + VerticalTrack: '░', + + HorizontalThumbStart: '█', + HorizontalThumbMiddle: '█', + HorizontalThumbEnd: '█', + HorizontalTrack: '░', + } +} + +// DottedBar returns a scrollbar that uses a mix of dots and bars for both +// vertical and horizontal scrollbars. +func DottedBar() Type { + return Type{ + VerticalThumbStart: '⣿', + VerticalThumbMiddle: '⣿', + VerticalThumbEnd: '⣿', + VerticalTrack: '⣿', + + HorizontalThumbStart: '⣤', + HorizontalThumbMiddle: '⣤', + HorizontalThumbEnd: '⣤', + HorizontalTrack: '⣤', + } +} + +// ASCIIBar returns a scrollbar that uses basic ASCII characters for both +// vertical and horizontal scrollbars. +func ASCIIBar() Type { + return Type{ + VerticalThumbStart: '|', + VerticalThumbMiddle: '|', + VerticalThumbEnd: '|', + VerticalTrack: '|', + + HorizontalThumbStart: '-', + HorizontalThumbMiddle: '-', + HorizontalThumbEnd: '-', + HorizontalTrack: '-', + } +} diff --git a/scrollbar/testdata/TestScrollbar/block-horizontal-10perc-end.golden b/scrollbar/testdata/TestScrollbar/block-horizontal-10perc-end.golden new file mode 100644 index 00000000..9374cc20 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-horizontal-10perc-end.golden @@ -0,0 +1 @@ +░░░░░░░░░█ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-horizontal-10perc-middle.golden b/scrollbar/testdata/TestScrollbar/block-horizontal-10perc-middle.golden new file mode 100644 index 00000000..7798a8bf --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-horizontal-10perc-middle.golden @@ -0,0 +1 @@ +░░░░░█░░░░ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-horizontal-10perc-start.golden b/scrollbar/testdata/TestScrollbar/block-horizontal-10perc-start.golden new file mode 100644 index 00000000..4c7675b3 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-horizontal-10perc-start.golden @@ -0,0 +1 @@ +█░░░░░░░░░ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-horizontal-33perc-end.golden b/scrollbar/testdata/TestScrollbar/block-horizontal-33perc-end.golden new file mode 100644 index 00000000..5ba2968d --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-horizontal-33perc-end.golden @@ -0,0 +1 @@ +░░░░░░███ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-horizontal-33perc-middle.golden b/scrollbar/testdata/TestScrollbar/block-horizontal-33perc-middle.golden new file mode 100644 index 00000000..545c82d8 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-horizontal-33perc-middle.golden @@ -0,0 +1 @@ +░░░███░░░ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-horizontal-33perc-start.golden b/scrollbar/testdata/TestScrollbar/block-horizontal-33perc-start.golden new file mode 100644 index 00000000..c1de9993 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-horizontal-33perc-start.golden @@ -0,0 +1 @@ +███░░░░░░ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-vertical-10perc-end.golden b/scrollbar/testdata/TestScrollbar/block-vertical-10perc-end.golden new file mode 100644 index 00000000..1f814d1d --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-vertical-10perc-end.golden @@ -0,0 +1,10 @@ +░ +░ +░ +░ +░ +░ +░ +░ +░ +█ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-vertical-10perc-middle.golden b/scrollbar/testdata/TestScrollbar/block-vertical-10perc-middle.golden new file mode 100644 index 00000000..2b143f58 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-vertical-10perc-middle.golden @@ -0,0 +1,10 @@ +░ +░ +░ +░ +░ +█ +░ +░ +░ +░ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-vertical-10perc-start.golden b/scrollbar/testdata/TestScrollbar/block-vertical-10perc-start.golden new file mode 100644 index 00000000..e91732a8 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-vertical-10perc-start.golden @@ -0,0 +1,10 @@ +█ +░ +░ +░ +░ +░ +░ +░ +░ +░ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-vertical-33perc-end.golden b/scrollbar/testdata/TestScrollbar/block-vertical-33perc-end.golden new file mode 100644 index 00000000..610bf965 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-vertical-33perc-end.golden @@ -0,0 +1,9 @@ +░ +░ +░ +░ +░ +░ +█ +█ +█ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-vertical-33perc-middle.golden b/scrollbar/testdata/TestScrollbar/block-vertical-33perc-middle.golden new file mode 100644 index 00000000..e3cf8f6f --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-vertical-33perc-middle.golden @@ -0,0 +1,9 @@ +░ +░ +░ +█ +█ +█ +░ +░ +░ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/block-vertical-33perc-start.golden b/scrollbar/testdata/TestScrollbar/block-vertical-33perc-start.golden new file mode 100644 index 00000000..d7201079 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/block-vertical-33perc-start.golden @@ -0,0 +1,9 @@ +█ +█ +█ +░ +░ +░ +░ +░ +░ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-end.golden b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-end.golden new file mode 100644 index 00000000..1d649342 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-end.golden @@ -0,0 +1 @@ +━━━━━━━◉━◉ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-middle.golden b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-middle.golden new file mode 100644 index 00000000..32ac1602 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-middle.golden @@ -0,0 +1 @@ +━━━━━◉━◉━━ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-start.golden b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-start.golden new file mode 100644 index 00000000..ae506918 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-10perc-start.golden @@ -0,0 +1 @@ +◉━◉━━━━━━━ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-end.golden b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-end.golden new file mode 100644 index 00000000..81725005 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-end.golden @@ -0,0 +1 @@ +━━━━━━◉━◉ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-middle.golden b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-middle.golden new file mode 100644 index 00000000..23f483c5 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-middle.golden @@ -0,0 +1 @@ +━━━◉━◉━━━ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-start.golden b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-start.golden new file mode 100644 index 00000000..53eca977 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-horizontal-33perc-start.golden @@ -0,0 +1 @@ +◉━◉━━━━━━ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-end.golden b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-end.golden new file mode 100644 index 00000000..f6bea2cb --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-end.golden @@ -0,0 +1,10 @@ +┃ +┃ +┃ +┃ +┃ +┃ +┃ +◉ +┃ +◉ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-middle.golden b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-middle.golden new file mode 100644 index 00000000..d44e3bab --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-middle.golden @@ -0,0 +1,10 @@ +┃ +┃ +┃ +┃ +┃ +◉ +┃ +◉ +┃ +┃ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-start.golden b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-start.golden new file mode 100644 index 00000000..34b47dcd --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-10perc-start.golden @@ -0,0 +1,10 @@ +◉ +┃ +◉ +┃ +┃ +┃ +┃ +┃ +┃ +┃ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-end.golden b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-end.golden new file mode 100644 index 00000000..9ee239cf --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-end.golden @@ -0,0 +1,9 @@ +┃ +┃ +┃ +┃ +┃ +┃ +◉ +┃ +◉ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-middle.golden b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-middle.golden new file mode 100644 index 00000000..1bfbf721 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-middle.golden @@ -0,0 +1,9 @@ +┃ +┃ +┃ +◉ +┃ +◉ +┃ +┃ +┃ \ No newline at end of file diff --git a/scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-start.golden b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-start.golden new file mode 100644 index 00000000..a8f827f8 --- /dev/null +++ b/scrollbar/testdata/TestScrollbar/slim-circles-vertical-33perc-start.golden @@ -0,0 +1,9 @@ +◉ +┃ +◉ +┃ +┃ +┃ +┃ +┃ +┃ \ No newline at end of file diff --git a/viewport/scrollbar.go b/viewport/scrollbar.go new file mode 100644 index 00000000..dbcea854 --- /dev/null +++ b/viewport/scrollbar.go @@ -0,0 +1,121 @@ +package viewport + +import ( + "github.com/charmbracelet/bubbles/v2/scrollbar" + "github.com/charmbracelet/lipgloss/v2" +) + +// WithScrollbars sets the scrollbar type, and whether to enable the x and y +// scrollbars. Scrollbars will still only be rendered for each axis only if +// required (i.e. if the content is longer than the viewport in that given +// axis). +func WithScrollbars(scrollbarType scrollbar.Type, x, y bool) Option { + return func(m *Model) { + if x { + m.xscrollbarEnabled = x + m.xscrollbar = scrollbar.New( + scrollbar.WithType(scrollbarType), + scrollbar.WithPosition(scrollbar.Horizontal), + ) + } + if y { + m.yscrollbarEnabled = y + m.yscrollbar = scrollbar.New( + scrollbar.WithType(scrollbarType), + scrollbar.WithPosition(scrollbar.Vertical), + ) + } + m.calculateScrollbar() + } +} + +// SetScrollbarStyles sets the styles for the scrollbars. +func (m *Model) SetScrollbarStyles(style scrollbar.Styles) { + m.xscrollbar.SetStyles(style) + m.yscrollbar.SetStyles(style) +} + +// calculateScrollbar calculates if any scrollbars should be enabled, and if so, +// adjusts the rendered dimensions of the viewport, in addition to updating +// the scrollbar's content state. This ensures that the scrollbars do not take up +// any rendered space unless required. +func (m *Model) calculateScrollbar() { + if !m.xscrollbarEnabled && !m.yscrollbarEnabled { + m.renderedWidth = m.actualWidth + m.renderedHeight = m.actualHeight + return + } + + totalLines := m.TotalLineCount() + + m.yscrollbarRender = m.yscrollbarEnabled && totalLines > m.actualHeight && m.actualWidth > 1 + if m.yscrollbarRender { + m.renderedWidth = max(0, m.actualWidth-m.yscrollbar.Width()) + } else { + m.renderedWidth = m.actualWidth + } + + m.xscrollbarRender = m.xscrollbarEnabled && m.longestLineWidth > m.actualWidth && !m.SoftWrap && m.actualHeight > 1 + if m.xscrollbarRender { + m.renderedHeight = max(0, m.actualHeight-m.xscrollbar.Height()) + // Recalculate vertical scrollbar enablement logic again, as they are + // co-dependent on each other given each can change the rendered dimensions. + if !m.yscrollbarRender { + m.yscrollbarRender = m.yscrollbarEnabled && totalLines > m.renderedHeight && m.renderedWidth > 1 + if m.yscrollbarRender { + m.renderedWidth = max(0, m.actualWidth-m.yscrollbar.Width()) + } + } + } else { + m.renderedHeight = m.actualHeight + } + + if m.yscrollbarRender { + m.yscrollbar.SetContentState( + m.TotalLineCount(), + m.renderedHeight, + m.yOffset, + ) + m.yscrollbar.SetHeight(m.renderedHeight) + } + + if m.xscrollbarRender { + m.xscrollbar.SetContentState( + m.longestLineWidth, + m.renderedWidth, + m.xOffset, + ) + m.xscrollbar.SetWidth(m.renderedWidth) + } +} + +// viewWithScrollbars renders the viewport with the scrollbars included. +func (m Model) viewWithScrollbars(content string) string { + switch { + case !m.yscrollbarRender && !m.xscrollbarRender: + return content + case m.yscrollbarRender && m.xscrollbarRender: + return lipgloss.JoinVertical( + lipgloss.Left, + lipgloss.JoinHorizontal( + lipgloss.Top, + content, + m.yscrollbar.View(), + ), + m.xscrollbar.View(), + ) + case m.yscrollbarRender: + return lipgloss.JoinHorizontal( + lipgloss.Top, + content, + m.yscrollbar.View(), + ) + case m.xscrollbarRender: + return lipgloss.JoinVertical( + lipgloss.Left, + content, + m.xscrollbar.View(), + ) + } + return content +} diff --git a/viewport/viewport.go b/viewport/viewport.go index a439aac0..f9c1c44e 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/charmbracelet/bubbles/v2/key" + "github.com/charmbracelet/bubbles/v2/scrollbar" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" @@ -26,7 +27,8 @@ type Option func(*Model) // viewport. Pass as an argument to [New]. func WithWidth(w int) Option { return func(m *Model) { - m.width = w + m.actualWidth = w + m.calculateScrollbar() } } @@ -34,7 +36,8 @@ func WithWidth(w int) Option { // viewport. Pass as an argument to [New]. func WithHeight(h int) Option { return func(m *Model) { - m.height = h + m.actualHeight = h + m.calculateScrollbar() } } @@ -50,8 +53,11 @@ func New(opts ...Option) (m Model) { // Model is the Bubble Tea model for this viewport element. type Model struct { - width int - height int + actualWidth int // Actual width of the viewport. Should not be used directly in most cases. + actualHeight int // Actual height of the viewport. Should not be used directly in most cases. + renderedWidth int // Rendered width of the viewport (accounting for space for the scrollbar). + renderedHeight int // Rendered height of the viewport (accounting for space for the scrollbar). + KeyMap KeyMap // Whether or not to wrap text. If false, it'll allow horizontal scrolling @@ -68,6 +74,13 @@ type Model struct { // The number of lines the mouse wheel will scroll. By default, this is 3. MouseWheelDelta int + xscrollbarEnabled bool + xscrollbarRender bool + xscrollbar scrollbar.Model + yscrollbarEnabled bool + yscrollbarRender bool + yscrollbar scrollbar.Model + // yOffset is the vertical scroll position. yOffset int @@ -161,22 +174,33 @@ func (m Model) Init() tea.Cmd { // Height returns the height of the viewport. func (m Model) Height() int { - return m.height + return m.actualHeight } // SetHeight sets the height of the viewport. func (m *Model) SetHeight(h int) { - m.height = h + m.SetDimensions(m.actualWidth, h) } // Width returns the width of the viewport. func (m Model) Width() int { - return m.width + return m.actualWidth } // SetWidth sets the width of the viewport. func (m *Model) SetWidth(w int) { - m.width = w + m.SetDimensions(w, m.actualHeight) +} + +// SetDimensions sets the width and height of the viewport at the same time, +// and is more efficient than calling [Model.SetWidth] and [Model.SetHeight] +// separately. +func (m *Model) SetDimensions(w, h int) { + m.actualWidth = w + m.actualHeight = h + m.ensureYOffsetInBounds() + m.ensureXOffsetInBounds() + m.calculateScrollbar() } // AtTop returns whether or not the viewport is at the very top position. @@ -199,11 +223,11 @@ func (m Model) PastBottom() bool { // ScrollPercent returns the amount scrolled as a float between 0 and 1. func (m Model) ScrollPercent() float64 { total, _, _ := m.calculateLine(0) - if m.Height() >= total { + if m.renderedHeight >= total { return 1.0 } y := float64(m.YOffset()) - h := float64(m.Height()) + h := float64(m.renderedHeight) t := float64(total) v := y / (t - h) return clamp(v, 0, 1) @@ -212,11 +236,11 @@ func (m Model) ScrollPercent() float64 { // HorizontalScrollPercent returns the amount horizontally scrolled as a float // between 0 and 1. func (m Model) HorizontalScrollPercent() float64 { - if m.xOffset >= m.longestLineWidth-m.Width() { + if m.xOffset >= m.longestLineWidth-m.renderedWidth { return 1.0 } y := float64(m.xOffset) - h := float64(m.Width()) + h := float64(m.renderedWidth) t := float64(m.longestLineWidth) v := y / (t - h) return clamp(v, 0, 1) @@ -252,6 +276,7 @@ func (m *Model) SetContentLines(lines []string) { m.longestLineWidth = maxLineWidth(m.lines) m.ClearHighlights() + m.calculateScrollbar() if m.YOffset() > m.maxYOffset() { m.GotoBottom() @@ -299,13 +324,13 @@ func (m Model) calculateLine(yoffset int) (total, ridx, voffset int) { // viewport's content and set height. func (m Model) maxYOffset() int { total, _, _ := m.calculateLine(0) - return max(0, total-m.Height()+m.Style.GetVerticalFrameSize()) + return max(0, total-m.renderedHeight+m.Style.GetVerticalFrameSize()) } // maxXOffset returns the maximum possible value of the x-offset based on the // viewport's content and set width. func (m Model) maxXOffset() int { - return max(0, m.longestLineWidth-m.Width()) + return max(0, m.longestLineWidth-m.renderedWidth) } // maxWidth returns the maximum width of the viewport. It accounts for the frame @@ -315,13 +340,13 @@ func (m Model) maxWidth() int { if m.LeftGutterFunc != nil { gutterSize = ansi.StringWidth(m.LeftGutterFunc(GutterContext{})) } - return max(0, m.Width()-m.Style.GetHorizontalFrameSize()-gutterSize) + return max(0, m.renderedWidth-m.Style.GetHorizontalFrameSize()-gutterSize) } // maxHeight returns the maximum height of the viewport. It accounts for the frame // size. func (m Model) maxHeight() int { - return max(0, m.Height()-m.Style.GetVerticalFrameSize()) + return max(0, m.renderedHeight-m.Style.GetVerticalFrameSize()) } // visibleLines returns the lines that should currently be visible in the @@ -457,9 +482,21 @@ func (m Model) setupGutter(lines []string, total, ridx int) []string { return lines } +// setYOffset sets the Y offset. It does not calculate the scrollbar, so that can +// be done separately in an aggregated manner. +func (m *Model) setYOffset(n int) { + m.yOffset = clamp(n, 0, m.maxYOffset()) +} + // SetYOffset sets the Y offset. func (m *Model) SetYOffset(n int) { - m.yOffset = clamp(n, 0, m.maxYOffset()) + m.setYOffset(n) + m.calculateScrollbar() +} + +// ensureYOffsetInBounds ensures that the Y offset is within the bounds of the viewport. +func (m *Model) ensureYOffsetInBounds() { + m.yOffset = clamp(m.yOffset, 0, m.maxYOffset()) } // YOffset returns the current Y offset - the vertical scroll position. @@ -469,14 +506,15 @@ func (m *Model) YOffset() int { return m.yOffset } func (m *Model) EnsureVisible(line, colstart, colend int) { maxWidth := m.maxWidth() if colend <= maxWidth { - m.SetXOffset(0) + m.setXOffset(0) } else { - m.SetXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural + m.setXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural } if line < m.YOffset() || line >= m.YOffset()+m.maxHeight() { - m.SetYOffset(line) + m.setYOffset(line) } + m.calculateScrollbar() } // PageDown moves the view down by the number of lines in the viewport. @@ -484,7 +522,7 @@ func (m *Model) PageDown() { if m.AtBottom() { return } - m.ScrollDown(m.Height()) + m.ScrollDown(m.renderedHeight) } // PageUp moves the view up by one height of the viewport. @@ -492,7 +530,7 @@ func (m *Model) PageUp() { if m.AtTop() { return } - m.ScrollUp(m.Height()) + m.ScrollUp(m.renderedHeight) } // HalfPageDown moves the view down by half the height of the viewport. @@ -500,7 +538,7 @@ func (m *Model) HalfPageDown() { if m.AtBottom() { return } - m.ScrollDown(m.Height() / 2) //nolint:mnd + m.ScrollDown(m.renderedHeight / 2) //nolint:mnd } // HalfPageUp moves the view up by half the height of the viewport. @@ -508,7 +546,7 @@ func (m *Model) HalfPageUp() { if m.AtTop() { return } - m.ScrollUp(m.Height() / 2) //nolint:mnd + m.ScrollUp(m.renderedHeight / 2) //nolint:mnd } // ScrollDown moves the view down by the given number of lines. @@ -544,15 +582,26 @@ func (m *Model) SetHorizontalStep(n int) { // XOffset returns the current X offset - the horizontal scroll position. func (m *Model) XOffset() int { return m.xOffset } -// SetXOffset sets the X offset. -// No-op when soft wrap is enabled. -func (m *Model) SetXOffset(n int) { +// setXOffset sets the X offset. It does not calculate the scrollbar, so that can +// be done separately in an aggregated manner. +func (m *Model) setXOffset(n int) { if m.SoftWrap { return } m.xOffset = clamp(n, 0, m.maxXOffset()) } +// SetXOffset sets the X offset. No-op when soft wrap is enabled. +func (m *Model) SetXOffset(n int) { + m.setXOffset(n) + m.calculateScrollbar() +} + +// ensureXOffsetInBounds ensures that the X offset is within the bounds of the viewport. +func (m *Model) ensureXOffsetInBounds() { + m.xOffset = clamp(m.xOffset, 0, m.maxXOffset()) +} + // ScrollLeft moves the viewport to the left by the given number of columns. func (m *Model) ScrollLeft(n int) { m.SetXOffset(m.xOffset - n) @@ -723,7 +772,7 @@ func (m Model) updateAsModel(msg tea.Msg) Model { // View renders the viewport into a string. func (m Model) View() string { - w, h := m.Width(), m.Height() + w, h := m.renderedWidth, m.renderedHeight if sw := m.Style.GetWidth(); sw != 0 { w = min(w, sw) } @@ -741,8 +790,13 @@ func (m Model) View() string { Width(contentWidth). // pad to width. Height(contentHeight). // pad to height. Render(strings.Join(m.visibleLines(), "\n")) + + if m.xscrollbarEnabled || m.yscrollbarEnabled { + return m.viewWithScrollbars(contents) + } + return m.Style. - UnsetWidth().UnsetHeight(). // Style size already applied in contents. + UnsetWidth().UnsetHeight(). Render(contents) }