Skip to content

list paginator rendering performance #810

@SirMetathyst

Description

@SirMetathyst

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

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

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()
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions