~sircmpwn/aerc

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch

[PATCH v5] Add Style configuration

Details
Message ID
<20200410135345.867815-1-sri@vathsan.com>
DKIM signature
pass
Download raw message
Patch: +776 -220
The following functionalities are added to configure aerc ui styles.
- Read stylesets from file with very basic fnmatch wildcard matching
- Add default styleset
- Support different stylesets as part of UiConfig allowing contextual
  styles.
- Move widgets/ui elements to use the stylesets.
- Add configuration manual for the styleset
---
Changes in v5:
- change "tab_default" to "tab" to remain consistent.
- Install aerc-stylesets.7
- Add MSGLIST_MARKED for styling marked messages
- Add StyleSetDirs to specify multiple directories to get stylesets

 Makefile                  |   7 +-
 config/aerc.conf.in       |  11 ++
 config/config.go          |  59 +++++++++-
 config/default_styleset   |  32 ++++++
 config/style.go           | 232 ++++++++++++++++++++++++++++++++++++++
 doc/aerc-config.5.scd     |  14 +++
 doc/aerc-stylesets.7.scd  | 173 ++++++++++++++++++++++++++++
 lib/ui/stack.go           |  10 +-
 lib/ui/tab.go             |  11 +-
 lib/ui/text.go            |  42 +------
 lib/ui/textinput.go       |  32 ++++--
 widgets/account-wizard.go | 102 +++++++++--------
 widgets/aerc.go           |   6 +-
 widgets/compose.go        |  62 +++++-----
 widgets/dirlist.go        |  12 +-
 widgets/exline.go         |   6 +-
 widgets/getpasswd.go      |  18 ++-
 widgets/msglist.go        |  40 ++++---
 widgets/msgviewer.go      |  63 +++++++----
 widgets/pgpinfo.go        |  32 +++---
 widgets/selecter.go       |  26 +++--
 widgets/spinner.go        |   6 +-
 22 files changed, 776 insertions(+), 220 deletions(-)
 create mode 100644 config/default_styleset
 create mode 100644 config/style.go
 create mode 100644 doc/aerc-stylesets.7.scd

diff --git a/Makefile b/Makefile
index 3f06c4da6bc3..5f037fd94801 100644
--- a/Makefile
+++ b/Makefile
@@ -36,7 +36,8 @@ DOCS := \
	aerc-notmuch.5 \
	aerc-smtp.5 \
	aerc-tutorial.7 \
	aerc-templates.7
	aerc-templates.7 \
	aerc-stylesets.7

.1.scd.1:
	scdoc < $< > $@
@@ -59,7 +60,7 @@ clean:

install: all
	mkdir -m755 -p $(BINDIR) $(MANDIR)/man1 $(MANDIR)/man5 $(MANDIR)/man7 \
		$(SHAREDIR) $(SHAREDIR)/filters $(SHAREDIR)/templates
		$(SHAREDIR) $(SHAREDIR)/filters $(SHAREDIR)/templates $(SHAREDIR)/stylesets
	install -m755 aerc $(BINDIR)/aerc
	install -m644 aerc.1 $(MANDIR)/man1/aerc.1
	install -m644 aerc-search.1 $(MANDIR)/man1/aerc-search.1
@@ -71,6 +72,7 @@ install: all
	install -m644 aerc-smtp.5 $(MANDIR)/man5/aerc-smtp.5
	install -m644 aerc-tutorial.7 $(MANDIR)/man7/aerc-tutorial.7
	install -m644 aerc-templates.7 $(MANDIR)/man7/aerc-templates.7
	install -m644 aerc-stylesets.7 $(MANDIR)/man7/aerc-stylesets.7
	install -m644 config/accounts.conf $(SHAREDIR)/accounts.conf
	install -m644 aerc.conf $(SHAREDIR)/aerc.conf
	install -m644 config/binds.conf $(SHAREDIR)/binds.conf
@@ -79,6 +81,7 @@ install: all
	install -m755 filters/plaintext $(SHAREDIR)/filters/plaintext
	install -m644 templates/quoted_reply $(SHAREDIR)/templates/quoted_reply
	install -m644 templates/forward_as_body $(SHAREDIR)/templates/forward_as_body
	install -m644 config/default_styleset $(SHAREDIR)/stylesets/default

RMDIR_IF_EMPTY:=sh -c '\
if test -d $$0 && ! ls -1qA $$0 | grep -q . ; then \
diff --git a/config/aerc.conf.in b/config/aerc.conf.in
index 917dc225182e..aa4d8403881b 100644
--- a/config/aerc.conf.in
+++ b/config/aerc.conf.in
@@ -67,6 +67,17 @@ sort=
# Default: false
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 5794388a7131..a6952786f8aa 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
@@ -330,6 +333,11 @@ func (config *AercConfig) LoadConfig(file *ini.File) error {
		if err := ui.MapTo(&config.Ui); err != nil {
			return err
		}

		stylesetsDirs := ui.Key("stylesets-dirs").String()
		if stylesetsDirs != "" {
			config.Ui.StyleSetDirs = strings.Split(stylesetsDirs, ":")
		}
	}
	for _, sectionName := range file.SectionStrings() {
		if !strings.Contains(sectionName, "ui:") {
@@ -344,6 +352,10 @@ func (config *AercConfig) LoadConfig(file *ini.File) error {
		if err := uiSection.MapTo(&uiSubConfig); err != nil {
			return err
		}
		stylesetsDirs := uiSection.Key("stylesets-dirs").String()
		if stylesetsDirs != "" {
			uiSubConfig.StyleSetDirs = strings.Split(stylesetsDirs, ":")
		}
		contextualUi :=
			UIConfigContext{
				UiConfig: uiSubConfig,
@@ -404,6 +416,7 @@ func (config *AercConfig) LoadConfig(file *ini.File) error {
			}
		}
	}

	return nil
}

@@ -460,6 +473,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{},
@@ -489,6 +504,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,
@@ -499,6 +515,19 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) {
	if err = config.LoadConfig(file); err != nil {
		return nil, err
	}

	if err = config.Ui.loadStyleSet(
		config.Ui.StyleSetDirs); err != nil {
		return nil, err
	}

	for idx, _ := range config.ContextualUis {
		if err = config.ContextualUis[idx].UiConfig.loadStyleSet(
			config.Ui.StyleSetDirs); err != nil {
			return nil, err
		}
	}

	if ui, err := file.GetSection("general"); err == nil {
		if err := ui.MapTo(&config.General); err != nil {
			return nil, err
@@ -606,8 +635,17 @@ 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 = make(StyleSet)
	if err := ui.style.ParseStyleSet(ui.StyleSetName, styleSetDirs); err != nil {
		return fmt.Errorf("Error whie parsing styleset \"%s\": %s", ui.StyleSetName, err)
	}

	return nil
}

func (config AercConfig) mergeContextualUi(baseUi UIConfig,
	contextType ContextType, s string) UIConfig {
	for _, contextualUi := range config.ContextualUis {
		if contextualUi.ContextType != contextType {
			continue
@@ -617,17 +655,26 @@ 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[so]
}
diff --git a/config/default_styleset b/config/default_styleset
new file mode 100644
index 000000000000..a7836c767c41
--- /dev/null
+++ b/config/default_styleset
@@ -0,0 +1,32 @@
# 
# 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=true

title.reverse=true
header.bold=true

error.fg=red
soft_error.fg=yellow
*error.bold=true

msglist_unread.bold=true

dirlist_selecting.reverse=true
dirlist_selecting.fg=gray

valid_pgp.fg=green

completion_pill.reverse=true

tab*.default=true
tab.reverse=true

selecter_focused.reverse=true
selecter_chooser.bold=true
diff --git a/config/style.go b/config/style.go
new file mode 100644
index 000000000000..788952b01318
--- /dev/null
+++ b/config/style.go
@@ -0,0 +1,232 @@
package config

import (
	"errors"
	"os"
	"path"
	"regexp"
	"strconv"
	"strings"

	"github.com/gdamore/tcell"
	"github.com/go-ini/ini"
	"github.com/mitchellh/go-homedir"
)

type StyleObject int32

const (
	STYLE_DEFAULT StyleObject = iota
	STYLE_SELECTED
	STYLE_TITLE
	STYLE_HEADER

	STYLE_ERROR
	STYLE_SOFT_ERROR

	STYLE_MSGLIST_DEFAULT
	STYLE_MSGLIST_UNREAD
	STYLE_MSGLIST_READ
	STYLE_MSGLIST_DELETED
	STYLE_MSGLIST_MARKED
	STYLE_MSGLIST_SELECTED

	STYLE_DIRLIST_DEFAULT
	STYLE_DIRLIST_SELECTED
	STYLE_DIRLIST_SELECTING

	STYLE_VALID_PGP

	STYLE_COMPLETION_DEFAULT
	STYLE_COMPLETION_SELECTED
	STYLE_COMPLETION_GUTTER
	STYLE_COMPLETION_PILL

	STYLE_TAB
	STYLE_TAB_SELECTED

	STYLE_STACK_DEFAULT

	STYLE_SELECTER_DEFAULT
	STYLE_SELECTER_FOCUSED
	STYLE_SELECTER_CHOOSER

	STYLE_SPINNER
)

var StyleNames = map[string]StyleObject{
	"default":  STYLE_DEFAULT,
	"selected": STYLE_SELECTED,
	"title":    STYLE_TITLE,
	"header":   STYLE_HEADER,

	"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_selected": STYLE_MSGLIST_SELECTED,

	"dirlist_default":   STYLE_DIRLIST_DEFAULT,
	"dirlist_selecting": STYLE_DIRLIST_SELECTING,
	"dirlist_selected":  STYLE_DIRLIST_SELECTED,

	"error":      STYLE_ERROR,
	"soft_error": STYLE_SOFT_ERROR,

	"valid_pgp": STYLE_VALID_PGP,

	"completion_default":  STYLE_COMPLETION_DEFAULT,
	"completion_selected": STYLE_COMPLETION_SELECTED,
	"completion_gutter":   STYLE_COMPLETION_GUTTER,
	"completion_pill":     STYLE_COMPLETION_PILL,

	"tab":          STYLE_TAB,
	"tab_selected": STYLE_TAB_SELECTED,

	"stack_default": STYLE_STACK_DEFAULT,

	"selecter_default": STYLE_SELECTER_DEFAULT,
	"selecter_focused": STYLE_SELECTER_FOCUSED,
	"selecter_chooser": STYLE_SELECTER_CHOOSER,

	"spinner": STYLE_SPINNER,
}

type StyleSet map[StyleObject]tcell.Style

func (ss StyleSet) reset() {
	for _, so := range StyleNames {
		ss[so] = tcell.StyleDefault
	}
}

func (ss StyleSet) updateStyle(so StyleObject, attr, val string) error {
	switch attr {
	case "fg":
		ss[so] = ss[so].Foreground(tcell.GetColor(val))
	case "bg":
		ss[so] = ss[so].Background(tcell.GetColor(val))
	case "bold":
		if state, err := strconv.ParseBool(val); err != nil {
			return errors.New("Invalid value for attribute. bold true or false")
		} else {
			ss[so] = ss[so].Bold(state)
		}
	case "blink":
		if state, err := strconv.ParseBool(val); err != nil {
			return errors.New("Invalid value for attribute. blink true or false")
		} else {
			ss[so] = ss[so].Blink(state)
		}
	case "underline":
		if state, err := strconv.ParseBool(val); err != nil {
			return errors.New("Invalid value for attribute. Underline true or false")
		} else {
			ss[so] = ss[so].Underline(state)
		}
	case "default":
		ss[so] = tcell.StyleDefault
	case "normal":
		ss[so] = ss[so].Normal()
	case "reverse":
		if state, err := strconv.ParseBool(val); err != nil {
			return errors.New(
				"Invalid value for attribute. Value:" + val + ". Reverse true or false")
		} else {
			ss[so] = ss[so].Reverse(state)
		}
	default:
		return errors.New("Unknown style attribute: " + attr)
	}

	return nil
}

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 "", errors.New("Can't find styleset - " + stylesetName)
}
func (ss StyleSet) ParseStyleSet(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
	}

	ss.reset()

	defaultSection, err := file.GetSection(ini.DefaultSection)
	if err != nil {
		return err
	}

	for _, key := range defaultSection.KeyStrings() {
		index := strings.Index(key, ".")
		styleName := key[:index]
		attr := key[index+1:]
		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.updateStyle(so, attr, val); err != nil {
					return err
				}
			}
		} else {
			so, ok := StyleNames[styleName]
			if !ok {
				return errors.New("Unknown style object: " + styleName)
			}
			if err := ss.updateStyle(so, attr, val); err != nil {
				return err
			}
		}
	}

	return nil
}

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()
}
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index 36ac9c66f0f5..db49df514e17 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -173,6 +173,20 @@ These options are configured in the *[ui]* section of aerc.conf.

	Default: 250ms

*stylesets-dirs*
	The directories where the stylesets are stored. The config takes a
	colon-seperated list of dirs.

	Default: "/usr/share/aerc/stylesets"

*styleset-name*
	The name of the styleset to be used to style the ui elements. The
	stylesets are stored in the 'stylesets' directory in the config
	directory.

	Default: default


## Contextual UI Configuration

The UI configuration can be specialized for accounts, specific mail
diff --git a/doc/aerc-stylesets.7.scd b/doc/aerc-stylesets.7.scd
new file mode 100644
index 000000000000..f2bb6c908448
--- /dev/null
+++ b/doc/aerc-stylesets.7.scd
@@ -0,0 +1,173 @@
aerc-stylesets(7)

# Name

aerc-stylesets - styleset file specification for *aerc*(1)

# SYNOPSIS

aerc uses a simple configuration syntax to configure the styleset for
its ui.

# Styleset Configuration

Aerc uses a simple configuration file to describe a styleset. The
styleset is described as key, value pairs. In each line, the key
represents the style object it signifies and the color/atrribute of
that is modified.

For example, in the line below, the foreground color of the
style object "msglist_unread" is set to "cornflowerblue"
```
msglist_unread.fg=cornflowerblue
```

The configuration also allows wildcard matching of the style_objects
to configure multiple style objects at a time.

## Style
The following options are available to be modified for each of the
style objects.

*fg*
	The foreground color of the style object is set.

	Syntax: `<style_object>.fg=<color>`

*bg*
	The background color of the style object is set.

	Syntax: `<style_object>.bg=<color>`

*bold*
	The bold attribute of the style object is set/unset.

	Syntax: `<style_object>.bold=<true|false>`

*blink*
	The blink attribute of the style object is set/unset.
	_The terminal needs to support blinking text_

	Syntax: `<style_object>.bold=<true|false>`

*underline*
	The underline attribute of the style object is set/unset.
	_The terminal needs to support underline text_

	Syntax: `<style_object>.underline=<true|false>`

*normal*
	All the attributes of the style object are unset.

	Syntax: `<style_object>.normal=<true>`
	_The value doesn't matter_

*reverse*
	Reverses the color of the style object. Exchanges the foreground
	and background colors.

	Syntax: `<style_object>.reverse=<true|false>`
	_If the value is false, it doesn't change anything_

*default*
	Set the style object to the default style of the context. Usually
	based on the terminal.

	Syntax: `<style_object>.default=<true>`
	_The value doesn't matter_

## Style Objects
The style objects represent the various ui elements or ui instances for
styling.

[[ *Style Object*
:[ *Description*
|  default
:  The default style object used for normal ui elements while not
   using specialized configuration.
|  selected
:  The style object used for selection while not using specialized
   configuration.
|  title
:  The style object used to style titles in ui elements.
|  header
:  The style object used to style headers in ui elements.
|  error
:  The style used to show errors.
|  soft_error
:  The style used whne showing soft errors.
|  valid_pgp
:  The style used to show message for a valid PGP signature.
|  msglist_default
:  The default style for messages in a message list.
|  msglist_unread
:  Unread messages in a message list.
|  msglist_read
:  Read messages in a message list.
|  msglist_deleted
:  The messages marked as deleted.
|  msglist_selected
:  The style used for currently selected message in a message list.
|  dirlist_default
:  The default style for directories in the directory list.
|  dirlist_selecting
:  The currently selected directory in the directory list.
|  dirlist_selected
:  The style used for the currently selected directory.
|  completion_default
:  The default style for the completion engine.
|  completion_selected
:  The currently selected item in the completion popup window.
|  completion_gutter
:  The completion gutter.
|  completion_pill
:  The completion pill.
|  tab
:  The default style for the tab bar.
|  tab_selected
:  The selected tab in the tab bar.
|  stack_default
:  The default style for ui stack element.
|  selecter_default
:  The default style for the selecter ui element.
|  selecter_focused
:  The focused item in a selecter ui element.
|  selecter_chooser
:  The item chooser in a selecter ui element.
|  spinner
:  The style for the loading spinner.

## fnmatch style wildcard matching
The styleset configuration can be made simpler by using the fnmatch
style wildcard matching for the style object.

The special characters used in the fnmatch wildcards are:
[[ *Pattern*
:[ *Meaning*
|  \*
:  Matches everything
|  \?
:  Matches any single character

For example, the folling wildcards can be made using this syntax.
[[ *Example*
:[ Description
|  \*.fg=blue
:  Set the foreground color of all style objects to blue.
|  \*selected.bg=hotpink
:  Set the background color of all style objects that end in selected
   to hotpink.

## Colors
The color values are set using the values accepted by the tcell library.
The values can be one of the follwing.

	*default*
		The color is set as per the system or terminal default.

	*<Color name>*
		Any w3c approved color name is used to set colors for the style.

	*<Hex code>*
		Hexcode for a color can be used. The format must be "\#XXXXXX"

diff --git a/lib/ui/stack.go b/lib/ui/stack.go
index 690a8699eb65..603d1c267cd8 100644
--- a/lib/ui/stack.go
+++ b/lib/ui/stack.go
@@ -3,16 +3,19 @@ package ui
import (
	"fmt"

	"git.sr.ht/~sircmpwn/aerc/config"

	"github.com/gdamore/tcell"
)

type Stack struct {
	children     []Drawable
	onInvalidate []func(d Drawable)
	uiConfig     config.UIConfig
}

func NewStack() *Stack {
	return &Stack{}
func NewStack(uiConfig config.UIConfig) *Stack {
	return &Stack{uiConfig: uiConfig}
}

func (stack *Stack) Children() []Drawable {
@@ -33,7 +36,8 @@ func (stack *Stack) Draw(ctx *Context) {
	if len(stack.children) > 0 {
		stack.Peek().Draw(ctx)
	} else {
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
			stack.uiConfig.GetStyle(config.STYLE_STACK_DEFAULT))
	}
}

diff --git a/lib/ui/tab.go b/lib/ui/tab.go
index 7d1ce63d34bc..6927da9f4739 100644
--- a/lib/ui/tab.go
+++ b/lib/ui/tab.go
@@ -278,9 +278,9 @@ func (tabs *Tabs) removeHistory(index int) {
func (strip *TabStrip) Draw(ctx *Context) {
	x := 0
	for i, tab := range strip.Tabs {
		style := tcell.StyleDefault.Reverse(true)
		style := strip.uiConfig.GetStyle(config.STYLE_TAB)
		if strip.Selected == i {
			style = tcell.StyleDefault
			style = strip.uiConfig.GetStyle(config.STYLE_TAB_SELECTED)
		}
		tabWidth := 32
		if ctx.Width()-x < tabWidth {
@@ -296,8 +296,8 @@ func (strip *TabStrip) Draw(ctx *Context) {
			break
		}
	}
	style := tcell.StyleDefault.Reverse(true)
	ctx.Fill(x, 0, ctx.Width()-x, 1, ' ', style)
	ctx.Fill(x, 0, ctx.Width()-x, 1, ' ',
		strip.uiConfig.GetStyle(config.STYLE_TAB))
}

func (strip *TabStrip) Invalidate() {
@@ -381,7 +381,8 @@ func (content *TabContent) Draw(ctx *Context) {
	if content.Selected >= len(content.Tabs) {
		width := ctx.Width()
		height := ctx.Height()
		ctx.Fill(0, 0, width, height, ' ', tcell.StyleDefault)
		ctx.Fill(0, 0, width, height, ' ',
			content.uiConfig.GetStyle(config.STYLE_TAB))
	}

	tab := content.Tabs[content.Selected]
diff --git a/lib/ui/text.go b/lib/ui/text.go
index 2b82598511e2..455c2eb63ee3 100644
--- a/lib/ui/text.go
+++ b/lib/ui/text.go
@@ -15,17 +15,13 @@ type Text struct {
	Invalidatable
	text     string
	strategy uint
	fg       tcell.Color
	bg       tcell.Color
	bold     bool
	reverse  bool
	style    tcell.Style
}

func NewText(text string) *Text {
func NewText(text string, style tcell.Style) *Text {
	return &Text{
		bg:   tcell.ColorDefault,
		fg:   tcell.ColorDefault,
		text: text,
		text:  text,
		style: style,
	}
}

@@ -41,25 +37,6 @@ func (t *Text) Strategy(strategy uint) *Text {
	return t
}

func (t *Text) Bold(bold bool) *Text {
	t.bold = bold
	t.Invalidate()
	return t
}

func (t *Text) Color(fg tcell.Color, bg tcell.Color) *Text {
	t.fg = fg
	t.bg = bg
	t.Invalidate()
	return t
}

func (t *Text) Reverse(reverse bool) *Text {
	t.reverse = reverse
	t.Invalidate()
	return t
}

func (t *Text) Draw(ctx *Context) {
	size := runewidth.StringWidth(t.text)
	x := 0
@@ -69,15 +46,8 @@ func (t *Text) Draw(ctx *Context) {
	if t.strategy == TEXT_RIGHT {
		x = ctx.Width() - size
	}
	style := tcell.StyleDefault.Background(t.bg).Foreground(t.fg)
	if t.bold {
		style = style.Bold(true)
	}
	if t.reverse {
		style = style.Reverse(true)
	}
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
	ctx.Printf(x, 0, style, "%s", t.text)
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', t.style)
	ctx.Printf(x, 0, t.style, "%s", t.text)
}

func (t *Text) Invalidate() {
diff --git a/lib/ui/textinput.go b/lib/ui/textinput.go
index f7301fb36cde..1ae39c347870 100644
--- a/lib/ui/textinput.go
+++ b/lib/ui/textinput.go
@@ -6,6 +6,8 @@ import (

	"github.com/gdamore/tcell"
	"github.com/mattn/go-runewidth"

	"git.sr.ht/~sircmpwn/aerc/config"
)

// TODO: Attach history providers
@@ -27,16 +29,18 @@ type TextInput struct {
	completeIndex     int
	completeDelay     time.Duration
	completeDebouncer *time.Timer
	uiConfig          config.UIConfig
}

// Creates a new TextInput. TextInputs will render a "textbox" in the entire
// context they're given, and process keypresses to build a string from user
// input.
func NewTextInput(text string) *TextInput {
func NewTextInput(text string, ui config.UIConfig) *TextInput {
	return &TextInput{
		cells: -1,
		text:  []rune(text),
		index: len([]rune(text)),
		cells:    -1,
		text:     []rune(text),
		index:    len([]rune(text)),
		uiConfig: ui,
	}
}

@@ -87,16 +91,18 @@ func (ti *TextInput) Draw(ctx *Context) {
		ti.ensureScroll()
	}
	ti.ctx = ctx // gross
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)

	defaultStyle := ti.uiConfig.GetStyle(config.STYLE_DEFAULT)
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)

	text := ti.text[scroll:]
	sindex := ti.index - scroll
	if ti.password {
		x := ctx.Printf(0, 0, tcell.StyleDefault, "%s", ti.prompt)
		x := ctx.Printf(0, 0, defaultStyle, "%s", ti.prompt)
		cells := runewidth.StringWidth(string(text))
		ctx.Fill(x, 0, cells, 1, '*', tcell.StyleDefault)
		ctx.Fill(x, 0, cells, 1, '*', defaultStyle)
	} else {
		ctx.Printf(0, 0, tcell.StyleDefault, "%s%s", ti.prompt, string(text))
		ctx.Printf(0, 0, defaultStyle, "%s%s", ti.prompt, string(text))
	}
	cells := runewidth.StringWidth(string(text[:sindex]) + ti.prompt)
	if ti.focus {
@@ -126,6 +132,7 @@ func (ti *TextInput) drawPopover(ctx *Context) {
			ti.Set(stem + ti.StringRight())
			ti.Invalidate()
		},
		uiConfig: ti.uiConfig,
	}
	width := maxLen(ti.completions) + 3
	height := len(ti.completions)
@@ -353,6 +360,7 @@ type completions struct {
	onSelect   func(int)
	onExec     func()
	onStem     func(string)
	uiConfig   config.UIConfig
}

func maxLen(ss []string) int {
@@ -367,10 +375,10 @@ func maxLen(ss []string) int {
}

func (c *completions) Draw(ctx *Context) {
	bg := tcell.StyleDefault
	sel := tcell.StyleDefault.Reverse(true)
	gutter := tcell.StyleDefault
	pill := tcell.StyleDefault.Reverse(true)
	bg := c.uiConfig.GetStyle(config.STYLE_COMPLETION_DEFAULT)
	sel := c.uiConfig.GetStyle(config.STYLE_COMPLETION_SELECTED)
	gutter := c.uiConfig.GetStyle(config.STYLE_COMPLETION_GUTTER)
	pill := c.uiConfig.GetStyle(config.STYLE_COMPLETION_SELECTED)

	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', bg)

diff --git a/widgets/account-wizard.go b/widgets/account-wizard.go
index d7b46b9ea3d9..6c67b0d31a53 100644
--- a/widgets/account-wizard.go
+++ b/widgets/account-wizard.go
@@ -76,21 +76,21 @@ type AccountWizard struct {

func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
	wizard := &AccountWizard{
		accountName:  ui.NewTextInput("").Prompt("> "),
		accountName:  ui.NewTextInput("", conf.Ui).Prompt("> "),
		aerc:         aerc,
		conf:         conf,
		temporary:    false,
		copySent:     true,
		email:        ui.NewTextInput("").Prompt("> "),
		fullName:     ui.NewTextInput("").Prompt("> "),
		imapPassword: ui.NewTextInput("").Prompt("] ").Password(true),
		imapServer:   ui.NewTextInput("").Prompt("> "),
		imapStr:      ui.NewText("imaps://"),
		imapUsername: ui.NewTextInput("").Prompt("> "),
		smtpPassword: ui.NewTextInput("").Prompt("] ").Password(true),
		smtpServer:   ui.NewTextInput("").Prompt("> "),
		smtpStr:      ui.NewText("smtps://"),
		smtpUsername: ui.NewTextInput("").Prompt("> "),
		email:        ui.NewTextInput("", conf.Ui).Prompt("> "),
		fullName:     ui.NewTextInput("", conf.Ui).Prompt("> "),
		imapPassword: ui.NewTextInput("", conf.Ui).Prompt("] ").Password(true),
		imapServer:   ui.NewTextInput("", conf.Ui).Prompt("> "),
		imapStr:      ui.NewText("imaps://", conf.Ui.GetStyle(config.STYLE_DEFAULT)),
		imapUsername: ui.NewTextInput("", conf.Ui).Prompt("> "),
		smtpPassword: ui.NewTextInput("", conf.Ui).Prompt("] ").Password(true),
		smtpServer:   ui.NewTextInput("", conf.Ui).Prompt("> "),
		smtpStr:      ui.NewText("smtps://", conf.Ui.GetStyle(config.STYLE_DEFAULT)),
		smtpUsername: ui.NewTextInput("", conf.Ui).Prompt("> "),
	}

	// Autofill some stuff for the user
@@ -151,33 +151,36 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
		{ui.SIZE_WEIGHT, 1},
	})
	basics.AddChild(
		ui.NewText("\nWelcome to aerc! Let's configure your account.\n\n" +
			"This wizard supports basic IMAP & SMTP configuration.\n" +
			"For other configurations, use <Ctrl+q> to exit and read the " +
			"aerc-config(5) man page.\n" +
			"Press <Tab> and <Shift+Tab> to cycle between each field in this form, or <Ctrl+j> and <Ctrl+k>."))
		ui.NewText("\nWelcome to aerc! Let's configure your account.\n\n"+
			"This wizard supports basic IMAP & SMTP configuration.\n"+
			"For other configurations, use <Ctrl+q> to exit and read the "+
			"aerc-config(5) man page.\n"+
			"Press <Tab> and <Shift+Tab> to cycle between each field in this form, "+
			"or <Ctrl+j> and <Ctrl+k>.",
			conf.Ui.GetStyle(config.STYLE_DEFAULT)))
	basics.AddChild(
		ui.NewText("Name for this account? (e.g. 'Personal' or 'Work')").
			Bold(true)).
		ui.NewText("Name for this account? (e.g. 'Personal' or 'Work')",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(1, 0)
	basics.AddChild(wizard.accountName).
		At(2, 0)
	basics.AddChild(ui.NewFill(' ')).
		At(3, 0)
	basics.AddChild(
		ui.NewText("Full name for outgoing emails? (e.g. 'John Doe')").
			Bold(true)).
		ui.NewText("Full name for outgoing emails? (e.g. 'John Doe')",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(4, 0)
	basics.AddChild(wizard.fullName).
		At(5, 0)
	basics.AddChild(ui.NewFill(' ')).
		At(6, 0)
	basics.AddChild(
		ui.NewText("Your email address? (e.g. 'john@example.org')").Bold(true)).
		ui.NewText("Your email address? (e.g. 'john@example.org')",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(7, 0)
	basics.AddChild(wizard.email).
		At(8, 0)
	selecter := NewSelecter([]string{"Next"}, 0).
	selecter := NewSelecter([]string{"Next"}, 0, conf.Ui).
		OnChoose(func(option string) {
			email := wizard.email.String()
			if strings.ContainsRune(email, '@') {
@@ -228,16 +231,19 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
	}).Columns([]ui.GridSpec{
		{ui.SIZE_WEIGHT, 1},
	})
	incoming.AddChild(ui.NewText("\nConfigure incoming mail (IMAP)"))
	incoming.AddChild(ui.NewText("\nConfigure incoming mail (IMAP)",
		conf.Ui.GetStyle(config.STYLE_DEFAULT)))
	incoming.AddChild(
		ui.NewText("Username").Bold(true)).
		ui.NewText("Username",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(1, 0)
	incoming.AddChild(wizard.imapUsername).
		At(2, 0)
	incoming.AddChild(ui.NewFill(' ')).
		At(3, 0)
	incoming.AddChild(
		ui.NewText("Password").Bold(true)).
		ui.NewText("Password",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(4, 0)
	incoming.AddChild(wizard.imapPassword).
		At(5, 0)
@@ -245,20 +251,22 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
		At(6, 0)
	incoming.AddChild(
		ui.NewText("Server address "+
			"(e.g. 'mail.example.org' or 'mail.example.org:1313')").Bold(true)).
			"(e.g. 'mail.example.org' or 'mail.example.org:1313')",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(7, 0)
	incoming.AddChild(wizard.imapServer).
		At(8, 0)
	incoming.AddChild(ui.NewFill(' ')).
		At(9, 0)
	incoming.AddChild(
		ui.NewText("Connection mode").Bold(true)).
		ui.NewText("Connection mode",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(10, 0)
	imapMode := NewSelecter([]string{
		"IMAP over SSL/TLS",
		"IMAP with STARTTLS",
		"Insecure IMAP",
	}, 0).Chooser(true).OnSelect(func(option string) {
	}, 0, conf.Ui).Chooser(true).OnSelect(func(option string) {
		switch option {
		case "IMAP over SSL/TLS":
			wizard.imapMode = IMAP_OVER_TLS
@@ -270,7 +278,7 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
		wizard.imapUri()
	})
	incoming.AddChild(imapMode).At(11, 0)
	selecter = NewSelecter([]string{"Previous", "Next"}, 1).
	selecter = NewSelecter([]string{"Previous", "Next"}, 1, conf.Ui).
		OnChoose(wizard.advance)
	incoming.AddChild(ui.NewFill(' ')).At(12, 0)
	incoming.AddChild(wizard.imapStr).At(13, 0)
@@ -305,16 +313,19 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
	}).Columns([]ui.GridSpec{
		{ui.SIZE_WEIGHT, 1},
	})
	outgoing.AddChild(ui.NewText("\nConfigure outgoing mail (SMTP)"))
	outgoing.AddChild(ui.NewText("\nConfigure outgoing mail (SMTP)",
		conf.Ui.GetStyle(config.STYLE_DEFAULT)))
	outgoing.AddChild(
		ui.NewText("Username").Bold(true)).
		ui.NewText("Username",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(1, 0)
	outgoing.AddChild(wizard.smtpUsername).
		At(2, 0)
	outgoing.AddChild(ui.NewFill(' ')).
		At(3, 0)
	outgoing.AddChild(
		ui.NewText("Password").Bold(true)).
		ui.NewText("Password",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(4, 0)
	outgoing.AddChild(wizard.smtpPassword).
		At(5, 0)
@@ -322,20 +333,22 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
		At(6, 0)
	outgoing.AddChild(
		ui.NewText("Server address "+
			"(e.g. 'mail.example.org' or 'mail.example.org:1313')").Bold(true)).
			"(e.g. 'mail.example.org' or 'mail.example.org:1313')",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(7, 0)
	outgoing.AddChild(wizard.smtpServer).
		At(8, 0)
	outgoing.AddChild(ui.NewFill(' ')).
		At(9, 0)
	outgoing.AddChild(
		ui.NewText("Connection mode").Bold(true)).
		ui.NewText("Connection mode",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(10, 0)
	smtpMode := NewSelecter([]string{
		"SMTP over SSL/TLS",
		"SMTP with STARTTLS",
		"Insecure SMTP",
	}, 0).Chooser(true).OnSelect(func(option string) {
	}, 0, conf.Ui).Chooser(true).OnSelect(func(option string) {
		switch option {
		case "SMTP over SSL/TLS":
			wizard.smtpMode = SMTP_OVER_TLS
@@ -347,15 +360,15 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
		wizard.smtpUri()
	})
	outgoing.AddChild(smtpMode).At(11, 0)
	selecter = NewSelecter([]string{"Previous", "Next"}, 1).
	selecter = NewSelecter([]string{"Previous", "Next"}, 1, conf.Ui).
		OnChoose(wizard.advance)
	outgoing.AddChild(ui.NewFill(' ')).At(12, 0)
	outgoing.AddChild(wizard.smtpStr).At(13, 0)
	outgoing.AddChild(ui.NewFill(' ')).At(14, 0)
	outgoing.AddChild(
		ui.NewText("Copy sent messages to 'Sent' folder?").Bold(true)).
		At(15, 0)
	copySent := NewSelecter([]string{"Yes", "No"}, 0).
		ui.NewText("Copy sent messages to 'Sent' folder?",
			conf.Ui.GetStyle(config.STYLE_HEADER))).At(15, 0)
	copySent := NewSelecter([]string{"Yes", "No"}, 0, conf.Ui).
		Chooser(true).OnChoose(func(option string) {
		switch option {
		case "Yes":
@@ -381,15 +394,16 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
		{ui.SIZE_WEIGHT, 1},
	})
	complete.AddChild(ui.NewText(
		"\nConfiguration complete!\n\n" +
			"You can go back and double check your settings, or choose 'Finish' to\n" +
			"save your settings to accounts.conf.\n\n" +
			"To add another account in the future, run ':new-account'."))
		"\nConfiguration complete!\n\n"+
			"You can go back and double check your settings, or choose 'Finish' to\n"+
			"save your settings to accounts.conf.\n\n"+
			"To add another account in the future, run ':new-account'.",
		conf.Ui.GetStyle(config.STYLE_DEFAULT)))
	selecter = NewSelecter([]string{
		"Previous",
		"Finish & open tutorial",
		"Finish",
	}, 1).OnChoose(func(option string) {
	}, 1, conf.Ui).OnChoose(func(option string) {
		switch option {
		case "Previous":
			wizard.advance("Previous")
diff --git a/widgets/aerc.go b/widgets/aerc.go
index 4c8d09df60a5..e27f4ba58890 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -45,7 +45,7 @@ func NewAerc(conf *config.AercConfig, logger *log.Logger,

	tabs := ui.NewTabs(&conf.Ui)

	statusbar := ui.NewStack()
	statusbar := ui.NewStack(conf.Ui)
	statusline := NewStatusLine()
	statusbar.Push(statusline)

@@ -70,7 +70,7 @@ func NewAerc(conf *config.AercConfig, logger *log.Logger,
		logger:     logger,
		statusbar:  statusbar,
		statusline: statusline,
		prompts:    ui.NewStack(),
		prompts:    ui.NewStack(conf.Ui),
		tabs:       tabs,
	}

@@ -506,7 +506,7 @@ func (aerc *Aerc) CloseBackends() error {
}

func (aerc *Aerc) GetPassword(title string, prompt string, cb func(string)) {
	aerc.getpasswd = NewGetPasswd(title, prompt, func(pw string) {
	aerc.getpasswd = NewGetPasswd(title, prompt, aerc.conf, func(pw string) {
		aerc.getpasswd = nil
		aerc.Invalidate()
		cb(pw)
diff --git a/widgets/compose.go b/widgets/compose.go
index a97e5fedc927..194208af649e 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -74,7 +74,7 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
		aerc.PushError(fmt.Sprintf("could not complete header: %v", err))
		worker.Logger.Printf("could not complete header: %v", err)
	}, aerc.Logger())
	layout, editors, focusable := buildComposeHeader(conf, cmpl, defaults)
	layout, editors, focusable := buildComposeHeader(aerc, cmpl, defaults)

	email, err := ioutil.TempFile("", "aerc-compose-*.eml")
	if err != nil {
@@ -110,21 +110,21 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
	return c, nil
}

func buildComposeHeader(conf *config.AercConfig, cmpl *completer.Completer,
func buildComposeHeader(aerc *Aerc, cmpl *completer.Completer,
	defaults map[string]string) (
	newLayout HeaderLayout,
	editors map[string]*headerEditor,
	focusable []ui.MouseableDrawableInteractive,
) {
	layout := conf.Compose.HeaderLayout
	layout := aerc.conf.Compose.HeaderLayout
	editors = make(map[string]*headerEditor)
	focusable = make([]ui.MouseableDrawableInteractive, 0)

	for _, row := range layout {
		for _, h := range row {
			e := newHeaderEditor(h, "")
			if conf.Ui.CompletionPopovers {
				e.input.TabComplete(cmpl.ForHeader(h), conf.Ui.CompletionDelay)
			e := newHeaderEditor(h, "", aerc.SelectedAccount().UiConfig())
			if aerc.conf.Ui.CompletionPopovers {
				e.input.TabComplete(cmpl.ForHeader(h), aerc.SelectedAccount().UiConfig().CompletionDelay)
			}
			editors[h] = e
			switch h {
@@ -141,9 +141,9 @@ func buildComposeHeader(conf *config.AercConfig, cmpl *completer.Completer,
	for _, h := range []string{"Cc", "Bcc"} {
		if val, ok := defaults[h]; ok && val != "" {
			if _, ok := editors[h]; !ok {
				e := newHeaderEditor(h, "")
				if conf.Ui.CompletionPopovers {
					e.input.TabComplete(cmpl.ForHeader(h), conf.Ui.CompletionDelay)
				e := newHeaderEditor(h, "", aerc.SelectedAccount().UiConfig())
				if aerc.conf.Ui.CompletionPopovers {
					e.input.TabComplete(cmpl.ForHeader(h), aerc.SelectedAccount().UiConfig().CompletionDelay)
				}
				editors[h] = e
				focusable = append(focusable, e)
@@ -738,7 +738,7 @@ func (c *Composer) AddEditor(header string, value string, appendHeader bool) {
		}
		return
	}
	e := newHeaderEditor(header, value)
	e := newHeaderEditor(header, value, c.aerc.SelectedAccount().UiConfig())
	if c.config.Ui.CompletionPopovers {
		e.input.TabComplete(c.completer.ForHeader(header), c.config.Ui.CompletionDelay)
	}
@@ -792,23 +792,27 @@ func (c *Composer) reloadEmail() error {
}

type headerEditor struct {
	name    string
	focused bool
	input   *ui.TextInput
	name     string
	focused  bool
	input    *ui.TextInput
	uiConfig config.UIConfig
}

func newHeaderEditor(name string, value string) *headerEditor {
func newHeaderEditor(name string, value string, uiConfig config.UIConfig) *headerEditor {
	return &headerEditor{
		input: ui.NewTextInput(value),
		name:  name,
		input:    ui.NewTextInput(value, uiConfig),
		name:     name,
		uiConfig: uiConfig,
	}
}

func (he *headerEditor) Draw(ctx *ui.Context) {
	name := he.name + " "
	size := runewidth.StringWidth(name)
	ctx.Fill(0, 0, size, ctx.Height(), ' ', tcell.StyleDefault)
	ctx.Printf(0, 0, tcell.StyleDefault.Bold(true), "%s", name)
	defaultStyle := he.uiConfig.GetStyle(config.STYLE_DEFAULT)
	headerStyle := he.uiConfig.GetStyle(config.STYLE_HEADER)
	ctx.Fill(0, 0, size, ctx.Height(), ' ', defaultStyle)
	ctx.Printf(0, 0, headerStyle, "%s", name)
	he.input.Draw(ctx.Subcontext(size, 0, ctx.Width()-size, 1))
}

@@ -869,21 +873,25 @@ func newReviewMessage(composer *Composer, err error) *reviewMessage {
		{ui.SIZE_WEIGHT, 1},
	})

	uiConfig := composer.config.Ui

	if err != nil {
		grid.AddChild(ui.NewText(err.Error()).
			Color(tcell.ColorRed, tcell.ColorDefault))
		grid.AddChild(ui.NewText("Press [q] to close this tab.")).At(1, 0)
		grid.AddChild(ui.NewText(err.Error(), uiConfig.GetStyle(config.STYLE_ERROR)))
		grid.AddChild(ui.NewText("Press [q] to close this tab.",
			uiConfig.GetStyle(config.STYLE_DEFAULT))).At(1, 0)
	} else {
		// TODO: source this from actual keybindings?
		grid.AddChild(ui.NewText(
			"Send this email? [y]es/[n]o/[e]dit/[a]ttach")).At(0, 0)
		grid.AddChild(ui.NewText("Attachments:").
			Reverse(true)).At(1, 0)
		grid.AddChild(ui.NewText("Send this email? [y]es/[n]o/[e]dit/[a]ttach",
			uiConfig.GetStyle(config.STYLE_DEFAULT))).At(0, 0)
		grid.AddChild(ui.NewText("Attachments:",
			uiConfig.GetStyle(config.STYLE_TITLE))).At(1, 0)
		if len(composer.attachments) == 0 {
			grid.AddChild(ui.NewText("(none)")).At(2, 0)
			grid.AddChild(ui.NewText("(none)",
				uiConfig.GetStyle(config.STYLE_DEFAULT))).At(2, 0)
		} else {
			for i, a := range composer.attachments {
				grid.AddChild(ui.NewText(a)).At(i+2, 0)
				grid.AddChild(ui.NewText(a, uiConfig.GetStyle(config.STYLE_DEFAULT))).
					At(i+2, 0)
			}
		}
	}
diff --git a/widgets/dirlist.go b/widgets/dirlist.go
index 600b38c053c0..3fc6b574b40e 100644
--- a/widgets/dirlist.go
+++ b/widgets/dirlist.go
@@ -194,7 +194,8 @@ func (dirlist *DirectoryList) getRUEString(name string) string {
}

func (dirlist *DirectoryList) Draw(ctx *ui.Context) {
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
		dirlist.UiConfig().GetStyle(config.STYLE_DIRLIST_DEFAULT))

	if dirlist.spinner.IsRunning() {
		dirlist.spinner.Draw(ctx)
@@ -202,7 +203,7 @@ func (dirlist *DirectoryList) Draw(ctx *ui.Context) {
	}

	if len(dirlist.dirs) == 0 {
		style := tcell.StyleDefault
		style := dirlist.UiConfig().GetStyle(config.STYLE_DIRLIST_DEFAULT)
		ctx.Printf(0, 0, style, dirlist.UiConfig().EmptyDirlist)
		return
	}
@@ -212,12 +213,11 @@ func (dirlist *DirectoryList) Draw(ctx *ui.Context) {
		if row >= ctx.Height() {
			break
		}
		style := tcell.StyleDefault
		style := dirlist.UiConfig().GetStyle(config.STYLE_DIRLIST_DEFAULT)
		if name == dirlist.selected {
			style = style.Reverse(true)
			style = dirlist.UiConfig().GetStyle(config.STYLE_DIRLIST_SELECTED)
		} else if name == dirlist.selecting {
			style = style.Reverse(true)
			style = style.Foreground(tcell.ColorGray)
			style = dirlist.UiConfig().GetStyle(config.STYLE_DIRLIST_SELECTING)
		}
		ctx.Fill(0, row, ctx.Width(), 1, ' ', style)

diff --git a/widgets/exline.go b/widgets/exline.go
index 6def938eba3c..692c8e213666 100644
--- a/widgets/exline.go
+++ b/widgets/exline.go
@@ -15,13 +15,14 @@ type ExLine struct {
	tabcomplete func(cmd string) []string
	cmdHistory  lib.History
	input       *ui.TextInput
	conf        *config.AercConfig
}

func NewExLine(conf *config.AercConfig, cmd string, commit func(cmd string), finish func(),
	tabcomplete func(cmd string) []string,
	cmdHistory lib.History) *ExLine {

	input := ui.NewTextInput("").Prompt(":").Set(cmd)
	input := ui.NewTextInput("", conf.Ui).Prompt(":").Set(cmd)
	if conf.Ui.CompletionPopovers {
		input.TabComplete(tabcomplete, conf.Ui.CompletionDelay)
	}
@@ -31,6 +32,7 @@ func NewExLine(conf *config.AercConfig, cmd string, commit func(cmd string), fin
		tabcomplete: tabcomplete,
		cmdHistory:  cmdHistory,
		input:       input,
		conf:        conf,
	}
	input.OnInvalidate(func(d ui.Drawable) {
		exline.Invalidate()
@@ -41,7 +43,7 @@ func NewExLine(conf *config.AercConfig, cmd string, commit func(cmd string), fin
func NewPrompt(conf *config.AercConfig, prompt string, commit func(text string),
	tabcomplete func(cmd string) []string) *ExLine {

	input := ui.NewTextInput("").Prompt(prompt)
	input := ui.NewTextInput("", conf.Ui).Prompt(prompt)
	if conf.Ui.CompletionPopovers {
		input.TabComplete(tabcomplete, conf.Ui.CompletionDelay)
	}
diff --git a/widgets/getpasswd.go b/widgets/getpasswd.go
index 08702c582d04..70fc9a97b421 100644
--- a/widgets/getpasswd.go
+++ b/widgets/getpasswd.go
@@ -3,6 +3,7 @@ package widgets
import (
	"github.com/gdamore/tcell"

	"git.sr.ht/~sircmpwn/aerc/config"
	"git.sr.ht/~sircmpwn/aerc/lib/ui"
)

@@ -12,14 +13,16 @@ type GetPasswd struct {
	title    string
	prompt   string
	input    *ui.TextInput
	conf     *config.AercConfig
}

func NewGetPasswd(title string, prompt string, cb func(string)) *GetPasswd {
func NewGetPasswd(title string, prompt string, conf *config.AercConfig, cb func(string)) *GetPasswd {
	getpasswd := &GetPasswd{
		callback: cb,
		title:    title,
		prompt:   prompt,
		input:    ui.NewTextInput("").Password(true).Prompt("Password: "),
		conf:     conf,
		input:    ui.NewTextInput("", conf.Ui).Password(true).Prompt("Password: "),
	}
	getpasswd.input.OnInvalidate(func(_ ui.Drawable) {
		getpasswd.Invalidate()
@@ -29,10 +32,13 @@ func NewGetPasswd(title string, prompt string, cb func(string)) *GetPasswd {
}

func (gp *GetPasswd) Draw(ctx *ui.Context) {
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
	ctx.Fill(0, 0, ctx.Width(), 1, ' ', tcell.StyleDefault.Reverse(true))
	ctx.Printf(1, 0, tcell.StyleDefault.Reverse(true), "%s", gp.title)
	ctx.Printf(1, 1, tcell.StyleDefault, gp.prompt)
	defaultStyle := gp.conf.Ui.GetStyle(config.STYLE_DEFAULT)
	titleStyle := gp.conf.Ui.GetStyle(config.STYLE_TITLE)

	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
	ctx.Fill(0, 0, ctx.Width(), 1, ' ', titleStyle)
	ctx.Printf(1, 0, titleStyle, "%s", gp.title)
	ctx.Printf(1, 1, defaultStyle, gp.prompt)
	gp.input.Draw(ctx.Subcontext(1, 3, ctx.Width()-2, 1))
}

diff --git a/widgets/msglist.go b/widgets/msglist.go
index f36901f6b761..96430c3d305c 100644
--- a/widgets/msglist.go
+++ b/widgets/msglist.go
@@ -49,7 +49,8 @@ func (ml *MessageList) Invalidate() {

func (ml *MessageList) Draw(ctx *ui.Context) {
	ml.height = ctx.Height()
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
		ml.aerc.SelectedAccount().UiConfig().GetStyle(config.STYLE_MSGLIST_DEFAULT))

	store := ml.Store()
	if store == nil {
@@ -84,15 +85,17 @@ func (ml *MessageList) Draw(ctx *ui.Context) {
			continue
		}

		style := tcell.StyleDefault
		uiConfig := ml.conf.GetUiConfig(map[config.ContextType]string{
			config.UI_CONTEXT_ACCOUNT: ml.aerc.SelectedAccount().AccountConfig().Name,
			config.UI_CONTEXT_FOLDER:  ml.aerc.SelectedAccount().Directories().Selected(),
			config.UI_CONTEXT_SUBJECT: msg.Envelope.Subject,
		})

		style := uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT)

		// current row
		if row == ml.store.SelectedIndex()-ml.scroll {
			style = style.Reverse(true)
		}
		// deleted message
		if _, ok := store.Deleted[msg.Uid]; ok {
			style = style.Foreground(tcell.ColorGray)
			style = uiConfig.GetStyle(config.STYLE_MSGLIST_DELETED)
		}
		// unread message
		seen := false
@@ -102,16 +105,20 @@ func (ml *MessageList) Draw(ctx *ui.Context) {
			}
		}
		if !seen {
			style = style.Bold(true)
			style = uiConfig.GetStyle(config.STYLE_MSGLIST_UNREAD)
		}

		ctx.Fill(0, row, ctx.Width(), 1, ' ', style)
		uiConfig := ml.conf.GetUiConfig(map[config.ContextType]string{
			config.UI_CONTEXT_ACCOUNT: ml.aerc.SelectedAccount().AccountConfig().Name,
			config.UI_CONTEXT_FOLDER:  ml.aerc.SelectedAccount().Directories().Selected(),
			config.UI_CONTEXT_SUBJECT: msg.Envelope.Subject,
		})
		// marked message
		if store.IsMarked(msg.Uid) {
			style = uiConfig.GetStyle(config.STYLE_MSGLIST_MARKED)
		}

		// current row
		if row == ml.store.SelectedIndex()-ml.scroll {
			style = uiConfig.GetStyle(config.STYLE_MSGLIST_SELECTED)
		}

		ctx.Fill(0, row, ctx.Width(), 1, ' ', style)
		fmtStr, args, err := format.ParseMessageFormat(
			ml.aerc.SelectedAccount().acct.From,
			uiConfig.IndexFormat,
@@ -284,7 +291,8 @@ func (ml *MessageList) Scroll() {
}

func (ml *MessageList) drawEmptyMessage(ctx *ui.Context) {
	msg := ml.aerc.SelectedAccount().UiConfig().EmptyMessage
	uiConfig := ml.aerc.SelectedAccount().UiConfig()
	msg := uiConfig.EmptyMessage
	ctx.Printf((ctx.Width()/2)-(len(msg)/2), 0,
		tcell.StyleDefault, "%s", msg)
		uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT), "%s", msg)
}
diff --git a/widgets/msgviewer.go b/widgets/msgviewer.go
index 35fc4b6e3bae..25a66c4fe9e4 100644
--- a/widgets/msgviewer.go
+++ b/widgets/msgviewer.go
@@ -32,6 +32,7 @@ type MessageViewer struct {
	grid     *ui.Grid
	switcher *PartSwitcher
	msg      lib.MessageView
	uiConfig config.UIConfig
}

type PartSwitcher struct {
@@ -61,9 +62,11 @@ func NewMessageViewer(acct *AccountView,
	header, headerHeight := layout.grid(
		func(header string) ui.Drawable {
			return &HeaderView{
				conf: conf,
				Name: header,
				Value: fmtHeader(msg.MessageInfo(), header,
					acct.UiConfig().TimestampFormat),
				uiConfig: acct.UiConfig(),
			}
		},
	)
@@ -93,15 +96,16 @@ func NewMessageViewer(acct *AccountView,
	err := createSwitcher(acct, switcher, conf, msg)
	if err != nil {
		return &MessageViewer{
			err:  err,
			grid: grid,
			msg:  msg,
			err:      err,
			grid:     grid,
			msg:      msg,
			uiConfig: acct.UiConfig(),
		}
	}

	grid.AddChild(header).At(0, 0)
	if msg.PGPDetails() != nil {
		grid.AddChild(NewPGPInfo(msg.PGPDetails())).At(1, 0)
		grid.AddChild(NewPGPInfo(msg.PGPDetails(), acct.UiConfig())).At(1, 0)
		grid.AddChild(ui.NewFill(' ')).At(2, 0)
		grid.AddChild(switcher).At(3, 0)
	} else {
@@ -115,6 +119,7 @@ func NewMessageViewer(acct *AccountView,
		grid:     grid,
		msg:      msg,
		switcher: switcher,
		uiConfig: acct.UiConfig(),
	}
	switcher.mv = mv

@@ -223,8 +228,9 @@ func createSwitcher(acct *AccountView, switcher *PartSwitcher,

func (mv *MessageViewer) Draw(ctx *ui.Context) {
	if mv.err != nil {
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
		ctx.Printf(0, 0, tcell.StyleDefault, "%s", mv.err.Error())
		style := mv.acct.UiConfig().GetStyle(config.STYLE_DEFAULT)
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
		ctx.Printf(0, 0, style, "%s", mv.err.Error())
		return
	}
	mv.grid.Draw(ctx)
@@ -345,7 +351,10 @@ func (ps *PartSwitcher) Draw(ctx *ui.Context) {
	ps.height = ctx.Height()
	y := ctx.Height() - height
	for i, part := range ps.parts {
		style := tcell.StyleDefault.Reverse(ps.selected == i)
		style := ps.mv.uiConfig.GetStyle(config.STYLE_DEFAULT)
		if ps.selected == i {
			style = ps.mv.uiConfig.GetStyle(config.STYLE_SELECTED)
		}
		ctx.Fill(0, y+i, ctx.Width(), 1, ' ', style)
		name := fmt.Sprintf("%s/%s",
			strings.ToLower(part.part.MIMEType),
@@ -434,6 +443,7 @@ func (mv *MessageViewer) Focus(focus bool) {

type PartViewer struct {
	ui.Invalidatable
	conf        *config.AercConfig
	err         error
	fetched     bool
	filter      *exec.Cmd
@@ -448,6 +458,7 @@ type PartViewer struct {
	term        *Terminal
	selecter    *Selecter
	grid        *ui.Grid
	uiConfig    config.UIConfig
}

func NewPartViewer(acct *AccountView, conf *config.AercConfig,
@@ -517,7 +528,8 @@ func NewPartViewer(acct *AccountView, conf *config.AercConfig,
		{ui.SIZE_WEIGHT, 1},
	})

	selecter := NewSelecter([]string{"Save message", "Pipe to command"}, 0).
	selecter := NewSelecter([]string{"Save message", "Pipe to command"},
		0, acct.UiConfig()).
		OnChoose(func(option string) {
			switch option {
			case "Save message":
@@ -530,6 +542,7 @@ func NewPartViewer(acct *AccountView, conf *config.AercConfig,
	grid.AddChild(selecter).At(2, 0)

	pv := &PartViewer{
		conf:        conf,
		filter:      filter,
		index:       index,
		msg:         msg,
@@ -541,6 +554,7 @@ func NewPartViewer(acct *AccountView, conf *config.AercConfig,
		term:        term,
		selecter:    selecter,
		grid:        grid,
		uiConfig:    acct.UiConfig(),
	}

	if term != nil {
@@ -638,14 +652,16 @@ func (pv *PartViewer) Invalidate() {
}

func (pv *PartViewer) Draw(ctx *ui.Context) {
	style := pv.uiConfig.GetStyle(config.STYLE_DEFAULT)
	styleError := pv.uiConfig.GetStyle(config.STYLE_ERROR)
	if pv.filter == nil {
		// TODO: Let them download it directly or something
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
		ctx.Printf(0, 0, tcell.StyleDefault.Foreground(tcell.ColorRed),
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
		ctx.Printf(0, 0, styleError,
			"No filter configured for this mimetype ('%s/%s')",
			pv.part.MIMEType, pv.part.MIMESubType,
		)
		ctx.Printf(0, 2, tcell.StyleDefault,
		ctx.Printf(0, 2, style,
			"You can still :save the message or :pipe it to an external command")
		pv.selecter.Focus(true)
		pv.grid.Draw(ctx)
@@ -657,8 +673,8 @@ func (pv *PartViewer) Draw(ctx *ui.Context) {
		pv.fetched = true
	}
	if pv.err != nil {
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
		ctx.Printf(0, 0, tcell.StyleDefault, "%s", pv.err.Error())
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
		ctx.Printf(0, 0, style, "%s", pv.err.Error())
		return
	}
	pv.term.Draw(ctx)
@@ -680,8 +696,10 @@ func (pv *PartViewer) Event(event tcell.Event) bool {

type HeaderView struct {
	ui.Invalidatable
	Name  string
	Value string
	conf     *config.AercConfig
	Name     string
	Value    string
	uiConfig config.UIConfig
}

func (hv *HeaderView) Draw(ctx *ui.Context) {
@@ -689,18 +707,15 @@ func (hv *HeaderView) Draw(ctx *ui.Context) {
	size := runewidth.StringWidth(name)
	lim := ctx.Width() - size - 1
	value := runewidth.Truncate(" "+hv.Value, lim, "…")
	var (
		hstyle tcell.Style
		vstyle tcell.Style
	)

	vstyle := hv.uiConfig.GetStyle(config.STYLE_DEFAULT)
	hstyle := hv.uiConfig.GetStyle(config.STYLE_HEADER)

	// TODO: Make this more robust and less dumb
	if hv.Name == "PGP" {
		vstyle = tcell.StyleDefault.Foreground(tcell.ColorGreen)
		hstyle = tcell.StyleDefault.Bold(true)
	} else {
		vstyle = tcell.StyleDefault
		hstyle = tcell.StyleDefault.Bold(true)
		vstyle = hv.uiConfig.GetStyle(config.STYLE_VALID_PGP)
	}

	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', vstyle)
	ctx.Printf(0, 0, hstyle, "%s", name)
	ctx.Printf(size, 0, vstyle, "%s", value)
diff --git a/widgets/pgpinfo.go b/widgets/pgpinfo.go
index dc03cf63f800..1a1becf2b84d 100644
--- a/widgets/pgpinfo.go
+++ b/widgets/pgpinfo.go
@@ -3,40 +3,40 @@ package widgets
import (
	"errors"

	"git.sr.ht/~sircmpwn/aerc/config"
	"git.sr.ht/~sircmpwn/aerc/lib/ui"

	"github.com/gdamore/tcell"
	"golang.org/x/crypto/openpgp"
	pgperrors "golang.org/x/crypto/openpgp/errors"
)

type PGPInfo struct {
	ui.Invalidatable
	details *openpgp.MessageDetails
	details  *openpgp.MessageDetails
	uiConfig config.UIConfig
}

func NewPGPInfo(details *openpgp.MessageDetails) *PGPInfo {
	return &PGPInfo{details: details}
func NewPGPInfo(details *openpgp.MessageDetails, uiConfig config.UIConfig) *PGPInfo {
	return &PGPInfo{details: details, uiConfig: uiConfig}
}

func (p *PGPInfo) DrawSignature(ctx *ui.Context) {
	errorStyle := tcell.StyleDefault.Background(tcell.ColorRed).
		Foreground(tcell.ColorWhite).Bold(true)
	softErrorStyle := tcell.StyleDefault.Foreground(tcell.ColorYellow).Bold(true)
	validStyle := tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true)
	errorStyle := p.uiConfig.GetStyle(config.STYLE_ERROR)
	softErrorStyle := p.uiConfig.GetStyle(config.STYLE_SOFT_ERROR)
	validStyle := p.uiConfig.GetStyle(config.STYLE_VALID_PGP)
	defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)

	// TODO: Nicer prompt for TOFU, fetch from keyserver, etc
	if errors.Is(p.details.SignatureError, pgperrors.ErrUnknownIssuer) ||
		p.details.SignedBy == nil {

		x := ctx.Printf(0, 0, softErrorStyle, "*")
		x += ctx.Printf(x, 0, tcell.StyleDefault,
		x += ctx.Printf(x, 0, defaultStyle,
			" Signed with unknown key (%8X); authenticity unknown",
			p.details.SignedByKeyId)
	} else if p.details.SignatureError != nil {
		x := ctx.Printf(0, 0, errorStyle, "Invalid signature!")
		x += ctx.Printf(x, 0, tcell.StyleDefault.
			Foreground(tcell.ColorRed).Bold(true),
		x += ctx.Printf(x, 0, errorStyle,
			" This message may have been tampered with! (%s)",
			p.details.SignatureError.Error())
	} else {
@@ -47,14 +47,15 @@ func (p *PGPInfo) DrawSignature(ctx *ui.Context) {
			break
		}
		x := ctx.Printf(0, 0, validStyle, "✓ Authentic ")
		x += ctx.Printf(x, 0, tcell.StyleDefault,
		x += ctx.Printf(x, 0, defaultStyle,
			"Signature from %s (%8X)",
			ident.Name, p.details.SignedByKeyId)
	}
}

func (p *PGPInfo) DrawEncryption(ctx *ui.Context, y int) {
	validStyle := tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true)
	validStyle := p.uiConfig.GetStyle(config.STYLE_VALID_PGP)
	defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)
	entity := p.details.DecryptedWith.Entity
	var ident *openpgp.Identity
	// TODO: Pick identity more intelligently
@@ -63,12 +64,13 @@ func (p *PGPInfo) DrawEncryption(ctx *ui.Context, y int) {
	}

	x := ctx.Printf(0, y, validStyle, "✓ Encrypted ")
	x += ctx.Printf(x, y, tcell.StyleDefault,
	x += ctx.Printf(x, y, defaultStyle,
		"To %s (%8X) ", ident.Name, p.details.DecryptedWith.PublicKey.KeyId)
}

func (p *PGPInfo) Draw(ctx *ui.Context) {
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
	defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
	if p.details.IsSigned && p.details.IsEncrypted {
		p.DrawSignature(ctx)
		p.DrawEncryption(ctx, 1)
diff --git a/widgets/selecter.go b/widgets/selecter.go
index 7fae9cda453d..0faf37eafa15 100644
--- a/widgets/selecter.go
+++ b/widgets/selecter.go
@@ -3,24 +3,27 @@ package widgets
import (
	"github.com/gdamore/tcell"

	"git.sr.ht/~sircmpwn/aerc/config"
	"git.sr.ht/~sircmpwn/aerc/lib/ui"
)

type Selecter struct {
	ui.Invalidatable
	chooser bool
	focused bool
	focus   int
	options []string
	chooser  bool
	focused  bool
	focus    int
	options  []string
	uiConfig config.UIConfig

	onChoose func(option string)
	onSelect func(option string)
}

func NewSelecter(options []string, focus int) *Selecter {
func NewSelecter(options []string, focus int, uiConfig config.UIConfig) *Selecter {
	return &Selecter{
		focus:   focus,
		options: options,
		focus:    focus,
		options:  options,
		uiConfig: uiConfig,
	}
}

@@ -34,15 +37,16 @@ func (sel *Selecter) Invalidate() {
}

func (sel *Selecter) Draw(ctx *ui.Context) {
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
		sel.uiConfig.GetStyle(config.STYLE_SELECTER_DEFAULT))
	x := 2
	for i, option := range sel.options {
		style := tcell.StyleDefault
		style := sel.uiConfig.GetStyle(config.STYLE_SELECTER_DEFAULT)
		if sel.focus == i {
			if sel.focused {
				style = style.Reverse(true)
				style = sel.uiConfig.GetStyle(config.STYLE_SELECTER_FOCUSED)
			} else if sel.chooser {
				style = style.Bold(true)
				style = sel.uiConfig.GetStyle(config.STYLE_SELECTER_CHOOSER)
			}
		}
		x += ctx.Printf(x, 1, style, "[%s]", option)
diff --git a/widgets/spinner.go b/widgets/spinner.go
index 51b8c1b09a55..0c7242219f38 100644
--- a/widgets/spinner.go
+++ b/widgets/spinner.go
@@ -16,6 +16,7 @@ type Spinner struct {
	frame  int64 // access via atomic
	frames []string
	stop   chan struct{}
	style  tcell.Style
}

func NewSpinner(uiConf *config.UIConfig) *Spinner {
@@ -23,6 +24,7 @@ func NewSpinner(uiConf *config.UIConfig) *Spinner {
		stop:   make(chan struct{}),
		frame:  -1,
		frames: strings.Split(uiConf.Spinner, uiConf.SpinnerDelimiter),
		style:  uiConf.GetStyle(config.STYLE_SPINNER),
	}
	return &spinner
}
@@ -70,9 +72,9 @@ func (s *Spinner) Draw(ctx *ui.Context) {

	cur := int(atomic.LoadInt64(&s.frame) % int64(len(s.frames)))

	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', s.style)
	col := ctx.Width()/2 - len(s.frames[0])/2 + 1
	ctx.Printf(col, 0, tcell.StyleDefault, "%s", s.frames[cur])
	ctx.Printf(col, 0, s.style, "%s", s.frames[cur])
}

func (s *Spinner) Invalidate() {
-- 
2.26.0
Review patch Export thread (mbox)