package widgets
import (
"bytes"
"fmt"
"math"
"strings"
sortthread "github.com/emersion/go-imap-sortthread"
"github.com/emersion/go-message/mail"
"github.com/gdamore/tcell/v2"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/iterator"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/log"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
)
type MessageList struct {
Scrollable
height int
width int
nmsgs int
spinner *Spinner
store *lib.MessageStore
isInitalizing bool
aerc *Aerc
}
func NewMessageList(aerc *Aerc, account *AccountView) *MessageList {
ml := &MessageList{
spinner: NewSpinner(account.uiConf),
isInitalizing: true,
aerc: aerc,
}
// TODO: stop spinner, probably
ml.spinner.Start()
return ml
}
func (ml *MessageList) Invalidate() {
ui.Invalidate()
}
type messageRowParams struct {
uid uint32
needsHeaders bool
uiConfig *config.UIConfig
styles []config.StyleObject
headers *mail.Header
}
func (ml *MessageList) Draw(ctx *ui.Context) {
ml.height = ctx.Height()
ml.width = ctx.Width()
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 || len(store.Uids()) == 0 {
if ml.isInitalizing {
ml.spinner.Draw(ctx)
} else {
ml.spinner.Stop()
ml.drawEmptyMessage(ctx)
}
return
}
ml.UpdateScroller(ml.height, len(store.Uids()))
iter := store.UidsIterator()
for i := 0; iter.Next(); i++ {
if store.SelectedUid() == iter.Value().(uint32) {
ml.EnsureScroll(i)
break
}
}
textWidth := ctx.Width()
if ml.NeedScrollbar() {
textWidth -= 1
}
if textWidth <= 0 {
return
}
var needsHeaders []uint32
data := state.NewDataSetter()
data.SetAccount(acct.acct)
data.SetFolder(acct.Directories().SelectedDirectory())
customDraw := func(t *ui.Table, r int, c *ui.Context) bool {
row := &t.Rows[r]
params, _ := row.Priv.(messageRowParams)
if params.needsHeaders {
needsHeaders = append(needsHeaders, params.uid)
ml.spinner.Draw(ctx.Subcontext(0, r, c.Width(), 1))
return true
}
return false
}
getRowStyle := func(t *ui.Table, r int) tcell.Style {
var style tcell.Style
row := &t.Rows[r]
params, _ := row.Priv.(messageRowParams)
if params.uid == store.SelectedUid() {
style = params.uiConfig.MsgComposedStyleSelected(
config.STYLE_MSGLIST_DEFAULT, params.styles,
params.headers)
} else {
style = params.uiConfig.MsgComposedStyle(
config.STYLE_MSGLIST_DEFAULT, params.styles,
params.headers)
}
return style
}
table := ui.NewTable(
ml.height,
uiConfig.IndexColumns,
uiConfig.ColumnSeparator,
customDraw,
getRowStyle,
)
if store.ThreadedView() {
var (
lastSubject string
prevThread *types.Thread
i int = 0
)
factory := iterator.NewFactory(!store.ReverseThreadOrder())
threadLoop:
for iter := store.ThreadsIterator(); iter.Next(); {
var cur []*types.Thread
err := iter.Value().(*types.Thread).Walk(
func(t *types.Thread, _ int, _ error,
) error {
if t.Hidden || t.Deleted {
return nil
}
cur = append(cur, t)
return nil
})
if err != nil {
log.Errorf("thread walk: %v", err)
}
for curIter := factory.NewIterator(cur); curIter.Next(); {
if i < ml.Scroll() {
i++
continue
}
thread := curIter.Value().(*types.Thread)
if thread == nil {
continue
}
baseSubject := threadSubject(store, thread)
data.SetThreading(
threadPrefix(thread, store.ReverseThreadOrder(), true),
baseSubject == lastSubject && sameParent(thread, prevThread) && !isParent(thread),
)
lastSubject = baseSubject
prevThread = thread
if addMessage(store, thread.Uid, &table, data, uiConfig) {
break threadLoop
}
}
}
} else {
iter := store.UidsIterator()
for i := 0; iter.Next(); i++ {
if i < ml.Scroll() {
continue
}
uid := iter.Value().(uint32)
if addMessage(store, uid, &table, data, uiConfig) {
break
}
}
}
table.Draw(ctx.Subcontext(0, 0, textWidth, ctx.Height()))
if ml.NeedScrollbar() {
scrollbarCtx := ctx.Subcontext(textWidth, 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 addMessage(
store *lib.MessageStore, uid uint32,
table *ui.Table, data state.DataSetter,
uiConfig *config.UIConfig,
) bool {
msg := store.Messages[uid]
cells := make([]string, len(table.Columns))
params := messageRowParams{uid: uid, uiConfig: uiConfig}
if msg == nil || msg.Envelope == nil {
params.needsHeaders = true
return table.AddRow(cells, params)
}
if msg.Flags.Has(models.SeenFlag) {
params.styles = append(params.styles, config.STYLE_MSGLIST_READ)
} else {
params.styles = append(params.styles, config.STYLE_MSGLIST_UNREAD)
}
if msg.Flags.Has(models.AnsweredFlag) {
params.styles = append(params.styles, config.STYLE_MSGLIST_ANSWERED)
}
if msg.Flags.Has(models.FlaggedFlag) {
params.styles = append(params.styles, config.STYLE_MSGLIST_FLAGGED)
}
// deleted message
if _, ok := store.Deleted[msg.Uid]; ok {
params.styles = append(params.styles, config.STYLE_MSGLIST_DELETED)
}
// search result
if store.IsResult(msg.Uid) {
params.styles = append(params.styles, config.STYLE_MSGLIST_RESULT)
}
// marked message
marked := store.Marker().IsMarked(msg.Uid)
if marked {
params.styles = append(params.styles, config.STYLE_MSGLIST_MARKED)
}
data.SetInfo(msg, len(table.Rows), marked)
for c, col := range table.Columns {
var buf bytes.Buffer
err := col.Def.Template.Execute(&buf, data.Data())
if err != nil {
log.Errorf("<%s> %s", msg.Envelope.MessageId, err)
cells[c] = err.Error()
} else {
cells[c] = buf.String()
}
}
params.headers = msg.RFC822Headers
return table.AddRow(cells, params)
}
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) {
if event, ok := event.(*tcell.EventMouse); ok {
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, acct.UiConfig().AutoMarkRead,
store, ml.aerc.Crypto, ml.aerc.DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
ml.aerc.PushError(err.Error())
return
}
viewer := NewMessageViewer(acct, 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) Width() int {
return ml.width
}
func (ml *MessageList) storeUpdate(store *lib.MessageStore) {
if ml.Store() != store {
return
}
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()
uids := store.Uids()
ml.nmsgs = len(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 {
return ml.Store().Selected()
}
func (ml *MessageList) Select(index int) {
// Note that the msgstore.Select function expects a uid as argument
// whereas the msglist.Select expects the message number
store := ml.Store()
uids := store.Uids()
if len(uids) == 0 {
store.Select(lib.MagicUid)
return
}
iter := store.UidsIterator()
var uid uint32
if index < 0 {
uid = uids[iter.EndIndex()]
} else {
uid = uids[iter.StartIndex()]
for i := 0; iter.Next(); i++ {
if i >= index {
uid = iter.Value().(uint32)
break
}
}
}
store.Select(uid)
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, reverse bool, point bool) string {
var arrow string
if t.Parent != nil {
switch {
case t.NextSibling != nil:
arrow = "├─"
case reverse:
arrow = "┌─"
default:
arrow = "└─"
}
if point {
arrow += ">"
}
}
var prefix []string
for n := t; n.Parent != nil; n = n.Parent {
switch {
case n.Parent.NextSibling != nil && point:
prefix = append(prefix, "│ ")
case n.Parent.NextSibling != nil:
prefix = append(prefix, "│ ")
case point:
prefix = append(prefix, " ")
default:
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)
}
func sameParent(left, right *types.Thread) bool {
return left.Root() == right.Root()
}
func isParent(t *types.Thread) bool {
return t == t.Root()
}
func threadSubject(store *lib.MessageStore, thread *types.Thread) string {
msg, found := store.Messages[thread.Uid]
if !found || msg == nil || msg.Envelope == nil {
return ""
}
subject, _ := sortthread.GetBaseSubject(msg.Envelope.Subject)
return subject
}