about summary refs log tree commit diff stats
path: root/config
diff options
context:
space:
mode:
authorKalyan Sriram <coder.kalyan@gmail.com>2020-07-27 01:03:55 -0700
committerReto Brunner <reto@labrat.space>2020-07-30 19:35:59 +0200
commit1ff687ca2b0821c2cacc1fa725abb3302d2af9da (patch)
treeb84df04a645c1fd2ee94d7a08f2f9c717930e9ab /config
parent1bab1754f095a5c0537fc639d0214f6efbb340a2 (diff)
downloadaerc-1ff687ca2b0821c2cacc1fa725abb3302d2af9da.tar.gz
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.
Diffstat (limited to 'config')
-rw-r--r--config/aerc.conf.in11
-rw-r--r--config/config.go55
-rw-r--r--config/default_styleset33
-rw-r--r--config/style.go379
4 files changed, 472 insertions, 6 deletions
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()
+}