binds: add account specific bindings

When using aerc for multiple accounts often bindings might differ
slightly between accounts. For example:

* Account A archives to one directory (:archive)
* Account B archives to monthly directories (:archive month)

Add account specific bindings to allow the user to add a "context" to a
binding group using a context specifier and a regular expression.

Currently the only context specifier is 'account'.

The regular expression is validated against the accounts loaded from
accounts.conf and the configuration fails to load if there are no
matches.

Contextual bindings are merged with global bindings, with contextual
bindings taking precedence, when that context is active.

Bindings are be configured using a generic pattern of
'view:context=regexp'. E.g.:

    # Globally Applicable Archiving
    [messages]
    A = :read<Enter>:archive<Enter>

    # Monthly Archiving for 'Mailbox' Account
    [messages:account=Mailbox$]
    A = :read<Enter>:archive month<Enter>

In the above example all accounts matching the regular expression will
archive in the monthly format - all others will use the global binding.

Signed-off-by: Jonathan Bartlett <jonathan@jonnobrow.co.uk>
This commit is contained in:
Jonathan Bartlett 2021-12-10 21:27:29 +00:00 committed by Robin Jarry
parent b84374a572
commit 175d0efeb2
4 changed files with 199 additions and 58 deletions

View file

@ -55,6 +55,28 @@ func MergeBindings(bindings ...*KeyBindings) *KeyBindings {
return merged return merged
} }
func (config AercConfig) MergeContextualBinds(baseBinds *KeyBindings,
contextType ContextType, reg string, bindCtx string) *KeyBindings {
bindings := baseBinds
for _, contextualBind := range config.ContextualBinds {
if contextualBind.ContextType != contextType {
continue
}
if !contextualBind.Regex.Match([]byte(reg)) {
continue
}
if contextualBind.BindContext != bindCtx {
continue
}
bindings = MergeBindings(contextualBind.Bindings, bindings)
}
return bindings
}
func (bindings *KeyBindings) Add(binding *Binding) { func (bindings *KeyBindings) Add(binding *Binding) {
// TODO: Search for conflicts? // TODO: Search for conflicts?
bindings.bindings = append(bindings.bindings, binding) bindings.bindings = append(bindings.bindings, binding)

View file

@ -64,6 +64,7 @@ const (
UI_CONTEXT_FOLDER ContextType = iota UI_CONTEXT_FOLDER ContextType = iota
UI_CONTEXT_ACCOUNT UI_CONTEXT_ACCOUNT
UI_CONTEXT_SUBJECT UI_CONTEXT_SUBJECT
BIND_CONTEXT_ACCOUNT
) )
type UIConfigContext struct { type UIConfigContext struct {
@ -109,6 +110,13 @@ type BindingConfig struct {
Terminal *KeyBindings Terminal *KeyBindings
} }
type BindingConfigContext struct {
ContextType ContextType
Regex *regexp.Regexp
Bindings *KeyBindings
BindContext string
}
type ComposeConfig struct { type ComposeConfig struct {
Editor string `ini:"editor"` Editor string `ini:"editor"`
HeaderLayout [][]string `ini:"-"` HeaderLayout [][]string `ini:"-"`
@ -144,6 +152,7 @@ type TemplateConfig struct {
type AercConfig struct { type AercConfig struct {
Bindings BindingConfig Bindings BindingConfig
ContextualBinds []BindingConfigContext
Compose ComposeConfig Compose ComposeConfig
Ini *ini.File `ini:"-"` Ini *ini.File `ini:"-"`
Accounts []AccountConfig `ini:"-"` Accounts []AccountConfig `ini:"-"`
@ -357,6 +366,7 @@ func (config *AercConfig) LoadConfig(file *ini.File) error {
} }
} }
} }
if ui, err := file.GetSection("ui"); err == nil { if ui, err := file.GetSection("ui"); err == nil {
if err := ui.MapTo(&config.Ui); err != nil { if err := ui.MapTo(&config.Ui); err != nil {
return err return err
@ -365,6 +375,7 @@ func (config *AercConfig) LoadConfig(file *ini.File) error {
return err return err
} }
} }
for _, sectionName := range file.SectionStrings() { for _, sectionName := range file.SectionStrings() {
if !strings.Contains(sectionName, "ui:") { if !strings.Contains(sectionName, "ui:") {
continue continue
@ -526,6 +537,9 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) {
MessageView: NewKeyBindings(), MessageView: NewKeyBindings(),
Terminal: NewKeyBindings(), Terminal: NewKeyBindings(),
}, },
ContextualBinds: []BindingConfigContext{},
Ini: file, Ini: file,
Ui: UIConfig{ Ui: UIConfig{
@ -609,6 +623,7 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) {
} else { } else {
config.Accounts = accounts config.Accounts = accounts
} }
filename = path.Join(*root, "binds.conf") filename = path.Join(*root, "binds.conf")
binds, err := ini.Load(filename) binds, err := ini.Load(filename)
if err != nil { if err != nil {
@ -619,25 +634,49 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) {
return nil, err return nil, err
} }
} }
groups := map[string]**KeyBindings{
baseGroups := map[string]**KeyBindings{
"default": &config.Bindings.Global, "default": &config.Bindings.Global,
"compose": &config.Bindings.Compose, "compose": &config.Bindings.Compose,
"messages": &config.Bindings.MessageList, "messages": &config.Bindings.MessageList,
"terminal": &config.Bindings.Terminal, "terminal": &config.Bindings.Terminal,
"view": &config.Bindings.MessageView, "view": &config.Bindings.MessageView,
"compose::editor": &config.Bindings.ComposeEditor, "compose::editor": &config.Bindings.ComposeEditor,
"compose::review": &config.Bindings.ComposeReview, "compose::review": &config.Bindings.ComposeReview,
} }
for _, name := range binds.SectionStrings() {
sec, err := binds.GetSection(name) // Base Bindings
for _, sectionName := range binds.SectionStrings() {
// Handle :: delimeter
baseSectionName := strings.Replace(sectionName, "::", "////", -1)
sections := strings.Split(baseSectionName, ":")
baseOnly := len(sections) == 1
baseSectionName = strings.Replace(sections[0], "////", "::", -1)
group, ok := baseGroups[strings.ToLower(baseSectionName)]
if !ok {
return nil, errors.New("Unknown keybinding group " + sectionName)
}
if baseOnly {
err = config.LoadBinds(binds, baseSectionName, group)
if err != nil { if err != nil {
return nil, err return nil, err
} }
group, ok := groups[strings.ToLower(name)]
if !ok {
return nil, errors.New("Unknown keybinding group " + name)
} }
}
config.Bindings.Global.Globals = false
for _, contextBind := range config.ContextualBinds {
if contextBind.BindContext == "default" {
contextBind.Bindings.Globals = false
}
}
return config, nil
}
func LoadBindingSection(sec *ini.Section) (*KeyBindings, error) {
bindings := NewKeyBindings() bindings := NewKeyBindings()
for key, value := range sec.KeysHash() { for key, value := range sec.KeysHash() {
if key == "$ex" { if key == "$ex" {
@ -646,8 +685,7 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) {
return nil, err return nil, err
} }
if len(strokes) != 1 { if len(strokes) != 1 {
return nil, errors.New( return nil, errors.New("Invalid binding")
"Error: only one keystroke supported for $ex")
} }
bindings.ExKey = strokes[0] bindings.ExKey = strokes[0]
continue continue
@ -657,8 +695,7 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) {
continue continue
} }
if value != "true" { if value != "true" {
return nil, errors.New( return nil, errors.New("Invalid binding")
"Error: expected 'true' or 'false' for $noinherit")
} }
bindings.Globals = false bindings.Globals = false
continue continue
@ -669,11 +706,74 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) {
} }
bindings.Add(binding) bindings.Add(binding)
} }
*group = MergeBindings(bindings, *group) return bindings, nil
} }
// Globals can't inherit from themselves
config.Bindings.Global.Globals = false func (config *AercConfig) LoadBinds(binds *ini.File, baseName string, baseGroup **KeyBindings) error {
return config, nil
if sec, err := binds.GetSection(baseName); err == nil {
binds, err := LoadBindingSection(sec)
if err != nil {
return err
}
*baseGroup = MergeBindings(binds, *baseGroup)
}
for _, sectionName := range binds.SectionStrings() {
if !strings.Contains(sectionName, baseName+":") ||
strings.Contains(sectionName, baseName+"::") {
continue
}
bindSection, err := binds.GetSection(sectionName)
if err != nil {
return err
}
binds, err := LoadBindingSection(bindSection)
if err != nil {
return err
}
contextualBind :=
BindingConfigContext{
Bindings: binds,
BindContext: baseName,
}
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 config.Accounts {
matches := contextualBind.Regex.FindString(acctConf.Name)
if matches != "" {
valid = true
}
}
if !valid {
return fmt.Errorf("Invalid Account Name: %s", acctName)
}
contextualBind.ContextType = BIND_CONTEXT_ACCOUNT
default:
return fmt.Errorf("Unknown Context Bind Section: %s", sectionName)
}
config.ContextualBinds = append(config.ContextualBinds, contextualBind)
}
return nil
} }
// checkConfigPerms checks for too open permissions // checkConfigPerms checks for too open permissions

View file

@ -528,6 +528,21 @@ are:
*[terminal]* *[terminal]*
keybindings for terminal tabs keybindings for terminal tabs
You may also configure account specific key bindings for each context:
*[context:account=<AccountName>]*
keybindings for this context and account, where <AccountName> matches
the account name you provided in *accounts.conf*.
Example:
```
[messages:account=Mailbox]
c = :cf path:mailbox/** and<space>
[compose::editor:account=Mailbox2]
...
```
You may also configure global keybindings by placing them at the beginning of You may also configure global keybindings by placing them at the beginning of
the file, before specifying any context-specific sections. For each *key=value* the file, before specifying any context-specific sections. For each *key=value*
option specified, the _key_ is the keystrokes pressed (in order) to invoke this option specified, the _key_ is the keystrokes pressed (in order) to invoke this

View file

@ -182,22 +182,26 @@ func (aerc *Aerc) Draw(ctx *ui.Context) {
} }
func (aerc *Aerc) getBindings() *config.KeyBindings { func (aerc *Aerc) getBindings() *config.KeyBindings {
selectedAccountName := ""
if aerc.SelectedAccount() != nil {
selectedAccountName = aerc.SelectedAccount().acct.Name
}
switch view := aerc.SelectedTab().(type) { switch view := aerc.SelectedTab().(type) {
case *AccountView: case *AccountView:
return aerc.conf.Bindings.MessageList return aerc.conf.MergeContextualBinds(aerc.conf.Bindings.MessageList, config.BIND_CONTEXT_ACCOUNT, selectedAccountName, "messages")
case *AccountWizard: case *AccountWizard:
return aerc.conf.Bindings.AccountWizard return aerc.conf.Bindings.AccountWizard
case *Composer: case *Composer:
switch view.Bindings() { switch view.Bindings() {
case "compose::editor": case "compose::editor":
return aerc.conf.Bindings.ComposeEditor return aerc.conf.MergeContextualBinds(aerc.conf.Bindings.ComposeEditor, config.BIND_CONTEXT_ACCOUNT, selectedAccountName, "compose::editor")
case "compose::review": case "compose::review":
return aerc.conf.Bindings.ComposeReview return aerc.conf.MergeContextualBinds(aerc.conf.Bindings.ComposeReview, config.BIND_CONTEXT_ACCOUNT, selectedAccountName, "compose::review")
default: default:
return aerc.conf.Bindings.Compose return aerc.conf.MergeContextualBinds(aerc.conf.Bindings.Compose, config.BIND_CONTEXT_ACCOUNT, selectedAccountName, "compose")
} }
case *MessageViewer: case *MessageViewer:
return aerc.conf.Bindings.MessageView return aerc.conf.MergeContextualBinds(aerc.conf.Bindings.MessageView, config.BIND_CONTEXT_ACCOUNT, selectedAccountName, "view")
case *Terminal: case *Terminal:
return aerc.conf.Bindings.Terminal return aerc.conf.Bindings.Terminal
default: default: