From 67fb0938a66605a0b6a837005804637b348b250d Mon Sep 17 00:00:00 2001 From: Daniel Bridges Date: Mon, 22 Jul 2019 16:29:07 -0700 Subject: [PATCH] Support configurable header layout in compose widget --- commands/account/compose.go | 4 +- commands/msg/reply.go | 17 +-- commands/msg/unsubscribe.go | 14 +- config/aerc.conf.in | 7 + config/config.go | 16 ++- doc/aerc-config.5.scd | 6 + widgets/aerc.go | 4 +- widgets/compose.go | 253 ++++++++++++++++++++---------------- widgets/headerlayout.go | 41 ++++++ widgets/msgviewer.go | 47 ++----- 10 files changed, 240 insertions(+), 169 deletions(-) create mode 100644 widgets/headerlayout.go diff --git a/commands/account/compose.go b/commands/account/compose.go index cafba78..f615c0b 100644 --- a/commands/account/compose.go +++ b/commands/account/compose.go @@ -27,9 +27,9 @@ func (_ Compose) Execute(aerc *widgets.Aerc, args []string) error { } acct := aerc.SelectedAccount() composer := widgets.NewComposer( - aerc.Config(), acct.AccountConfig(), acct.Worker()) + aerc.Config(), acct.AccountConfig(), acct.Worker(), nil) tab := aerc.NewTab(composer, "New email") - composer.OnSubjectChange(func(subject string) { + composer.OnHeaderChange("Subject", func(subject string) { if subject == "" { tab.Name = "New email" } else { diff --git a/commands/msg/reply.go b/commands/msg/reply.go index 85c5d3a..029cb42 100644 --- a/commands/msg/reply.go +++ b/commands/msg/reply.go @@ -113,14 +113,15 @@ func (_ reply) Execute(aerc *widgets.Aerc, args []string) error { } } + defaults := map[string]string{ + "To": strings.Join(to, ", "), + "Cc": strings.Join(cc, ", "), + "Subject": subject, + "In-Reply-To": msg.Envelope.MessageId, + } + composer := widgets.NewComposer( - aerc.Config(), acct.AccountConfig(), acct.Worker()). - Defaults(map[string]string{ - "To": strings.Join(to, ", "), - "Cc": strings.Join(cc, ", "), - "Subject": subject, - "In-Reply-To": msg.Envelope.MessageId, - }) + aerc.Config(), acct.AccountConfig(), acct.Worker(), defaults) if args[0] == "reply" { composer.FocusTerminal() @@ -128,7 +129,7 @@ func (_ reply) Execute(aerc *widgets.Aerc, args []string) error { addTab := func() { tab := aerc.NewTab(composer, subject) - composer.OnSubjectChange(func(subject string) { + composer.OnHeaderChange("Subject", func(subject string) { if subject == "" { tab.Name = "New email" } else { diff --git a/commands/msg/unsubscribe.go b/commands/msg/unsubscribe.go index 720ff43..f18da07 100644 --- a/commands/msg/unsubscribe.go +++ b/commands/msg/unsubscribe.go @@ -83,15 +83,19 @@ func parseUnsubscribeMethods(header string) (methods []*url.URL) { func unsubscribeMailto(aerc *widgets.Aerc, u *url.URL) error { widget := aerc.SelectedTab().(widgets.ProvidesMessage) acct := widget.SelectedAccount() - composer := widgets.NewComposer(aerc.Config(), acct.AccountConfig(), - acct.Worker()) - composer.Defaults(map[string]string{ + defaults := map[string]string{ "To": u.Opaque, "Subject": u.Query().Get("subject"), - }) + } + composer := widgets.NewComposer( + aerc.Config(), + acct.AccountConfig(), + acct.Worker(), + defaults, + ) composer.SetContents(strings.NewReader(u.Query().Get("body"))) tab := aerc.NewTab(composer, "unsubscribe") - composer.OnSubjectChange(func(subject string) { + composer.OnHeaderChange("Subject", func(subject string) { if subject == "" { tab.Name = "unsubscribe" } else { diff --git a/config/aerc.conf.in b/config/aerc.conf.in index 5b080e9..55dfa13 100644 --- a/config/aerc.conf.in +++ b/config/aerc.conf.in @@ -81,6 +81,13 @@ always-show-mime=false # supports it. Defaults to $EDITOR, or vi. editor= +# +# 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 + [filters] # # Filters allow you to pipe an email body through a shell command to render diff --git a/config/config.go b/config/config.go index f863729..356d562 100644 --- a/config/config.go +++ b/config/config.go @@ -65,7 +65,8 @@ type BindingConfig struct { } type ComposeConfig struct { - Editor string `ini:"editor"` + Editor string `ini:"editor"` + HeaderLayout [][]string `ini:"-"` } type FilterConfig struct { @@ -278,6 +279,12 @@ func (config *AercConfig) LoadConfig(file *ini.File) error { if err := compose.MapTo(&config.Compose); err != nil { return err } + for key, val := range compose.KeysHash() { + switch key { + case "header-layout": + config.Compose.HeaderLayout = parseLayout(val) + } + } } if ui, err := file.GetSection("ui"); err == nil { if err := ui.MapTo(&config.Ui); err != nil { @@ -350,6 +357,13 @@ func LoadConfigFromFile(root *string, sharedir string) (*AercConfig, error) { {"Subject"}, }, }, + + Compose: ComposeConfig{ + HeaderLayout: [][]string{ + {"To", "From"}, + {"Subject"}, + }, + }, } // These bindings are not configurable config.Bindings.AccountWizard.ExKey = KeyStroke{ diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd index 08f65af..592b7af 100644 --- a/doc/aerc-config.5.scd +++ b/doc/aerc-config.5.scd @@ -151,6 +151,12 @@ These options are configured in the *[compose]* section of aerc.conf. embedded terminal, though it may also launch a graphical window if the environment supports it. Defaults to *$EDITOR*, or *vi*(1). +*header-layout* + Defines the default headers 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 + ## FILTERS Filters allow you to pipe an email body through a shell command to render diff --git a/widgets/aerc.go b/widgets/aerc.go index 3cf1f64..050ba77 100644 --- a/widgets/aerc.go +++ b/widgets/aerc.go @@ -353,7 +353,7 @@ func (aerc *Aerc) Mailto(addr *url.URL) error { } } composer := NewComposer(aerc.Config(), - acct.AccountConfig(), acct.Worker()).Defaults(defaults) + acct.AccountConfig(), acct.Worker(), defaults) composer.FocusSubject() title := "New email" if subj, ok := defaults["Subject"]; ok { @@ -361,7 +361,7 @@ func (aerc *Aerc) Mailto(addr *url.URL) error { composer.FocusTerminal() } tab := aerc.NewTab(composer, title) - composer.OnSubjectChange(func(subject string) { + composer.OnHeaderChange("Subject", func(subject string) { if subject == "" { tab.Name = "New email" } else { diff --git a/widgets/compose.go b/widgets/compose.go index 8277811..b45892f 100644 --- a/widgets/compose.go +++ b/widgets/compose.go @@ -24,11 +24,7 @@ import ( ) type Composer struct { - headers struct { - from *headerEditor - subject *headerEditor - to *headerEditor - } + editors map[string]*headerEditor acct *config.AccountConfig config *config.AercConfig @@ -45,77 +41,93 @@ type Composer struct { focused int } -// TODO: Let caller configure headers, initial body (for replies), etc func NewComposer(conf *config.AercConfig, - acct *config.AccountConfig, worker *types.Worker) *Composer { + acct *config.AccountConfig, worker *types.Worker, defaults map[string]string) *Composer { + + if defaults == nil { + defaults = make(map[string]string) + } + if from := defaults["From"]; from == "" { + defaults["From"] = acct.From + } + + layout, editors, focusable := buildComposeHeader(conf.Compose.HeaderLayout, defaults) + + header, headerHeight := layout.grid( + func(header string) ui.Drawable { return editors[header] }, + ) grid := ui.NewGrid().Rows([]ui.GridSpec{ - {ui.SIZE_EXACT, 3}, + {ui.SIZE_EXACT, headerHeight}, {ui.SIZE_WEIGHT, 1}, }).Columns([]ui.GridSpec{ {ui.SIZE_WEIGHT, 1}, }) - // TODO: let user specify extra headers to edit by default - headers := ui.NewGrid().Rows([]ui.GridSpec{ - {ui.SIZE_EXACT, 1}, // To/From - {ui.SIZE_EXACT, 1}, // Subject - {ui.SIZE_EXACT, 1}, // [spacer] - }).Columns([]ui.GridSpec{ - {ui.SIZE_WEIGHT, 1}, - {ui.SIZE_WEIGHT, 1}, - }) - - to := newHeaderEditor("To", "") - from := newHeaderEditor("From", acct.From) - subject := newHeaderEditor("Subject", "") - headers.AddChild(to).At(0, 0) - headers.AddChild(from).At(0, 1) - headers.AddChild(subject).At(1, 0).Span(1, 2) - headers.AddChild(ui.NewFill(' ')).At(2, 0).Span(1, 2) - email, err := ioutil.TempFile("", "aerc-compose-*.eml") if err != nil { // TODO: handle this better return nil } - grid.AddChild(headers).At(0, 0) + grid.AddChild(header).At(0, 0) c := &Composer{ - acct: acct, - config: conf, - email: email, - grid: grid, - worker: worker, + editors: editors, + acct: acct, + config: conf, + defaults: defaults, + email: email, + grid: grid, + worker: worker, // You have to backtab to get to "From", since you usually don't edit it focused: 1, - focusable: []ui.DrawableInteractive{from, to, subject}, + focusable: focusable, } - c.headers.to = to - c.headers.from = from - c.headers.subject = subject + c.ShowTerminal() return c } -// Sets additional headers to be added to the outgoing email (e.g. In-Reply-To) -func (c *Composer) Defaults(defaults map[string]string) *Composer { - c.defaults = defaults - if to, ok := defaults["To"]; ok { - c.headers.to.input.Set(to) - delete(defaults, "To") +func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (newLayout HeaderLayout, editors map[string]*headerEditor, focusable []ui.DrawableInteractive) { + editors = make(map[string]*headerEditor) + focusable = make([]ui.DrawableInteractive, 0) + + for _, row := range layout { + for _, h := range row { + e := newHeaderEditor(h, "") + editors[h] = e + switch h { + case "From": + // Prepend From to support backtab + focusable = append([]ui.DrawableInteractive{e}, focusable...) + default: + focusable = append(focusable, e) + } + } } - if from, ok := defaults["From"]; ok { - c.headers.from.input.Set(from) - delete(defaults, "From") + + // Add Cc/Bcc editors to layout if in defaults and not already visible + for _, h := range []string{"Cc", "Bcc"} { + if val, ok := defaults[h]; ok && val != "" { + if _, ok := editors[h]; !ok { + e := newHeaderEditor(h, "") + editors[h] = e + focusable = append(focusable, e) + layout = append(layout, []string{h}) + } + } } - if subject, ok := defaults["Subject"]; ok { - c.headers.subject.input.Set(subject) - delete(defaults, "Subject") + + // Set default values for all editors + for key := range editors { + if val, ok := defaults[key]; ok { + editors[key].input.Set(val) + delete(defaults, key) + } } - return c + return layout, editors, focusable } // Note: this does not reload the editor. You must call this before the first @@ -133,7 +145,7 @@ func (c *Composer) FocusTerminal() *Composer { return c } c.focusable[c.focused].Focus(false) - c.focused = 3 + c.focused = len(c.editors) c.focusable[c.focused].Focus(true) return c } @@ -145,10 +157,13 @@ func (c *Composer) FocusSubject() *Composer { return c } -func (c *Composer) OnSubjectChange(fn func(subject string)) { - c.headers.subject.OnChange(func() { - fn(c.headers.subject.input.String()) - }) +// OnHeaderChange registers an OnChange callback for the specified header. +func (c *Composer) OnHeaderChange(header string, fn func(subject string)) { + if editor, ok := c.editors[header]; ok { + editor.OnChange(func() { + fn(editor.input.String()) + }) + } } func (c *Composer) Draw(ctx *ui.Context) { @@ -209,7 +224,9 @@ func (c *Composer) Worker() *types.Worker { func (c *Composer) PrepareHeader() (*mail.Header, []string, error) { // Extract headers from the email, if present - c.email.Seek(0, os.SEEK_SET) + if err := c.reloadEmail(); err != nil { + return nil, nil, err + } var ( rcpts []string header mail.Header @@ -224,23 +241,62 @@ func (c *Composer) PrepareHeader() (*mail.Header, []string, error) { // Update headers mhdr := (*message.Header)(&header.Header) mhdr.SetText("Message-Id", mail.GenerateMessageID()) - if subject, _ := header.Subject(); subject == "" { - header.SetSubject(c.headers.subject.input.String()) + + headerKeys := make([]string, 0, len(c.editors)) + for key := range c.editors { + headerKeys = append(headerKeys, key) } - if date, err := header.Date(); err != nil || date == (time.Time{}) { - header.SetDate(time.Now()) - } - from := c.headers.from.input.String() - from_addrs, err := gomail.ParseAddressList(from) - if err != nil { - return nil, nil, errors.Wrapf(err, "ParseAddressList(%s)", from) - } else { - var simon_from []*mail.Address - for _, addr := range from_addrs { - simon_from = append(simon_from, (*mail.Address)(addr)) + // Ensure headers which require special processing are included. + for _, key := range []string{"To", "From", "Cc", "Bcc", "Subject", "Date"} { + if _, ok := c.editors[key]; !ok { + headerKeys = append(headerKeys, key) } - header.SetAddressList("From", simon_from) } + + for _, h := range headerKeys { + val := "" + editor, ok := c.editors[h] + if ok { + val = editor.input.String() + } else { + val, _ = mhdr.Text(h) + } + switch h { + case "Subject": + if subject, _ := header.Subject(); subject == "" { + header.SetSubject(val) + } + case "Date": + if date, err := header.Date(); err != nil || date == (time.Time{}) { + header.SetDate(time.Now()) + } + case "From", "To", "Cc", "Bcc": // Address headers + if val != "" { + hdrRcpts, err := gomail.ParseAddressList(val) + if err != nil { + return nil, nil, errors.Wrapf(err, "ParseAddressList(%s)", val) + } + edRcpts := make([]*mail.Address, len(hdrRcpts)) + for i, addr := range hdrRcpts { + edRcpts[i] = (*mail.Address)(addr) + } + header.SetAddressList(h, edRcpts) + if h != "From" { + for _, addr := range edRcpts { + rcpts = append(rcpts, addr.Address) + } + } + } + default: + // Handle user configured header editors. + if ok && !mhdr.Header.Has(h) { + if val := editor.input.String(); val != "" { + mhdr.SetText(h, val) + } + } + } + } + // Merge in additional headers txthdr := mhdr.Header for key, value := range c.defaults { @@ -248,56 +304,14 @@ func (c *Composer) PrepareHeader() (*mail.Header, []string, error) { mhdr.SetText(key, value) } } - if to := c.headers.to.input.String(); to != "" { - // Dammit Simon, this branch is 3x as long as it ought to be because - // your types aren't compatible enough with each other - to_rcpts, err := gomail.ParseAddressList(to) - if err != nil { - return nil, nil, errors.Wrapf(err, "ParseAddressList(%s)", to) - } - ed_rcpts, err := header.AddressList("To") - if err != nil { - return nil, nil, errors.Wrap(err, "AddressList(To)") - } - for _, addr := range to_rcpts { - ed_rcpts = append(ed_rcpts, (*mail.Address)(addr)) - } - header.SetAddressList("To", ed_rcpts) - for _, addr := range ed_rcpts { - rcpts = append(rcpts, addr.Address) - } - } - if cc, _ := mhdr.Text("Cc"); cc != "" { - cc_rcpts, err := gomail.ParseAddressList(cc) - if err != nil { - return nil, nil, errors.Wrapf(err, "ParseAddressList(%s)", cc) - } - // TODO: Update when the user inputs Cc's through the UI - for _, addr := range cc_rcpts { - rcpts = append(rcpts, addr.Address) - } - } - if bcc, _ := mhdr.Text("Bcc"); bcc != "" { - bcc_rcpts, err := gomail.ParseAddressList(bcc) - if err != nil { - return nil, nil, errors.Wrapf(err, "ParseAddressList(%s)", bcc) - } - // TODO: Update when the user inputs Bcc's through the UI - for _, addr := range bcc_rcpts { - rcpts = append(rcpts, addr.Address) - } - } + return &header, rcpts, nil } func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error { - name := c.email.Name() - c.email.Close() - file, err := os.Open(name) - if err != nil { - return errors.Wrap(err, "FileOpen") + if err := c.reloadEmail(); err != nil { + return err } - c.email = file var body io.Reader reader, err := mail.CreateReader(c.email) if err == nil { @@ -472,6 +486,17 @@ func (c *Composer) NextField() { c.focusable[c.focused].Focus(true) } +func (c *Composer) reloadEmail() error { + name := c.email.Name() + c.email.Close() + file, err := os.Open(name) + if err != nil { + return errors.Wrap(err, "ReloadEmail") + } + c.email = file + return nil +} + type headerEditor struct { name string input *ui.TextInput diff --git a/widgets/headerlayout.go b/widgets/headerlayout.go new file mode 100644 index 0000000..c6e6161 --- /dev/null +++ b/widgets/headerlayout.go @@ -0,0 +1,41 @@ +package widgets + +import ( + "git.sr.ht/~sircmpwn/aerc/lib/ui" + "git.sr.ht/~sircmpwn/aerc/models" +) + +type HeaderLayout [][]string + +// forMessage returns a filtered header layout, removing rows whose headers +// do not appear in the provided message. +func (layout HeaderLayout) forMessage(msg *models.MessageInfo) HeaderLayout { + headers := msg.RFC822Headers + result := make(HeaderLayout, 0, len(layout)) + for _, row := range layout { + // To preserve layout alignment, only hide rows if all columns are empty + for _, col := range row { + if headers.Get(col) != "" { + result = append(result, row) + break + } + } + } + return result +} + +// grid builds a ui grid, populating each cell by calling a callback function +// with the current header string. +func (layout HeaderLayout) grid(cb func(string) ui.Drawable) (grid *ui.Grid, height int) { + rowCount := len(layout) + 1 // extra row for spacer + grid = ui.MakeGrid(rowCount, 1, ui.SIZE_EXACT, ui.SIZE_WEIGHT) + for i, cols := range layout { + r := ui.MakeGrid(1, len(cols), ui.SIZE_EXACT, ui.SIZE_WEIGHT) + for j, col := range cols { + r.AddChild(cb(col)).At(0, j) + } + grid.AddChild(r).At(i, 0) + } + grid.AddChild(ui.NewFill(' ')).At(rowCount-1, 0) + return grid, rowCount +} diff --git a/widgets/msgviewer.go b/widgets/msgviewer.go index 7d92861..5b97f6f 100644 --- a/widgets/msgviewer.go +++ b/widgets/msgviewer.go @@ -46,7 +46,16 @@ type PartSwitcher struct { func NewMessageViewer(acct *AccountView, conf *config.AercConfig, store *lib.MessageStore, msg *models.MessageInfo) *MessageViewer { - header, headerHeight := createHeader(msg, conf.Viewer.HeaderLayout) + + layout := HeaderLayout(conf.Viewer.HeaderLayout).forMessage(msg) + header, headerHeight := layout.grid( + func(header string) ui.Drawable { + return &HeaderView{ + Name: header, + Value: fmtHeader(msg, header), + } + }, + ) grid := ui.NewGrid().Rows([]ui.GridSpec{ {ui.SIZE_EXACT, headerHeight}, @@ -78,42 +87,6 @@ func NewMessageViewer(acct *AccountView, conf *config.AercConfig, } } -func createHeader(msg *models.MessageInfo, layout [][]string) (grid *ui.Grid, height int) { - presentHeaders := presentHeaders(msg, layout) - rowCount := len(presentHeaders) + 1 // extra row for spacer - grid = ui.MakeGrid(rowCount, 1, ui.SIZE_EXACT, ui.SIZE_WEIGHT) - for i, cols := range presentHeaders { - r := ui.MakeGrid(1, len(cols), ui.SIZE_EXACT, ui.SIZE_WEIGHT) - for j, col := range cols { - r.AddChild( - &HeaderView{ - Name: col, - Value: fmtHeader(msg, col), - }).At(0, j) - } - grid.AddChild(r).At(i, 0) - } - grid.AddChild(ui.NewFill(' ')).At(rowCount-1, 0) - return grid, rowCount -} - -// presentHeaders returns a filtered header layout, removing rows whose headers -// do not appear in the provided message. -func presentHeaders(msg *models.MessageInfo, layout [][]string) [][]string { - headers := msg.RFC822Headers - result := make([][]string, 0, len(layout)) - for _, row := range layout { - // To preserve layout alignment, only hide rows if all columns are empty - for _, col := range row { - if headers.Get(col) != "" { - result = append(result, row) - break - } - } - } - return result -} - func fmtHeader(msg *models.MessageInfo, header string) string { switch header { case "From":