~rjarry/aerc

d2e74cdb91e140b50d14c3a8b315cde272f32587 — Robin Jarry 9 months ago 6af06c9
statusline: add column based render format

In the spirit of commit 535300cfdbfc ("config: add columns based index
format"), reuse the column definitions and table widget.

Add automatic translation of render-format to column definitions. Allow
empty columns to be compatible with the %m (mute) flag.

Rename the State object to AccountState to be more precise. Reuse that
object in state.TempateData to expose account state info. Move actual
status line rendering in StatusLine.Draw().

Add new template fields for status specific data:

{{.ConnectionInfo}}
	Connection state.
{{.ContentInfo}}
	General status information (e.g. filter, search)
{{.StatusInfo}}
	Combination of {{.ConnectionInfo}} and {{.StatusInfo}}
{{.TrayInfo}}
	General on/off information (e.g. passthrough, threading,
	sorting)
{{.PendingKeys}}
	Currently pressed key sequence that does not match any key
	binding and/or is incomplete.

Display a warning on startup if render-format has been converted to
status-columns.

Signed-off-by: Robin Jarry <robin@jarry.cc>
Acked-by: Tim Culverhouse <tim@timculverhouse.com>
M CHANGELOG.md => CHANGELOG.md +2 -0
@@ 13,6 13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add a `trimSignature` function to the templating engine.
- Change local domain name for SMTP with `smtp-domain=example.com` in
  `aerc.conf`
- New column-based status line format with `status-columns`.

### Changed



@@ 29,6 30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `[ui].index-format` setting has been replaced by `index-columns`.
- `[triggers].new-email` now needs to use `aerc-templates(7)` syntax instead
  of the (now deprecated) `index-format` placeholders.
- `[statusline].render-format` has been replaced by `status-columns`.

## [0.14.0](https://git.sr.ht/~rjarry/aerc/refs/0.14.0) - 2023-01-04


M commands/account/import-mbox.go => commands/account/import-mbox.go +1 -1
@@ 125,7 125,7 @@ func (ImportMbox) Execute(aerc *widgets.Aerc, args []string) error {
		}
		infoStr := fmt.Sprintf("%s: imported %d of %d successfully.", args[0], appended, len(messages))
		log.Debugf(infoStr)
		aerc.SetStatus(infoStr)
		aerc.PushSuccess(infoStr)
	}

	if len(store.Uids()) > 0 {

M config/aerc.conf => config/aerc.conf +23 -3
@@ 279,10 279,30 @@
#client-threads-delay=50ms

[statusline]
# Describes the format string for the statusline.
#
# Default: [%a] %S %>%T
#render-format=[%a] %S %>%T
# Describes the format for the status line. This is a comma separated list of
# column names with an optional align and width suffix. See [ui].index-columns
# for more details. To completely mute the status line except for push
# notifications, explicitly set status-columns to an empty string.
#
# Default: left<*,center:=,right>*
#status-columns=left<*,center:=,right>*

#
# Each name in status-columns must have a corresponding column-$name setting.
# All column-$name settings accept golang text/template syntax. See
# aerc-templates(7) for available template attributes and functions.
#
# Default settings
#column-left=[{{.Account}}] {{.StatusInfo}}
#column-center={{.PendingKeys}}
#column-right={{.TrayInfo}}

#
# String separator inserted between columns.
# See [ui].column-separator for more details.
#
#column-separator=" "

# Specifies the separator between grouped statusline elements.
#

M config/columns.go => config/columns.go +14 -1
@@ 2,6 2,7 @@ package config

import (
	"bytes"
	"crypto/sha256"
	"fmt"
	"reflect"
	"regexp"


@@ 113,7 114,7 @@ func ParseColumnDefs(key *ini.Key, section *ini.Section) ([]*ColumnDef, error) {
		columns = append(columns, c)
	}
	if len(columns) == 0 {
		return nil, fmt.Errorf("%s cannot be empty", key.Name())
		return nil, nil
	}
	return columns, nil
}


@@ 156,3 157,15 @@ func ColumnDefsToIni(defs []*ColumnDef, keyName string) string {

	return s.String()
}

var templateFieldNameRe = regexp.MustCompile(`\{\{\.?(\w+)\}\}`)

func columnNameFromTemplate(s string) string {
	match := templateFieldNameRe.FindStringSubmatch(s)
	if match == nil {
		h := sha256.New()
		h.Write([]byte(s))
		return fmt.Sprintf("%x", h.Sum(nil)[:3])
	}
	return strings.ReplaceAll(strings.ToLower(match[1]), "info", "")
}

M config/statusline.go => config/statusline.go +133 -5
@@ 1,21 1,50 @@
package config

import (
	"regexp"
	"strings"

	"git.sr.ht/~rjarry/aerc/lib/templates"
	"git.sr.ht/~rjarry/aerc/log"
	"github.com/go-ini/ini"
)

type StatuslineConfig struct {
	StatusColumns   []*ColumnDef `ini:"-"`
	ColumnSeparator string       `ini:"column-separator"`
	Separator       string       `ini:"separator"`
	DisplayMode     string       `ini:"display-mode"`
	// deprecated
	RenderFormat string `ini:"render-format"`
	Separator    string
	DisplayMode  string `ini:"display-mode"`
}

func defaultStatuslineConfig() *StatuslineConfig {
	left, _ := templates.ParseTemplate("column-left", `[{{.Account}}] {{.StatusInfo}}`)
	center, _ := templates.ParseTemplate("column-center", `{{.PendingKeys}}`)
	right, _ := templates.ParseTemplate("column-right", `{{.TrayInfo}}`)
	return &StatuslineConfig{
		RenderFormat: "[%a] %S %>%T",
		Separator:    " | ",
		DisplayMode:  "",
		StatusColumns: []*ColumnDef{
			{
				Name:     "left",
				Template: left,
				Flags:    ALIGN_LEFT | WIDTH_AUTO,
			},
			{
				Name:     "center",
				Template: center,
				Flags:    ALIGN_CENTER | WIDTH_FIT,
			},
			{
				Name:     "right",
				Template: right,
				Flags:    ALIGN_RIGHT | WIDTH_AUTO,
			},
		},
		ColumnSeparator: " ",
		Separator:       " | ",
		DisplayMode:     "text",
		// deprecated
		RenderFormat: "",
	}
}



@@ 29,7 58,106 @@ func parseStatusline(file *ini.File) error {
	if err := statusline.MapTo(&Statusline); err != nil {
		return err
	}

	if key, err := statusline.GetKey("status-columns"); err == nil {
		columns, err := ParseColumnDefs(key, statusline)
		if err != nil {
			return err
		}
		Statusline.StatusColumns = columns
	} else if Statusline.RenderFormat != "" {
		columns, err := convertRenderFormat()
		if err != nil {
			return err
		}
		Statusline.StatusColumns = columns
		log.Warnf("%s %s",
			"The [statusline] render-format setting has been replaced by status-columns.",
			"render-format will be removed in aerc 0.17.")
		Warnings = append(Warnings, Warning{
			Title: "DEPRECATION WARNING: [statusline].render-format",
			Body: `
The render-format setting is deprecated. It has been replaced by status-columns.

Your configuration in this instance was automatically converted to:

[statusline]
` + ColumnDefsToIni(columns, "status-columns") + `
Your configuration file was not changed. To make this change permanent and to
dismiss this deprecation warning on launch, copy the above lines into aerc.conf
and remove index-format from it. See aerc-config(5) for more details.

index-format will be removed in aerc 0.17.
`,
		})
	}

out:
	log.Debugf("aerc.conf: [statusline] %#v", Statusline)
	return nil
}

var (
	renderFmtRe    = regexp.MustCompile(`%(-?\d+)?(\.\d+)?[acdmSTp]`)
	statuslineMute = false
)

func convertRenderFormat() ([]*ColumnDef, error) {
	var columns []*ColumnDef

	tokens := strings.Split(Statusline.RenderFormat, "%>")

	left := renderFmtRe.ReplaceAllStringFunc(
		tokens[0], renderVerbToTemplate)
	left = strings.TrimSpace(left)
	t, err := templates.ParseTemplate("column-left", left)
	if err != nil {
		return nil, err
	}
	columns = append(columns, &ColumnDef{
		Name:     "left",
		Template: t,
		Flags:    ALIGN_LEFT | WIDTH_AUTO,
	})

	if len(tokens) == 2 {
		right := renderFmtRe.ReplaceAllStringFunc(
			tokens[1], renderVerbToTemplate)
		right = strings.TrimSpace(right)
		t, err := templates.ParseTemplate("column-right", right)
		if err != nil {
			return nil, err
		}
		columns = append(columns, &ColumnDef{
			Name:     "right",
			Template: t,
			Flags:    ALIGN_RIGHT | WIDTH_AUTO,
		})
	}

	if statuslineMute {
		columns = nil
	}

	return columns, nil
}

func renderVerbToTemplate(verb string) (template string) {
	switch verb[len(verb)-1] {
	case 'a':
		template = `{{.Account}}`
	case 'c':
		template = `{{.ConnectionInfo}}`
	case 'd':
		template = `{{.Folder}}`
	case 'S':
		template = `{{.StatusInfo}}`
	case 'T':
		template = `{{.TrayInfo}}`
	case 'p':
		template = `{{cwd}}`
	case 'm':
		statuslineMute = true
	}
	return template
}

M config/templates.go => config/templates.go +6 -0
@@ 104,3 104,9 @@ func (d *dummyData) OriginalHeader(string) string    { return "" }
func (d *dummyData) Recent(...string) int            { return 1 }
func (d *dummyData) Unread(...string) int            { return 3 }
func (d *dummyData) Exists(...string) int            { return 14 }
func (d *dummyData) Connected() bool                 { return false }
func (d *dummyData) ConnectionInfo() string          { return "" }
func (d *dummyData) ContentInfo() string             { return "" }
func (d *dummyData) StatusInfo() string              { return "" }
func (d *dummyData) TrayInfo() string                { return "" }
func (d *dummyData) PendingKeys() string             { return "" }

M config/ui.go => config/ui.go +1 -1
@@ 494,7 494,7 @@ func indexVerbToTemplate(verb rune) (f, name string) {
		f = "%" + string(verb)
	}
	if name == "" {
		name = "wtf"
		name = columnNameFromTemplate(f)
	}
	return
}

M doc/aerc-config.5.scd => doc/aerc-config.5.scd +30 -28
@@ 422,41 422,43 @@ index-format=...

These options are configured in the *[statusline]* section of _aerc.conf_.

*render-format* = _<format>_
	Describes the format string for the statusline format.
*status-columns* = _<column1,column2,column3...>_
	Describes the format for the statusline. This is a comma separated list
	of column names with an optional align and width suffix. See
	*[ui].index-columns* for more details.

	For a minimal statusline that only shows the current account and
	the connection information, use _[%a] %c_.
	To completely mute the statusline (except for push notifications),
	explicitly set *status-columns* to an empty string:

	To completely mute the statusline (except for push notifications), use
	_%m_ only.
		status-columns=

	Default: _[%a] %S %>%T_
	Default: _left<\*,center>=,right>\*_

[- *Format specifier*
:[ *Description*
|  _%%_
:  literal %
|  _%a_
:  active account name
|  _%d_
:  active directory name
|  _%c_
:  connection state
|  _%p_
:  current path
|  _%m_
:  mute statusline and show only push notifications
|  _%S_
:  general status information (e.g. connection state, filter, search)
|  _%T_
:  general on/off information (e.g. passthrough, threading, sorting)
|  _%>_
:  does not print anything but all format specifier that follow will be right justified.
*column-separator* = _"<separator>"_
	String separator inserted between columns. See *[ui].column-separator*
	for more details.

	Default: _" "_

*column-<name>* = _<go template>_
	Each name in *status-columns* must have a corresponding *column-<name>*
	setting. All *column-<name>* settings accept golang text/template
	syntax.

	By default, these columns are defined:

	```
	column-left = [{{.Account}}] {{.StatusInfo}}
	column-center = {{.PendingKeys}}
	column-right = {{.TrayInfo}}
	```

	See *aerc-templates*(7) for all available symbols and functions.

*separator* = _"<string>"_
	Specifies the separator between grouped statusline elements (e.g. for
	the _%S_ and _%T_ specifiers in *render-format*).
	the _{{.ContentInfo}}_, _{{.TrayInfo}}_ and _{{.StatusInfo}}_ in
	*column-<name>*).

	Default: _" | "_


M doc/aerc-templates.7.scd => doc/aerc-templates.7.scd +39 -0
@@ 162,6 162,45 @@ available always.
	{{.Exists "archive" "spam" "foo/baz" "foo/bar"}}
	```

*Status line*

	The following data will only be available in the status line templates:

	Connection state.

	```
	{{.Connected}}
	{{.ConnectionInfo}}
	```

	General status information (e.g. filter, search) separated with
	*[statusline].separator*.

	```
	{{.ContentInfo}}
	```

	Combination of *{{.ConnectionInfo}}* and *{{.StatusInfo}}* separated
	with *[statusline].separator*.

	```
	{{.StatusInfo}}
	```

	General on/off information (e.g. passthrough, threading, sorting),
	separated with *[statusline].separator*.

	```
	{{.TrayInfo}}
	```

	Currently pressed key sequence that does not match any key binding
	and/or is incomplete.

	```
	{{.PendingKeys}}
	```

# TEMPLATE FUNCTIONS

Besides the standard functions described in go's text/template documentation,

D lib/state/renderer.go => lib/state/renderer.go +0 -205
@@ 1,205 0,0 @@
package state

import (
	"errors"
	"fmt"
	"os"
	"strings"
	"unicode"

	"git.sr.ht/~rjarry/aerc/config"
	"github.com/mattn/go-runewidth"
)

type renderParams struct {
	width int
	sep   string
	acct  *accountState
	fldr  *folderState
}

type renderFunc func(r renderParams) string

func newRenderer() renderFunc {
	var texter Texter
	switch strings.ToLower(config.Statusline.DisplayMode) {
	case "icon":
		texter = &icon{}
	default:
		texter = &text{}
	}

	return renderer(texter)
}

func renderer(texter Texter) renderFunc {
	var leftFmt, rightFmt string
	if idx := strings.Index(config.Statusline.RenderFormat, "%>"); idx < 0 {
		leftFmt = config.Statusline.RenderFormat
	} else {
		leftFmt = config.Statusline.RenderFormat[:idx]
		rightFmt = strings.Replace(config.Statusline.RenderFormat[idx:], "%>", "", 1)
	}

	return func(r renderParams) string {
		lfmtStr, largs, err := parseStatuslineFormat(leftFmt, texter, r)
		if err != nil {
			return err.Error()
		}
		rfmtStr, rargs, err := parseStatuslineFormat(rightFmt, texter, r)
		if err != nil {
			return err.Error()
		}
		leftText, rightText := fmt.Sprintf(lfmtStr, largs...), fmt.Sprintf(rfmtStr, rargs...)
		return runewidth.FillRight(leftText, r.width-len(rightText)-1) + rightText
	}
}

func connectionInfo(acct *accountState, texter Texter) (conn string) {
	if acct.ConnActivity != "" {
		conn += acct.ConnActivity
	} else {
		if acct.Connected {
			conn += texter.Connected()
		} else {
			conn += texter.Disconnected()
		}
	}
	return
}

func contentInfo(acct *accountState, fldr *folderState, texter Texter) []string {
	var status []string
	if fldr.FilterActivity != "" {
		status = append(status, fldr.FilterActivity)
	} else if fldr.Filter != "" {
		status = append(status, texter.FormatFilter(fldr.Filter))
	}
	if fldr.Search != "" {
		status = append(status, texter.FormatSearch(fldr.Search))
	}
	return status
}

func trayInfo(acct *accountState, fldr *folderState, texter Texter) []string {
	var tray []string
	if fldr.Sorting {
		tray = append(tray, texter.Sorting())
	}
	if fldr.Threading {
		tray = append(tray, texter.Threading())
	}
	if acct.Passthrough {
		tray = append(tray, texter.Passthrough())
	}
	return tray
}

func parseStatuslineFormat(format string, texter Texter, r renderParams) (string, []interface{}, error) {
	retval := make([]byte, 0, len(format))
	var args []interface{}
	mute := false

	var c rune
	for i, ni := 0, 0; i < len(format); {
		ni = strings.IndexByte(format[i:], '%')
		if ni < 0 {
			ni = len(format)
			retval = append(retval, []byte(format[i:ni])...)
			break
		}
		ni += i + 1
		// Check for fmt flags
		if ni == len(format) {
			goto handle_end_error
		}
		c = rune(format[ni])
		if c == '+' || c == '-' || c == '#' || c == ' ' || c == '0' {
			ni++
		}

		// Check for precision and width
		if ni == len(format) {
			goto handle_end_error
		}
		c = rune(format[ni])
		for unicode.IsDigit(c) {
			ni++
			c = rune(format[ni])
		}
		if c == '.' {
			ni++
			c = rune(format[ni])
			for unicode.IsDigit(c) {
				ni++
				c = rune(format[ni])
			}
		}

		retval = append(retval, []byte(format[i:ni])...)
		// Get final format verb
		if ni == len(format) {
			goto handle_end_error
		}
		c = rune(format[ni])
		switch c {
		case '%':
			retval = append(retval, '%')
		case 'a':
			retval = append(retval, 's')
			args = append(args, r.acct.Name)
		case 'c':
			retval = append(retval, 's')
			args = append(args, connectionInfo(r.acct, texter))
		case 'd':
			retval = append(retval, 's')
			args = append(args, r.fldr.Name)
		case 'm':
			mute = true
		case 'S':
			var status []string
			if conn := connectionInfo(r.acct, texter); conn != "" {
				status = append(status, conn)
			}

			if r.acct.Connected {
				status = append(status, contentInfo(r.acct, r.fldr, texter)...)
			}
			retval = append(retval, 's')
			args = append(args, strings.Join(status, r.sep))
		case 'T':
			var tray []string
			if r.acct.Connected {
				tray = trayInfo(r.acct, r.fldr, texter)
			}
			retval = append(retval, 's')
			args = append(args, strings.Join(tray, r.sep))
		case 'p':
			path, err := os.Getwd()
			if err == nil {
				home, _ := os.UserHomeDir()
				if strings.HasPrefix(path, home) {
					path = strings.Replace(path, home, "~", 1)
				}
				retval = append(retval, 's')
				args = append(args, path)
			}
		default:
			// Just ignore it and print as is
			// so %k in index format becomes %%k to Printf
			retval = append(retval, '%')
			retval = append(retval, byte(c))
		}
		i = ni + 1
	}

	if mute {
		return "", nil, nil
	}

	return string(retval), args, nil

handle_end_error:
	return "", nil,
		errors.New("reached end of string while parsing statusline format")
}

M lib/state/state.go => lib/state/state.go +24 -62
@@ 2,27 2,16 @@ package state

import (
	"fmt"

	"git.sr.ht/~rjarry/aerc/config"
)

type State struct {
	renderer renderFunc
	acct     *accountState
	fldr     map[string]*folderState
	width    int
}

type accountState struct {
	Name         string
	Multiple     bool
	ConnActivity string
type AccountState struct {
	Connected    bool
	Passthrough  bool
	connActivity string
	passthrough  bool
	folders      map[string]*folderState
}

type folderState struct {
	Name           string
	Search         string
	Filter         string
	FilterActivity string


@@ 30,60 19,33 @@ type folderState struct {
	Threading      bool
}

func NewState(name string, multipleAccts bool) *State {
	return &State{
		renderer: newRenderer(),
		acct:     &accountState{Name: name, Multiple: multipleAccts},
		fldr:     make(map[string]*folderState),
	}
}

func (s *State) StatusLine(folder string) string {
	return s.renderer(renderParams{
		width: s.width,
		sep:   config.Statusline.Separator,
		acct:  s.acct,
		fldr:  s.folderState(folder),
	})
}

func (s *State) folderState(folder string) *folderState {
	if _, ok := s.fldr[folder]; !ok {
		s.fldr[folder] = &folderState{Name: folder}
func (s *AccountState) folderState(folder string) *folderState {
	if s.folders == nil {
		s.folders = make(map[string]*folderState)
	}
	return s.fldr[folder]
}

func (s *State) SetWidth(w int) bool {
	changeState := false
	if s.width != w {
		s.width = w
		changeState = true
	if _, ok := s.folders[folder]; !ok {
		s.folders[folder] = &folderState{}
	}
	return changeState
}

func (s *State) Connected() bool {
	return s.acct.Connected
	return s.folders[folder]
}

type SetStateFunc func(s *State, folder string)
type SetStateFunc func(s *AccountState, folder string)

func SetConnected(state bool) SetStateFunc {
	return func(s *State, folder string) {
		s.acct.ConnActivity = ""
		s.acct.Connected = state
	return func(s *AccountState, folder string) {
		s.connActivity = ""
		s.Connected = state
	}
}

func ConnectionActivity(desc string) SetStateFunc {
	return func(s *State, folder string) {
		s.acct.ConnActivity = desc
	return func(s *AccountState, folder string) {
		s.connActivity = desc
	}
}

func SearchFilterClear() SetStateFunc {
	return func(s *State, folder string) {
	return func(s *AccountState, folder string) {
		s.folderState(folder).Search = ""
		s.folderState(folder).FilterActivity = ""
		s.folderState(folder).Filter = ""


@@ 91,13 53,13 @@ func SearchFilterClear() SetStateFunc {
}

func FilterActivity(str string) SetStateFunc {
	return func(s *State, folder string) {
	return func(s *AccountState, folder string) {
		s.folderState(folder).FilterActivity = str
	}
}

func FilterResult(str string) SetStateFunc {
	return func(s *State, folder string) {
	return func(s *AccountState, folder string) {
		s.folderState(folder).FilterActivity = ""
		s.folderState(folder).Filter = concatFilters(s.folderState(folder).Filter, str)
	}


@@ 111,25 73,25 @@ func concatFilters(existing, next string) string {
}

func Search(desc string) SetStateFunc {
	return func(s *State, folder string) {
	return func(s *AccountState, folder string) {
		s.folderState(folder).Search = desc
	}
}

func Sorting(on bool) SetStateFunc {
	return func(s *State, folder string) {
	return func(s *AccountState, folder string) {
		s.folderState(folder).Sorting = on
	}
}

func Threading(on bool) SetStateFunc {
	return func(s *State, folder string) {
	return func(s *AccountState, folder string) {
		s.folderState(folder).Threading = on
	}
}

func Passthrough(on bool) SetStateFunc {
	return func(s *State, folder string) {
		s.acct.Passthrough = on
	return func(s *AccountState, folder string) {
		s.passthrough = on
	}
}

M lib/state/templates.go => lib/state/templates.go +78 -0
@@ 30,6 30,9 @@ type TemplateData struct {
	folder      string // selected folder name
	folders     []string
	getRUEcount func(string) (int, int, int)

	state       *AccountState
	pendingKeys []config.KeyStroke
}

// only used for compose/reply/forward


@@ 65,6 68,14 @@ func (d *TemplateData) SetRUE(folders []string, cb func(string) (int, int, int))
	d.getRUEcount = cb
}

func (d *TemplateData) SetState(state *AccountState) {
	d.state = state
}

func (d *TemplateData) SetPendingKeys(keys []config.KeyStroke) {
	d.pendingKeys = keys
}

func (d *TemplateData) Account() string {
	if d.account != nil {
		return d.account.Name


@@ 357,3 368,70 @@ func (d *TemplateData) Exists(folders ...string) int {
	_, _, e := d.rue(folders...)
	return e
}

func (d *TemplateData) Connected() bool {
	if d.state != nil {
		return d.state.Connected
	}
	return false
}

func (d *TemplateData) ConnectionInfo() string {
	switch {
	case d.state == nil:
		return ""
	case d.state.connActivity != "":
		return d.state.connActivity
	case d.state.Connected:
		return texter().Connected()
	default:
		return texter().Disconnected()
	}
}

func (d *TemplateData) ContentInfo() string {
	if d.state == nil {
		return ""
	}
	var content []string
	fldr := d.state.folderState(d.folder)
	if fldr.FilterActivity != "" {
		content = append(content, fldr.FilterActivity)
	} else if fldr.Filter != "" {
		content = append(content, texter().FormatFilter(fldr.Filter))
	}
	if fldr.Search != "" {
		content = append(content, texter().FormatSearch(fldr.Search))
	}
	return strings.Join(content, config.Statusline.Separator)
}

func (d *TemplateData) StatusInfo() string {
	stat := d.ConnectionInfo()
	if content := d.ContentInfo(); content != "" {
		stat += config.Statusline.Separator + content
	}
	return stat
}

func (d *TemplateData) TrayInfo() string {
	if d.state == nil {
		return ""
	}
	var tray []string
	fldr := d.state.folderState(d.folder)
	if fldr.Sorting {
		tray = append(tray, texter().Sorting())
	}
	if fldr.Threading {
		tray = append(tray, texter().Threading())
	}
	if d.state.passthrough {
		tray = append(tray, texter().Passthrough())
	}
	return strings.Join(tray, config.Statusline.Separator)
}

func (d *TemplateData) PendingKeys() string {
	return config.FormatKeyStrokes(d.pendingKeys)
}

M lib/state/texter.go => lib/state/texter.go +19 -2
@@ 1,8 1,12 @@
package state

import "strings"
import (
	"strings"

type Texter interface {
	"git.sr.ht/~rjarry/aerc/config"
)

type texterInterface interface {
	Connected() string
	Disconnected() string
	Passthrough() string


@@ 14,6 18,8 @@ type Texter interface {

type text struct{}

var txt text

func (t text) Connected() string {
	return "Connected"
}


@@ 44,6 50,8 @@ func (t text) FormatSearch(s string) string {

type icon struct{}

var icn icon

func (i icon) Connected() string {
	return "✓"
}


@@ 71,3 79,12 @@ func (i icon) FormatFilter(s string) string {
func (i icon) FormatSearch(s string) string {
	return strings.ReplaceAll(s, "search", "🔎")
}

func texter() texterInterface {
	switch strings.ToLower(config.Statusline.DisplayMode) {
	case "icon":
		return &icn
	default:
		return &txt
	}
}

M models/templates.go => models/templates.go +6 -0
@@ 33,4 33,10 @@ type TemplateData interface {
	Recent(folders ...string) int
	Unread(folders ...string) int
	Exists(folders ...string) int
	Connected() bool
	ConnectionInfo() string
	ContentInfo() string
	StatusInfo() string
	TrayInfo() string
	PendingKeys() string
}

M widgets/account.go => widgets/account.go +4 -8
@@ 35,7 35,7 @@ type AccountView struct {
	tab     *ui.Tab
	msglist *MessageList
	worker  *types.Worker
	state   *state.State
	state   state.AccountState
	newConn bool // True if this is a first run after a new connection/reconnection
	uiConf  *config.UIConfig



@@ 66,7 66,6 @@ func NewAccountView(
		acct:   acct,
		aerc:   aerc,
		host:   host,
		state:  state.NewState(acct.Name, len(config.Accounts) > 1),
		uiConf: acctUiConf,
	}



@@ 117,14 116,14 @@ func NewAccountView(

func (acct *AccountView) SetStatus(setters ...state.SetStateFunc) {
	for _, fn := range setters {
		fn(acct.state, acct.SelectedDirectory())
		fn(&acct.state, acct.SelectedDirectory())
	}
	acct.UpdateStatus()
}

func (acct *AccountView) UpdateStatus() {
	if acct.isSelected() {
		acct.host.SetStatus(acct.state.StatusLine(acct.SelectedDirectory()))
		acct.host.UpdateStatus()
	}
}



@@ 157,9 156,6 @@ func (acct *AccountView) Invalidate() {
}

func (acct *AccountView) Draw(ctx *ui.Context) {
	if acct.state.SetWidth(ctx.Width()) {
		acct.UpdateStatus()
	}
	acct.grid.Draw(ctx)
}



@@ 480,7 476,7 @@ func (acct *AccountView) CheckMailTimer(d time.Duration) {
	go func() {
		defer log.PanicHandler()
		for range acct.ticker.C {
			if !acct.state.Connected() {
			if !acct.state.Connected {
				continue
			}
			acct.CheckMail()

M widgets/aerc.go => widgets/aerc.go +5 -13
@@ 60,7 60,7 @@ func NewAerc(
	tabs := ui.NewTabs(config.Ui)

	statusbar := ui.NewStack(config.Ui)
	statusline := NewStatusLine(config.Ui)
	statusline := &StatusLine{}
	statusbar.Push(statusline)

	grid := ui.NewGrid().Rows([]ui.GridSpec{


@@ 538,24 538,16 @@ func (aerc *Aerc) SelectPreviousTab() bool {
	return aerc.tabs.SelectPrevious()
}

func (aerc *Aerc) SetStatus(status string) *StatusMessage {
	return aerc.statusline.Set(status)
}

func (aerc *Aerc) UpdateStatus() {
	if acct := aerc.SelectedAccount(); acct != nil {
		acct.UpdateStatus()
		aerc.statusline.Update(acct)
	} else {
		aerc.ClearStatus()
		aerc.statusline.Clear()
	}
}

func (aerc *Aerc) ClearStatus() {
	aerc.statusline.Set("")
}

func (aerc *Aerc) SetError(status string) *StatusMessage {
	return aerc.statusline.SetError(status)
func (aerc *Aerc) SetError(err string) {
	aerc.statusline.SetError(err)
}

func (aerc *Aerc) PushStatus(text string, expiry time.Duration) *StatusMessage {

M widgets/status.go => widgets/status.go +60 -35
@@ 1,20 1,24 @@
package widgets

import (
	"bytes"
	"time"

	"github.com/gdamore/tcell/v2"
	"github.com/mattn/go-runewidth"

	"git.sr.ht/~rjarry/aerc/config"
	"git.sr.ht/~rjarry/aerc/lib/state"
	"git.sr.ht/~rjarry/aerc/lib/templates"
	"git.sr.ht/~rjarry/aerc/lib/ui"
	"git.sr.ht/~rjarry/aerc/log"
)

type StatusLine struct {
	stack    []*StatusMessage
	fallback StatusMessage
	aerc     *Aerc
	stack []*StatusMessage
	aerc  *Aerc
	acct  *AccountView
	err   string
}

type StatusMessage struct {


@@ 22,51 26,72 @@ type StatusMessage struct {
	message string
}

func NewStatusLine(uiConfig *config.UIConfig) *StatusLine {
	return &StatusLine{
		fallback: StatusMessage{
			style:   uiConfig.GetStyle(config.STYLE_STATUSLINE_DEFAULT),
			message: "Idle",
		},
	}
}

func (status *StatusLine) Invalidate() {
	ui.Invalidate()
}

func (status *StatusLine) Draw(ctx *ui.Context) {
	line := &status.fallback
	if len(status.stack) != 0 {
		line = status.stack[len(status.stack)-1]
	}
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', line.style)
	pendingKeys := ""
	if status.aerc != nil {
		for _, pendingKey := range status.aerc.pendingKeys {
			pendingKeys += string(pendingKey.Rune)
	style := status.uiConfig().GetStyle(config.STYLE_STATUSLINE_DEFAULT)
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
	switch {
	case len(status.stack) != 0:
		line := status.stack[len(status.stack)-1]
		msg := runewidth.Truncate(line.message, ctx.Width(), "")
		msg = runewidth.FillRight(msg, ctx.Width())
		ctx.Printf(0, 0, line.style, "%s", msg)
	case status.err != "":
		msg := runewidth.Truncate(status.err, ctx.Width(), "")
		msg = runewidth.FillRight(msg, ctx.Width())
		style := status.uiConfig().GetStyle(config.STYLE_STATUSLINE_ERROR)
		ctx.Printf(0, 0, style, "%s", msg)
	case status.aerc != nil && status.acct != nil:
		var data state.TemplateData
		data.SetPendingKeys(status.aerc.pendingKeys)
		data.SetState(&status.acct.state)
		data.SetAccount(status.acct.acct)
		data.SetFolder(status.acct.Directories().Selected())
		msg, _ := status.acct.SelectedMessage()
		data.SetInfo(msg, 0, false)
		table := ui.NewTable(
			ctx.Height(),
			config.Statusline.StatusColumns,
			config.Statusline.ColumnSeparator,
			nil,
			func(*ui.Table, int) tcell.Style { return style },
		)
		var buf bytes.Buffer
		cells := make([]string, len(table.Columns))
		for c, col := range table.Columns {
			err := templates.Render(col.Def.Template, &buf, &data)
			if err != nil {
				log.Errorf("%s", err)
				cells[c] = err.Error()
			} else {
				cells[c] = buf.String()
			}
			buf.Reset()
		}
		table.AddRow(cells, nil)
		table.Draw(ctx)
	}
	message := runewidth.FillRight(line.message, ctx.Width()-len(pendingKeys)-5)
	ctx.Printf(0, 0, line.style, "%s%s", message, pendingKeys)
}

func (status *StatusLine) Set(text string) *StatusMessage {
	status.fallback = StatusMessage{
		style:   status.uiConfig().GetStyle(config.STYLE_STATUSLINE_DEFAULT),
		message: text,
	}
func (status *StatusLine) Update(acct *AccountView) {
	status.acct = acct
	status.Invalidate()
	return &status.fallback
}

func (status *StatusLine) SetError(text string) *StatusMessage {
	status.fallback = StatusMessage{
		style:   status.uiConfig().GetStyle(config.STYLE_STATUSLINE_ERROR),
		message: text,
func (status *StatusLine) SetError(err string) {
	prev := status.err
	status.err = err
	if prev != status.err {
		status.Invalidate()
	}
	status.Invalidate()
	return &status.fallback
}

func (status *StatusLine) Clear() {
	status.SetError("")
	status.acct = nil
}

func (status *StatusLine) Push(text string, expiry time.Duration) *StatusMessage {

M widgets/tabhost.go => widgets/tabhost.go +2 -2
@@ 6,8 6,8 @@ import (

type TabHost interface {
	BeginExCommand(cmd string)
	SetStatus(status string) *StatusMessage
	SetError(err string) *StatusMessage
	UpdateStatus()
	SetError(err string)
	PushStatus(text string, expiry time.Duration) *StatusMessage
	PushError(text string) *StatusMessage
	PushSuccess(text string) *StatusMessage