aerc/widgets/dirlist.go
Tim Culverhouse 4240f1fbfd perf: reduce calls to GetUiConfig
GetUiConfig was being called many times, and came up as a high CPU user
in a cpuprofile. Every call would merge a UIConfig, which is a costly
operation. Ideally, we would only need to have a config for every
account X every directory. We also have a context for subjects. This
patch stores all FOLDER and ACCOUNT level configs and reuses those
merged objects. The SUBJECT contexts are not stored in favor of merging
on-the-go, with a TODO comment to deprecate that feature and implement a
better per-message styling option. I suspect this feature is not used
very much.

Before applying this patch with my setup, GetUiConfig is called 1159
times just to open aerc. After applying, this is reduced to 37.

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
Acked-by: Robin Jarry <robin@jarry.cc>
2022-07-03 21:32:24 +02:00

555 lines
13 KiB
Go

package widgets
import (
"context"
"fmt"
"log"
"math"
"os"
"regexp"
"sort"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/mattn/go-runewidth"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/format"
libsort "git.sr.ht/~rjarry/aerc/lib/sort"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/logging"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type DirectoryLister interface {
ui.Drawable
Selected() string
Select(string)
UpdateList(func([]string))
List() []string
ClearList()
NextPrev(int)
CollapseFolder()
ExpandFolder()
SelectedMsgStore() (*lib.MessageStore, bool)
MsgStore(string) (*lib.MessageStore, bool)
SetMsgStore(string, *lib.MessageStore)
FilterDirs([]string, []string, bool) []string
UiConfig() *config.UIConfig
}
type DirectoryList struct {
ui.Invalidatable
Scrollable
aercConf *config.AercConfig
acctConf *config.AccountConfig
store *lib.DirStore
dirs []string
logger *log.Logger
selecting string
selected string
spinner *Spinner
worker *types.Worker
skipSelect context.Context
skipSelectCancel context.CancelFunc
connected bool
uiConf map[string]*config.UIConfig
}
func NewDirectoryList(conf *config.AercConfig, acctConf *config.AccountConfig,
logger *log.Logger, worker *types.Worker,
) DirectoryLister {
ctx, cancel := context.WithCancel(context.Background())
uiConfMap := make(map[string]*config.UIConfig)
dirlist := &DirectoryList{
aercConf: conf,
acctConf: acctConf,
logger: logger,
store: lib.NewDirStore(),
worker: worker,
skipSelect: ctx,
skipSelectCancel: cancel,
uiConf: uiConfMap,
}
uiConf := dirlist.UiConfig()
dirlist.spinner = NewSpinner(uiConf)
dirlist.spinner.OnInvalidate(func(_ ui.Drawable) {
dirlist.Invalidate()
})
dirlist.spinner.Start()
if uiConf.DirListTree {
return NewDirectoryTree(dirlist, string(os.PathSeparator))
}
return dirlist
}
func (dirlist *DirectoryList) UiConfig() *config.UIConfig {
if ui, ok := dirlist.uiConf[dirlist.Selected()]; ok {
return ui
}
ui := dirlist.aercConf.GetUiConfig(map[config.ContextType]string{
config.UI_CONTEXT_ACCOUNT: dirlist.acctConf.Name,
config.UI_CONTEXT_FOLDER: dirlist.Selected(),
})
dirlist.uiConf[dirlist.Selected()] = ui
return ui
}
func (dirlist *DirectoryList) List() []string {
return dirlist.store.List()
}
func (dirlist *DirectoryList) ClearList() {
dirlist.dirs = []string{}
}
func (dirlist *DirectoryList) UpdateList(done func(dirs []string)) {
// TODO: move this logic into dirstore
var dirs []string
dirlist.worker.PostAction(
&types.ListDirectories{}, func(msg types.WorkerMessage) {
switch msg := msg.(type) {
case *types.Directory:
dirs = append(dirs, msg.Dir.Name)
case *types.Done:
dirlist.store.Update(dirs)
dirlist.filterDirsByFoldersConfig()
dirlist.sortDirsByFoldersSortConfig()
dirlist.store.Update(dirlist.dirs)
dirlist.spinner.Stop()
dirlist.Invalidate()
if done != nil {
done(dirs)
}
}
})
}
func (dirlist *DirectoryList) CollapseFolder() {
// no effect for the DirectoryList
}
func (dirlist *DirectoryList) ExpandFolder() {
// no effect for the DirectoryList
}
func (dirlist *DirectoryList) Select(name string) {
dirlist.selecting = name
dirlist.skipSelectCancel()
ctx, cancel := context.WithCancel(context.Background())
dirlist.skipSelect = ctx
dirlist.skipSelectCancel = cancel
go func(ctx context.Context) {
defer logging.PanicHandler()
select {
case <-time.After(dirlist.UiConfig().DirListDelay):
newStore := true
for _, s := range dirlist.store.List() {
if s == dirlist.selecting {
newStore = false
}
}
dirlist.worker.PostAction(&types.OpenDirectory{Directory: name},
func(msg types.WorkerMessage) {
switch msg.(type) {
case *types.Error:
dirlist.selecting = ""
dirlist.selected = ""
case *types.Done:
dirlist.selected = dirlist.selecting
dirlist.filterDirsByFoldersConfig()
hasSelected := false
for _, d := range dirlist.dirs {
if d == dirlist.selected {
hasSelected = true
break
}
}
if !hasSelected && dirlist.selected != "" {
dirlist.dirs = append(dirlist.dirs, dirlist.selected)
}
if dirlist.acctConf.EnableFoldersSort {
sort.Strings(dirlist.dirs)
}
dirlist.sortDirsByFoldersSortConfig()
if newStore {
dirlist.worker.PostAction(&types.FetchDirectoryContents{}, nil)
}
}
dirlist.Invalidate()
})
dirlist.Invalidate()
case <-ctx.Done():
dirlist.logger.Println("dirlist: skip", name)
return
}
}(ctx)
}
func (dirlist *DirectoryList) Selected() string {
return dirlist.selected
}
func (dirlist *DirectoryList) Invalidate() {
dirlist.DoInvalidate(dirlist)
}
func (dirlist *DirectoryList) getDirString(name string, width int, recentUnseen func() string) string {
percent := false
rightJustify := false
formatted := ""
doRightJustify := func(s string) {
formatted = runewidth.FillRight(formatted, width-len(s))
formatted = runewidth.Truncate(formatted, width-len(s), "…")
}
for _, char := range dirlist.UiConfig().DirListFormat {
switch char {
case '%':
if percent {
formatted += string(char)
percent = false
} else {
percent = true
}
case '>':
if percent {
rightJustify = true
}
case 'N':
name = format.CompactPath(name, os.PathSeparator)
fallthrough
case 'n':
if percent {
if rightJustify {
doRightJustify(name)
rightJustify = false
}
formatted += name
percent = false
}
case 'r':
if percent {
rString := recentUnseen()
if rightJustify {
doRightJustify(rString)
rightJustify = false
}
formatted += rString
percent = false
}
default:
formatted += string(char)
}
}
return formatted
}
func (dirlist *DirectoryList) getRUEString(name string) string {
msgStore, ok := dirlist.MsgStore(name)
if !ok {
return ""
}
if !msgStore.DirInfo.AccurateCounts {
msgStore.DirInfo.Recent, msgStore.DirInfo.Unseen = countRUE(msgStore)
}
di := msgStore.DirInfo
rueString := ""
if di.Recent > 0 {
rueString = fmt.Sprintf("%d/%d/%d", di.Recent, di.Unseen, di.Exists)
} else if di.Unseen > 0 {
rueString = fmt.Sprintf("%d/%d", di.Unseen, di.Exists)
} else if di.Exists > 0 {
rueString = fmt.Sprintf("%d", di.Exists)
}
return rueString
}
func (dirlist *DirectoryList) Draw(ctx *ui.Context) {
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
dirlist.UiConfig().GetStyle(config.STYLE_DIRLIST_DEFAULT))
if dirlist.spinner.IsRunning() {
dirlist.spinner.Draw(ctx)
return
}
if len(dirlist.dirs) == 0 {
style := dirlist.UiConfig().GetStyle(config.STYLE_DIRLIST_DEFAULT)
ctx.Printf(0, 0, style, dirlist.UiConfig().EmptyDirlist)
return
}
dirlist.UpdateScroller(ctx.Height(), len(dirlist.dirs))
dirlist.EnsureScroll(findString(dirlist.dirs, dirlist.selecting))
textWidth := ctx.Width()
if dirlist.NeedScrollbar() {
textWidth -= 1
}
if textWidth < 0 {
textWidth = 0
}
for i, name := range dirlist.dirs {
if i < dirlist.Scroll() {
continue
}
row := i - dirlist.Scroll()
if row >= ctx.Height() {
break
}
dirStyle := []config.StyleObject{}
s := dirlist.getRUEString(name)
switch strings.Count(s, "/") {
case 1:
dirStyle = append(dirStyle, config.STYLE_DIRLIST_UNREAD)
case 2:
dirStyle = append(dirStyle, config.STYLE_DIRLIST_RECENT)
}
style := dirlist.UiConfig().GetComposedStyle(
config.STYLE_DIRLIST_DEFAULT, dirStyle)
if name == dirlist.selecting {
style = dirlist.UiConfig().GetComposedStyleSelected(
config.STYLE_DIRLIST_DEFAULT, dirStyle)
}
ctx.Fill(0, row, textWidth, 1, ' ', style)
dirString := dirlist.getDirString(name, textWidth, func() string {
return s
})
ctx.Printf(0, row, style, dirString)
}
if dirlist.NeedScrollbar() {
scrollBarCtx := ctx.Subcontext(ctx.Width()-1, 0, 1, ctx.Height())
dirlist.drawScrollbar(scrollBarCtx)
}
}
func (dirlist *DirectoryList) drawScrollbar(ctx *ui.Context) {
gutterStyle := tcell.StyleDefault
pillStyle := tcell.StyleDefault.Reverse(true)
// gutter
ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle)
// pill
pillSize := int(math.Ceil(float64(ctx.Height()) * dirlist.PercentVisible()))
pillOffset := int(math.Floor(float64(ctx.Height()) * dirlist.PercentScrolled()))
ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
}
func (dirlist *DirectoryList) MouseEvent(localX int, localY int, event tcell.Event) {
switch event := event.(type) {
case *tcell.EventMouse:
switch event.Buttons() {
case tcell.Button1:
clickedDir, ok := dirlist.Clicked(localX, localY)
if ok {
dirlist.Select(clickedDir)
}
case tcell.WheelDown:
dirlist.Next()
case tcell.WheelUp:
dirlist.Prev()
}
}
}
func (dirlist *DirectoryList) Clicked(x int, y int) (string, bool) {
if dirlist.dirs == nil || len(dirlist.dirs) == 0 {
return "", false
}
for i, name := range dirlist.dirs {
if i == y {
return name, true
}
}
return "", false
}
func (dirlist *DirectoryList) NextPrev(delta int) {
curIdx := findString(dirlist.dirs, dirlist.selecting)
if curIdx == len(dirlist.dirs) {
return
}
newIdx := curIdx + delta
ndirs := len(dirlist.dirs)
if ndirs == 0 {
return
}
if newIdx < 0 {
newIdx = ndirs - 1
} else if newIdx >= ndirs {
newIdx = 0
}
dirlist.Select(dirlist.dirs[newIdx])
}
func (dirlist *DirectoryList) Next() {
dirlist.NextPrev(1)
}
func (dirlist *DirectoryList) Prev() {
dirlist.NextPrev(-1)
}
func folderMatches(folder string, pattern string) bool {
if len(pattern) == 0 {
return false
}
if pattern[0] == '~' {
r, err := regexp.Compile(pattern[1:])
if err != nil {
return false
}
return r.Match([]byte(folder))
}
return pattern == folder
}
// sortDirsByFoldersSortConfig sets dirlist.dirs to be sorted based on the
// AccountConfig.FoldersSort option. Folders not included in the option
// will be appended at the end in alphabetical order
func (dirlist *DirectoryList) sortDirsByFoldersSortConfig() {
if !dirlist.acctConf.EnableFoldersSort {
return
}
sort.Slice(dirlist.dirs, func(i, j int) bool {
foldersSort := dirlist.acctConf.FoldersSort
iInFoldersSort := findString(foldersSort, dirlist.dirs[i])
jInFoldersSort := findString(foldersSort, dirlist.dirs[j])
if iInFoldersSort >= 0 && jInFoldersSort >= 0 {
return iInFoldersSort < jInFoldersSort
}
if iInFoldersSort >= 0 {
return true
}
if jInFoldersSort >= 0 {
return false
}
return dirlist.dirs[i] < dirlist.dirs[j]
})
}
// filterDirsByFoldersConfig sets dirlist.dirs to the filtered subset of the
// dirstore, based on AccountConfig.Folders (inclusion) and
// AccountConfig.FoldersExclude (exclusion), in that order.
func (dirlist *DirectoryList) filterDirsByFoldersConfig() {
dirlist.dirs = dirlist.store.List()
// 'folders' (if available) is used to make the initial list and
// 'folders-exclude' removes from that list.
configFolders := dirlist.acctConf.Folders
dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFolders, false)
configFoldersExclude := dirlist.acctConf.FoldersExclude
dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFoldersExclude, true)
}
// FilterDirs filters directories by the supplied filter. If exclude is false,
// the filter will only include directories from orig which exist in filters.
// If exclude is true, the directories in filters are removed from orig
func (dirlist *DirectoryList) FilterDirs(orig, filters []string, exclude bool) []string {
if len(filters) == 0 {
return orig
}
var dest []string
for _, folder := range orig {
// When excluding, include things by default, and vice-versa
include := exclude
for _, f := range filters {
if folderMatches(folder, f) {
// If matched an exclusion, don't include
// If matched an inclusion, do include
include = !exclude
break
}
}
if include {
dest = append(dest, folder)
}
}
return dest
}
func (dirlist *DirectoryList) SelectedMsgStore() (*lib.MessageStore, bool) {
return dirlist.store.MessageStore(dirlist.selected)
}
func (dirlist *DirectoryList) MsgStore(name string) (*lib.MessageStore, bool) {
return dirlist.store.MessageStore(name)
}
func (dirlist *DirectoryList) SetMsgStore(name string, msgStore *lib.MessageStore) {
dirlist.store.SetMessageStore(name, msgStore)
msgStore.OnUpdateDirs(func() {
dirlist.Invalidate()
})
}
func findString(slice []string, str string) int {
for i, s := range slice {
if str == s {
return i
}
}
return -1
}
func (dirlist *DirectoryList) getSortCriteria() []*types.SortCriterion {
if len(dirlist.UiConfig().Sort) == 0 {
return nil
}
criteria, err := libsort.GetSortCriteria(dirlist.UiConfig().Sort)
if err != nil {
dirlist.logger.Printf("getSortCriteria failed: %v", err)
return nil
}
return criteria
}
func countRUE(msgStore *lib.MessageStore) (recent, unread int) {
for _, msg := range msgStore.Messages {
if msg == nil {
continue
}
seen := false
for _, flag := range msg.Flags {
if flag == models.SeenFlag {
seen = true
}
if flag == models.RecentFlag {
recent++
}
}
if !seen {
unread++
}
}
return recent, unread
}