feat: render scrollbar for model list (dialog and onboarding) (#2978)

Andrey Nering created

Change summary

internal/ui/common/scrollbar.go |  2 +-
internal/ui/dialog/models.go    | 12 +++++++++++-
internal/ui/list/list.go        | 27 +++++++++++++++++++++++++++
3 files changed, 39 insertions(+), 2 deletions(-)

Detailed changes

internal/ui/common/scrollbar.go 🔗

@@ -23,7 +23,7 @@ func Scrollbar(s *styles.Styles, height, contentSize, viewportSize, offset int)
 	}
 
 	// Calculate where the thumb starts.
-	trackSpace := height - thumbSize
+	trackSpace := height - thumbSize + 1
 	thumbPos := 0
 	if trackSpace > 0 && maxOffset > 0 {
 		thumbPos = min(trackSpace, offset*trackSpace/maxOffset)

internal/ui/dialog/models.go 🔗

@@ -10,6 +10,7 @@ import (
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/catwalk/pkg/catwalk"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/util"
@@ -265,9 +266,14 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		t.Dialog.View.GetVerticalFrameSize()
 
 	m.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
-	m.list.SetSize(innerWidth, height-heightOffset)
 	m.help.SetWidth(innerWidth)
 
+	listHeight := height - heightOffset
+	m.list.SetSize(innerWidth, listHeight)
+	listTotalHeight := m.list.TotalHeight()
+	listWidth := max(0, innerWidth-3) // Reserve space for scrollbar.
+	m.list.SetSize(listWidth, listHeight)
+
 	rc := NewRenderContext(t, width)
 	rc.Title = "Switch Model"
 	rc.TitleInfo = m.modelTypeRadioView()
@@ -281,6 +287,10 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	rc.AddPart(inputView)
 
 	listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
+	scrollbar := common.Scrollbar(t, listHeight, listTotalHeight, listHeight, m.list.Offset())
+	if scrollbar != "" {
+		listView = lipgloss.JoinHorizontal(lipgloss.Top, listView, scrollbar)
+	}
 	rc.AddPart(listView)
 
 	rc.Help = m.help.View(m)

internal/ui/list/list.go 🔗

@@ -157,6 +157,33 @@ func (l *List) Len() int {
 	return len(l.items)
 }
 
+// TotalHeight returns the total height of all items in the list.
+func (l *List) TotalHeight() int {
+	total := 0
+	for idx := range l.items {
+		item := l.getItem(idx)
+		total += item.height
+		if l.gap > 0 && idx < len(l.items)-1 {
+			total += l.gap
+		}
+	}
+	return total
+}
+
+// Offset returns the current scroll offset in lines from the top.
+func (l *List) Offset() int {
+	offset := 0
+	for idx := 0; idx < l.offsetIdx; idx++ {
+		item := l.getItem(idx)
+		offset += item.height
+		if l.gap > 0 && idx < len(l.items)-1 {
+			offset += l.gap
+		}
+	}
+	offset += l.offsetLine
+	return offset
+}
+
 // lastOffsetItem returns the index and line offsets of the last item that can
 // be partially visible in the viewport.
 func (l *List) lastOffsetItem() (int, int, int) {