charmbracelet / charmbracelet/bubbles

list paginator rendering performance

Open
#810 1 comment 1 reaction 0 assignees View on GitHub
Dominant language
Go
Stars
8.9k
Forks
457
Avg merge
1d 18h
Merged PRs (30d)
5

Description

**Describe the bug**
slow rendering performance on list with several thousand items

tested on HP 14-bp059na laptop, Intel Celeron CPU

**Setup**
Please complete the following information along with version numbers, if applicable.
- OS: Alpine Linux edge
- Shell: Fish 4.0.2
- Window Manager: sway 1.11
- Terminal Emulator: Foot 1.22.3 +pgo +ime +graphemes -assertions (not in server mode)
- Terminal Multiplexer: tmux 3.5a (in use during run)

**To Reproduce**
Steps to reproduce the behavior:
1. wait for about 8000 results in list
2. use up/down arrows to navigate list. noticeable scroll delay

**Source Code**
```go
package main

import (
"fmt"
"log"
"net/http"
_ "net/http/pprof"
"os"
"strings"
"time"

"github.com/PuerkitoBio/goquery"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)

type searchRequest struct {
Page int
}

type bestRatedResult struct {
title string
desc string
genre bestRatedGenre
}

func (i bestRatedResult) Title() string { return i.title }
func (i bestRatedResult) Description() string { return i.desc }
func (i bestRatedResult) FilterValue() string { return i.title }

type bestRatedGenre int

const (
bestRatedGenreAll bestRatedGenre = iota
bestRatedGenreAction
bestRatedGenreAdventure
bestRatedGenreComedy
bestRatedGenreContemporary
bestRatedGenreDrama
bestRatedGenreFantasy
bestRatedGenreHistorical
bestRatedGenreHorror
bestRatedGenreMystery
bestRatedGenrePsychological
bestRatedGenreRomance
bestRatedGenreSatire
bestRatedGenreSciFi
bestRatedGenreShortStory
bestRatedGenreTragedy
bestRatedGenreMAX
)

var (
bestRatedGenreNames = [...]string{
bestRatedGenreAll: "",
bestRatedGenreAction: "action",
bestRatedGenreAdventure: "adventure",
bestRatedGenreComedy: "comedy",
bestRatedGenreContemporary: "contemporary",
bestRatedGenreDrama: "drama",
bestRatedGenreFantasy: "fantasy",
bestRatedGenreHistorical: "historical",
bestRatedGenreHorror: "horror",
bestRatedGenreMystery: "mystery",
bestRatedGenrePsychological: "psychological",
bestRatedGenreRomance: "romance",
bestRatedGenreSatire: "satire",
bestRatedGenreSciFi: "sci_fi",
bestRatedGenreShortStory: "one_shot",
bestRatedGenreTragedy: "tragedy",
}
)

func BestRatedGenreToDisplayName(genre bestRatedGenre) string {
if genre == bestRatedGenreAll {
return "ALL GENRES"
}
if genre == bestRatedGenreSciFi {
return "Sci Fi"
} else if genre == bestRatedGenreShortStory {
return "Short Story"
}
// using depricated strings.Title is fine because only ascii is expected
return strings.Title(bestRatedGenreNames[genre])
}

type (
foundBestRatedPageMsg struct {
page int
genre bestRatedGenre
results []bestRatedResult
}
fetchBestRatedFinishedMsg struct{}
)

type httpStatusMsg int

type httpErrMsg struct{ error }

func (e httpErrMsg) Error() string { return e.error.Error() }

type state int

const (
stateShowBestRated state = iota
)

type model struct {
searchRequest searchRequest

state state

height int
width int

// best rated
spinner spinner.Model
list list.Model
viewport viewport.Model

showSpinner bool

selectedBestRated int
selectedBestRatedGenre bestRatedGenre
}

func newModel() model {

s := spinner.New()
s.Spinner = spinner.Line

d := list.NewDefaultDelegate()
d.Styles.SelectedTitle = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#60a5fa")).
Foreground(lipgloss.Color("#60a5fa")).
Padding(0, 0, 0, 1)

d.Styles.SelectedDesc = d.Styles.SelectedTitle.
Foreground(lipgloss.Color("#3b82f6"))

l := list.New(nil, d, 0, 0)
l.SetShowStatusBar(false)
l.SetShowTitle(false)
l.SetShowPagination(true)
l.SetShowHelp(false)
v := viewport.New(0, 0)

return model{
list: l,
spinner: s,
viewport: v,
}
}

func (m model) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, fetchBestRated(1, m.selectedBestRatedGenre))
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd

switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
if m.list.FilterState() == list.FilterApplied || m.list.FilterState() == list.Filtering {
m.list.ResetFilter()
}
return m, nil
case "ctrl+c", "q":
if m.list.FilterState() == list.Filtering {
newModel, cmd := m.updateBestRated(msg)
return newModel, cmd
}
return m, tea.Quit
}

case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height

m.setSizeForBestRated(m.width, m.height)
}

switch m.state {
case stateShowBestRated:
newModel, cmd := m.updateBestRated(msg)
cmds = append(cmds, cmd)
return newModel, tea.Batch(cmds...)
}

return m, tea.Batch(cmds...)
}

func (m model) View() string {

switch m.state {
case stateShowBestRated:
return m.bestRatedView()
}

return "state error"
}

func (m *model) setSizeForBestRated(width, height int) {

m.width = max(75, width/2)
m.height = height

if m.selectedBestRated > 0 {
m.list.SetSize(m.width, m.height/2)
} else {
m.list.SetSize(m.width, m.height-5)
}
}

func (m model) updateBestRated(msg tea.Msg) (model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "g":
if m.selectedBestRated > 0 {
break
}
if m.list.FilterState() == list.Filtering {
break
}
m.list.ResetFilter()
m.selectedBestRatedGenre += 1
if m.selectedBestRatedGenre >= bestRatedGenreMAX {
m.selectedBestRatedGenre = 0
}
return m, tea.Batch(cmds...)

case "enter":
if m.list.SelectedItem() == nil {
break
}
if m.list.FilterState() == list.Filtering {
break
}
itemIndex := m.list.GlobalIndex()
item := m.list.SelectedItem().(bestRatedResult)
m.selectedBestRated = itemIndex + 1
m.viewport.SetContent(lipgloss.NewStyle().Width(m.width - 3).Render(item.desc))
m.list.SetSize(m.width, (m.height-5)/2)

case "left":
if m.selectedBestRated > 0 {
m.selectedBestRated = 0
m.list.SetSize(m.width, m.height-5)
} else {
break
}
}

case spinner.TickMsg:
var spinnerCmd tea.Cmd
m.spinner, spinnerCmd = m.spinner.Update(msg)
cmds = append(cmds, spinnerCmd)

case fetchBestRatedFinishedMsg:
m.showSpinner = false

case foundBestRatedPageMsg:
if m.selectedBestRatedGenre != msg.genre {
listCmd := m.list.SetItems([]list.Item{})
cmds = append(cmds, listCmd)
cmds = append(cmds, fetchBestRated(1, m.selectedBestRatedGenre))
return m, tea.Batch(cmds...)
}

m.showSpinner = true

var lastCmd tea.Cmd
for _, result := range msg.results {
lastCmd = m.list.InsertItem(len(m.list.Items()), result)
}

setItemsCmd := m.list.SetItems(m.list.Items())

cmds = append(cmds, setItemsCmd)
cmds = append(cmds, lastCmd)
cmds = append(cmds, fetchBestRated(msg.page+1, msg.genre))
}

if m.selectedBestRated == 0 {
var listCmd tea.Cmd
m.list, listCmd = m.list.Update(msg)
cmds = append(cmds, listCmd)
} else {
var viewportCmd tea.Cmd
m.viewport, viewportCmd = m.viewport.Update(msg)
cmds = append(cmds, viewportCmd)
}

return m, tea.Batch(cmds...)
}

func (m model) bestRatedView() string {

var (
sections []string
availHeight = m.height
)

if availHeight <= 0 {
return ""
}

{
var (
view string
// spinnerLeftGap = " "
)

// view += " "
// view += spinnerView + spinnerLeftGap
// view += "Royal Road - Best Rated"

spinner := " "
if m.showSpinner {
spinnerView := m.spinner.View()
spinner = lipgloss.NewStyle().MarginLeft(1).MarginRight(1).MarginTop(1).Foreground(lipgloss.Color("#525252")).Render(spinnerView)
}

title := lipgloss.NewStyle().MarginTop(1).
Background(lipgloss.Color("#3b82f6")).
PaddingLeft(1).
PaddingRight(1).
Bold(true).
Render("Royal Road - Best Rated")

view = lipgloss.JoinHorizontal(lipgloss.Left, spinner, title)

sections = append(sections, view)
availHeight -= lipgloss.Height(view)
}

{
var (
view string
)

view += " "
view += fmt.Sprintf("%d results", len(m.list.Items()))
view += " | "
view += BestRatedGenreToDisplayName(m.selectedBestRatedGenre)

view = lipgloss.NewStyle().MarginTop(1).
Foreground(lipgloss.Color("#525252")).Render(view)

sections = append(sections, view)
availHeight -= lipgloss.Height(view)
}

{
view := lipgloss.NewStyle().MarginLeft(1).MarginBottom(1).Render(m.list.View())

sections = append(sections, view)
availHeight -= lipgloss.Height(view)
}

if m.selectedBestRated > 0 {

m.viewport.Width = m.width
m.viewport.Height = availHeight - 1

view := lipgloss.NewStyle().MarginLeft(3).MarginBottom(1).Foreground(lipgloss.Color("#71717a")).
Render(m.viewport.View())

sections = append(sections, view)
availHeight -= lipgloss.Height(view)
}

if availHeight < 0 {
panic(fmt.Sprintf("no height %d", availHeight))
}

return lipgloss.JoinVertical(lipgloss.Left, sections...)
}

// new experimental version
func fetchBestRated(page int, genre bestRatedGenre) tea.Cmd {

return func() tea.Msg {

time.Sleep(100 * time.Millisecond)

res, _ := http.Get(fmt.Sprintf("https://www.royalroad.com/fictions/best-rated?page=%d&genre=%s",
page, bestRatedGenreNames[genre]))
// if err != nil {
// return httpErrMsg{err}
// }

defer res.Body.Close()
// if res.StatusCode != 200 {
// return httpStatusMsg(res.StatusCode)
// }

doc, _ := goquery.NewDocumentFromReader(res.Body)
// if err != nil {
// return httpErrMsg{err}
// }

// on last page?
last := doc.Find("pagination li").Last()
if last != nil {
if last.Text() == "Last" {
return fetchBestRatedFinishedMsg{}
}
}

var results []bestRatedResult
doc.Find(".row .fiction-list-item").Each(func(i int, s *goquery.Selection) {
title := strings.TrimSpace(s.Find(".fiction-title").Text())
desc := strings.TrimSpace(s.Find("div:nth-child(7)").Text())
results = append(results, bestRatedResult{title: title, desc: desc, genre: genre})
})

return foundBestRatedPageMsg{page: page, genre: genre, results: results}
}
}

func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
p := tea.NewProgram(newModel(), tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Println("Error running program: ", err)
os.Exit(1)
}
}
```

**Expected behavior**
no performance issue / no lag

**Screenshots**
Add screenshots to help explain your problem.

**Additional context**

**fix**
I was able to fix the performance issue with the following code in `bubbles/paginator/paginator.go` in dotsView but I don't know why this fixed my issue since this code shouldn't have been run after the dots reach the screen width. When I reach the thousands of results and experience the issue I am already seeing the arabic formatting.

I have replaced the concat with a string builder
```go
func (m Model) dotsView() string {
var sb strings.Builder
for i := 0; i < m.TotalPages; i++ {
if i == m.Page {
sb.WriteString(m.ActiveDot)
continue
}
sb.WriteString(m.InactiveDot)
}
return sb.String()
}
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.