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:
parent
eb7e45d43b
commit
ce18e92881
8 changed files with 411 additions and 97 deletions
|
@ -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
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
194
lib/statusline/renderer.go
Normal 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")
|
||||
}
|
|
@ -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
73
lib/statusline/texter.go
Normal 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", "🔎")
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue