-
Notifications
You must be signed in to change notification settings - Fork 331
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:
- wait for about 8000 results in list
- 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()
}