statusline: refactor to make it more customizable

Refactor statusline by clearly separating the rendering part from the
text display. Use printf-like format string for statusline
customization.

Document printf-like format string to customize the statusline.

Allow to completely mute the statusline (except for push notifications)
with a format specifier.

Provide a display mode with unicode icons for the status elements.

Implements: https://todo.sr.ht/~rjarry/aerc/34
Signed-off-by: Koni Marti <koni.marti@gmail.com>
Acked-by: Robin Jarry <robin@jarry.cc>
This commit is contained in:
Koni Marti 2022-04-18 16:06:27 +02:00 committed by Robin Jarry
parent eb7e45d43b
commit ce18e92881
8 changed files with 411 additions and 97 deletions

View File

@ -139,6 +139,23 @@ styleset-name=default
# Default: false
#threading-enabled=false
[statusline]
# Describes the format string for the statusline.
#
# Default: [%a] %S %>%T
render-format=[%a] %S %>%T
# Specifies the separator between grouped statusline elements.
#
# Default: " | "
# separator=
# Defines the mode for displaying the status elements.
# Options: text, icon
#
# Default: text
# display-mode=
[viewer]
#
# Specifies the pager to use when displaying emails. Note that some filters

View File

@ -147,6 +147,12 @@ type ViewerConfig struct {
KeyPassthrough bool `ini:"-"`
}
type StatuslineConfig struct {
RenderFormat string `ini:"render-format"`
Separator string
DisplayMode string `ini:"display-mode"`
}
type TriggersConfig struct {
NewEmail string `ini:"new-email"`
ExecuteCommand func(command []string) error
@ -163,11 +169,12 @@ type AercConfig struct {
Bindings BindingConfig
ContextualBinds []BindingConfigContext
Compose ComposeConfig
Ini *ini.File `ini:"-"`
Accounts []AccountConfig `ini:"-"`
Filters []FilterConfig `ini:"-"`
Viewer ViewerConfig `ini:"-"`
Triggers TriggersConfig `ini:"-"`
Ini *ini.File `ini:"-"`
Accounts []AccountConfig `ini:"-"`
Filters []FilterConfig `ini:"-"`
Viewer ViewerConfig `ini:"-"`
Statusline StatuslineConfig `ini:"-"`
Triggers TriggersConfig `ini:"-"`
Ui UIConfig
ContextualUis []UIConfigContext
General GeneralConfig
@ -410,6 +417,11 @@ func (config *AercConfig) LoadConfig(file *ini.File) error {
}
}
}
if statusline, err := file.GetSection("statusline"); err == nil {
if err := statusline.MapTo(&config.Statusline); err != nil {
return err
}
}
if compose, err := file.GetSection("compose"); err == nil {
if err := compose.MapTo(&config.Compose); err != nil {
return err
@ -654,6 +666,12 @@ func LoadConfigFromFile(root *string, logger *log.Logger) (*AercConfig, error) {
},
},
Statusline: StatuslineConfig{
RenderFormat: "[%a] %S %>%T",
Separator: " | ",
DisplayMode: "",
},
Compose: ComposeConfig{
HeaderLayout: [][]string{
{"To", "From"},

View File

@ -295,6 +295,52 @@ index-format=...
index-format=...
```
## STATUSLINE
These options are configured in the *[statusline]* section of aerc.conf.
*render-format*
Describes the format string for the statusline format.
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 notficiations), use
%m only.
Default: [%a] %S %>%T
[- *Format specifier*
:[ *Description*
| %%
: literal %
| %a
: active account name
| %d
: active directory name
| %c
: connection state
| %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.
*separator*
Specifies the separator between grouped statusline elements (e.g. for
the %S and %T specifiers in *render-format*).
Default: " | "
*display-mode*
Defines the mode for displaying the status elements.
Options: text, icon
Default: text
## VIEWER

View File

@ -1,32 +0,0 @@
package statusline
type folderState struct {
Search string
Filter string
FilterActivity string
Sorting string
Threading string
}
func (fs *folderState) State() []string {
var line []string
if fs.FilterActivity != "" {
line = append(line, fs.FilterActivity)
} else {
if fs.Filter != "" {
line = append(line, fs.Filter)
}
}
if fs.Search != "" {
line = append(line, fs.Search)
}
if fs.Sorting != "" {
line = append(line, fs.Sorting)
}
if fs.Threading != "" {
line = append(line, fs.Threading)
}
return line
}

194
lib/statusline/renderer.go Normal file
View File

@ -0,0 +1,194 @@
package statusline
import (
"errors"
"fmt"
"strings"
"unicode"
"github.com/mattn/go-runewidth"
)
type renderParams struct {
width int
sep string
acct *accountState
fldr *folderState
}
type renderFunc func(r renderParams) string
func newRenderer(renderFormat, textMode string) renderFunc {
var texter Texter
switch strings.ToLower(textMode) {
case "icon":
texter = &icon{}
default:
texter = &text{}
}
return renderer(texter, renderFormat)
}
func renderer(texter Texter, renderFormat string) renderFunc {
var leftFmt, rightFmt string
if idx := strings.Index(renderFormat, "%>"); idx < 0 {
leftFmt = renderFormat
} else {
leftFmt, rightFmt = renderFormat[:idx], strings.Replace(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))
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")
}

View File

@ -2,76 +2,80 @@ package statusline
import (
"fmt"
"strings"
"git.sr.ht/~rjarry/aerc/config"
)
type State struct {
Name string
Multiple bool
Separator string
Connection string
ConnActivity string
Connected bool
Passthrough string
fs map[string]*folderState
separator string
renderer renderFunc
acct *accountState
fldr map[string]*folderState
width int
}
func NewState(name string, multipleAccts bool, sep string) *State {
return &State{Name: name, Multiple: multipleAccts, Separator: sep,
fs: make(map[string]*folderState)}
type accountState struct {
Name string
Multiple bool
ConnActivity string
Connected bool
Passthrough bool
}
type folderState struct {
Name string
Search string
Filter string
FilterActivity string
Sorting bool
Threading bool
}
func NewState(name string, multipleAccts bool, conf config.StatuslineConfig) *State {
return &State{separator: conf.Separator,
renderer: newRenderer(conf.RenderFormat, conf.DisplayMode),
acct: &accountState{Name: name, Multiple: multipleAccts},
fldr: make(map[string]*folderState),
}
}
func (s *State) StatusLine(folder string) string {
var line []string
if s.Connection != "" || s.ConnActivity != "" {
conn := s.Connection
if s.ConnActivity != "" {
conn = s.ConnActivity
}
if s.Multiple {
line = append(line, fmt.Sprintf("[%s] %s", s.Name, conn))
} else {
line = append(line, conn)
}
}
if s.Connected {
if s.Passthrough != "" {
line = append(line, s.Passthrough)
}
if folder != "" {
line = append(line, s.folderState(folder).State()...)
}
}
return strings.Join(line, s.Separator)
return s.renderer(renderParams{
width: s.width,
sep: s.separator,
acct: s.acct,
fldr: s.folderState(folder),
})
}
func (s *State) folderState(folder string) *folderState {
if _, ok := s.fs[folder]; !ok {
s.fs[folder] = &folderState{}
if _, ok := s.fldr[folder]; !ok {
s.fldr[folder] = &folderState{Name: folder}
}
return s.fs[folder]
return s.fldr[folder]
}
func (s *State) SetWidth(w int) bool {
changeState := false
if s.width != w {
s.width = w
changeState = true
}
return changeState
}
type SetStateFunc func(s *State, folder string)
func Connected(state bool) SetStateFunc {
return func(s *State, folder string) {
s.ConnActivity = ""
s.Connected = state
if state {
s.Connection = "Connected"
} else {
s.Connection = "Disconnected"
}
s.acct.ConnActivity = ""
s.acct.Connected = state
}
}
func ConnectionActivity(desc string) SetStateFunc {
return func(s *State, folder string) {
s.ConnActivity = desc
s.acct.ConnActivity = desc
}
}
@ -111,27 +115,18 @@ func Search(desc string) SetStateFunc {
func Sorting(on bool) SetStateFunc {
return func(s *State, folder string) {
s.folderState(folder).Sorting = ""
if on {
s.folderState(folder).Sorting = "sorting"
}
s.folderState(folder).Sorting = on
}
}
func Threading(on bool) SetStateFunc {
return func(s *State, folder string) {
s.folderState(folder).Threading = ""
if on {
s.folderState(folder).Threading = "threading"
}
s.folderState(folder).Threading = on
}
}
func Passthrough(on bool) SetStateFunc {
return func(s *State, folder string) {
s.Passthrough = ""
if on {
s.Passthrough = "passthrough"
}
s.acct.Passthrough = on
}
}

73
lib/statusline/texter.go Normal file
View File

@ -0,0 +1,73 @@
package statusline
import "strings"
type Texter interface {
Connected() string
Disconnected() string
Passthrough() string
Sorting() string
Threading() string
FormatFilter(string) string
FormatSearch(string) string
}
type text struct{}
func (t text) Connected() string {
return "Connected"
}
func (t text) Disconnected() string {
return "Disconnected"
}
func (t text) Passthrough() string {
return "passthrough"
}
func (t text) Sorting() string {
return "sorting"
}
func (t text) Threading() string {
return "threading"
}
func (t text) FormatFilter(s string) string {
return s
}
func (t text) FormatSearch(s string) string {
return s
}
type icon struct{}
func (i icon) Connected() string {
return "✓"
}
func (i icon) Disconnected() string {
return "✘"
}
func (i icon) Passthrough() string {
return "➔"
}
func (i icon) Sorting() string {
return "⚙"
}
func (i icon) Threading() string {
return "🧵"
}
func (i icon) FormatFilter(s string) string {
return strings.ReplaceAll(s, "filter", "🔦")
}
func (i icon) FormatSearch(s string) string {
return strings.ReplaceAll(s, "search", "🔎")
}

View File

@ -59,7 +59,7 @@ func NewAccountView(aerc *Aerc, conf *config.AercConfig, acct *config.AccountCon
conf: conf,
host: host,
logger: logger,
state: statusline.NewState(acct.Name, len(conf.Accounts) > 1, " | "),
state: statusline.NewState(acct.Name, len(conf.Accounts) > 1, conf.Statusline),
}
view.grid = ui.NewGrid().Rows([]ui.GridSpec{
@ -170,6 +170,9 @@ func (acct *AccountView) Invalidate() {
}
func (acct *AccountView) Draw(ctx *ui.Context) {
if acct.state.SetWidth(ctx.Width()) {
acct.UpdateStatus()
}
acct.grid.Draw(ctx)
}