init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+22
View File
@@ -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).
+376
View File
@@ -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
}
+866
View File
@@ -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
+186
View File
@@ -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
View File
@@ -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
}
+103
View File
@@ -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}))
}
}
+22
View File
@@ -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",
}
}
+118
View File
@@ -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
}
+44
View File
@@ -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())
}
+178
View File
@@ -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
+36
View File
@@ -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
}
+96
View File
@@ -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
}
+79
View File
@@ -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")
}
+29
View File
@@ -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
}
+31
View File
@@ -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
View File
@@ -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())
}
+68
View File
@@ -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
}
+38
View File
@@ -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
View File
@@ -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),
}
+101
View File
@@ -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)
}
})
}
+125
View File
@@ -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
View File
@@ -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)
}
+32
View File
@@ -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
}