init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
#
|
||||
# aerc accounts configuration
|
||||
#
|
||||
# This file configures each of the available mail accounts.
|
||||
|
||||
# You may add an arbitrary number of sections like so:
|
||||
#
|
||||
# [Personal]
|
||||
# source=imaps://username[:password]@hostname[:port]
|
||||
# outgoing=smtps+plain://username[:password]@hostname[:port]
|
||||
# copy-to=Sent
|
||||
# from=Joe Bloe <joe@example.org>
|
||||
#
|
||||
# [Work]
|
||||
# source=imaps://username[:password]@hostname[:port]
|
||||
# outgoing=/usr/bin/sendmail
|
||||
# from=Jane Plain <jane@example.org>
|
||||
# folders=INBOX,Sent,Archives
|
||||
# default=Archives
|
||||
#
|
||||
# Each supported protocol may have some arbitrary number of extra configuration
|
||||
# options. See aerc-[protocol](5) for details (i.e. aerc-imap).
|
||||
@@ -0,0 +1,376 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
var (
|
||||
EnablePinentry func()
|
||||
DisablePinentry func()
|
||||
SetPinentryEnv func(*exec.Cmd)
|
||||
)
|
||||
|
||||
type RemoteConfig struct {
|
||||
Value string
|
||||
PasswordCmd string
|
||||
CacheCmd bool
|
||||
cache string
|
||||
}
|
||||
|
||||
func (c *RemoteConfig) parseValue() (*url.URL, error) {
|
||||
return url.Parse(c.Value)
|
||||
}
|
||||
|
||||
func (c *RemoteConfig) ConnectionString() (string, error) {
|
||||
if c.Value == "" || c.PasswordCmd == "" {
|
||||
return c.Value, nil
|
||||
}
|
||||
|
||||
u, err := c.parseValue()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// ignore the command if a password is specified
|
||||
if _, exists := u.User.Password(); exists {
|
||||
return c.Value, nil
|
||||
}
|
||||
|
||||
// don't attempt to parse the command if the url is a path (ie /usr/bin/sendmail)
|
||||
if !u.IsAbs() {
|
||||
return c.Value, nil
|
||||
}
|
||||
|
||||
pw := c.cache
|
||||
|
||||
if pw == "" {
|
||||
usePinentry := EnablePinentry != nil &&
|
||||
DisablePinentry != nil &&
|
||||
SetPinentryEnv != nil
|
||||
|
||||
cmd := exec.Command("sh", "-c", c.PasswordCmd)
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
cmd.Stderr = buf
|
||||
|
||||
if usePinentry {
|
||||
EnablePinentry()
|
||||
defer DisablePinentry()
|
||||
SetPinentryEnv(cmd)
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read password: %v: %w",
|
||||
buf.String(), err)
|
||||
}
|
||||
pw = strings.TrimSpace(string(output))
|
||||
}
|
||||
u.User = url.UserPassword(u.User.Username(), pw)
|
||||
if c.CacheCmd {
|
||||
c.cache = pw
|
||||
}
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
type AccountConfig struct {
|
||||
Name string
|
||||
Backend string
|
||||
// backend specific
|
||||
Params map[string]string
|
||||
|
||||
Archive string `ini:"archive" default:"Archive"`
|
||||
CopyTo []string `ini:"copy-to" delim:","`
|
||||
CopyToReplied bool `ini:"copy-to-replied" default:"false"`
|
||||
StripBcc bool `ini:"strip-bcc" default:"true"`
|
||||
Default string `ini:"default" default:"INBOX"`
|
||||
Postpone string `ini:"postpone" default:"Drafts"`
|
||||
From *mail.Address `ini:"from"`
|
||||
UseEnvelopeFrom bool `ini:"use-envelope-from" default:"false"`
|
||||
Aliases []*mail.Address `ini:"aliases"`
|
||||
Source string `ini:"source" parse:"ParseSource"`
|
||||
Folders []string `ini:"folders" delim:","`
|
||||
FoldersExclude []string `ini:"folders-exclude" delim:","`
|
||||
Headers []string `ini:"headers" delim:","`
|
||||
HeadersExclude []string `ini:"headers-exclude" delim:","`
|
||||
Outgoing RemoteConfig `ini:"outgoing" parse:"ParseOutgoing"`
|
||||
SignatureFile string `ini:"signature-file"`
|
||||
SignatureCmd string `ini:"signature-cmd"`
|
||||
EnableFoldersSort bool `ini:"enable-folders-sort" default:"true"`
|
||||
FoldersSort []string `ini:"folders-sort" delim:","`
|
||||
AddressBookCmd string `ini:"address-book-cmd"`
|
||||
SendAsUTC bool `ini:"send-as-utc" default:"false"`
|
||||
SendWithHostname bool `ini:"send-with-hostname" default:"false"`
|
||||
LocalizedRe *regexp.Regexp `ini:"subject-re-pattern" default:"(?i)^((AW|RE|SV|VS|ODP|R): ?)+"`
|
||||
|
||||
// CheckMail
|
||||
CheckMail time.Duration `ini:"check-mail"`
|
||||
CheckMailCmd string `ini:"check-mail-cmd"`
|
||||
CheckMailTimeout time.Duration `ini:"check-mail-timeout" default:"10s"`
|
||||
CheckMailInclude []string `ini:"check-mail-include"`
|
||||
CheckMailExclude []string `ini:"check-mail-exclude"`
|
||||
|
||||
// PGP Config
|
||||
PgpKeyId string `ini:"pgp-key-id"`
|
||||
PgpAutoSign bool `ini:"pgp-auto-sign"`
|
||||
PgpAttachKey bool `ini:"pgp-attach-key"`
|
||||
PgpOpportunisticEncrypt bool `ini:"pgp-opportunistic-encrypt"`
|
||||
PgpErrorLevel int `ini:"pgp-error-level" parse:"ParsePgpErrorLevel" default:"warn"`
|
||||
PgpSelfEncrypt bool `ini:"pgp-self-encrypt"`
|
||||
|
||||
// AuthRes
|
||||
TrustedAuthRes []string `ini:"trusted-authres" delim:","`
|
||||
}
|
||||
|
||||
const (
|
||||
PgpErrorLevelNone = iota
|
||||
PgpErrorLevelWarn
|
||||
PgpErrorLevelError
|
||||
)
|
||||
|
||||
var Accounts []*AccountConfig
|
||||
|
||||
func parseAccountsFromFile(root string, accts []string, filename string) error {
|
||||
log.Debugf("Parsing accounts configuration from %s", filename)
|
||||
|
||||
file, err := ini.LoadSources(ini.LoadOptions{
|
||||
KeyValueDelimiters: "=",
|
||||
}, filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
starttls_warned := false
|
||||
var globals *ini.Section
|
||||
for _, _sec := range file.SectionStrings() {
|
||||
if _sec == "DEFAULT" {
|
||||
globals = file.Section(_sec)
|
||||
continue
|
||||
}
|
||||
if len(accts) > 0 && !contains(accts, _sec) {
|
||||
continue
|
||||
}
|
||||
sec := file.Section(_sec)
|
||||
for key, val := range globals.KeysHash() {
|
||||
if !sec.HasKey(key) {
|
||||
_, _ = sec.NewKey(key, val)
|
||||
}
|
||||
}
|
||||
|
||||
account, err := ParseAccountConfig(_sec, sec)
|
||||
if err != nil {
|
||||
log.Errorf("failed to load account [%s]: %s", _sec, err)
|
||||
Warnings = append(Warnings, Warning{
|
||||
Title: "accounts.conf: error",
|
||||
Body: fmt.Sprintf(
|
||||
"Failed to load account [%s]:\n\n%s",
|
||||
_sec, err,
|
||||
),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if _, ok := account.Params["smtp-starttls"]; ok && !starttls_warned {
|
||||
Warnings = append(Warnings, Warning{
|
||||
Title: "accounts.conf: smtp-starttls is deprecated",
|
||||
Body: `
|
||||
SMTP connections now use STARTTLS by default and the smtp-starttls setting is ignored.
|
||||
|
||||
If you want to disable STARTTLS, append +insecure to the schema.
|
||||
`,
|
||||
})
|
||||
starttls_warned = true
|
||||
}
|
||||
|
||||
log.Debugf("accounts.conf: [%s] from = %s", account.Name, account.From)
|
||||
Accounts = append(Accounts, account)
|
||||
}
|
||||
if len(accts) > 0 {
|
||||
// Sort accounts struct to match the specified order, if we
|
||||
// have one
|
||||
var acctnames []string
|
||||
for _, acc := range Accounts {
|
||||
acctnames = append(acctnames, acc.Name)
|
||||
}
|
||||
var sortaccts []string
|
||||
for _, acc := range accts {
|
||||
if contains(acctnames, acc) {
|
||||
sortaccts = append(sortaccts, acc)
|
||||
} else {
|
||||
log.Errorf("account [%s] not found", acc)
|
||||
}
|
||||
}
|
||||
|
||||
idx := make(map[string]int)
|
||||
for i, acct := range sortaccts {
|
||||
idx[acct] = i
|
||||
}
|
||||
sort.Slice(Accounts, func(i, j int) bool {
|
||||
return idx[Accounts[i].Name] < idx[Accounts[j].Name]
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseAccounts(root string, accts []string, filename string) error {
|
||||
if filename == "" {
|
||||
filename = path.Join(root, "accounts.conf")
|
||||
err := checkConfigPerms(filename)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// No config triggers account configuration wizard
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := parseAccountsFromFile(root, accts, filename); err != nil {
|
||||
return fmt.Errorf("%s: %w", filename, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseAccountConfig(name string, section *ini.Section) (*AccountConfig, error) {
|
||||
account := AccountConfig{
|
||||
Name: name,
|
||||
Params: make(map[string]string),
|
||||
}
|
||||
if err := MapToStruct(section, &account, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, val := range section.KeysHash() {
|
||||
backendSpecific := true
|
||||
typ := reflect.TypeOf(account)
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
if field.Tag.Get("ini") == key {
|
||||
backendSpecific = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if backendSpecific {
|
||||
account.Params[key] = val
|
||||
}
|
||||
}
|
||||
if account.Source == "" {
|
||||
return nil, fmt.Errorf("missing 'source' parameter")
|
||||
}
|
||||
|
||||
account.Backend = parseBackend(account.Source)
|
||||
if account.From == nil {
|
||||
return nil, fmt.Errorf("missing 'from' parameter")
|
||||
}
|
||||
if len(account.Headers) > 0 {
|
||||
defaults := []string{
|
||||
"date",
|
||||
"subject",
|
||||
"from",
|
||||
"sender",
|
||||
"reply-to",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"in-reply-to",
|
||||
"message-id",
|
||||
"references",
|
||||
}
|
||||
account.Headers = append(account.Headers, defaults...)
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func parseBackend(source string) string {
|
||||
u, err := url.Parse(source)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(u.Scheme, "imap") {
|
||||
return "imap"
|
||||
}
|
||||
if strings.HasPrefix(u.Scheme, "maildir") {
|
||||
return "maildir"
|
||||
}
|
||||
if strings.HasPrefix(u.Scheme, "jmap") {
|
||||
return "jmap"
|
||||
}
|
||||
return u.Scheme
|
||||
}
|
||||
|
||||
func (a *AccountConfig) ParseSource(sec *ini.Section, key *ini.Key) (string, error) {
|
||||
var remote RemoteConfig
|
||||
remote.Value = key.String()
|
||||
if k, err := sec.GetKey("source-cred-cmd"); err == nil {
|
||||
remote.PasswordCmd = k.String()
|
||||
}
|
||||
return remote.ConnectionString()
|
||||
}
|
||||
|
||||
func (a *AccountConfig) ParseOutgoing(sec *ini.Section, key *ini.Key) (RemoteConfig, error) {
|
||||
var remote RemoteConfig
|
||||
remote.Value = key.String()
|
||||
if k, err := sec.GetKey("outgoing-cred-cmd"); err == nil {
|
||||
remote.PasswordCmd = k.String()
|
||||
}
|
||||
if k, err := sec.GetKey("outgoing-cred-cmd-cache"); err == nil {
|
||||
cache, err := k.Bool()
|
||||
if err != nil {
|
||||
return remote, err
|
||||
}
|
||||
remote.CacheCmd = cache
|
||||
}
|
||||
_, err := remote.parseValue()
|
||||
return remote, err
|
||||
}
|
||||
|
||||
func (a *AccountConfig) ParsePgpErrorLevel(sec *ini.Section, key *ini.Key) (int, error) {
|
||||
var level int
|
||||
var err error
|
||||
switch strings.ToLower(key.String()) {
|
||||
case "none":
|
||||
level = PgpErrorLevelNone
|
||||
case "warn":
|
||||
level = PgpErrorLevelWarn
|
||||
case "error":
|
||||
level = PgpErrorLevelError
|
||||
default:
|
||||
err = fmt.Errorf("unknown level: %s", key.String())
|
||||
}
|
||||
return level, err
|
||||
}
|
||||
|
||||
// checkConfigPerms checks for too open permissions
|
||||
// printing the fix on stdout and returning an error
|
||||
func checkConfigPerms(filename string) error {
|
||||
info, err := os.Stat(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
perms := info.Mode().Perm()
|
||||
if perms&0o44 != 0 && !General.UnsafeAccountsConf {
|
||||
// group or others have read access
|
||||
fmt.Fprintf(os.Stderr, "The file %v has too open permissions.\n", filename)
|
||||
fmt.Fprintln(os.Stderr, "This is a security issue (it contains passwords).")
|
||||
fmt.Fprintf(os.Stderr, "To fix it, run `chmod 600 %v`\n", filename)
|
||||
return errors.New("account.conf permissions too lax")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
#
|
||||
# aerc main configuration
|
||||
|
||||
[general]
|
||||
#
|
||||
# Used as a default path for save operations if no other path is specified.
|
||||
# ~ is expanded to the current user home dir.
|
||||
#
|
||||
#default-save-path=
|
||||
|
||||
# If set to "gpg", aerc will use system gpg binary and keystore for all crypto
|
||||
# operations. If set to "internal", the internal openpgp keyring will be used.
|
||||
# If set to "auto", the system gpg will be preferred unless the internal
|
||||
# keyring already exists, in which case the latter will be used.
|
||||
#
|
||||
# Default: auto
|
||||
#pgp-provider=auto
|
||||
|
||||
# By default, the file permissions of accounts.conf must be restrictive and
|
||||
# only allow reading by the file owner (0600). Set this option to true to
|
||||
# ignore this permission check. Use this with care as it may expose your
|
||||
# credentials.
|
||||
#
|
||||
# Default: false
|
||||
#unsafe-accounts-conf=false
|
||||
|
||||
# Output log messages to specified file. A path starting with ~/ is expanded to
|
||||
# the user home dir. When redirecting aerc's output to a file using > shell
|
||||
# redirection, this setting is ignored and log messages are printed to stdout.
|
||||
#
|
||||
#log-file=
|
||||
|
||||
# Only log messages above the specified level to log-file. Supported levels
|
||||
# are: trace, debug, info, warn and error. When redirecting aerc's output to
|
||||
# a file using > shell redirection, this setting is ignored and the log level
|
||||
# is forced to trace.
|
||||
#
|
||||
# Default: info
|
||||
#log-level=info
|
||||
|
||||
# Disable IPC entirely. Don't run commands (including mailto:... and mbox:...)
|
||||
# in an existing aerc instance, and don't start an IPC server to allow
|
||||
# subsequent aerc instances to run commands in the current one.
|
||||
#
|
||||
# Default: false
|
||||
#disable-ipc=false
|
||||
|
||||
# Don't run mailto:... commands over IPC; start a new aerc instance with the
|
||||
# composer instead.
|
||||
#
|
||||
# Default: false
|
||||
#disable-ipc-mailto=false
|
||||
#
|
||||
# Don't run mbox:... commands over IPC; start a new aerc instance with the mbox
|
||||
# file instead.
|
||||
#
|
||||
# Default: false
|
||||
#disable-ipc-mbox=false
|
||||
|
||||
# Set the $TERM environment variable used for the embedded terminal.
|
||||
#
|
||||
# Default: xterm-256color
|
||||
#term=xterm-256color
|
||||
|
||||
# Display OSC8 strings in the embedded terminal
|
||||
#
|
||||
# Default: false
|
||||
#enable-osc8=false
|
||||
|
||||
# Default shell command to use for :menu. This will be executed with sh -c and
|
||||
# will run in an popover dialog.
|
||||
#
|
||||
# Any occurrence of %f will be replaced by a temporary file path where the
|
||||
# command is expected to write output lines to be consumed by :menu. Otherwise,
|
||||
# the lines will be read from the command's standard output.
|
||||
#
|
||||
# Examples:
|
||||
# default-menu-cmd=fzf
|
||||
# default-menu-cmd=fzf --multi
|
||||
# default-menu-cmd=dmenu -l 20
|
||||
# default-menu-cmd=ranger --choosefiles=%f
|
||||
#
|
||||
#default-menu-cmd=
|
||||
|
||||
[ui]
|
||||
#
|
||||
# Describes the format for each row in a mailbox view. This is a comma
|
||||
# separated list of column names with an optional align and width suffix. After
|
||||
# the column name, one of the '<' (left), ':' (center) or '>' (right) alignment
|
||||
# characters can be added (by default, left) followed by an optional width
|
||||
# specifier. The width is either an integer representing a fixed number of
|
||||
# characters, or a percentage between 1% and 99% representing a fraction of the
|
||||
# terminal width. It can also be one of the '*' (auto) or '=' (fit) special
|
||||
# width specifiers. Auto width columns will be equally attributed the remaining
|
||||
# terminal width. Fit width columns take the width of their contents. If no
|
||||
# width specifier is set, '*' is used by default.
|
||||
#
|
||||
# Default: flags:4,name<20%,subject,date>=
|
||||
#index-columns=flags:4,name<20%,subject,date>=
|
||||
|
||||
#
|
||||
# Each name in index-columns must have a corresponding column-$name setting.
|
||||
# All column-$name settings accept golang text/template syntax. See
|
||||
# aerc-templates(7) for available template attributes and functions.
|
||||
#
|
||||
# Here are some examples to show the To field instead of the From field for
|
||||
# an email (modifying column-name):
|
||||
#
|
||||
# 1. a generic one
|
||||
# column-name={{ .Peer | names | join ", " }}
|
||||
# 2. based upon the selected folder
|
||||
# column-name={{if match .Folder "^(Gesendet|Sent)$"}}{{index (.To | names) 0}}{{else}}{{index (.From | names) 0}}{{end}}
|
||||
#
|
||||
# Default settings
|
||||
#column-flags={{.Flags | join ""}}
|
||||
#column-name={{index (.From | names) 0}}
|
||||
#column-subject={{.ThreadPrefix}}{{.Subject}}
|
||||
#column-date={{.DateAutoFormat .Date.Local}}
|
||||
|
||||
#
|
||||
# String separator inserted between columns. When the column width specifier is
|
||||
# an exact number of characters, the separator is added to it (i.e. the exact
|
||||
# width will be fully available for the column contents).
|
||||
#
|
||||
# Default: " "
|
||||
#column-separator=" "
|
||||
|
||||
#
|
||||
# See time.Time#Format at https://godoc.org/time#Time.Format
|
||||
#
|
||||
# Default: 2006 Jan 02
|
||||
#timestamp-format=2006 Jan 02
|
||||
|
||||
#
|
||||
# Index-only time format for messages that were received/sent today.
|
||||
# If this is empty, timestamp-format is used instead.
|
||||
#
|
||||
# Default: 15:04
|
||||
#this-day-time-format=15:04
|
||||
|
||||
#
|
||||
# Index-only time format for messages that were received/sent within the last
|
||||
# 7 days. If this is empty, timestamp-format is used instead.
|
||||
#
|
||||
# Default: Jan 02
|
||||
#this-week-time-format=Jan 02
|
||||
|
||||
#
|
||||
# Index-only time format for messages that were received/sent this year.
|
||||
# If this is empty, timestamp-format is used instead.
|
||||
#
|
||||
#Default: Jan 02
|
||||
#this-year-time-format=Jan 02
|
||||
|
||||
#
|
||||
# Overrides timestamp-format for the message view.
|
||||
#
|
||||
# Default: 2006 Jan 02, 15:04 GMT-0700
|
||||
#message-view-timestamp-format=2006 Jan 02, 15:04 GMT-0700
|
||||
|
||||
#
|
||||
# If set, overrides timestamp-format in the message view for messages
|
||||
# that were received/sent today.
|
||||
#
|
||||
#message-view-this-day-time-format=
|
||||
|
||||
# If set, overrides timestamp-format in the message view for messages
|
||||
# that were received/sent within the last 7 days.
|
||||
#
|
||||
#message-view-this-week-time-format=
|
||||
|
||||
#
|
||||
# If set, overrides *timestamp-format* in the message view for messages
|
||||
# that were received/sent this year.
|
||||
#
|
||||
#message-view-this-year-time-format=
|
||||
|
||||
#
|
||||
# Width of the sidebar, including the border.
|
||||
#
|
||||
# Default: 22
|
||||
#sidebar-width=22
|
||||
|
||||
#
|
||||
# Default split layout for message list tabs. The syntax is:
|
||||
#
|
||||
# [<direction>] <size>
|
||||
#
|
||||
# <direction> is optional and defaults to horizontal. It can take one
|
||||
# of the following values: h, horiz, horizontal, v, vert, vertical.
|
||||
#
|
||||
# <size> is a positive integer representing the size (in terminal cells)
|
||||
# of the message list window.
|
||||
#
|
||||
#message-list-split=
|
||||
|
||||
#
|
||||
# Message to display when viewing an empty folder.
|
||||
#
|
||||
# Default: (no messages)
|
||||
#empty-message=(no messages)
|
||||
|
||||
# Message to display when no folders exists or are all filtered
|
||||
#
|
||||
# Default: (no folders)
|
||||
#empty-dirlist=(no folders)
|
||||
#
|
||||
# Value to set {{.Subject}} template to when subject is empty.
|
||||
#
|
||||
# Default: (no subject)
|
||||
#empty-subject=(no subject)
|
||||
|
||||
# Enable mouse events in the ui, e.g. clicking and scrolling with the mousewheel
|
||||
#
|
||||
# Default: false
|
||||
#mouse-enabled=false
|
||||
|
||||
#
|
||||
# Ring the bell when new messages are received
|
||||
#
|
||||
# Default: true
|
||||
#new-message-bell=true
|
||||
|
||||
#
|
||||
# Template to use for Account tab titles
|
||||
#
|
||||
# Default: {{.Account}}
|
||||
#tab-title-account={{.Account}}
|
||||
|
||||
#
|
||||
# Template to use for Composer tab titles
|
||||
#
|
||||
# Default: {{if .To}}to:{{index (.To | shortmboxes) 0}} {{end}}{{.SubjectBase}}
|
||||
#tab-title-composer={{if .To}}to:{{index (.To | shortmboxes) 0}} {{end}}{{.SubjectBase}}
|
||||
|
||||
#
|
||||
# Template to use for Message Viewer tab titles
|
||||
#
|
||||
# Default: {{.Subject}}
|
||||
#tab-title-viewer={{.Subject}}
|
||||
|
||||
|
||||
# Marker to show before a pinned tab's name.
|
||||
#
|
||||
# Default: `
|
||||
#pinned-tab-marker='`'
|
||||
|
||||
# Template for the left side of the directory list.
|
||||
# See aerc-templates(7) for all available fields and functions.
|
||||
#
|
||||
# Default: {{.Folder}}
|
||||
#dirlist-left={{.Folder}}
|
||||
|
||||
# Template for the right side of the directory list.
|
||||
# See aerc-templates(7) for all available fields and functions.
|
||||
#
|
||||
# Default: {{if .Unread}}{{humanReadable .Unread}}{{end}}
|
||||
#dirlist-right={{if .Unread}}{{humanReadable .Unread}}{{end}}
|
||||
|
||||
# Delay after which the messages are actually listed when entering a directory.
|
||||
# This avoids loading messages when skipping over folders and makes the UI more
|
||||
# responsive. If you do not want that, set it to 0s.
|
||||
#
|
||||
# Default: 200ms
|
||||
#dirlist-delay=200ms
|
||||
|
||||
# Display the directory list as a foldable tree that allows to collapse and
|
||||
# expand the folders.
|
||||
#
|
||||
# Default: false
|
||||
#dirlist-tree=false
|
||||
|
||||
# If dirlist-tree is enabled, set level at which folders are collapsed by
|
||||
# default. Set to 0 to disable.
|
||||
#
|
||||
# Default: 0
|
||||
#dirlist-collapse=0
|
||||
|
||||
# List of space-separated criteria to sort the messages by, see *sort*
|
||||
# command in *aerc*(1) for reference. Prefixing a criterion with "-r "
|
||||
# reverses that criterion.
|
||||
#
|
||||
# Example: "from -r date"
|
||||
#
|
||||
#sort=
|
||||
|
||||
# Moves to next message when the current message is deleted
|
||||
#
|
||||
# Default: true
|
||||
#next-message-on-delete=true
|
||||
|
||||
# Automatically set the "seen" flag when a message is opened in the message
|
||||
# viewer.
|
||||
#
|
||||
# Default: true
|
||||
#auto-mark-read=true
|
||||
|
||||
# The directories where the stylesets are stored. It takes a colon-separated
|
||||
# list of directories. If this is unset or if a styleset cannot be found, the
|
||||
# following paths will be used as a fallback in that order:
|
||||
#
|
||||
# ${XDG_CONFIG_HOME:-~/.config}/aerc/stylesets
|
||||
# ${XDG_DATA_HOME:-~/.local/share}/aerc/stylesets
|
||||
# /usr/local/share/aerc/stylesets
|
||||
# /usr/share/aerc/stylesets
|
||||
#
|
||||
#stylesets-dirs=
|
||||
|
||||
# Uncomment to use box-drawing characters for vertical and horizontal borders.
|
||||
#
|
||||
# Default: "│" and "─"
|
||||
#border-char-vertical="│"
|
||||
#border-char-horizontal="─"
|
||||
|
||||
# Sets the styleset to use for the aerc ui elements.
|
||||
#
|
||||
# Default: default
|
||||
#styleset-name=default
|
||||
|
||||
# Activates fuzzy search in commands and their arguments: the typed string is
|
||||
# searched in the command or option in any position, and need not be
|
||||
# consecutive characters in the command or option.
|
||||
#
|
||||
# Default: false
|
||||
#fuzzy-complete=false
|
||||
|
||||
# How long to wait after the last input before auto-completion is triggered.
|
||||
#
|
||||
# Default: 250ms
|
||||
#completion-delay=250ms
|
||||
|
||||
# The minimum required characters to allow auto-completion to be triggered after
|
||||
# completion-delay.
|
||||
#
|
||||
# Setting this to "manual" disables automatic completion, leaving only the
|
||||
# manually triggered completion with the $complete key (see aerc-binds(5) for
|
||||
# more details).
|
||||
#
|
||||
# Default: 1
|
||||
#completion-min-chars=1
|
||||
|
||||
#
|
||||
# Global switch for completion popovers
|
||||
#
|
||||
# Default: true
|
||||
#completion-popovers=true
|
||||
|
||||
# Uncomment to use UTF-8 symbols to indicate PGP status of messages
|
||||
#
|
||||
# Default: ASCII
|
||||
#icon-unencrypted=
|
||||
#icon-encrypted=✔
|
||||
#icon-signed=✔
|
||||
#icon-signed-encrypted=✔
|
||||
#icon-unknown=✘
|
||||
#icon-invalid=⚠
|
||||
|
||||
# Reverses the order of the message list. By default, the message list is
|
||||
# ordered with the newest (highest UID) message on top. Reversing the order
|
||||
# will put the oldest (lowest UID) message on top. This can be useful in cases
|
||||
# where the backend does not support sorting.
|
||||
#
|
||||
# Default: false
|
||||
#reverse-msglist-order = false
|
||||
|
||||
# Reverse display of the message threads. Default order is the initial
|
||||
# message is on the top with all the replies being displayed below. The
|
||||
# reverse option will put the initial message at the bottom with the
|
||||
# replies on top.
|
||||
#
|
||||
# Default: false
|
||||
#reverse-thread-order=false
|
||||
|
||||
# Positions the cursor on the last message in the message list (at the
|
||||
# bottom of the view) when opening a new folder.
|
||||
#
|
||||
# Default: false
|
||||
#select-last-message=false
|
||||
|
||||
# Sort the thread siblings according to the sort criteria for the messages. If
|
||||
# sort-thread-siblings is false, the thread siblings will be sorted based on
|
||||
# the message UID in ascending order. This option is only applicable for
|
||||
# client-side threading with a backend that enables sorting. Note that there's
|
||||
# a performance impact when sorting is activated.
|
||||
#
|
||||
# Default: false
|
||||
#sort-thread-siblings=false
|
||||
|
||||
# Set the scroll offset in number of lines from the top and bottom of the
|
||||
# message list.
|
||||
#
|
||||
# Default: 0
|
||||
#msglist-scroll-offset = 0
|
||||
|
||||
#
|
||||
# Enable a threaded view of messages. If this is not supported by the backend
|
||||
# (IMAP server or notmuch), threads will be built by the client.
|
||||
#
|
||||
# Default: false
|
||||
#threading-enabled=false
|
||||
|
||||
# Force client-side thread building
|
||||
#
|
||||
# Default: false
|
||||
#force-client-threads=false
|
||||
|
||||
# If no References nor In-Reply-To headers can be matched to build client side
|
||||
# threads, fallback to similar subjects.
|
||||
#
|
||||
# Default: false
|
||||
#threading-by-subject=false
|
||||
|
||||
# Show thread context enables messages which do not match the current query (or
|
||||
# belong to the current mailbox) to be shown for context. These messages can be
|
||||
# styled separately using "msglist_thread_context" in a styleset. This feature
|
||||
# is not supported by all backends
|
||||
#
|
||||
# Default: false
|
||||
#show-thread-context=false
|
||||
|
||||
# Debounce client-side thread building
|
||||
#
|
||||
# Default: 50ms
|
||||
#client-threads-delay=50ms
|
||||
|
||||
#
|
||||
# Thread prefix customization:
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the arrow head.
|
||||
#
|
||||
# Default: ">"
|
||||
#thread-prefix-tip = ">"
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the arrow indentation.
|
||||
#
|
||||
# Default: " "
|
||||
#thread-prefix-indent = " "
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the vertical extension of
|
||||
# the arrow.
|
||||
#
|
||||
# Default: "│"
|
||||
#thread-prefix-stem = "│"
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the horizontal extension
|
||||
# of the arrow.
|
||||
#
|
||||
# Default: ""
|
||||
#thread-prefix-limb = ""
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the folded thread
|
||||
# indicator.
|
||||
#
|
||||
# Default: "+"
|
||||
#thread-prefix-folded = "+"
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the unfolded thread
|
||||
# indicator.
|
||||
#
|
||||
# Default: ""
|
||||
#thread-prefix-unfolded = ""
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the first child connector.
|
||||
#
|
||||
# Default: ""
|
||||
#thread-prefix-first-child = ""
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the connector used if
|
||||
# the message has siblings.
|
||||
#
|
||||
# Default: "├─"
|
||||
#thread-prefix-has-siblings = "├─"
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the connector used if the
|
||||
# message has no parents and no children.
|
||||
#
|
||||
# Default: ""
|
||||
#thread-prefix-lone = ""
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the connector used if the
|
||||
# message has no parents and has children.
|
||||
#
|
||||
# Default: ""
|
||||
#thread-prefix-orphan = ""
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the connector for the last
|
||||
# sibling.
|
||||
#
|
||||
# Default: "└─"
|
||||
#thread-prefix-last-sibling = "└─"
|
||||
|
||||
#
|
||||
# Customize the reversed thread prefix appearance by selecting the connector for
|
||||
# the last sibling.
|
||||
#
|
||||
# Default: "┌─"
|
||||
#thread-prefix-last-sibling-reverse = "┌─"
|
||||
|
||||
#
|
||||
# Customize the thread prefix appearance by selecting the connector for dummy
|
||||
# thread.
|
||||
#
|
||||
# Default: "┬─"
|
||||
#thread-prefix-dummy = "┬─"
|
||||
|
||||
#
|
||||
# Customize the reversed thread prefix appearance by selecting the connector for
|
||||
# dummy thread.
|
||||
#
|
||||
# Default: "┴─"
|
||||
#thread-prefix-dummy-reverse = "┴─"
|
||||
|
||||
#
|
||||
# Customize the reversed thread prefix appearance by selecting the first child
|
||||
# connector.
|
||||
#
|
||||
# Default: ""
|
||||
#thread-prefix-first-child-reverse = ""
|
||||
|
||||
#
|
||||
# Customize the reversed thread prefix appearance by selecting the connector
|
||||
# used if the message has no parents and has children.
|
||||
#
|
||||
# Default: ""
|
||||
#thread-prefix-orphan-reverse = ""
|
||||
|
||||
[statusline]
|
||||
#
|
||||
# Describes the format for the status line. This is a comma separated list of
|
||||
# column names with an optional align and width suffix. See [ui].index-columns
|
||||
# for more details. To completely mute the status line except for push
|
||||
# notifications, explicitly set status-columns to an empty string.
|
||||
#
|
||||
# Default: left<*,center:=,right>*
|
||||
#status-columns=left<*,center:=,right>*
|
||||
|
||||
#
|
||||
# Each name in status-columns must have a corresponding column-$name setting.
|
||||
# All column-$name settings accept golang text/template syntax. See
|
||||
# aerc-templates(7) for available template attributes and functions.
|
||||
#
|
||||
# Default settings
|
||||
#column-left=[{{.Account}}] {{.StatusInfo}}
|
||||
#column-center={{.PendingKeys}}
|
||||
#column-right={{.TrayInfo}} | {{cwd}}
|
||||
|
||||
#
|
||||
# String separator inserted between columns.
|
||||
# See [ui].column-separator for more details.
|
||||
#
|
||||
#column-separator=" "
|
||||
|
||||
# Specifies the separator between grouped statusline elements.
|
||||
#
|
||||
# Default: " | "
|
||||
#separator=" | "
|
||||
|
||||
# Defines the mode for displaying the status elements.
|
||||
# Options: text, icon
|
||||
#
|
||||
# Default: text
|
||||
#display-mode=text
|
||||
|
||||
[viewer]
|
||||
#
|
||||
# Specifies the pager to use when displaying emails. Note that some filters
|
||||
# may add ANSI codes to add color to rendered emails, so you may want to use a
|
||||
# pager which supports ANSI codes.
|
||||
#
|
||||
# Default: less -Rc
|
||||
#pager=less -Rc
|
||||
|
||||
#
|
||||
# If an email offers several versions (multipart), you can configure which
|
||||
# mimetype to prefer. For example, this can be used to prefer plaintext over
|
||||
# html emails.
|
||||
#
|
||||
# Default: text/plain,text/html
|
||||
#alternatives=text/plain,text/html
|
||||
|
||||
#
|
||||
# Default setting to determine whether to show full headers or only parsed
|
||||
# ones in message viewer.
|
||||
#
|
||||
# Default: false
|
||||
#show-headers=false
|
||||
|
||||
#
|
||||
# Layout of headers when viewing a message. To display multiple headers in the
|
||||
# same row, separate them with a pipe, e.g. "From|To". Rows will be hidden if
|
||||
# none of their specified headers are present in the message.
|
||||
#
|
||||
# Default: From|To,Cc|Bcc,Date,Subject
|
||||
#header-layout=From|To,Cc|Bcc,Date,Subject
|
||||
|
||||
# Whether to always show the mimetype of an email, even when it is just a single part
|
||||
#
|
||||
# Default: false
|
||||
#always-show-mime=false
|
||||
|
||||
# Define the maximum height of the mimetype switcher before a scrollbar is used.
|
||||
# The height of the mimetype switcher is restricted to half of the display
|
||||
# height. If the provided value for the height is zero, the number of parts will
|
||||
# be used as the height of the type switcher.
|
||||
#
|
||||
# Default: 0
|
||||
#max-mime-height = 0
|
||||
|
||||
# Parses and extracts http links when viewing a message. Links can then be
|
||||
# accessed with the open-link command.
|
||||
#
|
||||
# Default: true
|
||||
#parse-http-links=true
|
||||
|
||||
[compose]
|
||||
#
|
||||
# Specifies the command to run the editor with. It will be shown in an embedded
|
||||
# terminal, though it may also launch a graphical window if the environment
|
||||
# supports it. Defaults to $EDITOR, or vi.
|
||||
#editor=
|
||||
|
||||
#
|
||||
# When set, aerc will create and read .eml files for composing that have
|
||||
# non-standard \n linebreaks. This is only relevant if the used editor does not
|
||||
# support CRLF linebreaks.
|
||||
#
|
||||
#lf-editor=false
|
||||
|
||||
#
|
||||
# Default header fields to display when composing a message. To display
|
||||
# multiple headers in the same row, separate them with a pipe, e.g. "To|From".
|
||||
#
|
||||
# Default: To|From,Subject
|
||||
#header-layout=To|From,Subject
|
||||
|
||||
#
|
||||
# Edit headers into the text editor instead than separate fields.
|
||||
#
|
||||
# When this is true, address-book-cmd is not supported and address completion
|
||||
# is left to the editor itself. Also, displaying multiple headers on the same
|
||||
# line is not possible.
|
||||
#
|
||||
# Default: false
|
||||
#edit-headers=false
|
||||
|
||||
#
|
||||
# Sets focus to the email body when the composer window opens.
|
||||
#
|
||||
# Default: false
|
||||
#focus-body=false
|
||||
|
||||
#
|
||||
# Specifies the command to be used to tab-complete email addresses. Any
|
||||
# occurrence of "%s" in the address-book-cmd will be replaced with what the
|
||||
# user has typed so far.
|
||||
#
|
||||
# The command must output the completions to standard output, one completion
|
||||
# per line. Each line must be tab-delimited, with an email address occurring as
|
||||
# the first field. Only the email address field is required. The second field,
|
||||
# if present, will be treated as the contact name. Additional fields are
|
||||
# ignored.
|
||||
#
|
||||
# This parameter can also be set per account in accounts.conf.
|
||||
#address-book-cmd=
|
||||
|
||||
# Specifies the command to be used to select attachments. Any occurrence of
|
||||
# '%s' in the file-picker-cmd will be replaced with the argument <arg>
|
||||
# to :attach -m <arg>. Any occurrence of '%f' will be replaced by the
|
||||
# location of a temporary file, from which aerc will read the selected files.
|
||||
#
|
||||
# If '%f' is not present, the command must output the selected files to
|
||||
# standard output, one file per line. If it is present, then aerc does not
|
||||
# capture the standard output and instead reads the files from the temporary
|
||||
# file which should have the same format.
|
||||
#file-picker-cmd=
|
||||
|
||||
#
|
||||
# Allow to address yourself when replying
|
||||
#
|
||||
# Default: true
|
||||
#reply-to-self=true
|
||||
|
||||
# Warn before sending an email with an empty subject.
|
||||
#
|
||||
# Default: false
|
||||
#empty-subject-warning=false
|
||||
|
||||
#
|
||||
# Warn before sending an email that matches the specified regexp but does not
|
||||
# have any attachments. Leave empty to disable this feature.
|
||||
#
|
||||
# Uses Go's regexp syntax, documented at https://golang.org/s/re2syntax. The
|
||||
# "(?im)" flags are set by default (case-insensitive and multi-line).
|
||||
#
|
||||
# Example:
|
||||
# no-attachment-warning=^[^>]*attach(ed|ment)
|
||||
#
|
||||
#no-attachment-warning=
|
||||
|
||||
#
|
||||
# When set, aerc will generate "format=flowed" bodies with a content type of
|
||||
# "text/plain; format=flowed" as described in RFC3676. This format is easier to
|
||||
# handle for some mailing software, and generally just looks like ordinary
|
||||
# text. To actually make use of this format's features, you'll need support in
|
||||
# your editor.
|
||||
#
|
||||
#format-flowed=false
|
||||
|
||||
[multipart-converters]
|
||||
#
|
||||
# Converters allow to generate multipart/alternative messages by converting the
|
||||
# main text/plain part into any other MIME type. Only exact MIME types are
|
||||
# accepted. The commands are invoked with sh -c and are expected to output
|
||||
# valid UTF-8 text.
|
||||
#
|
||||
# Example (obviously, this requires that you write your main text/plain body
|
||||
# using the markdown syntax):
|
||||
#text/html=pandoc -f markdown -t html --standalone
|
||||
|
||||
[filters]
|
||||
#
|
||||
# Filters allow you to pipe an email body through a shell command to render
|
||||
# certain emails differently, e.g. highlighting them with ANSI escape codes.
|
||||
#
|
||||
# The commands are invoked with sh -c. The following folders are prepended to
|
||||
# the system $PATH to allow referencing filters from their name only:
|
||||
#
|
||||
# ${XDG_CONFIG_HOME:-~/.config}/aerc/filters
|
||||
# ~/.local/libexec/aerc/filters
|
||||
# ${XDG_DATA_HOME:-~/.local/share}/aerc/filters
|
||||
# $PREFIX/libexec/aerc/filters
|
||||
# $PREFIX/share/aerc/filters
|
||||
# /usr/libexec/aerc/filters
|
||||
# /usr/share/aerc/filters
|
||||
#
|
||||
# If you want to run a program in your default $PATH which has the same name
|
||||
# as a builtin filter (e.g. /usr/bin/colorize), use its absolute path.
|
||||
#
|
||||
# The following variables are defined in the filter command environment:
|
||||
#
|
||||
# AERC_MIME_TYPE the part MIME type/subtype
|
||||
# AERC_FORMAT the part content type format= parameter
|
||||
# AERC_FILENAME the attachment filename (if any)
|
||||
# AERC_SUBJECT the message Subject header value
|
||||
# AERC_FROM the message From header value
|
||||
#
|
||||
# The first filter which matches the email's mimetype will be used, so order
|
||||
# them from most to least specific.
|
||||
#
|
||||
# You can also match on non-mimetypes, by prefixing with the header to match
|
||||
# against (non-case-sensitive) and a comma, e.g. subject,text will match a
|
||||
# subject which contains "text". Use header,~regex to match against a regex.
|
||||
#
|
||||
text/plain=colorize
|
||||
text/calendar=calendar
|
||||
message/delivery-status=colorize
|
||||
message/rfc822=colorize
|
||||
#text/html=pandoc -f html -t plain | colorize
|
||||
text/html=! html
|
||||
#text/html=! w3m -T text/html -I UTF-8
|
||||
#text/*=bat -fP --file-name="$AERC_FILENAME"
|
||||
#application/x-sh=bat -fP -l sh
|
||||
#image/*=catimg -w $(tput cols) -
|
||||
#subject,~Git(hub|lab)=lolcat -f
|
||||
#from,thatguywhodoesnothardwraphismessages=wrap -w 100 | colorize
|
||||
|
||||
# This special filter is only used to post-process email headers when
|
||||
# [viewer].show-headers=true
|
||||
# By default, headers are piped directly into the pager.
|
||||
#
|
||||
.headers=colorize
|
||||
|
||||
[openers]
|
||||
#
|
||||
# Openers allow you to specify the command to use for the :open and :open-link
|
||||
# actions on a per-MIME-type basis. The :open-link URL scheme is used to
|
||||
# determine the MIME type as follows: x-scheme-handler/<scheme>.
|
||||
#
|
||||
# {} is expanded as the temporary filename or URL to be opened with proper
|
||||
# shell quoting. If it is not encountered in the command, the filename/URL will
|
||||
# be appended to the end of the command. The command will then be executed with
|
||||
# `sh -c`.
|
||||
#
|
||||
# Like [filters], openers support basic shell globbing. The first opener which
|
||||
# matches the part's MIME type (or URL scheme handler MIME type) will be used,
|
||||
# so order them from most to least specific.
|
||||
#
|
||||
# Examples:
|
||||
# x-scheme-handler/irc=hexchat
|
||||
# x-scheme-handler/http*=printf '%s' {} | wl-copy
|
||||
# text/html=surf -dfgms
|
||||
# text/plain=gvim {} +125
|
||||
# message/rfc822=thunderbird
|
||||
|
||||
[hooks]
|
||||
#
|
||||
# Hooks are triggered whenever the associated event occurs.
|
||||
|
||||
#
|
||||
# Executed when a new email arrives in the selected folder
|
||||
#mail-received=notify-send "[$AERC_ACCOUNT/$AERC_FOLDER] New mail from $AERC_FROM_NAME" "$AERC_SUBJECT"
|
||||
|
||||
#
|
||||
# Executed when mail is deleted from a folder
|
||||
#mail-deleted=mbsync "$AERC_ACCOUNT:$AERC_FOLDER" &
|
||||
|
||||
#
|
||||
# Executed when aerc adds mail to a folder
|
||||
#mail-added=mbsync "$AERC_ACCOUNT:$AERC_FOLDER" &
|
||||
|
||||
#
|
||||
# Executed when aerc starts
|
||||
#aerc-startup=aerc :terminal calcurse && aerc :next-tab
|
||||
|
||||
#
|
||||
# Executed when aerc shuts down.
|
||||
#aerc-shutdown=
|
||||
|
||||
#
|
||||
# Executed when notmuch tags are modified.
|
||||
#tag-modified=
|
||||
|
||||
#
|
||||
# Executed when flags are changed on a message.
|
||||
#flag-changed=mbsync "$AERC_ACCOUNT:$AERC_FOLDER" &
|
||||
|
||||
[templates]
|
||||
# Templates are used to populate email bodies automatically.
|
||||
#
|
||||
|
||||
# The directories where the templates are stored. It takes a colon-separated
|
||||
# list of directories. If this is unset or if a template cannot be found, the
|
||||
# following paths will be used as a fallback in that order:
|
||||
#
|
||||
# ${XDG_CONFIG_HOME:-~/.config}/aerc/templates
|
||||
# ${XDG_DATA_HOME:-~/.local/share}/aerc/templates
|
||||
# /usr/local/share/aerc/templates
|
||||
# /usr/share/aerc/templates
|
||||
#
|
||||
#template-dirs=
|
||||
|
||||
# The default template to be used for new messages.
|
||||
#
|
||||
# default: new_message
|
||||
#new-message=new_message
|
||||
|
||||
# The default template to be used for quoted replies.
|
||||
#
|
||||
# default: quoted_reply
|
||||
#quoted-reply=quoted_reply
|
||||
|
||||
# The default template to be used for forward as body.
|
||||
#
|
||||
# default: forward_as_body
|
||||
#forwards=forward_as_body
|
||||
@@ -0,0 +1,186 @@
|
||||
# Binds are of the form <key sequence> = <command to run>
|
||||
# To use '=' in a key sequence, substitute it with "Eq": "<Ctrl+Eq>"
|
||||
# If you wish to bind #, you can wrap the key sequence in quotes: "#" = quit
|
||||
<C-p> = :prev-tab<Enter>
|
||||
<C-PgUp> = :prev-tab<Enter>
|
||||
<C-n> = :next-tab<Enter>
|
||||
<C-PgDn> = :next-tab<Enter>
|
||||
\[t = :prev-tab<Enter>
|
||||
\]t = :next-tab<Enter>
|
||||
<C-t> = :term<Enter>
|
||||
? = :help keys<Enter>
|
||||
<C-c> = :prompt 'Quit?' quit<Enter>
|
||||
<C-q> = :prompt 'Quit?' quit<Enter>
|
||||
<C-z> = :suspend<Enter>
|
||||
|
||||
[messages]
|
||||
q = :prompt 'Quit?' quit<Enter>
|
||||
|
||||
j = :next<Enter>
|
||||
<Down> = :next<Enter>
|
||||
<C-d> = :next 50%<Enter>
|
||||
<C-f> = :next 100%<Enter>
|
||||
<PgDn> = :next 100%<Enter>
|
||||
|
||||
k = :prev<Enter>
|
||||
<Up> = :prev<Enter>
|
||||
<C-u> = :prev 50%<Enter>
|
||||
<C-b> = :prev 100%<Enter>
|
||||
<PgUp> = :prev 100%<Enter>
|
||||
g = :select 0<Enter>
|
||||
G = :select -1<Enter>
|
||||
|
||||
J = :next-folder<Enter>
|
||||
<C-Down> = :next-folder<Enter>
|
||||
K = :prev-folder<Enter>
|
||||
<C-Up> = :prev-folder<Enter>
|
||||
H = :collapse-folder<Enter>
|
||||
<C-Left> = :collapse-folder<Enter>
|
||||
L = :expand-folder<Enter>
|
||||
<C-Right> = :expand-folder<Enter>
|
||||
|
||||
v = :mark -t<Enter>
|
||||
<Space> = :mark -t<Enter>:next<Enter>
|
||||
V = :mark -v<Enter>
|
||||
|
||||
T = :toggle-threads<Enter>
|
||||
zc = :fold<Enter>
|
||||
zo = :unfold<Enter>
|
||||
za = :fold -t<Enter>
|
||||
zM = :fold -a<Enter>
|
||||
zR = :unfold -a<Enter>
|
||||
<tab> = :fold -t<Enter>
|
||||
|
||||
zz = :align center<Enter>
|
||||
zt = :align top<Enter>
|
||||
zb = :align bottom<Enter>
|
||||
|
||||
<Enter> = :view<Enter>
|
||||
d = :choose -o y 'Really delete this message' delete-message<Enter>
|
||||
D = :delete<Enter>
|
||||
a = :archive flat<Enter>
|
||||
A = :unmark -a<Enter>:mark -T<Enter>:archive flat<Enter>
|
||||
|
||||
C = :compose<Enter>
|
||||
m = :compose<Enter>
|
||||
|
||||
b = :bounce<space>
|
||||
|
||||
rr = :reply -a<Enter>
|
||||
rq = :reply -aq<Enter>
|
||||
Rr = :reply<Enter>
|
||||
Rq = :reply -q<Enter>
|
||||
|
||||
c = :cf<space>
|
||||
$ = :term<space>
|
||||
! = :term<space>
|
||||
| = :pipe<space>
|
||||
|
||||
/ = :search<space>
|
||||
\ = :filter<space>
|
||||
n = :next-result<Enter>
|
||||
N = :prev-result<Enter>
|
||||
<Esc> = :clear<Enter>
|
||||
|
||||
s = :split<Enter>
|
||||
S = :vsplit<Enter>
|
||||
|
||||
pl = :patch list<Enter>
|
||||
pa = :patch apply <Tab>
|
||||
pd = :patch drop <Tab>
|
||||
pb = :patch rebase<Enter>
|
||||
pt = :patch term<Enter>
|
||||
ps = :patch switch <Tab>
|
||||
|
||||
[messages:folder=Drafts]
|
||||
<Enter> = :recall<Enter>
|
||||
|
||||
[view]
|
||||
/ = :toggle-key-passthrough<Enter>/
|
||||
q = :close<Enter>
|
||||
O = :open<Enter>
|
||||
o = :open<Enter>
|
||||
S = :save<space>
|
||||
| = :pipe<space>
|
||||
D = :delete<Enter>
|
||||
A = :archive flat<Enter>
|
||||
|
||||
<C-l> = :open-link <space>
|
||||
|
||||
f = :forward<Enter>
|
||||
rr = :reply -a<Enter>
|
||||
rq = :reply -aq<Enter>
|
||||
Rr = :reply<Enter>
|
||||
Rq = :reply -q<Enter>
|
||||
|
||||
H = :toggle-headers<Enter>
|
||||
<C-k> = :prev-part<Enter>
|
||||
<C-Up> = :prev-part<Enter>
|
||||
<C-j> = :next-part<Enter>
|
||||
<C-Down> = :next-part<Enter>
|
||||
J = :next<Enter>
|
||||
<C-Right> = :next<Enter>
|
||||
K = :prev<Enter>
|
||||
<C-Left> = :prev<Enter>
|
||||
|
||||
[view::passthrough]
|
||||
$noinherit = true
|
||||
$ex = <C-x>
|
||||
<Esc> = :toggle-key-passthrough<Enter>
|
||||
|
||||
[compose]
|
||||
# Keybindings used when the embedded terminal is not selected in the compose
|
||||
# view
|
||||
$noinherit = true
|
||||
$ex = <C-x>
|
||||
$complete = <C-o>
|
||||
<C-k> = :prev-field<Enter>
|
||||
<C-Up> = :prev-field<Enter>
|
||||
<C-j> = :next-field<Enter>
|
||||
<C-Down> = :next-field<Enter>
|
||||
<A-p> = :switch-account -p<Enter>
|
||||
<C-Left> = :switch-account -p<Enter>
|
||||
<A-n> = :switch-account -n<Enter>
|
||||
<C-Right> = :switch-account -n<Enter>
|
||||
<tab> = :next-field<Enter>
|
||||
<backtab> = :prev-field<Enter>
|
||||
<C-p> = :prev-tab<Enter>
|
||||
<C-PgUp> = :prev-tab<Enter>
|
||||
<C-n> = :next-tab<Enter>
|
||||
<C-PgDn> = :next-tab<Enter>
|
||||
|
||||
[compose::editor]
|
||||
# Keybindings used when the embedded terminal is selected in the compose view
|
||||
$noinherit = true
|
||||
$ex = <C-x>
|
||||
<C-k> = :prev-field<Enter>
|
||||
<C-Up> = :prev-field<Enter>
|
||||
<C-j> = :next-field<Enter>
|
||||
<C-Down> = :next-field<Enter>
|
||||
<C-p> = :prev-tab<Enter>
|
||||
<C-PgUp> = :prev-tab<Enter>
|
||||
<C-n> = :next-tab<Enter>
|
||||
<C-PgDn> = :next-tab<Enter>
|
||||
|
||||
[compose::review]
|
||||
# Keybindings used when reviewing a message to be sent
|
||||
# Inline comments are used as descriptions on the review screen
|
||||
y = :send<Enter> # Send
|
||||
n = :abort<Enter> # Abort (discard message, no confirmation)
|
||||
s = :sign<Enter> # Toggle signing of this message with your PGP key
|
||||
x = :encrypt<Enter> # Toggle encryption of this message to all recipients
|
||||
v = :preview<Enter> # Preview message
|
||||
p = :postpone<Enter> # Postpone
|
||||
q = :choose -o d discard abort -o p postpone postpone<Enter> # Abort or postpone
|
||||
e = :edit<Enter> # Edit (body and headers)
|
||||
a = :attach<space> # Add attachment
|
||||
d = :detach<space> # Remove attachment
|
||||
|
||||
[terminal]
|
||||
$noinherit = true
|
||||
$ex = <C-x>
|
||||
|
||||
<C-p> = :prev-tab<Enter>
|
||||
<C-n> = :next-tab<Enter>
|
||||
<C-PgUp> = :prev-tab<Enter>
|
||||
<C-PgDn> = :next-tab<Enter>
|
||||
+719
@@ -0,0 +1,719 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rockorager/vaxis"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type BindingConfig struct {
|
||||
Global *KeyBindings
|
||||
AccountWizard *KeyBindings
|
||||
Compose *KeyBindings
|
||||
ComposeEditor *KeyBindings
|
||||
ComposeReview *KeyBindings
|
||||
MessageList *KeyBindings
|
||||
MessageView *KeyBindings
|
||||
MessageViewPassthrough *KeyBindings
|
||||
Terminal *KeyBindings
|
||||
}
|
||||
|
||||
type bindsContextType int
|
||||
|
||||
const (
|
||||
bindsContextFolder bindsContextType = iota
|
||||
bindsContextAccount
|
||||
)
|
||||
|
||||
type BindingConfigContext struct {
|
||||
ContextType bindsContextType
|
||||
Regex *regexp.Regexp
|
||||
Bindings *KeyBindings
|
||||
}
|
||||
|
||||
type KeyStroke struct {
|
||||
Modifiers vaxis.ModifierMask
|
||||
Key rune
|
||||
}
|
||||
|
||||
type Binding struct {
|
||||
Output []KeyStroke
|
||||
Input []KeyStroke
|
||||
|
||||
Annotation string
|
||||
}
|
||||
|
||||
type KeyBindings struct {
|
||||
Bindings []*Binding
|
||||
// If false, disable global keybindings in this context
|
||||
Globals bool
|
||||
// Which key opens the ex line (default is :)
|
||||
ExKey KeyStroke
|
||||
// Which key triggers completion (default is <tab>)
|
||||
CompleteKey KeyStroke
|
||||
|
||||
// private
|
||||
contextualBinds []*BindingConfigContext
|
||||
contextualCounts map[bindsContextType]int
|
||||
contextualCache map[bindsContextKey]*KeyBindings
|
||||
}
|
||||
|
||||
type bindsContextKey struct {
|
||||
ctxType bindsContextType
|
||||
value string
|
||||
}
|
||||
|
||||
const (
|
||||
BINDING_FOUND = iota
|
||||
BINDING_INCOMPLETE
|
||||
BINDING_NOT_FOUND
|
||||
)
|
||||
|
||||
type BindingSearchResult int
|
||||
|
||||
func defaultBindsConfig() *BindingConfig {
|
||||
// These bindings are not configurable
|
||||
wizard := NewKeyBindings()
|
||||
wizard.ExKey = KeyStroke{Key: 'e', Modifiers: vaxis.ModCtrl}
|
||||
wizard.Globals = false
|
||||
quit, _ := ParseBinding("<C-q>", ":quit<Enter>", "Quit aerc")
|
||||
wizard.Add(quit)
|
||||
return &BindingConfig{
|
||||
Global: NewKeyBindings(),
|
||||
AccountWizard: wizard,
|
||||
Compose: NewKeyBindings(),
|
||||
ComposeEditor: NewKeyBindings(),
|
||||
ComposeReview: NewKeyBindings(),
|
||||
MessageList: NewKeyBindings(),
|
||||
MessageView: NewKeyBindings(),
|
||||
MessageViewPassthrough: NewKeyBindings(),
|
||||
Terminal: NewKeyBindings(),
|
||||
}
|
||||
}
|
||||
|
||||
var Binds = defaultBindsConfig()
|
||||
|
||||
func parseBindsFromFile(root string, filename string) error {
|
||||
log.Debugf("Parsing key bindings configuration from %s", filename)
|
||||
binds, err := ini.LoadSources(ini.LoadOptions{
|
||||
KeyValueDelimiters: "=",
|
||||
// IgnoreInlineComment is set to true which tells ini's parser
|
||||
// to treat comments (#) on the same line as part of the value;
|
||||
// hence we need cut the comment off ourselves later
|
||||
IgnoreInlineComment: true,
|
||||
}, filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseGroups := map[string]**KeyBindings{
|
||||
"default": &Binds.Global,
|
||||
"compose": &Binds.Compose,
|
||||
"messages": &Binds.MessageList,
|
||||
"terminal": &Binds.Terminal,
|
||||
"view": &Binds.MessageView,
|
||||
"view::passthrough": &Binds.MessageViewPassthrough,
|
||||
"compose::editor": &Binds.ComposeEditor,
|
||||
"compose::review": &Binds.ComposeReview,
|
||||
}
|
||||
|
||||
// Base Bindings
|
||||
for _, sectionName := range binds.SectionStrings() {
|
||||
// Handle :: delimiter
|
||||
baseSectionName := strings.ReplaceAll(sectionName, "::", "////")
|
||||
sections := strings.Split(baseSectionName, ":")
|
||||
baseOnly := len(sections) == 1
|
||||
baseSectionName = strings.ReplaceAll(sections[0], "////", "::")
|
||||
|
||||
group, ok := baseGroups[strings.ToLower(baseSectionName)]
|
||||
if !ok {
|
||||
return errors.New("Unknown keybinding group " + sectionName)
|
||||
}
|
||||
|
||||
if baseOnly {
|
||||
err = LoadBinds(binds, baseSectionName, group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("binds.conf: %#v", Binds)
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBinds(root string, filename string) error {
|
||||
if filename == "" {
|
||||
filename = path.Join(root, "binds.conf")
|
||||
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Printf("%s not found, installing the system default\n", filename)
|
||||
if err := installTemplate(root, "binds.conf"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
SetBindsFilename(filename)
|
||||
if err := parseBindsFromFile(root, filename); err != nil {
|
||||
return fmt.Errorf("%s: %w", filename, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadBindingSection(sec *ini.Section) (*KeyBindings, error) {
|
||||
bindings := NewKeyBindings()
|
||||
for _, k := range sec.Keys() {
|
||||
key := k.Name()
|
||||
value, annotation, _ := strings.Cut(k.String(), " # ")
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "$ex":
|
||||
strokes, err := ParseKeyStrokes(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(strokes) != 1 {
|
||||
return nil, errors.New("Invalid binding")
|
||||
}
|
||||
bindings.ExKey = strokes[0]
|
||||
case "$noinherit":
|
||||
if value == "false" {
|
||||
continue
|
||||
}
|
||||
if value != "true" {
|
||||
return nil, errors.New("Invalid binding")
|
||||
}
|
||||
bindings.Globals = false
|
||||
case "$complete":
|
||||
strokes, err := ParseKeyStrokes(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(strokes) != 1 {
|
||||
return nil, errors.New("Invalid binding")
|
||||
}
|
||||
bindings.CompleteKey = strokes[0]
|
||||
default:
|
||||
annotation = strings.TrimSpace(annotation)
|
||||
binding, err := ParseBinding(key, value, annotation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bindings.Add(binding)
|
||||
}
|
||||
}
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
func LoadBinds(binds *ini.File, baseName string, baseGroup **KeyBindings) error {
|
||||
if sec, err := binds.GetSection(baseName); err == nil {
|
||||
binds, err := LoadBindingSection(sec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*baseGroup = MergeBindings(binds, *baseGroup)
|
||||
}
|
||||
|
||||
b := *baseGroup
|
||||
|
||||
if baseName == "default" {
|
||||
b.Globals = false
|
||||
}
|
||||
|
||||
for _, sectionName := range binds.SectionStrings() {
|
||||
if !strings.HasPrefix(sectionName, baseName+":") ||
|
||||
strings.HasPrefix(sectionName, baseName+"::") {
|
||||
continue
|
||||
}
|
||||
|
||||
bindSection, err := binds.GetSection(sectionName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
binds, err := LoadBindingSection(bindSection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if baseName == "default" {
|
||||
binds.Globals = false
|
||||
}
|
||||
|
||||
contextualBind := BindingConfigContext{
|
||||
Bindings: binds,
|
||||
}
|
||||
|
||||
var index int
|
||||
if strings.Contains(sectionName, "=") {
|
||||
index = strings.Index(sectionName, "=")
|
||||
value := string(sectionName[index+1:])
|
||||
contextualBind.Regex, err = regexp.Compile(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("Invalid Bind Context regex in %s", sectionName)
|
||||
}
|
||||
|
||||
switch sectionName[len(baseName)+1 : index] {
|
||||
case "account":
|
||||
acctName := sectionName[index+1:]
|
||||
valid := false
|
||||
for _, acctConf := range Accounts {
|
||||
matches := contextualBind.Regex.FindString(acctConf.Name)
|
||||
if matches != "" {
|
||||
valid = true
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
log.Warnf("binds.conf: unexistent account: %s", acctName)
|
||||
continue
|
||||
}
|
||||
contextualBind.ContextType = bindsContextAccount
|
||||
case "folder":
|
||||
// No validation needed. If the folder doesn't exist, the binds
|
||||
// never get used
|
||||
contextualBind.ContextType = bindsContextFolder
|
||||
default:
|
||||
return fmt.Errorf("Unknown Context Bind Section: %s", sectionName)
|
||||
}
|
||||
b.contextualBinds = append(b.contextualBinds, &contextualBind)
|
||||
b.contextualCounts[contextualBind.ContextType]++
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewKeyBindings() *KeyBindings {
|
||||
return &KeyBindings{
|
||||
ExKey: KeyStroke{0, ':'},
|
||||
CompleteKey: KeyStroke{0, vaxis.KeyTab},
|
||||
Globals: true,
|
||||
contextualCache: make(map[bindsContextKey]*KeyBindings),
|
||||
contextualCounts: make(map[bindsContextType]int),
|
||||
}
|
||||
}
|
||||
|
||||
func areBindingsInputsEqual(a, b *Binding) bool {
|
||||
if len(a.Input) != len(b.Input) {
|
||||
return false
|
||||
}
|
||||
|
||||
for idx := range a.Input {
|
||||
if a.Input[idx] != b.Input[idx] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// this scans the bindings slice for copies and leaves just the first ones
|
||||
// it also removes empty bindings, the ones that do nothing, so you can
|
||||
// override and erase parent bindings with the context ones
|
||||
func filterAndCleanBindings(bindings []*Binding) []*Binding {
|
||||
// 1. remove a binding if we already have one with the same input
|
||||
res1 := []*Binding{}
|
||||
for _, b := range bindings {
|
||||
// do we already have one here?
|
||||
found := false
|
||||
for _, r := range res1 {
|
||||
if areBindingsInputsEqual(b, r) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// add it if we don't
|
||||
if !found {
|
||||
res1 = append(res1, b)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. clean up the empty bindings
|
||||
res2 := []*Binding{}
|
||||
for _, b := range res1 {
|
||||
if len(b.Output) > 0 {
|
||||
res2 = append(res2, b)
|
||||
}
|
||||
}
|
||||
|
||||
return res2
|
||||
}
|
||||
|
||||
func MergeBindings(bindings ...*KeyBindings) *KeyBindings {
|
||||
merged := NewKeyBindings()
|
||||
for _, b := range bindings {
|
||||
merged.Bindings = append(merged.Bindings, b.Bindings...)
|
||||
if !b.Globals {
|
||||
break
|
||||
}
|
||||
}
|
||||
merged.Bindings = filterAndCleanBindings(merged.Bindings)
|
||||
merged.ExKey = bindings[0].ExKey
|
||||
merged.CompleteKey = bindings[0].CompleteKey
|
||||
merged.Globals = bindings[0].Globals
|
||||
for _, b := range bindings {
|
||||
merged.contextualBinds = append(merged.contextualBinds, b.contextualBinds...)
|
||||
for t, c := range b.contextualCounts {
|
||||
merged.contextualCounts[t] += c
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func (base *KeyBindings) contextual(
|
||||
contextType bindsContextType, reg string,
|
||||
) *KeyBindings {
|
||||
if base.contextualCounts[contextType] == 0 {
|
||||
// shortcut if no contextual binds for that type
|
||||
return base
|
||||
}
|
||||
|
||||
key := bindsContextKey{ctxType: contextType, value: reg}
|
||||
c, found := base.contextualCache[key]
|
||||
if found {
|
||||
return c
|
||||
}
|
||||
|
||||
c = base
|
||||
for _, contextualBind := range base.contextualBinds {
|
||||
if contextualBind.ContextType != contextType {
|
||||
continue
|
||||
}
|
||||
if !contextualBind.Regex.Match([]byte(reg)) {
|
||||
continue
|
||||
}
|
||||
c = MergeBindings(contextualBind.Bindings, c)
|
||||
}
|
||||
base.contextualCache[key] = c
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (bindings *KeyBindings) ForAccount(account string) *KeyBindings {
|
||||
return bindings.contextual(bindsContextAccount, account)
|
||||
}
|
||||
|
||||
func (bindings *KeyBindings) ForFolder(folder string) *KeyBindings {
|
||||
return bindings.contextual(bindsContextFolder, folder)
|
||||
}
|
||||
|
||||
func (bindings *KeyBindings) Add(binding *Binding) {
|
||||
// TODO: Search for conflicts?
|
||||
bindings.Bindings = append(bindings.Bindings, binding)
|
||||
}
|
||||
|
||||
func (bindings *KeyBindings) GetBinding(
|
||||
input []KeyStroke,
|
||||
) (BindingSearchResult, []KeyStroke) {
|
||||
incomplete := false
|
||||
// TODO: This could probably be a sorted list to speed things up
|
||||
// TODO: Deal with bindings that share a prefix
|
||||
for _, binding := range bindings.Bindings {
|
||||
if len(binding.Input) < len(input) {
|
||||
continue
|
||||
}
|
||||
for i, stroke := range input {
|
||||
if stroke.Modifiers != binding.Input[i].Modifiers {
|
||||
goto next
|
||||
}
|
||||
if stroke.Key != binding.Input[i].Key {
|
||||
goto next
|
||||
}
|
||||
}
|
||||
if len(binding.Input) != len(input) {
|
||||
incomplete = true
|
||||
} else {
|
||||
return BINDING_FOUND, binding.Output
|
||||
}
|
||||
next:
|
||||
}
|
||||
if incomplete {
|
||||
return BINDING_INCOMPLETE, nil
|
||||
}
|
||||
return BINDING_NOT_FOUND, nil
|
||||
}
|
||||
|
||||
func (bindings *KeyBindings) GetReverseBindings(output []KeyStroke) [][]KeyStroke {
|
||||
var inputs [][]KeyStroke
|
||||
|
||||
for _, binding := range bindings.Bindings {
|
||||
if len(binding.Output) != len(output) {
|
||||
continue
|
||||
}
|
||||
for i, stroke := range output {
|
||||
if stroke.Modifiers != binding.Output[i].Modifiers {
|
||||
goto next
|
||||
}
|
||||
if stroke.Key != binding.Output[i].Key {
|
||||
goto next
|
||||
}
|
||||
}
|
||||
inputs = append(inputs, binding.Input)
|
||||
next:
|
||||
}
|
||||
return inputs
|
||||
}
|
||||
|
||||
func FormatKeyStrokes(keystrokes []KeyStroke) string {
|
||||
var sb strings.Builder
|
||||
|
||||
for _, stroke := range keystrokes {
|
||||
special := false
|
||||
s := ""
|
||||
for name, ks := range keyNames {
|
||||
if (ks.Modifiers == stroke.Modifiers || ks.Modifiers == vaxis.ModifierMask(0)) && ks.Key == stroke.Key {
|
||||
switch name {
|
||||
case "cr":
|
||||
special = true
|
||||
s = "enter"
|
||||
case "space":
|
||||
s = " "
|
||||
case "semicolon":
|
||||
s = ";"
|
||||
default:
|
||||
special = true
|
||||
s = name
|
||||
}
|
||||
// remove any modifiers this named key comes
|
||||
// with so we format properly
|
||||
stroke.Modifiers &^= ks.Modifiers
|
||||
break
|
||||
}
|
||||
}
|
||||
if stroke.Modifiers != vaxis.ModifierMask(0) {
|
||||
special = true
|
||||
}
|
||||
if special {
|
||||
sb.WriteString("<")
|
||||
}
|
||||
if stroke.Modifiers&vaxis.ModCtrl > 0 {
|
||||
sb.WriteString("c-")
|
||||
}
|
||||
if stroke.Modifiers&vaxis.ModAlt > 0 {
|
||||
sb.WriteString("a-")
|
||||
}
|
||||
if stroke.Modifiers&vaxis.ModShift > 0 {
|
||||
sb.WriteString("s-")
|
||||
}
|
||||
if s == "" && stroke.Key < unicode.MaxRune {
|
||||
s = string(stroke.Key)
|
||||
}
|
||||
sb.WriteString(s)
|
||||
if special {
|
||||
sb.WriteString(">")
|
||||
}
|
||||
}
|
||||
|
||||
// replace leading & trailing spaces with explicit <space> keystrokes
|
||||
buf := sb.String()
|
||||
match := spaceTrimRe.FindStringSubmatch(buf)
|
||||
if len(match) == 4 {
|
||||
prefix := strings.ReplaceAll(match[1], " ", "<space>")
|
||||
suffix := strings.ReplaceAll(match[3], " ", "<space>")
|
||||
buf = prefix + match[2] + suffix
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
var spaceTrimRe = regexp.MustCompile(`^(\s*)(.*?)(\s*)$`)
|
||||
|
||||
var keyNames = map[string]KeyStroke{
|
||||
"space": {vaxis.ModifierMask(0), ' '},
|
||||
"semicolon": {vaxis.ModifierMask(0), ';'},
|
||||
"enter": {vaxis.ModifierMask(0), vaxis.KeyEnter},
|
||||
"up": {vaxis.ModifierMask(0), vaxis.KeyUp},
|
||||
"down": {vaxis.ModifierMask(0), vaxis.KeyDown},
|
||||
"right": {vaxis.ModifierMask(0), vaxis.KeyRight},
|
||||
"left": {vaxis.ModifierMask(0), vaxis.KeyLeft},
|
||||
"upleft": {vaxis.ModifierMask(0), vaxis.KeyUpLeft},
|
||||
"upright": {vaxis.ModifierMask(0), vaxis.KeyUpRight},
|
||||
"downleft": {vaxis.ModifierMask(0), vaxis.KeyDownLeft},
|
||||
"downright": {vaxis.ModifierMask(0), vaxis.KeyDownRight},
|
||||
"center": {vaxis.ModifierMask(0), vaxis.KeyCenter},
|
||||
"pgup": {vaxis.ModifierMask(0), vaxis.KeyPgUp},
|
||||
"pgdn": {vaxis.ModifierMask(0), vaxis.KeyPgDown},
|
||||
"home": {vaxis.ModifierMask(0), vaxis.KeyHome},
|
||||
"end": {vaxis.ModifierMask(0), vaxis.KeyEnd},
|
||||
"insert": {vaxis.ModifierMask(0), vaxis.KeyInsert},
|
||||
"delete": {vaxis.ModifierMask(0), vaxis.KeyDelete},
|
||||
"backspace": {vaxis.ModifierMask(0), vaxis.KeyBackspace},
|
||||
// "help": {vaxis.ModifierMask(0), vaxis.KeyHelp},
|
||||
"exit": {vaxis.ModifierMask(0), vaxis.KeyExit},
|
||||
"clear": {vaxis.ModifierMask(0), vaxis.KeyClear},
|
||||
"cancel": {vaxis.ModifierMask(0), vaxis.KeyCancel},
|
||||
"print": {vaxis.ModifierMask(0), vaxis.KeyPrint},
|
||||
"pause": {vaxis.ModifierMask(0), vaxis.KeyPause},
|
||||
"backtab": {vaxis.ModShift, vaxis.KeyTab},
|
||||
"f1": {vaxis.ModifierMask(0), vaxis.KeyF01},
|
||||
"f2": {vaxis.ModifierMask(0), vaxis.KeyF02},
|
||||
"f3": {vaxis.ModifierMask(0), vaxis.KeyF03},
|
||||
"f4": {vaxis.ModifierMask(0), vaxis.KeyF04},
|
||||
"f5": {vaxis.ModifierMask(0), vaxis.KeyF05},
|
||||
"f6": {vaxis.ModifierMask(0), vaxis.KeyF06},
|
||||
"f7": {vaxis.ModifierMask(0), vaxis.KeyF07},
|
||||
"f8": {vaxis.ModifierMask(0), vaxis.KeyF08},
|
||||
"f9": {vaxis.ModifierMask(0), vaxis.KeyF09},
|
||||
"f10": {vaxis.ModifierMask(0), vaxis.KeyF10},
|
||||
"f11": {vaxis.ModifierMask(0), vaxis.KeyF11},
|
||||
"f12": {vaxis.ModifierMask(0), vaxis.KeyF12},
|
||||
"f13": {vaxis.ModifierMask(0), vaxis.KeyF13},
|
||||
"f14": {vaxis.ModifierMask(0), vaxis.KeyF14},
|
||||
"f15": {vaxis.ModifierMask(0), vaxis.KeyF15},
|
||||
"f16": {vaxis.ModifierMask(0), vaxis.KeyF16},
|
||||
"f17": {vaxis.ModifierMask(0), vaxis.KeyF17},
|
||||
"f18": {vaxis.ModifierMask(0), vaxis.KeyF18},
|
||||
"f19": {vaxis.ModifierMask(0), vaxis.KeyF19},
|
||||
"f20": {vaxis.ModifierMask(0), vaxis.KeyF20},
|
||||
"f21": {vaxis.ModifierMask(0), vaxis.KeyF21},
|
||||
"f22": {vaxis.ModifierMask(0), vaxis.KeyF22},
|
||||
"f23": {vaxis.ModifierMask(0), vaxis.KeyF23},
|
||||
"f24": {vaxis.ModifierMask(0), vaxis.KeyF24},
|
||||
"f25": {vaxis.ModifierMask(0), vaxis.KeyF25},
|
||||
"f26": {vaxis.ModifierMask(0), vaxis.KeyF26},
|
||||
"f27": {vaxis.ModifierMask(0), vaxis.KeyF27},
|
||||
"f28": {vaxis.ModifierMask(0), vaxis.KeyF28},
|
||||
"f29": {vaxis.ModifierMask(0), vaxis.KeyF29},
|
||||
"f30": {vaxis.ModifierMask(0), vaxis.KeyF30},
|
||||
"f31": {vaxis.ModifierMask(0), vaxis.KeyF31},
|
||||
"f32": {vaxis.ModifierMask(0), vaxis.KeyF32},
|
||||
"f33": {vaxis.ModifierMask(0), vaxis.KeyF33},
|
||||
"f34": {vaxis.ModifierMask(0), vaxis.KeyF34},
|
||||
"f35": {vaxis.ModifierMask(0), vaxis.KeyF35},
|
||||
"f36": {vaxis.ModifierMask(0), vaxis.KeyF36},
|
||||
"f37": {vaxis.ModifierMask(0), vaxis.KeyF37},
|
||||
"f38": {vaxis.ModifierMask(0), vaxis.KeyF38},
|
||||
"f39": {vaxis.ModifierMask(0), vaxis.KeyF39},
|
||||
"f40": {vaxis.ModifierMask(0), vaxis.KeyF40},
|
||||
"f41": {vaxis.ModifierMask(0), vaxis.KeyF41},
|
||||
"f42": {vaxis.ModifierMask(0), vaxis.KeyF42},
|
||||
"f43": {vaxis.ModifierMask(0), vaxis.KeyF43},
|
||||
"f44": {vaxis.ModifierMask(0), vaxis.KeyF44},
|
||||
"f45": {vaxis.ModifierMask(0), vaxis.KeyF45},
|
||||
"f46": {vaxis.ModifierMask(0), vaxis.KeyF46},
|
||||
"f47": {vaxis.ModifierMask(0), vaxis.KeyF47},
|
||||
"f48": {vaxis.ModifierMask(0), vaxis.KeyF48},
|
||||
"f49": {vaxis.ModifierMask(0), vaxis.KeyF49},
|
||||
"f50": {vaxis.ModifierMask(0), vaxis.KeyF50},
|
||||
"f51": {vaxis.ModifierMask(0), vaxis.KeyF51},
|
||||
"f52": {vaxis.ModifierMask(0), vaxis.KeyF52},
|
||||
"f53": {vaxis.ModifierMask(0), vaxis.KeyF53},
|
||||
"f54": {vaxis.ModifierMask(0), vaxis.KeyF54},
|
||||
"f55": {vaxis.ModifierMask(0), vaxis.KeyF55},
|
||||
"f56": {vaxis.ModifierMask(0), vaxis.KeyF56},
|
||||
"f57": {vaxis.ModifierMask(0), vaxis.KeyF57},
|
||||
"f58": {vaxis.ModifierMask(0), vaxis.KeyF58},
|
||||
"f59": {vaxis.ModifierMask(0), vaxis.KeyF59},
|
||||
"f60": {vaxis.ModifierMask(0), vaxis.KeyF60},
|
||||
"f61": {vaxis.ModifierMask(0), vaxis.KeyF61},
|
||||
"f62": {vaxis.ModifierMask(0), vaxis.KeyF62},
|
||||
"f63": {vaxis.ModifierMask(0), vaxis.KeyF63},
|
||||
"tab": {vaxis.ModifierMask(0), vaxis.KeyTab},
|
||||
"cr": {vaxis.ModifierMask(0), vaxis.KeyEnter},
|
||||
"esc": {vaxis.ModifierMask(0), vaxis.KeyEsc},
|
||||
"del": {vaxis.ModifierMask(0), vaxis.KeyDelete},
|
||||
}
|
||||
|
||||
func ParseKeyStrokes(keystrokes string) ([]KeyStroke, error) {
|
||||
var strokes []KeyStroke
|
||||
buf := bytes.NewBufferString(keystrokes)
|
||||
for {
|
||||
tok, _, err := buf.ReadRune()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO: make it possible to bind to < or > themselves (and default to
|
||||
// switching accounts)
|
||||
switch tok {
|
||||
case '<':
|
||||
name, err := buf.ReadString(byte('>'))
|
||||
switch {
|
||||
case err == io.EOF:
|
||||
return nil, errors.New("Expecting '>'")
|
||||
case err != nil:
|
||||
return nil, err
|
||||
case name == ">":
|
||||
return nil, errors.New("Expected a key name")
|
||||
}
|
||||
name = name[:len(name)-1]
|
||||
args := strings.Split(name, "-")
|
||||
// check if the last char was a '-' and we'll add it
|
||||
// back. We check for "--" in case it was an invalid
|
||||
// keystroke (ie <C->)
|
||||
if strings.HasSuffix(name, "--") {
|
||||
args = append(args, "-")
|
||||
}
|
||||
ks := KeyStroke{}
|
||||
for i, arg := range args {
|
||||
if i == len(args)-1 {
|
||||
key, ok := keyNames[strings.ToLower(arg)]
|
||||
if !ok {
|
||||
r, n := utf8.DecodeRuneInString(arg)
|
||||
if n != len(arg) {
|
||||
return nil, fmt.Errorf("Unknown key '%s'", name)
|
||||
}
|
||||
key = KeyStroke{Key: r}
|
||||
}
|
||||
ks.Key = key.Key
|
||||
ks.Modifiers |= key.Modifiers
|
||||
strokes = append(strokes, ks)
|
||||
}
|
||||
switch strings.ToLower(arg) {
|
||||
case "s", "S":
|
||||
ks.Modifiers |= vaxis.ModShift
|
||||
case "a", "A":
|
||||
ks.Modifiers |= vaxis.ModAlt
|
||||
case "c", "C":
|
||||
ks.Modifiers |= vaxis.ModCtrl
|
||||
}
|
||||
}
|
||||
case '>':
|
||||
return nil, errors.New("Found '>' without '<'")
|
||||
case '\\':
|
||||
tok, _, err = buf.ReadRune()
|
||||
if err == io.EOF {
|
||||
tok = '\\'
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
strokes = append(strokes, KeyStroke{
|
||||
Modifiers: vaxis.ModifierMask(0),
|
||||
Key: tok,
|
||||
})
|
||||
}
|
||||
}
|
||||
return strokes, nil
|
||||
}
|
||||
|
||||
func ParseBinding(input, output, annotation string) (*Binding, error) {
|
||||
in, err := ParseKeyStrokes(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := ParseKeyStrokes(output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Binding{
|
||||
Input: in,
|
||||
Output: out,
|
||||
Annotation: annotation,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.sr.ht/~rockorager/vaxis"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetBinding(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
bindings := NewKeyBindings()
|
||||
add := func(binding, cmd string) {
|
||||
b, err := ParseBinding(binding, cmd, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bindings.Add(b)
|
||||
}
|
||||
|
||||
add("abc", ":abc")
|
||||
add("cba", ":cba")
|
||||
add("foo", ":foo")
|
||||
add("bar", ":bar")
|
||||
|
||||
test := func(input []KeyStroke, result int, output string) {
|
||||
_output, _ := ParseKeyStrokes(output)
|
||||
r, out := bindings.GetBinding(input)
|
||||
assert.Equal(result, int(r), fmt.Sprintf(
|
||||
"%s: Expected result %d, got %d", output, result, r))
|
||||
assert.Equal(_output, out, fmt.Sprintf(
|
||||
"%s: Expected output %v, got %v", output, _output, out))
|
||||
}
|
||||
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModifierMask(0), 'a'},
|
||||
}, BINDING_INCOMPLETE, "")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModifierMask(0), 'a'},
|
||||
{vaxis.ModifierMask(0), 'b'},
|
||||
{vaxis.ModifierMask(0), 'c'},
|
||||
}, BINDING_FOUND, ":abc")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModifierMask(0), 'c'},
|
||||
{vaxis.ModifierMask(0), 'b'},
|
||||
{vaxis.ModifierMask(0), 'a'},
|
||||
}, BINDING_FOUND, ":cba")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModifierMask(0), 'f'},
|
||||
{vaxis.ModifierMask(0), 'o'},
|
||||
}, BINDING_INCOMPLETE, "")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModifierMask(0), '4'},
|
||||
{vaxis.ModifierMask(0), '0'},
|
||||
{vaxis.ModifierMask(0), '4'},
|
||||
}, BINDING_NOT_FOUND, "")
|
||||
|
||||
add("<C-a>", "c-a")
|
||||
add("<C-Down>", ":next")
|
||||
add("<C-PgUp>", ":prev")
|
||||
add("<C-Enter>", ":open")
|
||||
add("<C-->", ":open")
|
||||
add("<S-up>", ":open")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModCtrl, 'a'},
|
||||
}, BINDING_FOUND, "c-a")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModCtrl, vaxis.KeyDown},
|
||||
}, BINDING_FOUND, ":next")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModCtrl, vaxis.KeyPgUp},
|
||||
}, BINDING_FOUND, ":prev")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModCtrl, vaxis.KeyPgDown},
|
||||
}, BINDING_NOT_FOUND, "")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModCtrl, vaxis.KeyEnter},
|
||||
}, BINDING_FOUND, ":open")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModCtrl, '-'},
|
||||
}, BINDING_FOUND, ":open")
|
||||
test([]KeyStroke{
|
||||
{vaxis.ModShift, vaxis.KeyUp},
|
||||
}, BINDING_FOUND, ":open")
|
||||
}
|
||||
|
||||
func TestKeyStrokeFormatting(t *testing.T) {
|
||||
tests := []struct {
|
||||
stroke KeyStroke
|
||||
formatted string
|
||||
}{
|
||||
{KeyStroke{vaxis.ModifierMask(0), vaxis.KeyLeft}, "<left>"},
|
||||
{KeyStroke{vaxis.ModCtrl, vaxis.KeyLeft}, "<c-left>"},
|
||||
{KeyStroke{vaxis.ModCtrl, 'e'}, "<c-e>"},
|
||||
{KeyStroke{vaxis.ModifierMask(0), vaxis.KeySpace}, "<space>"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, test.formatted, FormatKeyStrokes([]KeyStroke{test.stroke}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func EditorCmds() []string {
|
||||
return []string{
|
||||
Compose.Editor,
|
||||
os.Getenv("EDITOR"),
|
||||
"vi",
|
||||
"nano",
|
||||
}
|
||||
}
|
||||
|
||||
func PagerCmds() []string {
|
||||
return []string{
|
||||
Viewer.Pager,
|
||||
os.Getenv("PAGER"),
|
||||
"less -Rc",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type ColumnFlags uint32
|
||||
|
||||
func (f ColumnFlags) Has(o ColumnFlags) bool { return f&o == o }
|
||||
|
||||
const (
|
||||
ALIGN_LEFT ColumnFlags = 1 << iota
|
||||
ALIGN_CENTER
|
||||
ALIGN_RIGHT
|
||||
WIDTH_AUTO // whatever is left
|
||||
WIDTH_FRACTION // ratio of total width
|
||||
WIDTH_EXACT // exact number of characters
|
||||
WIDTH_FIT // fit to column content width
|
||||
)
|
||||
|
||||
type ColumnDef struct {
|
||||
Name string
|
||||
Flags ColumnFlags
|
||||
Width float64
|
||||
Template *template.Template
|
||||
}
|
||||
|
||||
var columnRe = regexp.MustCompile(`^([\w-]+)(?:([<:>])(=|\*|\d+%?)?)?$`)
|
||||
|
||||
func parseColumnDef(col string, section *ini.Section) (*ColumnDef, error) {
|
||||
col = strings.TrimSpace(col)
|
||||
match := columnRe.FindStringSubmatch(col)
|
||||
if match == nil {
|
||||
return nil, fmt.Errorf("invalid column def: %v", col)
|
||||
}
|
||||
name := match[1]
|
||||
keyName := fmt.Sprintf("column-%s", name)
|
||||
|
||||
var flags ColumnFlags
|
||||
switch match[2] {
|
||||
case "<", "":
|
||||
flags |= ALIGN_LEFT
|
||||
case ":":
|
||||
flags |= ALIGN_CENTER
|
||||
case ">":
|
||||
flags |= ALIGN_RIGHT
|
||||
}
|
||||
|
||||
var width float64 = 0
|
||||
switch match[3] {
|
||||
case "=":
|
||||
flags |= WIDTH_FIT
|
||||
case "*", "":
|
||||
flags |= WIDTH_AUTO
|
||||
default:
|
||||
s := match[3]
|
||||
var divider float64 = 1
|
||||
if strings.HasSuffix(s, "%") {
|
||||
divider = 100
|
||||
s = strings.TrimSuffix(s, "%")
|
||||
flags |= WIDTH_FRACTION
|
||||
} else {
|
||||
flags |= WIDTH_EXACT
|
||||
}
|
||||
w, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", keyName, err)
|
||||
}
|
||||
if divider == 100 && w > 100 {
|
||||
return nil, fmt.Errorf("%s: invalid width %.0f%%", keyName, w)
|
||||
}
|
||||
width = w / divider
|
||||
}
|
||||
key, err := section.GetKey(keyName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t, err := templates.ParseTemplate(keyName, key.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = templates.Render(t, &bytes.Buffer{}, &dummyData{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ColumnDef{
|
||||
Name: name,
|
||||
Flags: flags,
|
||||
Width: width,
|
||||
Template: t,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ParseColumnDefs(key *ini.Key, section *ini.Section) ([]*ColumnDef, error) {
|
||||
var columns []*ColumnDef
|
||||
for _, col := range key.Strings(",") {
|
||||
c, err := parseColumnDef(col, section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns = append(columns, c)
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type ComposeConfig struct {
|
||||
Editor string `ini:"editor"`
|
||||
HeaderLayout [][]string `ini:"header-layout" parse:"ParseLayout" default:"To|From,Subject"`
|
||||
AddressBookCmd string `ini:"address-book-cmd"`
|
||||
ReplyToSelf bool `ini:"reply-to-self" default:"true"`
|
||||
NoAttachmentWarning *regexp.Regexp `ini:"no-attachment-warning" parse:"ParseNoAttachmentWarning"`
|
||||
EmptySubjectWarning bool `ini:"empty-subject-warning"`
|
||||
FilePickerCmd string `ini:"file-picker-cmd"`
|
||||
FormatFlowed bool `ini:"format-flowed"`
|
||||
EditHeaders bool `ini:"edit-headers"`
|
||||
FocusBody bool `ini:"focus-body"`
|
||||
LFEditor bool `ini:"lf-editor"`
|
||||
}
|
||||
|
||||
var Compose = new(ComposeConfig)
|
||||
|
||||
func parseCompose(file *ini.File) error {
|
||||
if err := MapToStruct(file.Section("compose"), Compose, true); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debugf("aerc.conf: [compose] %#v", Compose)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ComposeConfig) ParseLayout(sec *ini.Section, key *ini.Key) ([][]string, error) {
|
||||
layout := parseLayout(key.String())
|
||||
return layout, nil
|
||||
}
|
||||
|
||||
func (c *ComposeConfig) ParseNoAttachmentWarning(sec *ini.Section, key *ini.Key) (*regexp.Regexp, error) {
|
||||
if key.String() == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return regexp.Compile(`(?im)` + key.String())
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
// Set at build time
|
||||
var (
|
||||
shareDir string
|
||||
libexecDir string
|
||||
)
|
||||
|
||||
func buildDefaultDirs() []string {
|
||||
var defaultDirs []string
|
||||
|
||||
prefixes := []string{
|
||||
xdg.ConfigPath(),
|
||||
"~/.local/libexec",
|
||||
xdg.DataPath(),
|
||||
}
|
||||
|
||||
// Add XDG_CONFIG_HOME and XDG_DATA_HOME
|
||||
for _, v := range prefixes {
|
||||
if v != "" {
|
||||
defaultDirs = append(defaultDirs, xdg.ExpandHome(v, "aerc"))
|
||||
}
|
||||
}
|
||||
|
||||
// Trim null chars inserted post-build by systems like Conda
|
||||
shareDir := strings.TrimRight(shareDir, "\x00")
|
||||
libexecDir := strings.TrimRight(libexecDir, "\x00")
|
||||
|
||||
// Add custom buildtime dirs
|
||||
if libexecDir != "" && libexecDir != "/usr/local/libexec/aerc" {
|
||||
defaultDirs = append(defaultDirs, xdg.ExpandHome(libexecDir))
|
||||
}
|
||||
if shareDir != "" && shareDir != "/usr/local/share/aerc" {
|
||||
defaultDirs = append(defaultDirs, xdg.ExpandHome(shareDir))
|
||||
}
|
||||
|
||||
// Add fixed fallback locations
|
||||
defaultDirs = append(defaultDirs, "/usr/local/libexec/aerc")
|
||||
defaultDirs = append(defaultDirs, "/usr/local/share/aerc")
|
||||
defaultDirs = append(defaultDirs, "/usr/libexec/aerc")
|
||||
defaultDirs = append(defaultDirs, "/usr/share/aerc")
|
||||
|
||||
return defaultDirs
|
||||
}
|
||||
|
||||
var SearchDirs = buildDefaultDirs()
|
||||
|
||||
func installTemplate(root, name string) error {
|
||||
var err error
|
||||
if _, err = os.Stat(root); os.IsNotExist(err) {
|
||||
err = os.MkdirAll(root, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var data []byte
|
||||
for _, dir := range SearchDirs {
|
||||
data, err = os.ReadFile(path.Join(dir, name))
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(path.Join(root, name), data, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseConf(filename string) error {
|
||||
file, err := ini.LoadSources(ini.LoadOptions{
|
||||
KeyValueDelimiters: "=",
|
||||
}, filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseGeneral(file); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseFilters(file); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseCompose(file); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseConverters(file); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseViewer(file); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseStatusline(file); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseOpeners(file); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseHooks(file); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseUi(file); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseTemplates(file); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadConfigFromFile(
|
||||
root *string, accts []string, filename, bindPath, acctPath string,
|
||||
) error {
|
||||
if root == nil {
|
||||
_root := xdg.ConfigPath("aerc")
|
||||
root = &_root
|
||||
}
|
||||
if filename == "" {
|
||||
filename = path.Join(*root, "aerc.conf")
|
||||
// if it doesn't exist copy over the template, then load
|
||||
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Printf("%s not found, installing the system default\n", filename)
|
||||
if err := installTemplate(*root, "aerc.conf"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
SetConfFilename(filename)
|
||||
if err := parseConf(filename); err != nil {
|
||||
return fmt.Errorf("%s: %w", filename, err)
|
||||
}
|
||||
if err := parseAccounts(*root, accts, acctPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := parseBinds(*root, bindPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLayout(layout string) [][]string {
|
||||
rows := strings.Split(layout, ",")
|
||||
l := make([][]string, len(rows))
|
||||
for i, r := range rows {
|
||||
l[i] = strings.Split(r, "|")
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func contains(list []string, v string) bool {
|
||||
for _, item := range list {
|
||||
if item == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// warning message related to configuration (deprecation, etc.)
|
||||
type Warning struct {
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
var Warnings []Warning
|
||||
@@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
var Converters = make(map[string]string)
|
||||
|
||||
func parseConverters(file *ini.File) error {
|
||||
converters, err := file.GetSection("multipart-converters")
|
||||
if err != nil {
|
||||
goto out
|
||||
}
|
||||
|
||||
for mimeType, command := range converters.KeysHash() {
|
||||
mimeType = strings.ToLower(mimeType)
|
||||
if mimeType == "text/plain" {
|
||||
return fmt.Errorf(
|
||||
"multipart-converters: text/plain is reserved")
|
||||
}
|
||||
if !strings.HasPrefix(mimeType, "text/") {
|
||||
return fmt.Errorf(
|
||||
"multipart-converters: %q: only text/* MIME types are supported",
|
||||
mimeType)
|
||||
}
|
||||
Converters[mimeType] = command
|
||||
}
|
||||
|
||||
out:
|
||||
log.Debugf("aerc.conf: [multipart-converters] %#v", Converters)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type FilterType int
|
||||
|
||||
const (
|
||||
FILTER_MIMETYPE FilterType = iota
|
||||
FILTER_HEADER
|
||||
FILTER_HEADERS
|
||||
FILTER_FILENAME
|
||||
)
|
||||
|
||||
type FilterConfig struct {
|
||||
Type FilterType
|
||||
Filter string
|
||||
Command string
|
||||
NeedsPager bool
|
||||
Header string
|
||||
Regex *regexp.Regexp
|
||||
}
|
||||
|
||||
var Filters []*FilterConfig
|
||||
|
||||
func parseFilters(file *ini.File) error {
|
||||
filters, err := file.GetSection("filters")
|
||||
if err != nil {
|
||||
goto end
|
||||
}
|
||||
|
||||
for _, key := range filters.Keys() {
|
||||
pager := true
|
||||
cmd := key.Value()
|
||||
if strings.HasPrefix(cmd, "!") {
|
||||
cmd = strings.TrimLeft(cmd, "! \t")
|
||||
pager = false
|
||||
}
|
||||
filter := FilterConfig{
|
||||
Command: cmd,
|
||||
NeedsPager: pager,
|
||||
Filter: key.Name(),
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(filter.Filter, ".filename,~"):
|
||||
filter.Type = FILTER_FILENAME
|
||||
regex := filter.Filter[strings.Index(filter.Filter, "~")+1:]
|
||||
filter.Regex, err = regexp.Compile(regex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.HasPrefix(filter.Filter, ".filename,"):
|
||||
filter.Type = FILTER_FILENAME
|
||||
value := filter.Filter[strings.Index(filter.Filter, ",")+1:]
|
||||
filter.Regex, err = regexp.Compile(regexp.QuoteMeta(value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.Contains(filter.Filter, ",~"):
|
||||
filter.Type = FILTER_HEADER
|
||||
//nolint:gocritic // guarded by strings.Contains
|
||||
header := filter.Filter[:strings.Index(filter.Filter, ",")]
|
||||
regex := filter.Filter[strings.Index(filter.Filter, "~")+1:]
|
||||
filter.Header = strings.ToLower(header)
|
||||
filter.Regex, err = regexp.Compile(regex)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.ContainsRune(filter.Filter, ','):
|
||||
filter.Type = FILTER_HEADER
|
||||
//nolint:gocritic // guarded by strings.Contains
|
||||
header := filter.Filter[:strings.Index(filter.Filter, ",")]
|
||||
value := filter.Filter[strings.Index(filter.Filter, ",")+1:]
|
||||
filter.Header = strings.ToLower(header)
|
||||
filter.Regex, err = regexp.Compile(regexp.QuoteMeta(value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case filter.Filter == ".headers":
|
||||
filter.Type = FILTER_HEADERS
|
||||
default:
|
||||
filter.Type = FILTER_MIMETYPE
|
||||
}
|
||||
Filters = append(Filters, &filter)
|
||||
}
|
||||
|
||||
end:
|
||||
log.Debugf("aerc.conf: [filters] %#v", Filters)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"github.com/go-ini/ini"
|
||||
"github.com/mattn/go-isatty"
|
||||
)
|
||||
|
||||
type GeneralConfig struct {
|
||||
DefaultSavePath string `ini:"default-save-path"`
|
||||
PgpProvider string `ini:"pgp-provider" default:"auto" parse:"ParsePgpProvider"`
|
||||
UnsafeAccountsConf bool `ini:"unsafe-accounts-conf"`
|
||||
LogFile string `ini:"log-file"`
|
||||
LogLevel log.LogLevel `ini:"log-level" default:"info" parse:"ParseLogLevel"`
|
||||
DisableIPC bool `ini:"disable-ipc"`
|
||||
DisableIPCMailto bool `ini:"disable-ipc-mailto"`
|
||||
DisableIPCMbox bool `ini:"disable-ipc-mbox"`
|
||||
EnableOSC8 bool `ini:"enable-osc8" default:"false"`
|
||||
Term string `ini:"term" default:"xterm-256color"`
|
||||
DefaultMenuCmd string `ini:"default-menu-cmd"`
|
||||
QuakeMode bool `ini:"enable-quake-mode" default:"false"`
|
||||
UsePinentry bool `ini:"use-terminal-pinentry" default:"false"`
|
||||
}
|
||||
|
||||
var General = new(GeneralConfig)
|
||||
|
||||
func parseGeneral(file *ini.File) error {
|
||||
var logFile *os.File
|
||||
|
||||
if err := MapToStruct(file.Section("general"), General, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
useStdout := false
|
||||
if !isatty.IsTerminal(os.Stdout.Fd()) {
|
||||
logFile = os.Stdout
|
||||
useStdout = true
|
||||
// redirected to file, force TRACE level
|
||||
General.LogLevel = log.TRACE
|
||||
} else if General.LogFile != "" {
|
||||
var err error
|
||||
path := xdg.ExpandHome(General.LogFile)
|
||||
err = os.MkdirAll(filepath.Dir(path), 0o700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("log-file: %w", err)
|
||||
}
|
||||
logFile, err = os.OpenFile(path,
|
||||
os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err := log.Init(logFile, useStdout, General.LogLevel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("aerc.conf: [general] %#v", General)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gen *GeneralConfig) ParseLogLevel(sec *ini.Section, key *ini.Key) (log.LogLevel, error) {
|
||||
return log.ParseLevel(key.String())
|
||||
}
|
||||
|
||||
func (gen *GeneralConfig) ParsePgpProvider(sec *ini.Section, key *ini.Key) (string, error) {
|
||||
switch key.String() {
|
||||
case "gpg", "internal", "auto":
|
||||
return key.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("must be either auto, gpg or internal")
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type HooksConfig struct {
|
||||
AercStartup string `ini:"aerc-startup"`
|
||||
AercShutdown string `ini:"aerc-shutdown"`
|
||||
FlagChanged string `ini:"flag-changed"`
|
||||
MailReceived string `ini:"mail-received"`
|
||||
MailDeleted string `ini:"mail-deleted"`
|
||||
MailAdded string `ini:"mail-added"`
|
||||
MailSent string `ini:"mail-sent"`
|
||||
TagModified string `ini:"tag-modified"`
|
||||
}
|
||||
|
||||
var Hooks HooksConfig
|
||||
|
||||
func parseHooks(file *ini.File) error {
|
||||
err := MapToStruct(file.Section("hooks"), &Hooks, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("aerc.conf: [hooks] %#v", Hooks)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type Opener struct {
|
||||
Mime string
|
||||
Args string
|
||||
}
|
||||
|
||||
var Openers []Opener
|
||||
|
||||
func parseOpeners(file *ini.File) error {
|
||||
openers, err := file.GetSection("openers")
|
||||
if err != nil {
|
||||
goto out
|
||||
}
|
||||
|
||||
for _, key := range openers.Keys() {
|
||||
mime := strings.ToLower(key.Name())
|
||||
Openers = append(Openers, Opener{Mime: mime, Args: key.Value()})
|
||||
}
|
||||
|
||||
out:
|
||||
log.Debugf("aerc.conf: [openers] %#v", Openers)
|
||||
return nil
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
func MapToStruct(s *ini.Section, v interface{}, useDefaults bool) error {
|
||||
typ := reflect.TypeOf(v)
|
||||
val := reflect.ValueOf(v)
|
||||
if typ.Kind() == reflect.Ptr {
|
||||
typ = typ.Elem()
|
||||
val = val.Elem()
|
||||
} else {
|
||||
panic("MapToStruct requires a pointer")
|
||||
}
|
||||
if typ.Kind() != reflect.Struct {
|
||||
panic("MapToStruct requires a pointer to a struct")
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
fieldVal := val.Field(i)
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
name := fieldType.Tag.Get("ini")
|
||||
if name == "" || name == "-" {
|
||||
continue
|
||||
}
|
||||
key, err := s.GetKey(name)
|
||||
if err != nil {
|
||||
defValue, found := fieldType.Tag.Lookup("default")
|
||||
if useDefaults && found {
|
||||
key, _ = s.NewKey(name, defValue)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
err = setField(s, key, reflect.ValueOf(v), fieldVal, fieldType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[%s].%s: %w", s.Name(), name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setField(
|
||||
s *ini.Section, key *ini.Key, struc reflect.Value,
|
||||
fieldVal reflect.Value, fieldType reflect.StructField,
|
||||
) error {
|
||||
var methodValue reflect.Value
|
||||
method := getParseMethod(s, key, struc, fieldType)
|
||||
if method.IsValid() {
|
||||
in := []reflect.Value{reflect.ValueOf(s), reflect.ValueOf(key)}
|
||||
out := method.Call(in)
|
||||
err, _ := out[1].Interface().(error)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
methodValue = out[0]
|
||||
}
|
||||
|
||||
ft := fieldType.Type
|
||||
|
||||
switch ft.Kind() {
|
||||
case reflect.String:
|
||||
if method.IsValid() {
|
||||
fieldVal.SetString(methodValue.String())
|
||||
} else {
|
||||
fieldVal.SetString(key.String())
|
||||
}
|
||||
case reflect.Bool:
|
||||
if method.IsValid() {
|
||||
fieldVal.SetBool(methodValue.Bool())
|
||||
} else {
|
||||
boolVal, err := key.Bool()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.SetBool(boolVal)
|
||||
}
|
||||
case reflect.Int32:
|
||||
// impossible to differentiate rune from int32, they are aliases
|
||||
// this is an ugly hack but there is no alternative...
|
||||
if fieldType.Tag.Get("type") == "rune" {
|
||||
if method.IsValid() {
|
||||
fieldVal.Set(methodValue)
|
||||
} else {
|
||||
runes := []rune(key.String())
|
||||
if len(runes) != 1 {
|
||||
return errors.New("value must be 1 character long")
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(runes[0]))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
fallthrough
|
||||
case reflect.Int64:
|
||||
// ParseDuration will not return err for `0`, so check the type name
|
||||
if ft.PkgPath() == "time" && ft.Name() == "Duration" {
|
||||
durationVal, err := key.Duration()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(durationVal))
|
||||
return nil
|
||||
}
|
||||
fallthrough
|
||||
case reflect.Int, reflect.Int8, reflect.Int16:
|
||||
if method.IsValid() {
|
||||
fieldVal.SetInt(methodValue.Int())
|
||||
} else {
|
||||
intVal, err := key.Int64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.SetInt(intVal)
|
||||
}
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
if method.IsValid() {
|
||||
fieldVal.SetUint(methodValue.Uint())
|
||||
} else {
|
||||
uintVal, err := key.Uint64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.SetUint(uintVal)
|
||||
}
|
||||
case reflect.Float32, reflect.Float64:
|
||||
if method.IsValid() {
|
||||
fieldVal.SetFloat(methodValue.Float())
|
||||
} else {
|
||||
floatVal, err := key.Float64()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.SetFloat(floatVal)
|
||||
}
|
||||
case reflect.Slice, reflect.Array:
|
||||
switch {
|
||||
case method.IsValid():
|
||||
fieldVal.Set(methodValue)
|
||||
case ft.Elem().Kind() == reflect.Ptr &&
|
||||
typePath(ft.Elem().Elem()) == "net/mail.Address":
|
||||
addrs, err := mail.ParseAddressList(key.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(addrs))
|
||||
case ft.Elem().Kind() == reflect.String:
|
||||
delim := fieldType.Tag.Get("delim")
|
||||
fieldVal.Set(reflect.ValueOf(key.Strings(delim)))
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type []%s", typePath(ft.Elem())))
|
||||
}
|
||||
case reflect.Struct:
|
||||
if method.IsValid() {
|
||||
fieldVal.Set(methodValue)
|
||||
} else {
|
||||
panic(fmt.Sprintf("unsupported type %s", typePath(ft)))
|
||||
}
|
||||
case reflect.Ptr:
|
||||
if method.IsValid() {
|
||||
fieldVal.Set(methodValue)
|
||||
} else {
|
||||
switch typePath(ft.Elem()) {
|
||||
case "net/mail.Address":
|
||||
addr, err := mail.ParseAddress(key.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(addr))
|
||||
case "regexp.Regexp":
|
||||
r, err := regexp.Compile(key.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(r))
|
||||
case "text/template.Template":
|
||||
t, err := templates.ParseTemplate(key.String(), key.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldVal.Set(reflect.ValueOf(t))
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %s", typePath(ft)))
|
||||
}
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %s", typePath(ft)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getParseMethod(
|
||||
section *ini.Section, key *ini.Key,
|
||||
struc reflect.Value, typ reflect.StructField,
|
||||
) reflect.Value {
|
||||
methodName, found := typ.Tag.Lookup("parse")
|
||||
if !found {
|
||||
return reflect.Value{}
|
||||
}
|
||||
method := struc.MethodByName(methodName)
|
||||
if !method.IsValid() {
|
||||
panic(fmt.Sprintf("(*%s).%s: method not found",
|
||||
struc, methodName))
|
||||
}
|
||||
|
||||
if method.Type().NumIn() != 2 ||
|
||||
method.Type().In(0) != reflect.TypeOf(section) ||
|
||||
method.Type().In(1) != reflect.TypeOf(key) ||
|
||||
method.Type().NumOut() != 2 {
|
||||
panic(fmt.Sprintf("(*%s).%s: invalid signature, expected %s",
|
||||
struc.Elem().Type().Name(), methodName,
|
||||
"func(*ini.Section, *ini.Key) (any, error)"))
|
||||
}
|
||||
|
||||
return method
|
||||
}
|
||||
|
||||
func typePath(t reflect.Type) string {
|
||||
var prefix string
|
||||
if t.Kind() == reflect.Ptr {
|
||||
t = t.Elem()
|
||||
prefix = "*"
|
||||
}
|
||||
return fmt.Sprintf("%s%s.%s", prefix, t.PkgPath(), t.Name())
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
)
|
||||
|
||||
type reloadStore struct {
|
||||
binds string
|
||||
conf string
|
||||
}
|
||||
|
||||
var rlst reloadStore
|
||||
|
||||
func SetBindsFilename(fn string) {
|
||||
log.Debugf("reloader: set binds file: %s", fn)
|
||||
rlst.binds = fn
|
||||
}
|
||||
|
||||
func SetConfFilename(fn string) {
|
||||
log.Debugf("reloader: set conf file: %s", fn)
|
||||
rlst.conf = fn
|
||||
}
|
||||
|
||||
func ReloadBinds() (string, error) {
|
||||
f := rlst.binds
|
||||
if !exists(f) {
|
||||
return f, os.ErrNotExist
|
||||
}
|
||||
log.Debugf("reload binds file: %s", f)
|
||||
Binds = defaultBindsConfig()
|
||||
return f, parseBindsFromFile(filepath.Dir(f), f)
|
||||
}
|
||||
|
||||
func ReloadConf() (string, error) {
|
||||
f := rlst.conf
|
||||
if !exists(f) {
|
||||
return f, os.ErrNotExist
|
||||
}
|
||||
log.Debugf("reload conf file: %s", f)
|
||||
|
||||
General = new(GeneralConfig)
|
||||
Filters = nil
|
||||
Compose = new(ComposeConfig)
|
||||
Converters = make(map[string]string)
|
||||
Viewer = new(ViewerConfig)
|
||||
Statusline = new(StatuslineConfig)
|
||||
Openers = nil
|
||||
Hooks = HooksConfig{}
|
||||
Ui = defaultUIConfig()
|
||||
Templates = new(TemplateConfig)
|
||||
|
||||
return f, parseConf(f)
|
||||
}
|
||||
|
||||
func ReloadAccounts() error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func exists(fn string) bool {
|
||||
if _, err := os.Stat(fn); errors.Is(err, os.ErrNotExist) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type StatuslineConfig struct {
|
||||
StatusColumns []*ColumnDef `ini:"status-columns" parse:"ParseColumns" default:"left<*,center>=,right>*"`
|
||||
ColumnSeparator string `ini:"column-separator" default:" "`
|
||||
Separator string `ini:"separator" default:" | "`
|
||||
DisplayMode string `ini:"display-mode" default:"text"`
|
||||
}
|
||||
|
||||
var Statusline = new(StatuslineConfig)
|
||||
|
||||
func parseStatusline(file *ini.File) error {
|
||||
statusline := file.Section("statusline")
|
||||
if err := MapToStruct(statusline, Statusline, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("aerc.conf: [statusline] %#v", Statusline)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StatuslineConfig) ParseColumns(sec *ini.Section, key *ini.Key) ([]*ColumnDef, error) {
|
||||
if !sec.HasKey("column-left") {
|
||||
_, _ = sec.NewKey("column-left", "[{{.Account}}] {{.StatusInfo}}")
|
||||
}
|
||||
if !sec.HasKey("column-center") {
|
||||
_, _ = sec.NewKey("column-center", "{{.PendingKeys}}")
|
||||
}
|
||||
if !sec.HasKey("column-right") {
|
||||
_, _ = sec.NewKey("column-right", "{{.TrayInfo}} | {{cwd}}")
|
||||
}
|
||||
return ParseColumnDefs(key, sec)
|
||||
}
|
||||
+769
@@ -0,0 +1,769 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"git.sr.ht/~rockorager/vaxis"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
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_WARNING
|
||||
STYLE_STATUSLINE_SUCCESS
|
||||
|
||||
STYLE_MSGLIST_DEFAULT
|
||||
STYLE_MSGLIST_UNREAD
|
||||
STYLE_MSGLIST_READ
|
||||
STYLE_MSGLIST_FLAGGED
|
||||
STYLE_MSGLIST_DELETED
|
||||
STYLE_MSGLIST_MARKED
|
||||
STYLE_MSGLIST_RESULT
|
||||
STYLE_MSGLIST_ANSWERED
|
||||
STYLE_MSGLIST_FORWARDED
|
||||
STYLE_MSGLIST_THREAD_FOLDED
|
||||
STYLE_MSGLIST_GUTTER
|
||||
STYLE_MSGLIST_PILL
|
||||
STYLE_MSGLIST_THREAD_CONTEXT
|
||||
STYLE_MSGLIST_THREAD_ORPHAN
|
||||
|
||||
STYLE_DIRLIST_DEFAULT
|
||||
STYLE_DIRLIST_UNREAD
|
||||
STYLE_DIRLIST_RECENT
|
||||
|
||||
STYLE_PART_SWITCHER
|
||||
STYLE_PART_FILENAME
|
||||
STYLE_PART_MIMETYPE
|
||||
|
||||
STYLE_COMPLETION_DEFAULT
|
||||
STYLE_COMPLETION_DESCRIPTION
|
||||
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_warning": STYLE_STATUSLINE_WARNING,
|
||||
"statusline_success": STYLE_STATUSLINE_SUCCESS,
|
||||
|
||||
"msglist_default": STYLE_MSGLIST_DEFAULT,
|
||||
"msglist_unread": STYLE_MSGLIST_UNREAD,
|
||||
"msglist_read": STYLE_MSGLIST_READ,
|
||||
"msglist_flagged": STYLE_MSGLIST_FLAGGED,
|
||||
"msglist_deleted": STYLE_MSGLIST_DELETED,
|
||||
"msglist_marked": STYLE_MSGLIST_MARKED,
|
||||
"msglist_result": STYLE_MSGLIST_RESULT,
|
||||
"msglist_answered": STYLE_MSGLIST_ANSWERED,
|
||||
"msglist_forwarded": STYLE_MSGLIST_FORWARDED,
|
||||
"msglist_gutter": STYLE_MSGLIST_GUTTER,
|
||||
"msglist_pill": STYLE_MSGLIST_PILL,
|
||||
|
||||
"msglist_thread_folded": STYLE_MSGLIST_THREAD_FOLDED,
|
||||
"msglist_thread_context": STYLE_MSGLIST_THREAD_CONTEXT,
|
||||
"msglist_thread_orphan": STYLE_MSGLIST_THREAD_ORPHAN,
|
||||
|
||||
"dirlist_default": STYLE_DIRLIST_DEFAULT,
|
||||
"dirlist_unread": STYLE_DIRLIST_UNREAD,
|
||||
"dirlist_recent": STYLE_DIRLIST_RECENT,
|
||||
|
||||
"part_switcher": STYLE_PART_SWITCHER,
|
||||
"part_filename": STYLE_PART_FILENAME,
|
||||
"part_mimetype": STYLE_PART_MIMETYPE,
|
||||
|
||||
"completion_default": STYLE_COMPLETION_DEFAULT,
|
||||
"completion_description": STYLE_COMPLETION_DESCRIPTION,
|
||||
"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 StyleHeaderPattern struct {
|
||||
RawPattern string
|
||||
Re *regexp.Regexp
|
||||
}
|
||||
|
||||
type Style struct {
|
||||
Fg vaxis.Color
|
||||
Bg vaxis.Color
|
||||
Bold bool
|
||||
Blink bool
|
||||
Underline bool
|
||||
Reverse bool
|
||||
Italic bool
|
||||
Dim bool
|
||||
// Only for msglist, maps header -> pattern/regexp
|
||||
// All regexps must match in order for the style to be applied
|
||||
headerPatterns map[string]*StyleHeaderPattern
|
||||
}
|
||||
|
||||
func (s Style) Get() vaxis.Style {
|
||||
vx := vaxis.Style{
|
||||
Foreground: s.Fg,
|
||||
Background: s.Bg,
|
||||
}
|
||||
if s.Bold {
|
||||
vx.Attribute |= vaxis.AttrBold
|
||||
}
|
||||
if s.Blink {
|
||||
vx.Attribute |= vaxis.AttrBlink
|
||||
}
|
||||
if s.Underline {
|
||||
vx.UnderlineStyle |= vaxis.UnderlineSingle
|
||||
}
|
||||
if s.Reverse {
|
||||
vx.Attribute |= vaxis.AttrReverse
|
||||
}
|
||||
if s.Italic {
|
||||
vx.Attribute |= vaxis.AttrItalic
|
||||
}
|
||||
if s.Dim {
|
||||
vx.Attribute |= vaxis.AttrDim
|
||||
}
|
||||
return vx
|
||||
}
|
||||
|
||||
func (s *Style) Normal() {
|
||||
s.Bold = false
|
||||
s.Blink = false
|
||||
s.Underline = false
|
||||
s.Reverse = false
|
||||
s.Italic = false
|
||||
s.Dim = false
|
||||
}
|
||||
|
||||
func (s *Style) Default() *Style {
|
||||
s.Fg = 0
|
||||
s.Bg = 0
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Style) Reset() *Style {
|
||||
s.Default()
|
||||
s.Normal()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Style) hasSameHeaderPatterns(other map[string]*StyleHeaderPattern) bool {
|
||||
return maps.EqualFunc(s.headerPatterns, other, func(a, b *StyleHeaderPattern) bool {
|
||||
return a.RawPattern == b.RawPattern
|
||||
})
|
||||
}
|
||||
|
||||
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 extractColor(val string) vaxis.Color {
|
||||
// Check if the string can be interpreted as a number, indicating a
|
||||
// reference to the color number. Otherwise retrieve the number based
|
||||
// on the name.
|
||||
if i, err := strconv.ParseUint(val, 10, 8); err == nil {
|
||||
return vaxis.IndexColor(uint8(i))
|
||||
}
|
||||
if strings.HasPrefix(val, "#") {
|
||||
val = strings.TrimPrefix(val, "#")
|
||||
hex, err := strconv.ParseUint(val, 16, 32)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return vaxis.HexColor(uint32(hex))
|
||||
}
|
||||
return colorNames[val]
|
||||
}
|
||||
|
||||
func (s *Style) Set(attr, val string) error {
|
||||
switch attr {
|
||||
case "fg":
|
||||
s.Fg = extractColor(val)
|
||||
case "bg":
|
||||
s.Bg = extractColor(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 "italic":
|
||||
if state, err := boolSwitch(val, s.Italic); err != nil {
|
||||
return err
|
||||
} else {
|
||||
s.Italic = state
|
||||
}
|
||||
case "dim":
|
||||
if state, err := boolSwitch(val, s.Dim); err != nil {
|
||||
return err
|
||||
} else {
|
||||
s.Dim = state
|
||||
}
|
||||
case "default":
|
||||
s.Default()
|
||||
case "normal":
|
||||
s.Normal()
|
||||
default:
|
||||
return errors.New("Unknown style attribute: " + attr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Style) composeWith(styles []*Style) Style {
|
||||
newStyle := s
|
||||
for _, st := range styles {
|
||||
if st.Fg != s.Fg && st.Fg != 0 {
|
||||
newStyle.Fg = st.Fg
|
||||
}
|
||||
if st.Bg != s.Bg && st.Bg != 0 {
|
||||
newStyle.Bg = st.Bg
|
||||
}
|
||||
if st.Bold != s.Bold {
|
||||
newStyle.Bold = st.Bold
|
||||
}
|
||||
if st.Blink != s.Blink {
|
||||
newStyle.Blink = st.Blink
|
||||
}
|
||||
if st.Underline != s.Underline {
|
||||
newStyle.Underline = st.Underline
|
||||
}
|
||||
if st.Reverse != s.Reverse {
|
||||
newStyle.Reverse = st.Reverse
|
||||
}
|
||||
if st.Italic != s.Italic {
|
||||
newStyle.Italic = st.Italic
|
||||
}
|
||||
if st.Dim != s.Dim {
|
||||
newStyle.Dim = st.Dim
|
||||
}
|
||||
}
|
||||
return newStyle
|
||||
}
|
||||
|
||||
type StyleConf struct {
|
||||
base Style
|
||||
dynamic []Style
|
||||
}
|
||||
|
||||
type StyleSet struct {
|
||||
objects map[StyleObject]*StyleConf
|
||||
selected map[StyleObject]*StyleConf
|
||||
user map[string]*Style
|
||||
path string
|
||||
}
|
||||
|
||||
const defaultStyleset string = `
|
||||
*.selected.bg = 12
|
||||
*.selected.fg = 15
|
||||
*.selected.bold = true
|
||||
statusline_*.dim = true
|
||||
*warning.dim = false
|
||||
*warning.bold = true
|
||||
*warning.fg = 11
|
||||
*success.dim = false
|
||||
*success.bold = true
|
||||
*success.fg = 10
|
||||
*error.dim = false
|
||||
*error.bold = true
|
||||
*error.fg = 9
|
||||
border.fg = 12
|
||||
border.bold = true
|
||||
title.bg = 12
|
||||
title.fg = 15
|
||||
title.bold = true
|
||||
header.fg = 4
|
||||
header.bold = true
|
||||
msglist_unread.bold = true
|
||||
msglist_deleted.dim = true
|
||||
msglist_marked.bg = 6
|
||||
msglist_marked.fg = 15
|
||||
msglist_pill.bg = 12
|
||||
msglist_pill.fg = 15
|
||||
part_mimetype.fg = 12
|
||||
selector_chooser.bold = true
|
||||
selector_focused.bold = true
|
||||
selector_focused.bg = 12
|
||||
selector_focused.fg = 15
|
||||
completion_*.bg = 8
|
||||
completion_pill.bg = 12
|
||||
completion_default.fg = 15
|
||||
completion_description.fg = 15
|
||||
completion_description.dim = true
|
||||
`
|
||||
|
||||
func NewStyleSet() StyleSet {
|
||||
ss := StyleSet{
|
||||
objects: make(map[StyleObject]*StyleConf),
|
||||
selected: make(map[StyleObject]*StyleConf),
|
||||
user: make(map[string]*Style),
|
||||
}
|
||||
for _, so := range StyleNames {
|
||||
ss.objects[so] = new(StyleConf)
|
||||
ss.selected[so] = new(StyleConf)
|
||||
}
|
||||
f, err := ini.Load([]byte(defaultStyleset))
|
||||
if err == nil {
|
||||
err = ss.ParseStyleSet(f)
|
||||
}
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
func (c *StyleConf) getStyle(h *mail.Header) *Style {
|
||||
if h == nil {
|
||||
return &c.base
|
||||
}
|
||||
style := &c.base
|
||||
|
||||
// All dynamic styles must be iterated through, as later ones might be a
|
||||
// narrower match based due to multiple header patterns.
|
||||
for _, s := range c.dynamic {
|
||||
allMatch := true
|
||||
for header, pattern := range s.headerPatterns {
|
||||
val, _ := h.Text(header)
|
||||
allMatch = allMatch && pattern.Re.MatchString(val)
|
||||
}
|
||||
|
||||
if allMatch {
|
||||
s := c.base.composeWith([]*Style{&s})
|
||||
style = &s
|
||||
}
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
func (ss StyleSet) Get(so StyleObject, h *mail.Header) vaxis.Style {
|
||||
return ss.objects[so].getStyle(h).Get()
|
||||
}
|
||||
|
||||
func (ss StyleSet) Selected(so StyleObject, h *mail.Header) vaxis.Style {
|
||||
return ss.selected[so].getStyle(h).Get()
|
||||
}
|
||||
|
||||
func (ss StyleSet) UserStyle(name string) vaxis.Style {
|
||||
if style, found := ss.user[name]; found {
|
||||
return style.Get()
|
||||
}
|
||||
return vaxis.Style{}
|
||||
}
|
||||
|
||||
func (ss StyleSet) Compose(
|
||||
so StyleObject, sos []StyleObject, h *mail.Header,
|
||||
) vaxis.Style {
|
||||
base := *ss.objects[so].getStyle(h)
|
||||
styles := make([]*Style, len(sos))
|
||||
for i, so := range sos {
|
||||
styles[i] = ss.objects[so].getStyle(h)
|
||||
}
|
||||
|
||||
return base.composeWith(styles).Get()
|
||||
}
|
||||
|
||||
func (ss StyleSet) ComposeSelected(
|
||||
so StyleObject, sos []StyleObject, h *mail.Header,
|
||||
) vaxis.Style {
|
||||
base := *ss.selected[so].getStyle(h)
|
||||
styles := make([]*Style, len(sos))
|
||||
for i, so := range sos {
|
||||
styles[i] = ss.selected[so].getStyle(h)
|
||||
}
|
||||
|
||||
return base.composeWith(styles).Get()
|
||||
}
|
||||
|
||||
func findStyleSet(stylesetName string, stylesetsDir []string) (string, error) {
|
||||
for _, dir := range stylesetsDir {
|
||||
stylesetPath := xdg.ExpandHome(dir, stylesetName)
|
||||
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 {
|
||||
defaultSection, err := file.GetSection(ini.DefaultSection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// parse non-selected items first
|
||||
for _, key := range defaultSection.Keys() {
|
||||
err = ss.parseKey(key, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// override with selected items afterwards
|
||||
for _, key := range defaultSection.Keys() {
|
||||
err = ss.parseKey(key, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
user, err := file.GetSection("user")
|
||||
if err != nil {
|
||||
// This errors if the section doesn't exist, which is ok
|
||||
return nil
|
||||
}
|
||||
for _, key := range user.KeyStrings() {
|
||||
tokens := strings.Split(key, ".")
|
||||
var styleName, attr string
|
||||
switch len(tokens) {
|
||||
case 2:
|
||||
styleName, attr = tokens[0], tokens[1]
|
||||
default:
|
||||
return errors.New("Style parsing error: " + key)
|
||||
}
|
||||
val := user.KeysHash()[key]
|
||||
s, ok := ss.user[styleName]
|
||||
if !ok {
|
||||
// Haven't seen this name before, add it to the map
|
||||
s = &Style{}
|
||||
ss.user[styleName] = s
|
||||
}
|
||||
if err := s.Set(attr, val); err != nil {
|
||||
return fmt.Errorf("[user].%s=%s: %w", key, val, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
styleObjRe = regexp.MustCompile(`^([\w\*\?]+)(\.(?:[\w-]+,.+?)+?)?(\.selected)?\.(\w+)$`)
|
||||
styleHeaderPatternsRe = regexp.MustCompile(`([\w-]+),(~/(?:.+?)/|(?:.+?))\.`)
|
||||
)
|
||||
|
||||
func (ss *StyleSet) parseKey(key *ini.Key, selected bool) error {
|
||||
groups := styleObjRe.FindStringSubmatch(key.Name())
|
||||
if groups == nil {
|
||||
return errors.New("invalid style syntax: " + key.Name())
|
||||
}
|
||||
if (groups[3] == ".selected") != selected {
|
||||
return nil
|
||||
}
|
||||
obj, attr := groups[1], groups[4]
|
||||
|
||||
// As there can be multiple header patterns, match them separately, one
|
||||
// by one
|
||||
headerMatches := styleHeaderPatternsRe.FindAllStringSubmatch(groups[2]+".", -1)
|
||||
headerPatterns := make(map[string]*StyleHeaderPattern)
|
||||
for _, match := range headerMatches {
|
||||
headerPatterns[match[1]] = &StyleHeaderPattern{
|
||||
RawPattern: match[2],
|
||||
}
|
||||
}
|
||||
|
||||
objRe, err := fnmatchToRegex(obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
num := 0
|
||||
for sn, so := range StyleNames {
|
||||
if !objRe.MatchString(sn) {
|
||||
continue
|
||||
}
|
||||
if !selected {
|
||||
err = ss.objects[so].update(headerPatterns, attr, key.Value())
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s=%s: %w", key.Name(), key.Value(), err)
|
||||
}
|
||||
}
|
||||
err = ss.selected[so].update(headerPatterns, attr, key.Value())
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s=%s: %w", key.Name(), key.Value(), err)
|
||||
}
|
||||
num++
|
||||
}
|
||||
if num == 0 {
|
||||
return errors.New("unknown style object: " + obj)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *StyleConf) update(headerPatterns map[string]*StyleHeaderPattern, attr, val string) error {
|
||||
if len(headerPatterns) == 0 {
|
||||
return (&c.base).Set(attr, val)
|
||||
}
|
||||
|
||||
// Check existing entries and overwrite ones with same header/pattern
|
||||
for i := range c.dynamic {
|
||||
s := &c.dynamic[i]
|
||||
if s.hasSameHeaderPatterns(headerPatterns) {
|
||||
return s.Set(attr, val)
|
||||
}
|
||||
}
|
||||
|
||||
s := Style{}
|
||||
err := (&s).Set(attr, val)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range headerPatterns {
|
||||
var pattern string
|
||||
switch {
|
||||
case strings.HasPrefix(p.RawPattern, "~/"):
|
||||
pattern = p.RawPattern[2 : len(p.RawPattern)-1]
|
||||
case strings.HasPrefix(p.RawPattern, "~"):
|
||||
pattern = p.RawPattern[1:]
|
||||
default:
|
||||
pattern = "^" + regexp.QuoteMeta(p.RawPattern) + "$"
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Re = re
|
||||
}
|
||||
|
||||
s.headerPatterns = headerPatterns
|
||||
c.dynamic = append(c.dynamic, s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ss *StyleSet) LoadStyleSet(stylesetName string, stylesetDirs []string) error {
|
||||
filepath, err := findStyleSet(stylesetName, stylesetDirs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var options ini.LoadOptions
|
||||
options.SpaceBeforeInlineComment = true
|
||||
|
||||
file, err := ini.LoadSources(options, filepath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ss.path = filepath
|
||||
|
||||
return ss.ParseStyleSet(file)
|
||||
}
|
||||
|
||||
func fnmatchToRegex(pattern string) (*regexp.Regexp, error) {
|
||||
p := regexp.QuoteMeta(pattern)
|
||||
p = strings.ReplaceAll(p, `\*`, `.*`)
|
||||
return regexp.Compile(strings.ReplaceAll(p, `\?`, `.`))
|
||||
}
|
||||
|
||||
var colorNames = map[string]vaxis.Color{
|
||||
"black": vaxis.IndexColor(0),
|
||||
"maroon": vaxis.IndexColor(1),
|
||||
"green": vaxis.IndexColor(2),
|
||||
"olive": vaxis.IndexColor(3),
|
||||
"navy": vaxis.IndexColor(4),
|
||||
"purple": vaxis.IndexColor(5),
|
||||
"teal": vaxis.IndexColor(6),
|
||||
"silver": vaxis.IndexColor(7),
|
||||
"gray": vaxis.IndexColor(8),
|
||||
"red": vaxis.IndexColor(9),
|
||||
"lime": vaxis.IndexColor(10),
|
||||
"yellow": vaxis.IndexColor(11),
|
||||
"blue": vaxis.IndexColor(12),
|
||||
"fuchsia": vaxis.IndexColor(13),
|
||||
"aqua": vaxis.IndexColor(14),
|
||||
"white": vaxis.IndexColor(15),
|
||||
"aliceblue": vaxis.HexColor(0xF0F8FF),
|
||||
"antiquewhite": vaxis.HexColor(0xFAEBD7),
|
||||
"aquamarine": vaxis.HexColor(0x7FFFD4),
|
||||
"azure": vaxis.HexColor(0xF0FFFF),
|
||||
"beige": vaxis.HexColor(0xF5F5DC),
|
||||
"bisque": vaxis.HexColor(0xFFE4C4),
|
||||
"blanchedalmond": vaxis.HexColor(0xFFEBCD),
|
||||
"blueviolet": vaxis.HexColor(0x8A2BE2),
|
||||
"brown": vaxis.HexColor(0xA52A2A),
|
||||
"burlywood": vaxis.HexColor(0xDEB887),
|
||||
"cadetblue": vaxis.HexColor(0x5F9EA0),
|
||||
"chartreuse": vaxis.HexColor(0x7FFF00),
|
||||
"chocolate": vaxis.HexColor(0xD2691E),
|
||||
"coral": vaxis.HexColor(0xFF7F50),
|
||||
"cornflowerblue": vaxis.HexColor(0x6495ED),
|
||||
"cornsilk": vaxis.HexColor(0xFFF8DC),
|
||||
"crimson": vaxis.HexColor(0xDC143C),
|
||||
"darkblue": vaxis.HexColor(0x00008B),
|
||||
"darkcyan": vaxis.HexColor(0x008B8B),
|
||||
"darkgoldenrod": vaxis.HexColor(0xB8860B),
|
||||
"darkgray": vaxis.HexColor(0xA9A9A9),
|
||||
"darkgreen": vaxis.HexColor(0x006400),
|
||||
"darkkhaki": vaxis.HexColor(0xBDB76B),
|
||||
"darkmagenta": vaxis.HexColor(0x8B008B),
|
||||
"darkolivegreen": vaxis.HexColor(0x556B2F),
|
||||
"darkorange": vaxis.HexColor(0xFF8C00),
|
||||
"darkorchid": vaxis.HexColor(0x9932CC),
|
||||
"darkred": vaxis.HexColor(0x8B0000),
|
||||
"darksalmon": vaxis.HexColor(0xE9967A),
|
||||
"darkseagreen": vaxis.HexColor(0x8FBC8F),
|
||||
"darkslateblue": vaxis.HexColor(0x483D8B),
|
||||
"darkslategray": vaxis.HexColor(0x2F4F4F),
|
||||
"darkturquoise": vaxis.HexColor(0x00CED1),
|
||||
"darkviolet": vaxis.HexColor(0x9400D3),
|
||||
"deeppink": vaxis.HexColor(0xFF1493),
|
||||
"deepskyblue": vaxis.HexColor(0x00BFFF),
|
||||
"dimgray": vaxis.HexColor(0x696969),
|
||||
"dodgerblue": vaxis.HexColor(0x1E90FF),
|
||||
"firebrick": vaxis.HexColor(0xB22222),
|
||||
"floralwhite": vaxis.HexColor(0xFFFAF0),
|
||||
"forestgreen": vaxis.HexColor(0x228B22),
|
||||
"gainsboro": vaxis.HexColor(0xDCDCDC),
|
||||
"ghostwhite": vaxis.HexColor(0xF8F8FF),
|
||||
"gold": vaxis.HexColor(0xFFD700),
|
||||
"goldenrod": vaxis.HexColor(0xDAA520),
|
||||
"greenyellow": vaxis.HexColor(0xADFF2F),
|
||||
"honeydew": vaxis.HexColor(0xF0FFF0),
|
||||
"hotpink": vaxis.HexColor(0xFF69B4),
|
||||
"indianred": vaxis.HexColor(0xCD5C5C),
|
||||
"indigo": vaxis.HexColor(0x4B0082),
|
||||
"ivory": vaxis.HexColor(0xFFFFF0),
|
||||
"khaki": vaxis.HexColor(0xF0E68C),
|
||||
"lavender": vaxis.HexColor(0xE6E6FA),
|
||||
"lavenderblush": vaxis.HexColor(0xFFF0F5),
|
||||
"lawngreen": vaxis.HexColor(0x7CFC00),
|
||||
"lemonchiffon": vaxis.HexColor(0xFFFACD),
|
||||
"lightblue": vaxis.HexColor(0xADD8E6),
|
||||
"lightcoral": vaxis.HexColor(0xF08080),
|
||||
"lightcyan": vaxis.HexColor(0xE0FFFF),
|
||||
"lightgoldenrodyellow": vaxis.HexColor(0xFAFAD2),
|
||||
"lightgray": vaxis.HexColor(0xD3D3D3),
|
||||
"lightgreen": vaxis.HexColor(0x90EE90),
|
||||
"lightpink": vaxis.HexColor(0xFFB6C1),
|
||||
"lightsalmon": vaxis.HexColor(0xFFA07A),
|
||||
"lightseagreen": vaxis.HexColor(0x20B2AA),
|
||||
"lightskyblue": vaxis.HexColor(0x87CEFA),
|
||||
"lightslategray": vaxis.HexColor(0x778899),
|
||||
"lightsteelblue": vaxis.HexColor(0xB0C4DE),
|
||||
"lightyellow": vaxis.HexColor(0xFFFFE0),
|
||||
"limegreen": vaxis.HexColor(0x32CD32),
|
||||
"linen": vaxis.HexColor(0xFAF0E6),
|
||||
"mediumaquamarine": vaxis.HexColor(0x66CDAA),
|
||||
"mediumblue": vaxis.HexColor(0x0000CD),
|
||||
"mediumorchid": vaxis.HexColor(0xBA55D3),
|
||||
"mediumpurple": vaxis.HexColor(0x9370DB),
|
||||
"mediumseagreen": vaxis.HexColor(0x3CB371),
|
||||
"mediumslateblue": vaxis.HexColor(0x7B68EE),
|
||||
"mediumspringgreen": vaxis.HexColor(0x00FA9A),
|
||||
"mediumturquoise": vaxis.HexColor(0x48D1CC),
|
||||
"mediumvioletred": vaxis.HexColor(0xC71585),
|
||||
"midnightblue": vaxis.HexColor(0x191970),
|
||||
"mintcream": vaxis.HexColor(0xF5FFFA),
|
||||
"mistyrose": vaxis.HexColor(0xFFE4E1),
|
||||
"moccasin": vaxis.HexColor(0xFFE4B5),
|
||||
"navajowhite": vaxis.HexColor(0xFFDEAD),
|
||||
"oldlace": vaxis.HexColor(0xFDF5E6),
|
||||
"olivedrab": vaxis.HexColor(0x6B8E23),
|
||||
"orange": vaxis.HexColor(0xFFA500),
|
||||
"orangered": vaxis.HexColor(0xFF4500),
|
||||
"orchid": vaxis.HexColor(0xDA70D6),
|
||||
"palegoldenrod": vaxis.HexColor(0xEEE8AA),
|
||||
"palegreen": vaxis.HexColor(0x98FB98),
|
||||
"paleturquoise": vaxis.HexColor(0xAFEEEE),
|
||||
"palevioletred": vaxis.HexColor(0xDB7093),
|
||||
"papayawhip": vaxis.HexColor(0xFFEFD5),
|
||||
"peachpuff": vaxis.HexColor(0xFFDAB9),
|
||||
"peru": vaxis.HexColor(0xCD853F),
|
||||
"pink": vaxis.HexColor(0xFFC0CB),
|
||||
"plum": vaxis.HexColor(0xDDA0DD),
|
||||
"powderblue": vaxis.HexColor(0xB0E0E6),
|
||||
"rebeccapurple": vaxis.HexColor(0x663399),
|
||||
"rosybrown": vaxis.HexColor(0xBC8F8F),
|
||||
"royalblue": vaxis.HexColor(0x4169E1),
|
||||
"saddlebrown": vaxis.HexColor(0x8B4513),
|
||||
"salmon": vaxis.HexColor(0xFA8072),
|
||||
"sandybrown": vaxis.HexColor(0xF4A460),
|
||||
"seagreen": vaxis.HexColor(0x2E8B57),
|
||||
"seashell": vaxis.HexColor(0xFFF5EE),
|
||||
"sienna": vaxis.HexColor(0xA0522D),
|
||||
"skyblue": vaxis.HexColor(0x87CEEB),
|
||||
"slateblue": vaxis.HexColor(0x6A5ACD),
|
||||
"slategray": vaxis.HexColor(0x708090),
|
||||
"snow": vaxis.HexColor(0xFFFAFA),
|
||||
"springgreen": vaxis.HexColor(0x00FF7F),
|
||||
"steelblue": vaxis.HexColor(0x4682B4),
|
||||
"tan": vaxis.HexColor(0xD2B48C),
|
||||
"thistle": vaxis.HexColor(0xD8BFD8),
|
||||
"tomato": vaxis.HexColor(0xFF6347),
|
||||
"turquoise": vaxis.HexColor(0x40E0D0),
|
||||
"violet": vaxis.HexColor(0xEE82EE),
|
||||
"wheat": vaxis.HexColor(0xF5DEB3),
|
||||
"whitesmoke": vaxis.HexColor(0xF5F5F5),
|
||||
"yellowgreen": vaxis.HexColor(0x9ACD32),
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
const multiHeaderStyleset string = `
|
||||
msglist_*.fg = salmon
|
||||
msglist_*.From,~^"Bob Foo".fg = khaki
|
||||
msglist_*.From,~^"Bob Foo".selected.fg = palegreen
|
||||
msglist_*.Subject,~PATCH.From,~^"Bob Foo".fg = coral
|
||||
msglist_*.From,~^"Bob Foo".Subject,~PATCH.X-Baz,exact.X-Clacks-Overhead,~Pratchett$.fg = plum
|
||||
msglist_*.From,~^"Bob Foo".Subject,~PATCH.X-Clacks-Overhead,~Pratchett$.fg = pink
|
||||
msglist_*.From,~^"Bob Foo".List-ID,~/lists\.sr\.ht/.fg = pink
|
||||
`
|
||||
|
||||
func TestStyleMultiHeaderPattern(t *testing.T) {
|
||||
ini, err := ini.Load([]byte(multiHeaderStyleset))
|
||||
if err != nil {
|
||||
t.Errorf("failed to load styleset: %v", err)
|
||||
}
|
||||
|
||||
ss := NewStyleSet()
|
||||
err = ss.ParseStyleSet(ini)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse styleset: %v", err)
|
||||
}
|
||||
|
||||
t.Run("default color", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Alice Foo", "alice@foo.org"}})
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
if s.Foreground != colorNames["salmon"] {
|
||||
t.Errorf("expected:#%v got:#%v", colorNames["salmon"], s.Foreground)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single header", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "bob@foo.org"}})
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
if s.Foreground != colorNames["khaki"] {
|
||||
t.Errorf("expected:#%v got:#%v", colorNames["khaki"], s.Foreground)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two headers", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
|
||||
h.SetSubject("[PATCH] tests")
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
if s.Foreground != colorNames["coral"] {
|
||||
t.Errorf("expected:#%x got:#%x", colorNames["coral"], s.Foreground)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple headers", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
|
||||
h.SetSubject("[PATCH] tests")
|
||||
h.SetText("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
if s.Foreground != colorNames["pink"] {
|
||||
t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves order-sensitivity", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
|
||||
h.SetSubject("[PATCH] tests")
|
||||
h.SetText("X-Clacks-Overhead", "GNU Terry Pratchett")
|
||||
h.SetText("X-Baz", "exact")
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
|
||||
// The "pink" entry comes later, so will overrule the more exact
|
||||
// match with color "plum"
|
||||
if s.Foreground != colorNames["pink"] {
|
||||
t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles uris in regular expressions", func(t *testing.T) {
|
||||
var h mail.Header
|
||||
h.SetAddressList("From", []*mail.Address{{"Bob Foo", "Bob@foo.org"}})
|
||||
h.SetText("List-ID", "List-ID: ~rjarry/aerc-discuss <~rjarry/aerc-discuss.lists.sr.ht>")
|
||||
|
||||
s := ss.Get(STYLE_MSGLIST_DEFAULT, &h)
|
||||
if s.Foreground != colorNames["pink"] {
|
||||
t.Errorf("expected:#%x got:#%x", colorNames["pink"], s.Foreground)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type TemplateConfig struct {
|
||||
TemplateDirs []string `ini:"template-dirs" delim:":"`
|
||||
NewMessage string `ini:"new-message" default:"new_message"`
|
||||
QuotedReply string `ini:"quoted-reply" default:"quoted_reply"`
|
||||
Forwards string `ini:"forwards" default:"forward_as_body"`
|
||||
}
|
||||
|
||||
var Templates = new(TemplateConfig)
|
||||
|
||||
func parseTemplates(file *ini.File) error {
|
||||
if err := MapToStruct(file.Section("templates"), Templates, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// append default paths to template-dirs
|
||||
for _, dir := range SearchDirs {
|
||||
Templates.TemplateDirs = append(
|
||||
Templates.TemplateDirs, path.Join(dir, "templates"),
|
||||
)
|
||||
}
|
||||
|
||||
// we want to fail during startup if the templates are not ok
|
||||
// hence we do dummy executes here
|
||||
t := Templates
|
||||
if err := checkTemplate(t.NewMessage, t.TemplateDirs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkTemplate(t.QuotedReply, t.TemplateDirs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkTemplate(t.Forwards, t.TemplateDirs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("aerc.conf: [templates] %#v", Templates)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkTemplate(filename string, dirs []string) error {
|
||||
var data dummyData
|
||||
_, err := templates.ParseTemplateFromFile(filename, dirs, &data)
|
||||
return err
|
||||
}
|
||||
|
||||
// only for validation
|
||||
type dummyData struct{}
|
||||
|
||||
var (
|
||||
addr1 = mail.Address{Name: "John Foo", Address: "foo@bar.org"}
|
||||
addr2 = mail.Address{Name: "John Bar", Address: "bar@foo.org"}
|
||||
)
|
||||
|
||||
func (d *dummyData) Account() string { return "work" }
|
||||
func (d *dummyData) AccountBackend() string { return "maildir" }
|
||||
func (d *dummyData) AccountFrom() *mail.Address { return &addr1 }
|
||||
func (d *dummyData) Signature() string { return "" }
|
||||
func (d *dummyData) Folder() string { return "INBOX" }
|
||||
func (d *dummyData) To() []*mail.Address { return []*mail.Address{&addr1} }
|
||||
func (d *dummyData) Cc() []*mail.Address { return nil }
|
||||
func (d *dummyData) Bcc() []*mail.Address { return nil }
|
||||
func (d *dummyData) From() []*mail.Address { return []*mail.Address{&addr2} }
|
||||
func (d *dummyData) Peer() []*mail.Address { return d.From() }
|
||||
func (d *dummyData) ReplyTo() []*mail.Address { return nil }
|
||||
func (d *dummyData) Date() time.Time { return time.Now() }
|
||||
func (d *dummyData) DateAutoFormat(time.Time) string { return "" }
|
||||
func (d *dummyData) Header(string) string { return "" }
|
||||
func (d *dummyData) ThreadPrefix() string { return "└─>" }
|
||||
func (d *dummyData) ThreadCount() int { return 0 }
|
||||
func (d *dummyData) ThreadUnread() int { return 0 }
|
||||
func (d *dummyData) ThreadFolded() bool { return false }
|
||||
func (d *dummyData) ThreadContext() bool { return true }
|
||||
func (d *dummyData) ThreadOrphan() bool { return true }
|
||||
func (d *dummyData) Subject() string { return "Re: [PATCH] hey" }
|
||||
func (d *dummyData) SubjectBase() string { return "[PATCH] hey" }
|
||||
func (d *dummyData) Attach(string) string { return "" }
|
||||
func (d *dummyData) Number() int { return 0 }
|
||||
func (d *dummyData) Labels() []string { return nil }
|
||||
func (d *dummyData) Filename() string { return "" }
|
||||
func (d *dummyData) Filenames() []string { return nil }
|
||||
func (d *dummyData) Flags() []string { return nil }
|
||||
func (d *dummyData) IsReplied() bool { return true }
|
||||
func (d *dummyData) HasAttachment() bool { return true }
|
||||
func (d *dummyData) IsRecent() bool { return false }
|
||||
func (d *dummyData) IsUnread() bool { return false }
|
||||
func (d *dummyData) IsFlagged() bool { return false }
|
||||
func (d *dummyData) IsDraft() bool { return false }
|
||||
func (d *dummyData) IsMarked() bool { return false }
|
||||
func (d *dummyData) IsForwarded() bool { return false }
|
||||
func (d *dummyData) MessageId() string { return "123456789@foo.org" }
|
||||
func (d *dummyData) Size() int { return 420 }
|
||||
func (d *dummyData) OriginalText() string { return "Blah blah blah" }
|
||||
func (d *dummyData) OriginalDate() time.Time { return time.Now() }
|
||||
func (d *dummyData) OriginalFrom() []*mail.Address { return d.From() }
|
||||
func (d *dummyData) OriginalMIMEType() string { return "text/plain" }
|
||||
func (d *dummyData) OriginalHeader(string) string { return "" }
|
||||
func (d *dummyData) Recent(...string) int { return 1 }
|
||||
func (d *dummyData) Unread(...string) int { return 3 }
|
||||
func (d *dummyData) Exists(...string) int { return 14 }
|
||||
func (d *dummyData) RUE(...string) string { return "1/3/14" }
|
||||
func (d *dummyData) Connected() bool { return false }
|
||||
func (d *dummyData) ConnectionInfo() string { return "" }
|
||||
func (d *dummyData) ContentInfo() string { return "" }
|
||||
func (d *dummyData) StatusInfo() string { return "" }
|
||||
func (d *dummyData) TrayInfo() string { return "" }
|
||||
func (d *dummyData) PendingKeys() string { return "" }
|
||||
func (d *dummyData) Role() string { return "inbox" }
|
||||
|
||||
func (d *dummyData) Style(string, string) string { return "" }
|
||||
func (d *dummyData) StyleSwitch(string, ...models.Case) string { return "" }
|
||||
|
||||
func (d *dummyData) StyleMap([]string, ...models.Case) []string { return []string{} }
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rockorager/vaxis"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type UIConfig struct {
|
||||
IndexColumns []*ColumnDef `ini:"index-columns" parse:"ParseIndexColumns" default:"flags:4,name<20%,subject,date>="`
|
||||
ColumnSeparator string `ini:"column-separator" default:" "`
|
||||
|
||||
DirListLeft *template.Template `ini:"dirlist-left" default:"{{.Folder}}"`
|
||||
DirListRight *template.Template `ini:"dirlist-right" default:"{{if .Unread}}{{humanReadable .Unread}}{{end}}"`
|
||||
|
||||
AutoMarkRead bool `ini:"auto-mark-read" default:"true"`
|
||||
TimestampFormat string `ini:"timestamp-format" default:"2006 Jan 02"`
|
||||
ThisDayTimeFormat string `ini:"this-day-time-format" default:"15:04"`
|
||||
ThisWeekTimeFormat string `ini:"this-week-time-format" default:"Jan 02"`
|
||||
ThisYearTimeFormat string `ini:"this-year-time-format" default:"Jan 02"`
|
||||
MessageViewTimestampFormat string `ini:"message-view-timestamp-format" default:"2006 Jan 02, 15:04 GMT-0700"`
|
||||
MessageViewThisDayTimeFormat string `ini:"message-view-this-day-time-format"`
|
||||
MessageViewThisWeekTimeFormat string `ini:"message-view-this-week-time-format"`
|
||||
MessageViewThisYearTimeFormat string `ini:"message-view-this-year-time-format"`
|
||||
PinnedTabMarker string "ini:\"pinned-tab-marker\" default:\"`\""
|
||||
SidebarWidth int `ini:"sidebar-width" default:"22"`
|
||||
QuakeHeight int `ini:"quake-terminal-height" default:"20"`
|
||||
MessageListSplit SplitParams `ini:"message-list-split" parse:"ParseSplit"`
|
||||
EmptyMessage string `ini:"empty-message" default:"(no messages)"`
|
||||
EmptyDirlist string `ini:"empty-dirlist" default:"(no folders)"`
|
||||
EmptySubject string `ini:"empty-subject" default:"(no subject)"`
|
||||
MouseEnabled bool `ini:"mouse-enabled"`
|
||||
ThreadingEnabled bool `ini:"threading-enabled"`
|
||||
ForceClientThreads bool `ini:"force-client-threads"`
|
||||
ThreadingBySubject bool `ini:"threading-by-subject"`
|
||||
ClientThreadsDelay time.Duration `ini:"client-threads-delay" default:"50ms"`
|
||||
ThreadContext bool `ini:"show-thread-context"`
|
||||
FuzzyComplete bool `ini:"fuzzy-complete"`
|
||||
NewMessageBell bool `ini:"new-message-bell" default:"true"`
|
||||
Spinner string `ini:"spinner" default:"[..] , [..] , [..] , [..] , [..], [..] , [..] , [..] "`
|
||||
SpinnerDelimiter string `ini:"spinner-delimiter" default:","`
|
||||
SpinnerInterval time.Duration `ini:"spinner-interval" default:"200ms"`
|
||||
IconUnencrypted string `ini:"icon-unencrypted"`
|
||||
IconEncrypted string `ini:"icon-encrypted" default:"[e]"`
|
||||
IconSigned string `ini:"icon-signed" default:"[s]"`
|
||||
IconSignedEncrypted string `ini:"icon-signed-encrypted"`
|
||||
IconUnknown string `ini:"icon-unknown" default:"[s?]"`
|
||||
IconInvalid string `ini:"icon-invalid" default:"[s!]"`
|
||||
IconAttachment string `ini:"icon-attachment" default:"a"`
|
||||
IconReplied string `ini:"icon-replied" default:"r"`
|
||||
IconForwarded string `ini:"icon-forwarded" default:"f"`
|
||||
IconNew string `ini:"icon-new" default:"N"`
|
||||
IconOld string `ini:"icon-old" default:"O"`
|
||||
IconDraft string `ini:"icon-draft" default:"d"`
|
||||
IconFlagged string `ini:"icon-flagged" default:"!"`
|
||||
IconMarked string `ini:"icon-marked" default:"*"`
|
||||
IconDeleted string `ini:"icon-deleted" default:"X"`
|
||||
DirListDelay time.Duration `ini:"dirlist-delay" default:"200ms"`
|
||||
DirListTree bool `ini:"dirlist-tree"`
|
||||
DirListCollapse int `ini:"dirlist-collapse"`
|
||||
Sort []string `ini:"sort" delim:" "`
|
||||
NextMessageOnDelete bool `ini:"next-message-on-delete" default:"true"`
|
||||
CompletionDelay time.Duration `ini:"completion-delay" default:"250ms"`
|
||||
CompletionMinChars int `ini:"completion-min-chars" default:"1" parse:"ParseCompletionMinChars"`
|
||||
CompletionPopovers bool `ini:"completion-popovers" default:"true"`
|
||||
MsglistScrollOffset int `ini:"msglist-scroll-offset" default:"0"`
|
||||
DialogPosition string `ini:"dialog-position" default:"center" parse:"ParseDialogPosition"`
|
||||
DialogWidth int `ini:"dialog-width" default:"50" parse:"ParseDialogDimensions"`
|
||||
DialogHeight int `ini:"dialog-height" default:"50" parse:"ParseDialogDimensions"`
|
||||
StyleSetDirs []string `ini:"stylesets-dirs" delim:":"`
|
||||
StyleSetName string `ini:"styleset-name" default:"default"`
|
||||
style StyleSet
|
||||
// customize border appearance
|
||||
BorderCharVertical rune `ini:"border-char-vertical" default:"│" type:"rune"`
|
||||
BorderCharHorizontal rune `ini:"border-char-horizontal" default:"─" type:"rune"`
|
||||
|
||||
SelectLast bool `ini:"select-last-message" default:"false"`
|
||||
ReverseOrder bool `ini:"reverse-msglist-order"`
|
||||
ReverseThreadOrder bool `ini:"reverse-thread-order"`
|
||||
SortThreadSiblings bool `ini:"sort-thread-siblings"`
|
||||
|
||||
ThreadPrefixTip string `ini:"thread-prefix-tip" default:">"`
|
||||
ThreadPrefixIndent string `ini:"thread-prefix-indent" default:" "`
|
||||
ThreadPrefixStem string `ini:"thread-prefix-stem" default:"│"`
|
||||
ThreadPrefixLimb string `ini:"thread-prefix-limb" default:""`
|
||||
ThreadPrefixFolded string `ini:"thread-prefix-folded" default:"+"`
|
||||
ThreadPrefixUnfolded string `ini:"thread-prefix-unfolded" default:""`
|
||||
ThreadPrefixFirstChild string `ini:"thread-prefix-first-child" default:""`
|
||||
ThreadPrefixHasSiblings string `ini:"thread-prefix-has-siblings" default:"├─"`
|
||||
ThreadPrefixLone string `ini:"thread-prefix-lone" default:""`
|
||||
ThreadPrefixOrphan string `ini:"thread-prefix-orphan" default:""`
|
||||
ThreadPrefixLastSibling string `ini:"thread-prefix-last-sibling" default:"└─"`
|
||||
ThreadPrefixDummy string `ini:"thread-prefix-dummy" default:"┬─"`
|
||||
ThreadPrefixLastSiblingReverse string `ini:"thread-prefix-last-sibling-reverse" default:"┌─"`
|
||||
ThreadPrefixFirstChildReverse string `ini:"thread-prefix-first-child-reverse" default:""`
|
||||
ThreadPrefixOrphanReverse string `ini:"thread-prefix-orphan-reverse" default:""`
|
||||
ThreadPrefixDummyReverse string `ini:"thread-prefix-dummy-reverse" default:"┴─"`
|
||||
|
||||
// Tab Templates
|
||||
TabTitleAccount *template.Template `ini:"tab-title-account" default:"{{.Account}}"`
|
||||
TabTitleComposer *template.Template `ini:"tab-title-composer" default:"{{if .To}}to:{{index (.To | shortmboxes) 0}} {{end}}{{.SubjectBase}}"`
|
||||
TabTitleViewer *template.Template `ini:"tab-title-viewer" default:"{{.Subject}}"`
|
||||
|
||||
// private
|
||||
contextualUis []*UiConfigContext
|
||||
contextualCounts map[uiContextType]int
|
||||
contextualCache map[uiContextKey]*UIConfig
|
||||
}
|
||||
|
||||
type uiContextType int
|
||||
|
||||
const (
|
||||
uiContextFolder uiContextType = iota
|
||||
uiContextAccount
|
||||
)
|
||||
|
||||
type UiConfigContext struct {
|
||||
ContextType uiContextType
|
||||
Regex *regexp.Regexp
|
||||
UiConfig *UIConfig
|
||||
Section ini.Section
|
||||
}
|
||||
|
||||
type uiContextKey struct {
|
||||
ctxType uiContextType
|
||||
value string
|
||||
}
|
||||
|
||||
var Ui = defaultUIConfig()
|
||||
|
||||
func defaultUIConfig() *UIConfig {
|
||||
return &UIConfig{
|
||||
contextualCounts: make(map[uiContextType]int),
|
||||
contextualCache: make(map[uiContextKey]*UIConfig),
|
||||
}
|
||||
}
|
||||
|
||||
var uiContextualSectionRe = regexp.MustCompile(`^ui:(account|folder|subject)([~=])(.+)$`)
|
||||
|
||||
func parseUi(file *ini.File) error {
|
||||
if err := Ui.parse(file.Section("ui")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, section := range file.Sections() {
|
||||
var err error
|
||||
groups := uiContextualSectionRe.FindStringSubmatch(section.Name())
|
||||
if groups == nil {
|
||||
continue
|
||||
}
|
||||
ctx, separator, value := groups[1], groups[2], groups[3]
|
||||
|
||||
uiSubConfig := UIConfig{}
|
||||
if err = uiSubConfig.parse(section); err != nil {
|
||||
return err
|
||||
}
|
||||
contextualUi := UiConfigContext{
|
||||
UiConfig: &uiSubConfig,
|
||||
Section: *section,
|
||||
}
|
||||
|
||||
switch ctx {
|
||||
case "account":
|
||||
contextualUi.ContextType = uiContextAccount
|
||||
case "folder":
|
||||
contextualUi.ContextType = uiContextFolder
|
||||
}
|
||||
if separator == "=" {
|
||||
value = "^" + regexp.QuoteMeta(value) + "$"
|
||||
}
|
||||
contextualUi.Regex, err = regexp.Compile(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Ui.contextualUis = append(Ui.contextualUis, &contextualUi)
|
||||
Ui.contextualCounts[contextualUi.ContextType]++
|
||||
}
|
||||
|
||||
// append default paths to styleset-dirs
|
||||
for _, dir := range SearchDirs {
|
||||
Ui.StyleSetDirs = append(
|
||||
Ui.StyleSetDirs, path.Join(dir, "stylesets"),
|
||||
)
|
||||
}
|
||||
|
||||
if err := Ui.LoadStyle(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("aerc.conf: [ui] %#v", Ui)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (config *UIConfig) parse(section *ini.Section) error {
|
||||
if err := MapToStruct(section, config, section.Name() == "ui"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.MessageViewTimestampFormat == "" {
|
||||
config.MessageViewTimestampFormat = config.TimestampFormat
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*UIConfig) ParseIndexColumns(section *ini.Section, key *ini.Key) ([]*ColumnDef, error) {
|
||||
if !section.HasKey("column-date") {
|
||||
_, _ = section.NewKey("column-date", `{{.DateAutoFormat .Date.Local}}`)
|
||||
}
|
||||
if !section.HasKey("column-name") {
|
||||
_, _ = section.NewKey("column-name", `{{index (.From | names) 0}}`)
|
||||
}
|
||||
if !section.HasKey("column-flags") {
|
||||
_, _ = section.NewKey("column-flags", `{{.Flags | join ""}}`)
|
||||
}
|
||||
if !section.HasKey("column-subject") {
|
||||
_, _ = section.NewKey("column-subject", `{{.ThreadPrefix}}{{.Subject}}`)
|
||||
}
|
||||
return ParseColumnDefs(key, section)
|
||||
}
|
||||
|
||||
type SplitDirection int
|
||||
|
||||
const (
|
||||
SPLIT_NONE SplitDirection = iota
|
||||
SPLIT_HORIZONTAL
|
||||
SPLIT_VERTICAL
|
||||
)
|
||||
|
||||
type SplitParams struct {
|
||||
Direction SplitDirection
|
||||
Size int
|
||||
}
|
||||
|
||||
func (*UIConfig) ParseSplit(section *ini.Section, key *ini.Key) (p SplitParams, err error) {
|
||||
re := regexp.MustCompile(`^\s*(v(?:ert(?:ical)?)?|h(?:oriz(?:ontal)?)?)?\s+(\d+)\s*$`)
|
||||
match := re.FindStringSubmatch(key.String())
|
||||
if len(match) != 3 {
|
||||
err = fmt.Errorf("bad option value")
|
||||
return
|
||||
}
|
||||
p.Direction = SPLIT_HORIZONTAL
|
||||
switch match[1] {
|
||||
case "v", "vert", "vertical":
|
||||
p.Direction = SPLIT_VERTICAL
|
||||
case "h", "horiz", "horizontal":
|
||||
p.Direction = SPLIT_HORIZONTAL
|
||||
}
|
||||
size, e := strconv.ParseUint(match[2], 10, 32)
|
||||
if e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
p.Size = int(size)
|
||||
return
|
||||
}
|
||||
|
||||
func (*UIConfig) ParseDialogPosition(section *ini.Section, key *ini.Key) (string, error) {
|
||||
match, _ := regexp.MatchString(`^\s*(top|center|bottom)\s*$`, key.String())
|
||||
if !(match) {
|
||||
return "", fmt.Errorf("bad option value")
|
||||
}
|
||||
return key.String(), nil
|
||||
}
|
||||
|
||||
const (
|
||||
DIALOG_MIN_PROPORTION = 10
|
||||
DIALOG_MAX_PROPORTION = 100
|
||||
)
|
||||
|
||||
func (*UIConfig) ParseDialogDimensions(section *ini.Section, key *ini.Key) (int, error) {
|
||||
value, err := key.Int()
|
||||
if value < DIALOG_MIN_PROPORTION || value > DIALOG_MAX_PROPORTION || err != nil {
|
||||
return 0, fmt.Errorf("value out of range")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
const MANUAL_COMPLETE = math.MaxInt
|
||||
|
||||
func (*UIConfig) ParseCompletionMinChars(section *ini.Section, key *ini.Key) (int, error) {
|
||||
if key.String() == "manual" {
|
||||
return MANUAL_COMPLETE, nil
|
||||
}
|
||||
return key.Int()
|
||||
}
|
||||
|
||||
func (ui *UIConfig) ClearCache() {
|
||||
for k := range ui.contextualCache {
|
||||
delete(ui.contextualCache, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *UIConfig) LoadStyle() error {
|
||||
if err := ui.loadStyleSet(ui.StyleSetDirs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, contextualUi := range ui.contextualUis {
|
||||
if contextualUi.UiConfig.StyleSetName == "" &&
|
||||
len(contextualUi.UiConfig.StyleSetDirs) == 0 {
|
||||
continue // no need to do anything if nothing is overridden
|
||||
}
|
||||
// fill in the missing part from the base
|
||||
if contextualUi.UiConfig.StyleSetName == "" {
|
||||
contextualUi.UiConfig.StyleSetName = ui.StyleSetName
|
||||
} else if len(contextualUi.UiConfig.StyleSetDirs) == 0 {
|
||||
contextualUi.UiConfig.StyleSetDirs = ui.StyleSetDirs
|
||||
}
|
||||
// since at least one of them has changed, load the styleset
|
||||
if err := contextualUi.UiConfig.loadStyleSet(
|
||||
contextualUi.UiConfig.StyleSetDirs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ui *UIConfig) loadStyleSet(styleSetDirs []string) error {
|
||||
ui.style = NewStyleSet()
|
||||
err := ui.style.LoadStyleSet(ui.StyleSetName, styleSetDirs)
|
||||
if err != nil {
|
||||
if ui.style.path == "" {
|
||||
ui.style.path = ui.StyleSetName
|
||||
}
|
||||
return fmt.Errorf("%s: %w", ui.style.path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (base *UIConfig) mergeContextual(
|
||||
contextType uiContextType, s string,
|
||||
) *UIConfig {
|
||||
for _, contextualUi := range base.contextualUis {
|
||||
if contextualUi.ContextType != contextType {
|
||||
continue
|
||||
}
|
||||
if !contextualUi.Regex.Match([]byte(s)) {
|
||||
continue
|
||||
}
|
||||
ui := *base
|
||||
err := ui.parse(&contextualUi.Section)
|
||||
if err != nil {
|
||||
log.Warnf("merge ui failed: %v", err)
|
||||
}
|
||||
ui.contextualCache = make(map[uiContextKey]*UIConfig)
|
||||
ui.contextualCounts = base.contextualCounts
|
||||
ui.contextualUis = base.contextualUis
|
||||
if contextualUi.UiConfig.StyleSetName != "" {
|
||||
ui.style = contextualUi.UiConfig.style
|
||||
}
|
||||
return &ui
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func (uiConfig *UIConfig) GetUserStyle(name string) vaxis.Style {
|
||||
return uiConfig.style.UserStyle(name)
|
||||
}
|
||||
|
||||
func (uiConfig *UIConfig) GetStyle(so StyleObject) vaxis.Style {
|
||||
return uiConfig.style.Get(so, nil)
|
||||
}
|
||||
|
||||
func (uiConfig *UIConfig) GetStyleSelected(so StyleObject) vaxis.Style {
|
||||
return uiConfig.style.Selected(so, nil)
|
||||
}
|
||||
|
||||
func (uiConfig *UIConfig) GetComposedStyle(base StyleObject,
|
||||
styles []StyleObject,
|
||||
) vaxis.Style {
|
||||
return uiConfig.style.Compose(base, styles, nil)
|
||||
}
|
||||
|
||||
func (uiConfig *UIConfig) GetComposedStyleSelected(
|
||||
base StyleObject, styles []StyleObject,
|
||||
) vaxis.Style {
|
||||
return uiConfig.style.ComposeSelected(base, styles, nil)
|
||||
}
|
||||
|
||||
func (uiConfig *UIConfig) MsgComposedStyle(
|
||||
base StyleObject, styles []StyleObject, h *mail.Header,
|
||||
) vaxis.Style {
|
||||
return uiConfig.style.Compose(base, styles, h)
|
||||
}
|
||||
|
||||
func (uiConfig *UIConfig) MsgComposedStyleSelected(
|
||||
base StyleObject, styles []StyleObject, h *mail.Header,
|
||||
) vaxis.Style {
|
||||
return uiConfig.style.ComposeSelected(base, styles, h)
|
||||
}
|
||||
|
||||
func (uiConfig *UIConfig) StyleSetPath() string {
|
||||
return uiConfig.style.path
|
||||
}
|
||||
|
||||
func (base *UIConfig) contextual(ctxType uiContextType, value string) *UIConfig {
|
||||
if base.contextualCounts[ctxType] == 0 {
|
||||
// shortcut if no contextual ui for that type
|
||||
return base
|
||||
}
|
||||
key := uiContextKey{ctxType: ctxType, value: value}
|
||||
c, found := base.contextualCache[key]
|
||||
if !found {
|
||||
c = base.mergeContextual(ctxType, value)
|
||||
base.contextualCache[key] = c
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (base *UIConfig) ForAccount(account string) *UIConfig {
|
||||
return base.contextual(uiContextAccount, account)
|
||||
}
|
||||
|
||||
func (base *UIConfig) ForFolder(folder string) *UIConfig {
|
||||
return base.contextual(uiContextFolder, folder)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/go-ini/ini"
|
||||
)
|
||||
|
||||
type ViewerConfig struct {
|
||||
Pager string `ini:"pager" default:"less -Rc"`
|
||||
Alternatives []string `ini:"alternatives" default:"text/plain,text/html" delim:","`
|
||||
ShowHeaders bool `ini:"show-headers"`
|
||||
AlwaysShowMime bool `ini:"always-show-mime"`
|
||||
MaxMimeHeight int `ini:"max-mime-height" default:"0"`
|
||||
ParseHttpLinks bool `ini:"parse-http-links" default:"true"`
|
||||
HeaderLayout [][]string `ini:"header-layout" parse:"ParseLayout" default:"From|To,Cc|Bcc,Date,Subject"`
|
||||
KeyPassthrough bool
|
||||
}
|
||||
|
||||
var Viewer = new(ViewerConfig)
|
||||
|
||||
func parseViewer(file *ini.File) error {
|
||||
if err := MapToStruct(file.Section("viewer"), Viewer, true); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debugf("aerc.conf: [viewer] %#v", Viewer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *ViewerConfig) ParseLayout(sec *ini.Section, key *ini.Key) ([][]string, error) {
|
||||
layout := parseLayout(key.String())
|
||||
return layout, nil
|
||||
}
|
||||
Reference in New Issue
Block a user