From 1ff687ca2b0821c2cacc1fa725abb3302d2af9da Mon Sep 17 00:00:00 2001 From: Kalyan Sriram Date: Mon, 27 Jul 2020 01:03:55 -0700 Subject: Implement style configuration. Introduce the ability to configure stylesets, allowing customization of aerc's look (color scheme, font weight, etc). Default styleset is installed to /path/to/aerc/stylesets/default. --- config/aerc.conf.in | 11 ++ config/config.go | 55 ++++++- config/default_styleset | 33 +++++ config/style.go | 379 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 472 insertions(+), 6 deletions(-) create mode 100644 config/default_styleset create mode 100644 config/style.go (limited to 'config') diff --git a/config/aerc.conf.in b/config/aerc.conf.in index 3348efa..b9381a8 100644 --- a/config/aerc.conf.in +++ b/config/aerc.conf.in @@ -67,6 +67,17 @@ sort= # Default: true next-message-on-delete=true +# The directories where the stylesets are stored. It takes a colon-separated +# list of directories. +# +# default: @SHAREDIR@/stylesets/ +stylesets-dirs=@SHAREDIR@/stylesets/ + +# Sets the styleset to use for the aerc ui elements. +# +# Default: default +styleset-name=default + [viewer] # # Specifies the pager to use when displaying emails. Note that some filters diff --git a/config/config.go b/config/config.go index 00a52ce..9e78c86 100644 --- a/config/config.go +++ b/config/config.go @@ -45,6 +45,9 @@ type UIConfig struct { NextMessageOnDelete bool `ini:"next-message-on-delete"` CompletionDelay time.Duration `ini:"completion-delay"` CompletionPopovers bool `ini:"completion-popovers"` + StyleSetDirs []string `ini:"stylesets-dirs" delim:":"` + StyleSetName string `ini:"styleset-name"` + style StyleSet } type ContextType int @@ -411,6 +414,19 @@ func (config *AercConfig) LoadConfig(file *ini.File) error { } } } + + if err := config.Ui.loadStyleSet( + config.Ui.StyleSetDirs); err != nil { + return err + } + + for idx, _ := range config.ContextualUis { + if err := config.ContextualUis[idx].UiConfig.loadStyleSet( + config.Ui.StyleSetDirs); err != nil { + return err + } + } + return nil } @@ -471,6 +487,8 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) { NextMessageOnDelete: true, CompletionDelay: 250 * time.Millisecond, CompletionPopovers: true, + StyleSetDirs: []string{path.Join(sharedir, "stylesets")}, + StyleSetName: "default", }, ContextualUis: []UIConfigContext{}, @@ -500,6 +518,7 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) { Forwards: "forward_as_body", }, } + // These bindings are not configurable config.Bindings.AccountWizard.ExKey = KeyStroke{ Key: tcell.KeyCtrlE, @@ -510,6 +529,7 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) { if err = config.LoadConfig(file); err != nil { return nil, err } + if ui, err := file.GetSection("general"); err == nil { if err := ui.MapTo(&config.General); err != nil { return nil, err @@ -617,8 +637,18 @@ func parseLayout(layout string) [][]string { return l } -func (config *AercConfig) mergeContextualUi(baseUi *UIConfig, - contextType ContextType, s string) { +func (ui *UIConfig) loadStyleSet(styleSetDirs []string) error { + ui.style = NewStyleSet() + err := ui.style.LoadStyleSet(ui.StyleSetName, styleSetDirs) + if err != nil { + return fmt.Errorf("Unable to load default styleset: %s", err) + } + + return nil +} + +func (config AercConfig) mergeContextualUi(baseUi UIConfig, + contextType ContextType, s string) UIConfig { for _, contextualUi := range config.ContextualUis { if contextualUi.ContextType != contextType { continue @@ -628,17 +658,30 @@ func (config *AercConfig) mergeContextualUi(baseUi *UIConfig, continue } - mergo.MergeWithOverwrite(baseUi, contextualUi.UiConfig) - return + mergo.Merge(&baseUi, contextualUi.UiConfig, mergo.WithOverride) + if contextualUi.UiConfig.StyleSetName != "" { + baseUi.style = contextualUi.UiConfig.style + } + return baseUi } + + return baseUi } -func (config *AercConfig) GetUiConfig(params map[ContextType]string) UIConfig { +func (config AercConfig) GetUiConfig(params map[ContextType]string) UIConfig { baseUi := config.Ui for k, v := range params { - config.mergeContextualUi(&baseUi, k, v) + baseUi = config.mergeContextualUi(baseUi, k, v) } return baseUi } + +func (uiConfig UIConfig) GetStyle(so StyleObject) tcell.Style { + return uiConfig.style.Get(so) +} + +func (uiConfig UIConfig) GetStyleSelected(so StyleObject) tcell.Style { + return uiConfig.style.Selected(so) +} diff --git a/config/default_styleset b/config/default_styleset new file mode 100644 index 0000000..fa52f23 --- /dev/null +++ b/config/default_styleset @@ -0,0 +1,33 @@ +# +# aerc default styleset +# +# This styleset uses the terminal defaults as its base. +# More information on how to configure the styleset can be found in +# the *aerc-styleset.7* manpage. Please read the manual before +# modifying or creating a styleset. + +*.default=true +*.selected.reverse=toggle + +title.reverse=true +header.bold=true + +*error.bold=true +error.fg=red +warning.fg=yellow +success.fg=green + +statusline*.default=true +statusline_default.reverse=true +statusline_error.fg=red +statusline_error.reverse=true + +msglist_unread.bold=true + +completion_pill.reverse=true + +tab.reverse=true +border.reverse = true + +selector_focused.reverse=true +selector_chooser.bold=true diff --git a/config/style.go b/config/style.go new file mode 100644 index 0000000..f159be3 --- /dev/null +++ b/config/style.go @@ -0,0 +1,379 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path" + "regexp" + "strings" + + "github.com/gdamore/tcell" + "github.com/go-ini/ini" + "github.com/mitchellh/go-homedir" +) + +type StyleObject int32 + +const ( + STYLE_DEFAULT StyleObject = iota + STYLE_ERROR + STYLE_WARNING + STYLE_SUCCESS + + STYLE_TITLE + STYLE_HEADER + + STYLE_STATUSLINE_DEFAULT + STYLE_STATUSLINE_ERROR + STYLE_STATUSLINE_SUCCESS + + STYLE_MSGLIST_DEFAULT + STYLE_MSGLIST_UNREAD + STYLE_MSGLIST_READ + STYLE_MSGLIST_DELETED + STYLE_MSGLIST_MARKED + STYLE_MSGLIST_FLAGGED + + STYLE_DIRLIST_DEFAULT + + STYLE_COMPLETION_DEFAULT + STYLE_COMPLETION_GUTTER + STYLE_COMPLETION_PILL + + STYLE_TAB + STYLE_STACK + STYLE_SPINNER + STYLE_BORDER + + STYLE_SELECTOR_DEFAULT + STYLE_SELECTOR_FOCUSED + STYLE_SELECTOR_CHOOSER +) + +var StyleNames = map[string]StyleObject{ + "default": STYLE_DEFAULT, + "error": STYLE_ERROR, + "warning": STYLE_WARNING, + "success": STYLE_SUCCESS, + + "title": STYLE_TITLE, + "header": STYLE_HEADER, + + "statusline_default": STYLE_STATUSLINE_DEFAULT, + "statusline_error": STYLE_STATUSLINE_ERROR, + "statusline_success": STYLE_STATUSLINE_SUCCESS, + + "msglist_default": STYLE_MSGLIST_DEFAULT, + "msglist_unread": STYLE_MSGLIST_UNREAD, + "msglist_read": STYLE_MSGLIST_READ, + "msglist_deleted": STYLE_MSGLIST_DELETED, + "msglist_marked": STYLE_MSGLIST_MARKED, + "msglist_flagged": STYLE_MSGLIST_FLAGGED, + + "dirlist_default": STYLE_DIRLIST_DEFAULT, + + "completion_default": STYLE_COMPLETION_DEFAULT, + "completion_gutter": STYLE_COMPLETION_GUTTER, + "completion_pill": STYLE_COMPLETION_PILL, + + "tab": STYLE_TAB, + "stack": STYLE_STACK, + "spinner": STYLE_SPINNER, + "border": STYLE_BORDER, + + "selector_default": STYLE_SELECTOR_DEFAULT, + "selector_focused": STYLE_SELECTOR_FOCUSED, + "selector_chooser": STYLE_SELECTOR_CHOOSER, +} + +type Style struct { + Fg tcell.Color + Bg tcell.Color + Bold bool + Blink bool + Underline bool + Reverse bool +} + +func (s Style) Get() tcell.Style { + return tcell.StyleDefault. + Foreground(s.Fg). + Background(s.Bg). + Bold(s.Bold). + Blink(s.Blink). + Underline(s.Blink). + Reverse(s.Reverse) +} + +func (s *Style) Normal() { + s.Bold = false + s.Blink = false + s.Underline = false + s.Reverse = false +} + +func (s *Style) Default() *Style { + s.Fg = tcell.ColorDefault + s.Bg = tcell.ColorDefault + return s +} + +func (s *Style) Reset() *Style { + s.Default() + s.Normal() + return s +} + +func boolSwitch(val string, cur_val bool) (bool, error) { + switch val { + case "true": + return true, nil + case "false": + return false, nil + case "toggle": + return !cur_val, nil + default: + return cur_val, errors.New( + "Bool Switch attribute must be true, false, or toggle") + } +} + +func (s *Style) Set(attr, val string) error { + switch attr { + case "fg": + s.Fg = tcell.GetColor(val) + case "bg": + s.Bg = tcell.GetColor(val) + case "bold": + if state, err := boolSwitch(val, s.Bold); err != nil { + return err + } else { + s.Bold = state + } + case "blink": + if state, err := boolSwitch(val, s.Blink); err != nil { + return err + } else { + s.Blink = state + } + case "underline": + if state, err := boolSwitch(val, s.Underline); err != nil { + return err + } else { + s.Underline = state + } + case "reverse": + if state, err := boolSwitch(val, s.Reverse); err != nil { + return err + } else { + s.Reverse = state + } + case "default": + s.Default() + case "normal": + s.Normal() + default: + return errors.New("Unknown style attribute: " + attr) + } + + return nil +} + +type StyleSet struct { + objects map[StyleObject]*Style + selected map[StyleObject]*Style +} + +func NewStyleSet() StyleSet { + ss := StyleSet{ + objects: make(map[StyleObject]*Style), + selected: make(map[StyleObject]*Style), + } + for _, so := range StyleNames { + ss.objects[so] = new(Style) + ss.selected[so] = new(Style) + } + + return ss +} + +func (ss StyleSet) reset() { + for _, so := range StyleNames { + ss.objects[so].Reset() + ss.selected[so].Reset() + } +} + +func (ss StyleSet) Get(so StyleObject) tcell.Style { + return ss.objects[so].Get() +} + +func (ss StyleSet) Selected(so StyleObject) tcell.Style { + return ss.selected[so].Get() +} + +func findStyleSet(stylesetName string, stylesetsDir []string) (string, error) { + for _, dir := range stylesetsDir { + stylesetPath, err := homedir.Expand(path.Join(dir, stylesetName)) + if err != nil { + return "", err + } + + if _, err := os.Stat(stylesetPath); os.IsNotExist(err) { + continue + } + + return stylesetPath, nil + } + + return "", fmt.Errorf( + "Can't find styleset %q in any of %v", stylesetName, stylesetsDir) +} + +func (ss *StyleSet) ParseStyleSet(file *ini.File) error { + ss.reset() + + defaultSection, err := file.GetSection(ini.DefaultSection) + if err != nil { + return err + } + + selectedKeys := []string{} + + for _, key := range defaultSection.KeyStrings() { + tokens := strings.Split(key, ".") + var styleName, attr string + switch len(tokens) { + case 2: + styleName, attr = tokens[0], tokens[1] + case 3: + if tokens[1] != "selected" { + return errors.New("Unknown modifier: " + tokens[1]) + } + selectedKeys = append(selectedKeys, key) + continue + default: + return errors.New("Style parsing error: " + key) + } + val := defaultSection.KeysHash()[key] + + if strings.ContainsAny(styleName, "*?") { + regex := fnmatchToRegex(styleName) + for sn, so := range StyleNames { + matched, err := regexp.MatchString(regex, sn) + if err != nil { + return err + } + + if !matched { + continue + } + + if err := ss.objects[so].Set(attr, val); err != nil { + return err + } + if err := ss.selected[so].Set(attr, val); err != nil { + return err + } + } + } else { + so, ok := StyleNames[styleName] + if !ok { + return errors.New("Unknown style object: " + styleName) + } + if err := ss.objects[so].Set(attr, val); err != nil { + return err + } + if err := ss.selected[so].Set(attr, val); err != nil { + return err + } + } + } + + for _, key := range selectedKeys { + tokens := strings.Split(key, ".") + styleName, modifier, attr := tokens[0], tokens[1], tokens[2] + if modifier != "selected" { + return errors.New("Unknown modifier: " + modifier) + } + + val := defaultSection.KeysHash()[key] + + if strings.ContainsAny(styleName, "*?") { + regex := fnmatchToRegex(styleName) + for sn, so := range StyleNames { + matched, err := regexp.MatchString(regex, sn) + if err != nil { + return err + } + + if !matched { + continue + } + + if err := ss.selected[so].Set(attr, val); err != nil { + return err + } + } + } else { + so, ok := StyleNames[styleName] + if !ok { + return errors.New("Unknown style object: " + styleName) + } + if err := ss.selected[so].Set(attr, val); err != nil { + return err + } + } + } + + for _, key := range defaultSection.KeyStrings() { + tokens := strings.Split(key, ".") + styleName, attr := tokens[0], tokens[1] + val := defaultSection.KeysHash()[key] + + if styleName != "selected" { + continue + } + + for _, so := range StyleNames { + if err := ss.selected[so].Set(attr, val); err != nil { + return err + } + } + } + + return nil +} + +func (ss *StyleSet) LoadStyleSet(stylesetName string, stylesetDirs []string) error { + filepath, err := findStyleSet(stylesetName, stylesetDirs) + if err != nil { + return err + } + + file, err := ini.Load(filepath) + if err != nil { + return err + } + + return ss.ParseStyleSet(file) +} + +func fnmatchToRegex(pattern string) string { + n := len(pattern) + var regex strings.Builder + + for i := 0; i < n; i++ { + switch pattern[i] { + case '*': + regex.WriteString(".*") + case '?': + regex.WriteByte('.') + default: + regex.WriteByte(pattern[i]) + } + } + + return regex.String() +} -- cgit 1.4.1-2-gfad0