aerc/widgets/msglist.go
Koni Marti 9f4da4de0c msglist: update message counter upon filter change
Update message counter in msglist when the filter is changed (either set or
cleared in the msgstore).

When we apply a filter, we change the number of uids in the message
store. This can unintentionally trigger the storeUpdate() function of
the msglist which checks the number of uids for new messages and
advances the pointer by the difference in the number of messages. This
can be avoided when we update the message counter upon changing the
filter.

Fixes: https://todo.sr.ht/~rjarry/aerc/23
Signed-off-by: Koni Marti <koni.marti@gmail.com>
Acked-by: Robin Jarry <robin@jarry.cc>
2022-03-20 10:00:50 +01:00

442 lines
10 KiB
Go

package widgets
import (
"fmt"
"log"
"math"
"strings"
sortthread "github.com/emersion/go-imap-sortthread"
"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"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type MessageList struct {
ui.Invalidatable
Scrollable
conf *config.AercConfig
logger *log.Logger
height int
nmsgs int
spinner *Spinner
store *lib.MessageStore
isInitalizing bool
aerc *Aerc
}
func NewMessageList(conf *config.AercConfig, logger *log.Logger, aerc *Aerc) *MessageList {
ml := &MessageList{
conf: conf,
logger: logger,
spinner: NewSpinner(&conf.Ui),
isInitalizing: true,
aerc: aerc,
}
ml.spinner.OnInvalidate(func(_ ui.Drawable) {
ml.Invalidate()
})
// TODO: stop spinner, probably
ml.spinner.Start()
return ml
}
func (ml *MessageList) Invalidate() {
ml.DoInvalidate(ml)
}
func (ml *MessageList) Draw(ctx *ui.Context) {
ml.height = ctx.Height()
uiConfig := ml.aerc.SelectedAccountUiConfig()
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT))
acct := ml.aerc.SelectedAccount()
store := ml.Store()
if store == nil || acct == nil {
if ml.isInitalizing {
ml.spinner.Draw(ctx)
return
} else {
ml.spinner.Stop()
ml.drawEmptyMessage(ctx)
return
}
}
ml.UpdateScroller(ml.height, len(store.Uids()))
if store := ml.Store(); store != nil && len(store.Uids()) > 0 {
ml.EnsureScroll(store.SelectedIndex())
}
textWidth := ctx.Width()
if ml.NeedScrollbar() {
textWidth -= 1
}
if textWidth < 0 {
textWidth = 0
}
var (
needsHeaders []uint32
row int = 0
)
if uiConfig.ThreadingEnabled || store.BuildThreads() {
threads := store.Threads
counter := len(store.Uids())
for i := len(threads) - 1; i >= 0; i-- {
var lastSubject string
threads[i].Walk(func(t *types.Thread, _ int, currentErr error) error {
if currentErr != nil {
return currentErr
}
if t.Hidden || t.Deleted {
return nil
}
counter--
if counter > len(store.Uids())-1-ml.Scroll() {
//skip messages which are higher than the viewport
return nil
}
msg := store.Messages[t.Uid]
var prefix string
var subject string
var normalizedSubject string
if msg != nil {
prefix = threadPrefix(t)
if msg.Envelope != nil {
subject = msg.Envelope.Subject
normalizedSubject, _ = sortthread.GetBaseSubject(subject)
}
}
fmtCtx := format.Ctx{
FromAddress: acct.acct.From,
AccountName: acct.Name(),
MsgInfo: msg,
MsgNum: row,
MsgIsMarked: store.IsMarked(t.Uid),
ThreadPrefix: prefix,
ThreadSameSubject: normalizedSubject == lastSubject,
}
if ml.drawRow(textWidth, ctx, t.Uid, row, &needsHeaders, fmtCtx) {
return types.ErrSkipThread
}
lastSubject = normalizedSubject
row++
return nil
})
if row >= ctx.Height() {
break
}
}
} else {
uids := store.Uids()
for i := len(uids) - 1 - ml.Scroll(); i >= 0; i-- {
uid := uids[i]
msg := store.Messages[uid]
fmtCtx := format.Ctx{
FromAddress: acct.acct.From,
AccountName: acct.Name(),
MsgInfo: msg,
MsgNum: row,
MsgIsMarked: store.IsMarked(uid),
}
if ml.drawRow(textWidth, ctx, uid, row, &needsHeaders, fmtCtx) {
break
}
row += 1
}
}
if ml.NeedScrollbar() {
scrollbarCtx := ctx.Subcontext(ctx.Width()-1, 0, 1, ctx.Height())
ml.drawScrollbar(scrollbarCtx)
}
if len(store.Uids()) == 0 {
if store.Sorting {
ml.spinner.Start()
ml.spinner.Draw(ctx)
return
} else {
ml.drawEmptyMessage(ctx)
}
}
if len(needsHeaders) != 0 {
store.FetchHeaders(needsHeaders, nil)
ml.spinner.Start()
} else {
ml.spinner.Stop()
}
}
func (ml *MessageList) drawRow(textWidth int, ctx *ui.Context, uid uint32, row int, needsHeaders *[]uint32, fmtCtx format.Ctx) bool {
store := ml.store
msg := store.Messages[uid]
acct := ml.aerc.SelectedAccount()
if row >= ctx.Height() || acct == nil {
return true
}
if msg == nil {
*needsHeaders = append(*needsHeaders, uid)
ml.spinner.Draw(ctx.Subcontext(0, row, textWidth, 1))
return false
}
confParams := map[config.ContextType]string{
config.UI_CONTEXT_ACCOUNT: acct.AccountConfig().Name,
config.UI_CONTEXT_FOLDER: acct.Directories().Selected(),
}
if msg.Envelope != nil {
confParams[config.UI_CONTEXT_SUBJECT] = msg.Envelope.Subject
}
uiConfig := ml.conf.GetUiConfig(confParams)
msg_styles := []config.StyleObject{}
// unread message
seen := false
flagged := false
for _, flag := range msg.Flags {
switch flag {
case models.SeenFlag:
seen = true
case models.FlaggedFlag:
flagged = true
}
}
if seen {
msg_styles = append(msg_styles, config.STYLE_MSGLIST_READ)
} else {
msg_styles = append(msg_styles, config.STYLE_MSGLIST_UNREAD)
}
if flagged {
msg_styles = append(msg_styles, config.STYLE_MSGLIST_FLAGGED)
}
// deleted message
if _, ok := store.Deleted[msg.Uid]; ok {
msg_styles = append(msg_styles, config.STYLE_MSGLIST_DELETED)
}
// marked message
if store.IsMarked(msg.Uid) {
msg_styles = append(msg_styles, config.STYLE_MSGLIST_MARKED)
}
var style tcell.Style
// current row
if row == ml.store.SelectedIndex()-ml.Scroll() {
style = uiConfig.GetComposedStyleSelected(config.STYLE_MSGLIST_DEFAULT, msg_styles)
} else {
style = uiConfig.GetComposedStyle(config.STYLE_MSGLIST_DEFAULT, msg_styles)
}
ctx.Fill(0, row, ctx.Width(), 1, ' ', style)
fmtStr, args, err := format.ParseMessageFormat(
uiConfig.IndexFormat, uiConfig.TimestampFormat,
uiConfig.ThisDayTimeFormat,
uiConfig.ThisWeekTimeFormat,
uiConfig.ThisYearTimeFormat,
fmtCtx)
if err != nil {
ctx.Printf(0, row, style, "%v", err)
} else {
line := fmt.Sprintf(fmtStr, args...)
line = runewidth.Truncate(line, textWidth, "…")
ctx.Printf(0, row, style, "%s", line)
}
return false
}
func (ml *MessageList) 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()) * ml.PercentVisible()))
pillOffset := int(math.Floor(float64(ctx.Height()) * ml.PercentScrolled()))
ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
}
func (ml *MessageList) MouseEvent(localX int, localY int, event tcell.Event) {
switch event := event.(type) {
case *tcell.EventMouse:
switch event.Buttons() {
case tcell.Button1:
if ml.aerc == nil {
return
}
selectedMsg, ok := ml.Clicked(localX, localY)
if ok {
ml.Select(selectedMsg)
acct := ml.aerc.SelectedAccount()
if acct == nil || acct.Messages().Empty() {
return
}
store := acct.Messages().Store()
msg := acct.Messages().Selected()
if msg == nil {
return
}
lib.NewMessageStoreView(msg, store, ml.aerc.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
ml.aerc.PushError(err.Error())
return
}
viewer := NewMessageViewer(acct, ml.aerc.Config(), view)
ml.aerc.NewTab(viewer, msg.Envelope.Subject)
})
}
case tcell.WheelDown:
if ml.store != nil {
ml.store.Next()
}
ml.Invalidate()
case tcell.WheelUp:
if ml.store != nil {
ml.store.Prev()
}
ml.Invalidate()
}
}
}
func (ml *MessageList) Clicked(x, y int) (int, bool) {
store := ml.Store()
if store == nil || ml.nmsgs == 0 || y >= ml.nmsgs {
return 0, false
}
return y + ml.Scroll(), true
}
func (ml *MessageList) Height() int {
return ml.height
}
func (ml *MessageList) storeUpdate(store *lib.MessageStore) {
if ml.Store() != store {
return
}
uids := store.Uids()
if len(uids) > 0 {
// When new messages come in, advance the cursor accordingly
// Note that this assumes new messages are appended to the top, which
// isn't necessarily true once we implement SORT... ideally we'd look
// for the previously selected UID.
if len(uids) > ml.nmsgs && ml.nmsgs != 0 {
for i := 0; i < len(uids)-ml.nmsgs; i++ {
ml.Store().Next()
}
}
if len(uids) < ml.nmsgs && ml.nmsgs != 0 {
for i := 0; i < ml.nmsgs-len(uids); i++ {
ml.Store().Prev()
}
}
ml.nmsgs = len(uids)
}
ml.Invalidate()
}
func (ml *MessageList) SetStore(store *lib.MessageStore) {
if ml.Store() != store {
ml.Scrollable = Scrollable{}
}
ml.store = store
if store != nil {
ml.spinner.Stop()
ml.nmsgs = len(store.Uids())
store.OnUpdate(ml.storeUpdate)
store.OnFilterChange(func(store *lib.MessageStore) {
if ml.Store() != store {
return
}
ml.nmsgs = len(store.Uids())
})
} else {
ml.spinner.Start()
}
ml.Invalidate()
}
func (ml *MessageList) SetInitDone() {
ml.isInitalizing = false
}
func (ml *MessageList) Store() *lib.MessageStore {
return ml.store
}
func (ml *MessageList) Empty() bool {
store := ml.Store()
return store == nil || len(store.Uids()) == 0
}
func (ml *MessageList) Selected() *models.MessageInfo {
store := ml.Store()
uids := store.Uids()
return store.Messages[uids[len(uids)-ml.store.SelectedIndex()-1]]
}
func (ml *MessageList) Select(index int) {
store := ml.Store()
store.Select(index)
ml.Invalidate()
}
func (ml *MessageList) drawEmptyMessage(ctx *ui.Context) {
uiConfig := ml.aerc.SelectedAccountUiConfig()
msg := uiConfig.EmptyMessage
ctx.Printf((ctx.Width()/2)-(len(msg)/2), 0,
uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT), "%s", msg)
}
func threadPrefix(t *types.Thread) string {
var arrow string
if t.Parent != nil {
if t.NextSibling != nil {
arrow = "├─>"
} else {
arrow = "└─>"
}
}
var prefix []string
for n := t; n.Parent != nil; n = n.Parent {
if n.Parent.NextSibling != nil {
prefix = append(prefix, "│ ")
} else {
prefix = append(prefix, " ")
}
}
// prefix is now in a reverse order (inside --> outside), so turn it
for i, j := 0, len(prefix)-1; i < j; i, j = i+1, j-1 {
prefix[i], prefix[j] = prefix[j], prefix[i]
}
// we don't want to indent the first child, hence we strip that level
if len(prefix) > 0 {
prefix = prefix[1:]
}
ps := strings.Join(prefix, "")
return fmt.Sprintf("%v%v", ps, arrow)
}