From ce18e928813526e59462e391c09e868c62facb42 Mon Sep 17 00:00:00 2001 From: Koni Marti Date: Mon, 18 Apr 2022 16:06:27 +0200 Subject: [PATCH] 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 Acked-by: Robin Jarry --- config/aerc.conf | 17 +++ config/config.go | 28 ++++- doc/aerc-config.5.scd | 46 ++++++++ lib/statusline/folderstate.go | 32 ------ lib/statusline/renderer.go | 194 ++++++++++++++++++++++++++++++++++ lib/statusline/state.go | 113 ++++++++++---------- lib/statusline/texter.go | 73 +++++++++++++ widgets/account.go | 5 +- 8 files changed, 411 insertions(+), 97 deletions(-) delete mode 100644 lib/statusline/folderstate.go create mode 100644 lib/statusline/renderer.go create mode 100644 lib/statusline/texter.go diff --git a/config/aerc.conf b/config/aerc.conf index 1ad7ce5..458f635 100644 --- a/config/aerc.conf +++ b/config/aerc.conf @@ -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 diff --git a/config/config.go b/config/config.go index f8b2f65..8eeea10 100644 --- a/config/config.go +++ b/config/config.go @@ -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"}, diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd index 493fd71..b615629 100644 --- a/doc/aerc-config.5.scd +++ b/doc/aerc-config.5.scd @@ -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 diff --git a/lib/statusline/folderstate.go b/lib/statusline/folderstate.go deleted file mode 100644 index ff470b7..0000000 --- a/lib/statusline/folderstate.go +++ /dev/null @@ -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 -} diff --git a/lib/statusline/renderer.go b/lib/statusline/renderer.go new file mode 100644 index 0000000..2ab05dd --- /dev/null +++ b/lib/statusline/renderer.go @@ -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") +} diff --git a/lib/statusline/state.go b/lib/statusline/state.go index 895bb2c..3fecd0f 100644 --- a/lib/statusline/state.go +++ b/lib/statusline/state.go @@ -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 } } diff --git a/lib/statusline/texter.go b/lib/statusline/texter.go new file mode 100644 index 0000000..d06b198 --- /dev/null +++ b/lib/statusline/texter.go @@ -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", "๐Ÿ”Ž") +} diff --git a/widgets/account.go b/widgets/account.go index 994bba6..b34396b 100644 --- a/widgets/account.go +++ b/widgets/account.go @@ -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) }