aboutsummaryrefslogtreecommitdiffstats
path: root/app/msglist.go
diff options
context:
space:
mode:
authorRobin Jarry <robin@jarry.cc>2023-10-09 13:52:20 +0200
committerRobin Jarry <robin@jarry.cc>2023-10-10 11:37:56 +0200
commit598e4a5803578ab3e291f232d6aad31b4efd8ea4 (patch)
treec55e16d60e2c3eea2d6de27d1bac18db5670ec77 /app/msglist.go
parent61bca76423ee87bd59084a146eca71c6bae085e1 (diff)
downloadaerc-598e4a5803578ab3e291f232d6aad31b4efd8ea4.tar.gz
widgets: rename package to app
This is the central point of all aerc. Having it named widgets is confusing. Rename it to app. It will make a cleaner transition when making the app.Aerc object available globally in the next commit. Signed-off-by: Robin Jarry <robin@jarry.cc> Acked-by: Moritz Poldrack <moritz@poldrack.dev>
Diffstat (limited to 'app/msglist.go')
-rw-r--r--app/msglist.go497
1 files changed, 497 insertions, 0 deletions
diff --git a/app/msglist.go b/app/msglist.go
new file mode 100644
index 00000000..57771ae8
--- /dev/null
+++ b/app/msglist.go
@@ -0,0 +1,497 @@
+package app
+
+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/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
+ }
+ }
+
+ store.UpdateScroll(ml.Scroll(), ml.height)
+
+ 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,
+ )
+
+ showThreads := store.ThreadedView()
+ threadView := newThreadView(store)
+ iter = store.UidsIterator()
+ for i := 0; iter.Next(); i++ {
+ if i < ml.Scroll() {
+ continue
+ }
+ uid := iter.Value().(uint32)
+ if showThreads {
+ threadView.Update(data, uid)
+ }
+ 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)
+ }
+ // folded thread
+ templateData, ok := data.(models.TemplateData)
+ if ok {
+ if templateData.ThreadFolded() {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_FOLDED)
+ }
+ if templateData.ThreadContext() {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_CONTEXT)
+ }
+ }
+ // 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) {
+ uiConfig := ml.aerc.SelectedAccountUiConfig()
+ gutterStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_GUTTER)
+ pillStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_PILL)
+
+ // 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 countThreads(thread *types.Thread) (ctr int) {
+ if thread == nil {
+ return
+ }
+ _ = thread.Walk(func(t *types.Thread, _ int, _ error) error {
+ ctr++
+ return nil
+ })
+ return
+}
+
+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
+}
+
+type threadView struct {
+ store *lib.MessageStore
+ reverse bool
+ prev *types.Thread
+ prevSubj string
+}
+
+func newThreadView(store *lib.MessageStore) *threadView {
+ return &threadView{
+ store: store,
+ reverse: store.ReverseThreadOrder(),
+ }
+}
+
+func (t *threadView) Update(data state.DataSetter, uid uint32) {
+ prefix, same, count, folded, context := "", false, 0, false, false
+ thread, err := t.store.Thread(uid)
+ if thread != nil && err == nil {
+ prefix = threadPrefix(thread, t.reverse, true)
+ subject := threadSubject(t.store, thread)
+ same = subject == t.prevSubj && sameParent(thread, t.prev) && !isParent(thread)
+ t.prev = thread
+ t.prevSubj = subject
+ count = countThreads(thread)
+ folded = thread.FirstChild != nil && thread.FirstChild.Hidden
+ context = thread.Context
+ }
+ data.SetThreading(prefix, same, count, folded, context)
+}