aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKoni Marti <koni.marti@gmail.com>2022-07-27 23:39:16 +0200
committerRobin Jarry <robin@jarry.cc>2023-07-17 10:24:10 +0200
commit11e5390fa0acbcc609ca177777548dd2d725afbc (patch)
treef5474f504bc8e36b2356740a5b9e581db6bb4a83
parentcf061e1b0b536da1cc401c19af456ea373c47e6e (diff)
downloadaerc-11e5390fa0acbcc609ca177777548dd2d725afbc.tar.gz
aerc-11e5390fa0acbcc609ca177777548dd2d725afbc.zip
compose: implement embedded headers in editor
Implement embedded header mode in the composer widget. To activate it, use set [compose].edit-headers=true in aerc.conf. Signed-off-by: Koni Marti <koni.marti@gmail.com> Signed-off-by: Robin Jarry <robin@jarry.cc> Tested-by: Bence Ferdinandy <bence@ferdinandy.com>
-rw-r--r--CHANGELOG.md2
-rw-r--r--commands/compose/cc-bcc.go4
-rw-r--r--commands/compose/header.go4
-rw-r--r--config/aerc.conf10
-rw-r--r--config/compose.go1
-rw-r--r--doc/aerc-config.5.scd11
-rw-r--r--widgets/compose.go286
7 files changed, 262 insertions, 56 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 967642ea..1b254b69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- JMAP support.
- Folder name mapping with `folder-map` in `accounts.conf`.
- Add option `-d` to `:open` to automatically delete temporary files.
+- Edit email headers directly in the text editor with `[compose].edit-headers`
+ in `aerc.conf`.
### Fixed
diff --git a/commands/compose/cc-bcc.go b/commands/compose/cc-bcc.go
index 17e925e1..045f9092 100644
--- a/commands/compose/cc-bcc.go
+++ b/commands/compose/cc-bcc.go
@@ -29,9 +29,9 @@ func (CC) Execute(aerc *widgets.Aerc, args []string) error {
switch args[0] {
case "cc":
- composer.AddEditor("Cc", addrs, true)
+ return composer.AddEditor("Cc", addrs, true)
case "bcc":
- composer.AddEditor("Bcc", addrs, true)
+ return composer.AddEditor("Bcc", addrs, true)
}
return nil
diff --git a/commands/compose/header.go b/commands/compose/header.go
index 46cc23b9..c512e341 100644
--- a/commands/compose/header.go
+++ b/commands/compose/header.go
@@ -72,7 +72,5 @@ func (Header) Execute(aerc *widgets.Aerc, args []string) error {
}
}
- composer.AddEditor(args[optind], value, false)
-
- return nil
+ return composer.AddEditor(args[optind], value, false)
}
diff --git a/config/aerc.conf b/config/aerc.conf
index d6ffa389..d195563b 100644
--- a/config/aerc.conf
+++ b/config/aerc.conf
@@ -390,6 +390,16 @@
#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
+
+#
# 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.
diff --git a/config/compose.go b/config/compose.go
index 4b92bddc..37e46f96 100644
--- a/config/compose.go
+++ b/config/compose.go
@@ -16,6 +16,7 @@ type ComposeConfig struct {
EmptySubjectWarning bool `ini:"empty-subject-warning"`
FilePickerCmd string `ini:"file-picker-cmd"`
FormatFlowed bool `ini:"format-flowed"`
+ EditHeaders bool `ini:"edit-headers"`
}
var Compose = new(ComposeConfig)
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index 573ae557..0a4fd9eb 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -537,6 +537,17 @@ These options are configured in the *[compose]* section of _aerc.conf_.
Default: _To|From,Subject_
+*edit-headers* = _true_|_false_
+ Edit headers directly into the text editor instead of having separate UI
+ text inputs.
+
+ When this is set to _true_, the *:cc*, *:bcc* and *:header* commands do
+ not work, editing email headers are left to the text editor.
+ *address-book-cmd* is not supported and address completion is left to
+ the editor itself. *header-layout* is ignored.
+
+ Default: _false_
+
*address-book-cmd* = _<command>_
Specifies the command to be used to tab-complete email addresses. Any
occurrence of _%s_ in the *address-book-cmd* will be replaced with anything
diff --git a/widgets/compose.go b/widgets/compose.go
index 371e0839..9a54b632 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/emersion/go-message/mail"
@@ -44,8 +45,8 @@ type Composer struct {
attachments []lib.Attachment
editor *Terminal
email *os.File
- grid *ui.Grid
- heditors *ui.Grid // from, to, cc display a user can jump to
+ grid atomic.Value
+ heditors atomic.Value // from, to, cc display a user can jump to
review *reviewMessage
worker *types.Worker
completer *completer.Completer
@@ -135,6 +136,7 @@ func (c *Composer) SwitchAccount(newAcct *AccountView) error {
for _, editor := range c.editors {
editor.loadValue()
}
+ c.resetReview()
c.Invalidate()
log.Debugf("account successfully switched")
return nil
@@ -151,6 +153,12 @@ func (c *Composer) setupFor(view *AccountView) error {
if fl, err := c.header.AddressList("from"); err != nil || fl == nil {
c.header.SetAddressList("from", []*mail.Address{view.acct.From})
}
+ if !c.header.Has("to") {
+ c.header.SetAddressList("to", make([]*mail.Address, 0))
+ }
+ if !c.header.Has("subject") {
+ c.header.SetSubject("")
+ }
// update completer
cmd := view.acct.AddressBookCmd
@@ -182,9 +190,6 @@ func (c *Composer) setupFor(view *AccountView) error {
c.focused = len(c.focusable) - 1
}
- // redraw the grid
- c.updateGrid()
-
// update the crypto parts
c.crypto = nil
c.sign = false
@@ -201,6 +206,9 @@ func (c *Composer) setupFor(view *AccountView) error {
log.Warnf("failed to update crypto: %v", err)
}
+ // redraw the grid
+ c.updateGrid()
+
return nil
}
@@ -267,6 +275,14 @@ func (c *Composer) buildComposeHeader(aerc *Aerc, cmpl *completer.Completer) {
}
}
+func (c *Composer) headerOrder() []string {
+ var order []string
+ for _, row := range c.layout {
+ order = append(order, row...)
+ }
+ return order
+}
+
func (c *Composer) SetSent(archive string) {
c.sent = true
c.archive = archive
@@ -401,30 +417,20 @@ func (c *Composer) updateCrypto() error {
return err
}
}
- crHeight := 0
+
st := ""
switch {
case c.sign && c.encrypt:
st = fmt.Sprintf("Sign (%s) & Encrypt", c.crypto.signKey)
- crHeight = 1
case c.sign:
st = fmt.Sprintf("Sign (%s)", c.crypto.signKey)
- crHeight = 1
case c.encrypt:
st = "Encrypt"
- crHeight = 1
- default:
- st = ""
}
c.crypto.status.Text(st)
- hHeight := len(c.layout)
- c.grid.Rows([]ui.GridSpec{
- {Strategy: ui.SIZE_EXACT, Size: ui.Const(hHeight)},
- {Strategy: ui.SIZE_EXACT, Size: ui.Const(crHeight)},
- {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
- {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
- })
- c.grid.AddChild(c.crypto).At(1, 0)
+
+ c.updateGrid()
+
return nil
}
@@ -454,6 +460,44 @@ func (c *Composer) setContents(reader io.Reader) error {
if err != nil {
return err
}
+ if config.Compose.EditHeaders {
+ for _, h := range c.headerOrder() {
+ var value string
+ switch h {
+ case "to", "from", "cc", "bcc":
+ addresses, err := c.header.AddressList(h)
+ if err != nil {
+ log.Warnf("header.AddressList: %s", err)
+ value, err = c.header.Text(h)
+ if err != nil {
+ log.Warnf("header.Text: %s", err)
+ value = c.header.Get(h)
+ }
+ } else {
+ addr := make([]string, 0, len(addresses))
+ for _, a := range addresses {
+ addr = append(addr, format.AddressForHumans(a))
+ }
+ value = strings.Join(addr, ",\r\n\t")
+ }
+ default:
+ value, err = c.header.Text(h)
+ if err != nil {
+ log.Warnf("header.Text: %s", err)
+ value = c.header.Get(h)
+ }
+ }
+ key := textproto.CanonicalMIMEHeaderKey(h)
+ _, err = fmt.Fprintf(c.email, "%s: %s\r\n", key, value)
+ if err != nil {
+ return err
+ }
+ }
+ _, err = c.email.WriteString("\r\n")
+ if err != nil {
+ return err
+ }
+ }
return c.writeCRLF(reader)
}
@@ -512,6 +556,9 @@ func (c *Composer) addTemplate(
readers = append(readers, templateText)
}
if body != nil {
+ if len(readers) == 0 {
+ readers = append(readers, bytes.NewReader([]byte("\r\n")))
+ }
readers = append(readers, body)
}
if len(readers) == 0 {
@@ -613,6 +660,14 @@ func (c *Composer) GetBody() (*bytes.Buffer, error) {
return nil, err
}
scanner := bufio.NewScanner(c.email)
+ if config.Compose.EditHeaders {
+ // skip headers
+ for scanner.Scan() {
+ if scanner.Text() == "" {
+ break // stop on first empty line
+ }
+ }
+ }
// .eml files must always use '\r\n' line endings
buf := new(bytes.Buffer)
for scanner.Scan() {
@@ -628,6 +683,10 @@ func (c *Composer) GetBody() (*bytes.Buffer, error) {
func (c *Composer) FocusTerminal() *Composer {
c.Lock()
defer c.Unlock()
+ return c.focusTerminalPriv()
+}
+
+func (c *Composer) focusTerminalPriv() *Composer {
if c.editor == nil {
return c
}
@@ -662,7 +721,7 @@ func (c *Composer) OnClose(fn func(composer *Composer)) {
func (c *Composer) Draw(ctx *ui.Context) {
c.setTitle()
c.width = ctx.Width()
- c.grid.Draw(ctx)
+ c.grid.Load().(*ui.Grid).Draw(ctx)
}
func (c *Composer) Invalidate() {
@@ -716,7 +775,7 @@ func (c *Composer) MouseEvent(localX int, localY int, event tcell.Event) {
}
}
c.Unlock()
- c.grid.MouseEvent(localX, localY, event)
+ c.grid.Load().(*ui.Grid).MouseEvent(localX, localY, event)
c.Lock()
defer c.Unlock()
for i, e := range c.focusable {
@@ -781,6 +840,21 @@ func (c *Composer) PrepareHeader() (*mail.Header, error) {
return c.header, nil
}
+func (c *Composer) parseEmbeddedHeader() (*mail.Header, error) {
+ _, err := c.email.Seek(0, io.SeekStart)
+ if err != nil {
+ return nil, errors.Wrap(err, "Seek")
+ }
+ msg, err := mail.CreateReader(c.email)
+ if errors.Is(err, io.EOF) { // completely empty
+ h := mail.HeaderFromMap(make(map[string][]string))
+ return &h, nil
+ } else if err != nil {
+ return nil, fmt.Errorf("mail.ReadMessage: %w", err)
+ }
+ return &msg.Header, nil
+}
+
func getRecipientsEmail(c *Composer) ([]string, error) {
h, err := c.PrepareHeader()
if err != nil {
@@ -1031,9 +1105,9 @@ func (c *Composer) DeleteAttachment(name string) error {
func (c *Composer) resetReview() {
if c.review != nil {
- c.grid.RemoveChild(c.review)
+ c.grid.Load().(*ui.Grid).RemoveChild(c.review)
c.review = newReviewMessage(c, nil)
- c.grid.AddChild(c.review).At(3, 0)
+ c.grid.Load().(*ui.Grid).AddChild(c.review).At(3, 0)
}
}
@@ -1053,21 +1127,49 @@ func (c *Composer) termClosed(err error) {
if c.editor == nil {
return
}
- if c.editor.cmd.ProcessState.ExitCode() > 0 {
+ editor := c.editor
+ defer editor.Destroy()
+ c.editor = nil
+ c.focusable = c.focusable[:len(c.focusable)-1]
+ if c.focused >= len(c.focusable) {
+ c.focused = len(c.focusable) - 1
+ }
+
+ if editor.cmd.ProcessState.ExitCode() > 0 {
c.Close()
c.aerc.RemoveTab(c, true)
c.aerc.PushError("Editor exited with error. Compose aborted!")
return
}
- c.grid.RemoveChild(c.editor)
- c.review = newReviewMessage(c, err)
- c.grid.AddChild(c.review).At(3, 0)
- c.editor.Destroy()
- c.editor = nil
- c.focusable = c.focusable[:len(c.focusable)-1]
- if c.focused >= len(c.focusable) {
- c.focused = len(c.focusable) - 1
+
+ if config.Compose.EditHeaders {
+ // parse embedded header when editor is closed
+ embedHeader, err := c.parseEmbeddedHeader()
+ if err != nil {
+ c.aerc.PushError(err.Error())
+ err := c.showTerminal()
+ if err != nil {
+ c.Close()
+ c.aerc.RemoveTab(c, true)
+ c.aerc.PushError(err.Error())
+ }
+ return
+ }
+ for _, h := range c.headerOrder() {
+ if !embedHeader.Has(h) {
+ // user deleted header in text editor
+ c.delEditor(h)
+ }
+ }
+ hf := embedHeader.Fields()
+ for hf.Next() {
+ c.addEditor(hf.Key(), hf.Value(), false)
+ }
}
+
+ // prepare review window
+ c.review = newReviewMessage(c, err)
+ c.updateGrid()
}
func (c *Composer) ShowTerminal() error {
@@ -1084,8 +1186,12 @@ func (c *Composer) ShowTerminal() error {
if err != nil {
return err
}
- if c.review != nil {
- c.grid.RemoveChild(c.review)
+ return c.showTerminal()
+}
+
+func (c *Composer) showTerminal() error {
+ if c.editor != nil {
+ c.editor.Destroy()
}
cmds := []string{
config.Compose.Editor,
@@ -1104,14 +1210,21 @@ func (c *Composer) ShowTerminal() error {
}
c.editor.OnEvent = c.termEvent
c.editor.OnClose = c.termClosed
- c.grid.AddChild(c.editor).At(3, 0)
c.focusable = append(c.focusable, c.editor)
+ c.review = nil
+ c.updateGrid()
+ if config.Compose.EditHeaders {
+ c.focusTerminalPriv()
+ }
return nil
}
func (c *Composer) PrevField() {
c.Lock()
defer c.Unlock()
+ if config.Compose.EditHeaders && c.editor != nil {
+ return
+ }
c.focusable[c.focused].Focus(false)
c.focused--
if c.focused == -1 {
@@ -1123,6 +1236,9 @@ func (c *Composer) PrevField() {
func (c *Composer) NextField() {
c.Lock()
defer c.Unlock()
+ if config.Compose.EditHeaders && c.editor != nil {
+ return
+ }
c.focusable[c.focused].Focus(false)
c.focused = (c.focused + 1) % len(c.focusable)
c.focusable[c.focused].Focus(true)
@@ -1131,6 +1247,9 @@ func (c *Composer) NextField() {
func (c *Composer) FocusEditor(editor string) {
c.Lock()
defer c.Unlock()
+ if config.Compose.EditHeaders && c.editor != nil {
+ return
+ }
c.focusEditor(editor)
}
@@ -1148,9 +1267,21 @@ func (c *Composer) focusEditor(editor string) {
}
// AddEditor appends a new header editor to the compose window.
-func (c *Composer) AddEditor(header string, value string, appendHeader bool) {
+func (c *Composer) AddEditor(header string, value string, appendHeader bool) error {
c.Lock()
defer c.Unlock()
+ if config.Compose.EditHeaders && c.editor != nil {
+ return errors.New("header should be added directly in the text editor")
+ }
+ value = c.addEditor(header, value, appendHeader)
+ if value == "" {
+ c.focusEditor(c.editors[header].name)
+ }
+ c.updateGrid()
+ return nil
+}
+
+func (c *Composer) addEditor(header string, value string, appendHeader bool) string {
var editor *headerEditor
header = strings.ToLower(header)
if e, ok := c.editors[header]; ok {
@@ -1187,44 +1318,97 @@ func (c *Composer) AddEditor(header string, value string, appendHeader bool) {
c.editors[header].input.Set(value)
editor.storeValue()
}
- if value == "" {
- c.focusEditor(c.editors[header].name)
+ return value
+}
+
+func (c *Composer) delEditor(header string) {
+ header = strings.ToLower(header)
+ editor, ok := c.editors[header]
+ if !ok {
+ return
}
- c.updateGrid()
+
+ var layout HeaderLayout = make([][]string, 0, len(c.layout))
+ for _, row := range c.layout {
+ r := make([]string, 0, len(row))
+ for _, h := range row {
+ if h != header {
+ r = append(r, h)
+ }
+ }
+ if len(r) > 0 {
+ layout = append(layout, r)
+ }
+ }
+ c.layout = layout
+
+ focusable := make([]ui.MouseableDrawableInteractive, 0, len(c.focusable)-1)
+ for i, f := range c.focusable {
+ if f == editor {
+ if c.focused > 0 && c.focused >= i {
+ c.focused--
+ }
+ } else {
+ focusable = append(focusable, f)
+ }
+ }
+ focusable[c.focused].Focus(true)
+ c.focusable = focusable
+
+ delete(c.editors, header)
}
// updateGrid should be called when the underlying header layout is changed.
func (c *Composer) updateGrid() {
+ grid := ui.NewGrid().Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ if config.Compose.EditHeaders && c.review == nil {
+ grid.Rows([]ui.GridSpec{
+ // 0: editor
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+ if c.editor != nil {
+ grid.AddChild(c.editor).At(0, 0)
+ }
+ c.grid.Store(grid)
+ return
+ }
+
heditors, height := c.layout.grid(
func(h string) ui.Drawable {
return c.editors[h]
},
)
- if c.grid == nil {
- c.grid = ui.NewGrid().Columns([]ui.GridSpec{
- {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
- })
- }
crHeight := 0
if c.sign || c.encrypt {
crHeight = 1
}
- c.grid.Rows([]ui.GridSpec{
+ grid.Rows([]ui.GridSpec{
+ // 0: headers
{Strategy: ui.SIZE_EXACT, Size: ui.Const(height)},
+ // 1: crypto status
{Strategy: ui.SIZE_EXACT, Size: ui.Const(crHeight)},
+ // 2: filler line
{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
+ // 3: editor or review
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
})
- if c.heditors != nil {
- c.grid.RemoveChild(c.heditors)
- }
borderStyle := c.acct.UiConfig().GetStyle(config.STYLE_BORDER)
borderChar := c.acct.UiConfig().BorderCharHorizontal
- c.heditors = heditors
- c.grid.AddChild(c.heditors).At(0, 0)
- c.grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, 0)
+ grid.AddChild(heditors).At(0, 0)
+ grid.AddChild(c.crypto).At(1, 0)
+ grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, 0)
+ if c.review != nil {
+ grid.AddChild(c.review).At(3, 0)
+ } else if c.editor != nil {
+ grid.AddChild(c.editor).At(3, 0)
+ }
+ c.heditors.Store(heditors)
+ c.grid.Store(grid)
}
type headerEditor struct {