03650474e2
Also update to the tcell v2 PaletteColor api, which should keep the chosen theme of the user intact. Note, that if $TRUECOLOR is defined and a truecolor given, aerc will now stop clipping the value to one of the theme colors. Generally this is desired behaviour though.
884 lines
21 KiB
Go
884 lines
21 KiB
Go
package widgets
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"mime"
|
|
"net/http"
|
|
"net/textproto"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/emersion/go-message/mail"
|
|
"github.com/gdamore/tcell/v2"
|
|
"github.com/mattn/go-runewidth"
|
|
"github.com/mitchellh/go-homedir"
|
|
"github.com/pkg/errors"
|
|
|
|
"git.sr.ht/~sircmpwn/aerc/completer"
|
|
"git.sr.ht/~sircmpwn/aerc/config"
|
|
"git.sr.ht/~sircmpwn/aerc/lib/format"
|
|
"git.sr.ht/~sircmpwn/aerc/lib/templates"
|
|
"git.sr.ht/~sircmpwn/aerc/lib/ui"
|
|
"git.sr.ht/~sircmpwn/aerc/models"
|
|
"git.sr.ht/~sircmpwn/aerc/worker/types"
|
|
)
|
|
|
|
type Composer struct {
|
|
editors map[string]*headerEditor // indexes in lower case (from / cc / bcc)
|
|
header *mail.Header
|
|
parent models.OriginalMail // parent of current message, only set if reply
|
|
|
|
acctConfig *config.AccountConfig
|
|
config *config.AercConfig
|
|
acct *AccountView
|
|
aerc *Aerc
|
|
|
|
attachments []string
|
|
editor *Terminal
|
|
email *os.File
|
|
grid *ui.Grid
|
|
heditors *ui.Grid // from, to, cc display a user can jump to
|
|
review *reviewMessage
|
|
worker *types.Worker
|
|
completer *completer.Completer
|
|
|
|
layout HeaderLayout
|
|
focusable []ui.MouseableDrawableInteractive
|
|
focused int
|
|
sent bool
|
|
|
|
onClose []func(ti *Composer)
|
|
|
|
width int
|
|
}
|
|
|
|
func NewComposer(aerc *Aerc, acct *AccountView, conf *config.AercConfig,
|
|
acctConfig *config.AccountConfig, worker *types.Worker, template string,
|
|
h *mail.Header, orig models.OriginalMail) (*Composer, error) {
|
|
|
|
if h == nil {
|
|
h = new(mail.Header)
|
|
}
|
|
if fl, err := h.AddressList("from"); err != nil || fl == nil {
|
|
fl, err = mail.ParseAddressList(acctConfig.From)
|
|
// realistically this blows up way before us during the config loading
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if fl != nil {
|
|
h.SetAddressList("from", fl)
|
|
|
|
}
|
|
}
|
|
|
|
templateData := templates.ParseTemplateData(h, orig)
|
|
cmpl := completer.New(conf.Compose.AddressBookCmd, func(err error) {
|
|
aerc.PushError(
|
|
fmt.Sprintf("could not complete header: %v", err))
|
|
worker.Logger.Printf("could not complete header: %v", err)
|
|
}, aerc.Logger())
|
|
|
|
email, err := ioutil.TempFile("", "aerc-compose-*.eml")
|
|
if err != nil {
|
|
// TODO: handle this better
|
|
return nil, err
|
|
}
|
|
|
|
c := &Composer{
|
|
acct: acct,
|
|
acctConfig: acctConfig,
|
|
aerc: aerc,
|
|
config: conf,
|
|
header: h,
|
|
parent: orig,
|
|
email: email,
|
|
worker: worker,
|
|
// You have to backtab to get to "From", since you usually don't edit it
|
|
focused: 1,
|
|
completer: cmpl,
|
|
}
|
|
c.buildComposeHeader(aerc, cmpl)
|
|
|
|
if err := c.AddTemplate(template, templateData); err != nil {
|
|
return nil, err
|
|
}
|
|
c.AddSignature()
|
|
|
|
c.updateGrid()
|
|
c.ShowTerminal()
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Composer) buildComposeHeader(aerc *Aerc, cmpl *completer.Completer) {
|
|
|
|
c.layout = aerc.conf.Compose.HeaderLayout
|
|
c.editors = make(map[string]*headerEditor)
|
|
c.focusable = make([]ui.MouseableDrawableInteractive, 0)
|
|
|
|
for i, row := range c.layout {
|
|
for j, h := range row {
|
|
h = strings.ToLower(h)
|
|
c.layout[i][j] = h // normalize to lowercase
|
|
e := newHeaderEditor(h, c.header, aerc.SelectedAccount().UiConfig())
|
|
if aerc.conf.Ui.CompletionPopovers {
|
|
e.input.TabComplete(cmpl.ForHeader(h),
|
|
aerc.SelectedAccount().UiConfig().CompletionDelay)
|
|
}
|
|
c.editors[h] = e
|
|
switch h {
|
|
case "from":
|
|
// Prepend From to support backtab
|
|
c.focusable = append([]ui.MouseableDrawableInteractive{e}, c.focusable...)
|
|
default:
|
|
c.focusable = append(c.focusable, e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add Cc/Bcc editors to layout if present in header and not already visible
|
|
for _, h := range []string{"cc", "bcc"} {
|
|
if c.header.Has(h) {
|
|
if _, ok := c.editors[h]; !ok {
|
|
e := newHeaderEditor(h, c.header, aerc.SelectedAccount().UiConfig())
|
|
if aerc.conf.Ui.CompletionPopovers {
|
|
e.input.TabComplete(cmpl.ForHeader(h), aerc.SelectedAccount().UiConfig().CompletionDelay)
|
|
}
|
|
c.editors[h] = e
|
|
c.focusable = append(c.focusable, e)
|
|
c.layout = append(c.layout, []string{h})
|
|
}
|
|
}
|
|
}
|
|
|
|
// load current header values into all editors
|
|
for _, e := range c.editors {
|
|
e.loadValue()
|
|
}
|
|
}
|
|
|
|
func (c *Composer) SetSent() {
|
|
c.sent = true
|
|
}
|
|
|
|
func (c *Composer) Sent() bool {
|
|
return c.sent
|
|
}
|
|
|
|
// Note: this does not reload the editor. You must call this before the first
|
|
// Draw() call.
|
|
func (c *Composer) SetContents(reader io.Reader) *Composer {
|
|
c.email.Seek(0, io.SeekStart)
|
|
io.Copy(c.email, reader)
|
|
c.email.Sync()
|
|
c.email.Seek(0, io.SeekStart)
|
|
return c
|
|
}
|
|
|
|
func (c *Composer) AppendContents(reader io.Reader) {
|
|
c.email.Seek(0, io.SeekEnd)
|
|
io.Copy(c.email, reader)
|
|
c.email.Sync()
|
|
}
|
|
|
|
func (c *Composer) AddTemplate(template string, data interface{}) error {
|
|
if template == "" {
|
|
return nil
|
|
}
|
|
|
|
templateText, err := templates.ParseTemplateFromFile(
|
|
template, c.config.Templates.TemplateDirs, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
mr, err := mail.CreateReader(templateText)
|
|
if err != nil {
|
|
return fmt.Errorf("Template loading failed: %v", err)
|
|
}
|
|
|
|
// copy the headers contained in the template to the compose headers
|
|
hf := mr.Header.Fields()
|
|
for hf.Next() {
|
|
c.header.Set(hf.Key(), hf.Value())
|
|
}
|
|
|
|
part, err := mr.NextPart()
|
|
if err != nil {
|
|
return fmt.Errorf("Could not get body of template: %v", err)
|
|
}
|
|
|
|
c.AppendContents(part.Body)
|
|
return nil
|
|
}
|
|
|
|
func (c *Composer) AddSignature() {
|
|
var signature []byte
|
|
if c.acctConfig.SignatureCmd != "" {
|
|
var err error
|
|
signature, err = c.readSignatureFromCmd()
|
|
if err != nil {
|
|
signature = c.readSignatureFromFile()
|
|
}
|
|
} else {
|
|
signature = c.readSignatureFromFile()
|
|
}
|
|
c.AppendContents(bytes.NewReader(signature))
|
|
}
|
|
|
|
func (c *Composer) readSignatureFromCmd() ([]byte, error) {
|
|
sigCmd := c.acctConfig.SignatureCmd
|
|
cmd := exec.Command("sh", "-c", sigCmd)
|
|
signature, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return signature, nil
|
|
}
|
|
|
|
func (c *Composer) readSignatureFromFile() []byte {
|
|
sigFile := c.acctConfig.SignatureFile
|
|
if sigFile == "" {
|
|
return nil
|
|
}
|
|
sigFile, err := homedir.Expand(sigFile)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
signature, err := ioutil.ReadFile(sigFile)
|
|
if err != nil {
|
|
c.aerc.PushError(
|
|
fmt.Sprintf(" Error loading signature from file: %v", sigFile))
|
|
return nil
|
|
}
|
|
return signature
|
|
}
|
|
|
|
func (c *Composer) FocusTerminal() *Composer {
|
|
if c.editor == nil {
|
|
return c
|
|
}
|
|
c.focusable[c.focused].Focus(false)
|
|
c.focused = len(c.editors)
|
|
c.focusable[c.focused].Focus(true)
|
|
return c
|
|
}
|
|
|
|
func (c *Composer) FocusSubject() *Composer {
|
|
c.focusable[c.focused].Focus(false)
|
|
c.focused = 2
|
|
c.focusable[c.focused].Focus(true)
|
|
return c
|
|
}
|
|
|
|
func (c *Composer) FocusRecipient() *Composer {
|
|
c.focusable[c.focused].Focus(false)
|
|
c.focused = 1
|
|
c.focusable[c.focused].Focus(true)
|
|
return c
|
|
}
|
|
|
|
// OnHeaderChange registers an OnChange callback for the specified header.
|
|
func (c *Composer) OnHeaderChange(header string, fn func(subject string)) {
|
|
if editor, ok := c.editors[strings.ToLower(header)]; ok {
|
|
editor.OnChange(func() {
|
|
fn(editor.input.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func (c *Composer) OnClose(fn func(composer *Composer)) {
|
|
c.onClose = append(c.onClose, fn)
|
|
}
|
|
|
|
func (c *Composer) Draw(ctx *ui.Context) {
|
|
c.width = ctx.Width()
|
|
c.grid.Draw(ctx)
|
|
}
|
|
|
|
func (c *Composer) Invalidate() {
|
|
c.grid.Invalidate()
|
|
}
|
|
|
|
func (c *Composer) OnInvalidate(fn func(d ui.Drawable)) {
|
|
c.grid.OnInvalidate(func(_ ui.Drawable) {
|
|
fn(c)
|
|
})
|
|
}
|
|
|
|
func (c *Composer) Close() {
|
|
for _, onClose := range c.onClose {
|
|
onClose(c)
|
|
}
|
|
if c.email != nil {
|
|
path := c.email.Name()
|
|
c.email.Close()
|
|
os.Remove(path)
|
|
c.email = nil
|
|
}
|
|
if c.editor != nil {
|
|
c.editor.Destroy()
|
|
c.editor = nil
|
|
}
|
|
}
|
|
|
|
func (c *Composer) Bindings() string {
|
|
if c.editor == nil {
|
|
return "compose::review"
|
|
} else if c.editor == c.focusable[c.focused] {
|
|
return "compose::editor"
|
|
} else {
|
|
return "compose"
|
|
}
|
|
}
|
|
|
|
func (c *Composer) Event(event tcell.Event) bool {
|
|
if c.editor != nil {
|
|
return c.focusable[c.focused].Event(event)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *Composer) MouseEvent(localX int, localY int, event tcell.Event) {
|
|
c.grid.MouseEvent(localX, localY, event)
|
|
for _, e := range c.focusable {
|
|
he, ok := e.(*headerEditor)
|
|
if ok && he.focused {
|
|
c.FocusEditor(he)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Composer) Focus(focus bool) {
|
|
c.focusable[c.focused].Focus(focus)
|
|
}
|
|
|
|
func (c *Composer) Config() *config.AccountConfig {
|
|
return c.acctConfig
|
|
}
|
|
|
|
func (c *Composer) Account() *AccountView {
|
|
return c.acct
|
|
}
|
|
|
|
func (c *Composer) Worker() *types.Worker {
|
|
return c.worker
|
|
}
|
|
|
|
//PrepareHeader finalizes the header, adding the value from the editors
|
|
func (c *Composer) PrepareHeader() (*mail.Header, error) {
|
|
for _, editor := range c.editors {
|
|
editor.storeValue()
|
|
}
|
|
|
|
// control headers not normally set by the user
|
|
// repeated calls to PrepareHeader should be a noop
|
|
if !c.header.Has("Message-Id") {
|
|
err := c.header.GenerateMessageID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if !c.header.Has("Date") {
|
|
c.header.SetDate(time.Now())
|
|
}
|
|
return c.header, nil
|
|
}
|
|
|
|
func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error {
|
|
if err := c.reloadEmail(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(c.attachments) == 0 {
|
|
// don't create a multipart email if we only have text
|
|
return writeInlineBody(header, c.email, writer)
|
|
}
|
|
|
|
// otherwise create a multipart email,
|
|
// with a multipart/alternative part for the text
|
|
w, err := mail.CreateWriter(writer, *header)
|
|
if err != nil {
|
|
return errors.Wrap(err, "CreateWriter")
|
|
}
|
|
defer w.Close()
|
|
|
|
if err := writeMultipartBody(c.email, w); err != nil {
|
|
return errors.Wrap(err, "writeMultipartBody")
|
|
}
|
|
|
|
for _, a := range c.attachments {
|
|
if err := writeAttachment(a, w); err != nil {
|
|
return errors.Wrap(err, "writeAttachment")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func writeInlineBody(header *mail.Header, body io.Reader, writer io.Writer) error {
|
|
header.SetContentType("text/plain", map[string]string{"charset": "UTF-8"})
|
|
w, err := mail.CreateSingleInlineWriter(writer, *header)
|
|
if err != nil {
|
|
return errors.Wrap(err, "CreateSingleInlineWriter")
|
|
}
|
|
defer w.Close()
|
|
if _, err := io.Copy(w, body); err != nil {
|
|
return errors.Wrap(err, "io.Copy")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// write the message body to the multipart message
|
|
func writeMultipartBody(body io.Reader, w *mail.Writer) error {
|
|
bh := mail.InlineHeader{}
|
|
bh.SetContentType("text/plain", map[string]string{"charset": "UTF-8"})
|
|
|
|
bi, err := w.CreateInline()
|
|
if err != nil {
|
|
return errors.Wrap(err, "CreateInline")
|
|
}
|
|
defer bi.Close()
|
|
|
|
bw, err := bi.CreatePart(bh)
|
|
if err != nil {
|
|
return errors.Wrap(err, "CreatePart")
|
|
}
|
|
defer bw.Close()
|
|
if _, err := io.Copy(bw, body); err != nil {
|
|
return errors.Wrap(err, "io.Copy")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// write the attachment specified by path to the message
|
|
func writeAttachment(path string, writer *mail.Writer) error {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return errors.Wrap(err, "os.Open")
|
|
}
|
|
defer f.Close()
|
|
|
|
reader := bufio.NewReader(f)
|
|
|
|
// if we have an extension, prefer that instead of trying to sniff the header.
|
|
// That's generally more accurate than sniffing as lots of things are zip files
|
|
// under the hood, e.g. most office file types
|
|
ext := filepath.Ext(path)
|
|
var mimeString string
|
|
if mimeString = mime.TypeByExtension(ext); mimeString != "" {
|
|
// found it in the DB
|
|
} else {
|
|
// Sniff the mime type instead
|
|
// http.DetectContentType only cares about the first 512 bytes
|
|
head, err := reader.Peek(512)
|
|
if err != nil && err != io.EOF {
|
|
return errors.Wrap(err, "Peek")
|
|
}
|
|
mimeString = http.DetectContentType(head)
|
|
}
|
|
|
|
// mimeString can contain type and params (like text encoding),
|
|
// so we need to break them apart before passing them to the headers
|
|
mimeType, params, err := mime.ParseMediaType(mimeString)
|
|
if err != nil {
|
|
return errors.Wrap(err, "ParseMediaType")
|
|
}
|
|
filename := filepath.Base(path)
|
|
params["name"] = filename
|
|
|
|
// set header fields
|
|
ah := mail.AttachmentHeader{}
|
|
ah.SetContentType(mimeType, params)
|
|
// setting the filename auto sets the content disposition
|
|
ah.SetFilename(filename)
|
|
|
|
aw, err := writer.CreateAttachment(ah)
|
|
if err != nil {
|
|
return errors.Wrap(err, "CreateAttachment")
|
|
}
|
|
defer aw.Close()
|
|
|
|
if _, err := reader.WriteTo(aw); err != nil {
|
|
return errors.Wrap(err, "reader.WriteTo")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Composer) GetAttachments() []string {
|
|
return c.attachments
|
|
}
|
|
|
|
func (c *Composer) AddAttachment(path string) {
|
|
c.attachments = append(c.attachments, path)
|
|
c.resetReview()
|
|
}
|
|
|
|
func (c *Composer) DeleteAttachment(path string) error {
|
|
for i, a := range c.attachments {
|
|
if a == path {
|
|
c.attachments = append(c.attachments[:i], c.attachments[i+1:]...)
|
|
c.resetReview()
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return errors.New("attachment does not exist")
|
|
}
|
|
|
|
func (c *Composer) resetReview() {
|
|
if c.review != nil {
|
|
c.grid.RemoveChild(c.review)
|
|
c.review = newReviewMessage(c, nil)
|
|
c.grid.AddChild(c.review).At(2, 0)
|
|
}
|
|
}
|
|
|
|
func (c *Composer) termEvent(event tcell.Event) bool {
|
|
switch event := event.(type) {
|
|
case *tcell.EventMouse:
|
|
switch event.Buttons() {
|
|
case tcell.Button1:
|
|
c.FocusTerminal()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *Composer) termClosed(err error) {
|
|
c.grid.RemoveChild(c.editor)
|
|
c.review = newReviewMessage(c, err)
|
|
c.grid.AddChild(c.review).At(2, 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
|
|
}
|
|
}
|
|
|
|
func (c *Composer) ShowTerminal() {
|
|
if c.editor != nil {
|
|
return
|
|
}
|
|
if c.review != nil {
|
|
c.grid.RemoveChild(c.review)
|
|
}
|
|
editorName := c.config.Compose.Editor
|
|
if editorName == "" {
|
|
editorName = os.Getenv("EDITOR")
|
|
}
|
|
if editorName == "" {
|
|
editorName = "vi"
|
|
}
|
|
editor := exec.Command("/bin/sh", "-c", editorName+" "+c.email.Name())
|
|
c.editor, _ = NewTerminal(editor) // TODO: handle error
|
|
c.editor.OnEvent = c.termEvent
|
|
c.editor.OnClose = c.termClosed
|
|
c.grid.AddChild(c.editor).At(2, 0)
|
|
c.focusable = append(c.focusable, c.editor)
|
|
}
|
|
|
|
func (c *Composer) PrevField() {
|
|
c.focusable[c.focused].Focus(false)
|
|
c.focused--
|
|
if c.focused == -1 {
|
|
c.focused = len(c.focusable) - 1
|
|
}
|
|
c.focusable[c.focused].Focus(true)
|
|
}
|
|
|
|
func (c *Composer) NextField() {
|
|
c.focusable[c.focused].Focus(false)
|
|
c.focused = (c.focused + 1) % len(c.focusable)
|
|
c.focusable[c.focused].Focus(true)
|
|
}
|
|
|
|
func (c *Composer) FocusEditor(editor *headerEditor) {
|
|
c.focusable[c.focused].Focus(false)
|
|
for i, e := range c.focusable {
|
|
if e == editor {
|
|
c.focused = i
|
|
break
|
|
}
|
|
}
|
|
c.focusable[c.focused].Focus(true)
|
|
}
|
|
|
|
// AddEditor appends a new header editor to the compose window.
|
|
func (c *Composer) AddEditor(header string, value string, appendHeader bool) {
|
|
var editor *headerEditor
|
|
header = strings.ToLower(header)
|
|
if e, ok := c.editors[header]; ok {
|
|
e.storeValue() // flush modifications from the user to the header
|
|
editor = e
|
|
} else {
|
|
e := newHeaderEditor(header, c.header,
|
|
c.aerc.SelectedAccount().UiConfig())
|
|
if c.config.Ui.CompletionPopovers {
|
|
e.input.TabComplete(c.completer.ForHeader(header),
|
|
c.config.Ui.CompletionDelay)
|
|
}
|
|
c.editors[header] = e
|
|
c.layout = append(c.layout, []string{header})
|
|
// Insert focus of new editor before terminal editor
|
|
c.focusable = append(
|
|
c.focusable[:len(c.focusable)-1],
|
|
e,
|
|
c.focusable[len(c.focusable)-1],
|
|
)
|
|
editor = e
|
|
}
|
|
|
|
if appendHeader {
|
|
currVal := editor.input.String()
|
|
if currVal != "" {
|
|
value = strings.TrimSpace(currVal) + ", " + value
|
|
}
|
|
}
|
|
if value != "" || appendHeader {
|
|
c.editors[header].input.Set(value)
|
|
editor.storeValue()
|
|
}
|
|
if value == "" {
|
|
c.FocusEditor(c.editors[header])
|
|
}
|
|
c.updateGrid()
|
|
}
|
|
|
|
// updateGrid should be called when the underlying header layout is changed.
|
|
func (c *Composer) updateGrid() {
|
|
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{
|
|
{ui.SIZE_WEIGHT, ui.Const(1)},
|
|
})
|
|
}
|
|
|
|
c.grid.Rows([]ui.GridSpec{
|
|
{ui.SIZE_EXACT, ui.Const(height)},
|
|
{ui.SIZE_EXACT, ui.Const(1)},
|
|
{ui.SIZE_WEIGHT, ui.Const(1)},
|
|
})
|
|
|
|
if c.heditors != nil {
|
|
c.grid.RemoveChild(c.heditors)
|
|
}
|
|
c.heditors = heditors
|
|
c.grid.AddChild(c.heditors).At(0, 0)
|
|
c.grid.AddChild(ui.NewFill(' ')).At(1, 0)
|
|
}
|
|
|
|
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
|
|
header *mail.Header
|
|
focused bool
|
|
input *ui.TextInput
|
|
uiConfig config.UIConfig
|
|
}
|
|
|
|
func newHeaderEditor(name string, h *mail.Header,
|
|
uiConfig config.UIConfig) *headerEditor {
|
|
he := &headerEditor{
|
|
input: ui.NewTextInput("", uiConfig),
|
|
name: name,
|
|
header: h,
|
|
uiConfig: uiConfig,
|
|
}
|
|
he.loadValue()
|
|
return he
|
|
}
|
|
|
|
//extractHumanHeaderValue extracts the human readable string for key from the
|
|
//header. If a parsing error occurs the raw value is returned
|
|
func extractHumanHeaderValue(key string, h *mail.Header) string {
|
|
var val string
|
|
var err error
|
|
switch strings.ToLower(key) {
|
|
case "to", "from", "cc", "bcc":
|
|
var list []*mail.Address
|
|
list, err = h.AddressList(key)
|
|
val = format.FormatAddresses(list)
|
|
default:
|
|
val, err = h.Text(key)
|
|
}
|
|
if err != nil {
|
|
// if we can't parse it, show it raw
|
|
val = h.Get(key)
|
|
}
|
|
return val
|
|
}
|
|
|
|
//loadValue loads the value of he.name form the underlying header
|
|
//the value is decoded and meant for human consumption.
|
|
//decoding issues are ignored and return their raw values
|
|
func (he *headerEditor) loadValue() {
|
|
he.input.Set(extractHumanHeaderValue(he.name, he.header))
|
|
he.input.Invalidate()
|
|
}
|
|
|
|
//storeValue writes the current state back to the underlying header.
|
|
//errors are ignored
|
|
func (he *headerEditor) storeValue() {
|
|
val := he.input.String()
|
|
switch strings.ToLower(he.name) {
|
|
case "to", "from", "cc", "bcc":
|
|
list, err := mail.ParseAddressList(val)
|
|
if err == nil {
|
|
he.header.SetAddressList(he.name, list)
|
|
} else {
|
|
// garbage, but it'll blow up upon sending and the user can
|
|
// fix the issue
|
|
he.header.SetText(he.name, val)
|
|
}
|
|
val = format.FormatAddresses(list)
|
|
default:
|
|
he.header.SetText(he.name, val)
|
|
}
|
|
}
|
|
|
|
//setValue overwrites the current value of the header editor and flushes it
|
|
//to the underlying header
|
|
func (he *headerEditor) setValue(val string) {
|
|
he.input.Set(val)
|
|
he.storeValue()
|
|
}
|
|
|
|
func (he *headerEditor) Draw(ctx *ui.Context) {
|
|
normalized := textproto.CanonicalMIMEHeaderKey(he.name)
|
|
name := normalized + " "
|
|
size := runewidth.StringWidth(name)
|
|
defaultStyle := he.uiConfig.GetStyle(config.STYLE_DEFAULT)
|
|
headerStyle := he.uiConfig.GetStyle(config.STYLE_HEADER)
|
|
ctx.Fill(0, 0, size, ctx.Height(), ' ', defaultStyle)
|
|
ctx.Printf(0, 0, headerStyle, "%s", name)
|
|
he.input.Draw(ctx.Subcontext(size, 0, ctx.Width()-size, 1))
|
|
}
|
|
|
|
func (he *headerEditor) MouseEvent(localX int, localY int, event tcell.Event) {
|
|
switch event := event.(type) {
|
|
case *tcell.EventMouse:
|
|
switch event.Buttons() {
|
|
case tcell.Button1:
|
|
he.focused = true
|
|
}
|
|
|
|
width := runewidth.StringWidth(he.name + " ")
|
|
if localX >= width {
|
|
he.input.MouseEvent(localX-width, localY, event)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (he *headerEditor) Invalidate() {
|
|
he.input.Invalidate()
|
|
}
|
|
|
|
func (he *headerEditor) OnInvalidate(fn func(ui.Drawable)) {
|
|
he.input.OnInvalidate(func(_ ui.Drawable) {
|
|
fn(he)
|
|
})
|
|
}
|
|
|
|
func (he *headerEditor) Focus(focused bool) {
|
|
he.focused = focused
|
|
he.input.Focus(focused)
|
|
}
|
|
|
|
func (he *headerEditor) Event(event tcell.Event) bool {
|
|
return he.input.Event(event)
|
|
}
|
|
|
|
func (he *headerEditor) OnChange(fn func()) {
|
|
he.input.OnChange(func(_ *ui.TextInput) {
|
|
fn()
|
|
})
|
|
}
|
|
|
|
type reviewMessage struct {
|
|
composer *Composer
|
|
grid *ui.Grid
|
|
}
|
|
|
|
func newReviewMessage(composer *Composer, err error) *reviewMessage {
|
|
spec := []ui.GridSpec{
|
|
{ui.SIZE_EXACT, ui.Const(2)},
|
|
{ui.SIZE_EXACT, ui.Const(1)},
|
|
}
|
|
for i := 0; i < len(composer.attachments)-1; i++ {
|
|
spec = append(spec, ui.GridSpec{ui.SIZE_EXACT, ui.Const(1)})
|
|
}
|
|
// make the last element fill remaining space
|
|
spec = append(spec, ui.GridSpec{ui.SIZE_WEIGHT, ui.Const(1)})
|
|
|
|
grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{
|
|
{ui.SIZE_WEIGHT, ui.Const(1)},
|
|
})
|
|
|
|
uiConfig := composer.config.Ui
|
|
|
|
if err != nil {
|
|
grid.AddChild(ui.NewText(err.Error(), uiConfig.GetStyle(config.STYLE_ERROR)))
|
|
grid.AddChild(ui.NewText("Press [q] to close this tab.",
|
|
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(1, 0)
|
|
} else {
|
|
// TODO: source this from actual keybindings?
|
|
grid.AddChild(ui.NewText("Send this email? [y]es/[n]o/[e]dit/[a]ttach",
|
|
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(0, 0)
|
|
grid.AddChild(ui.NewText("Attachments:",
|
|
uiConfig.GetStyle(config.STYLE_TITLE))).At(1, 0)
|
|
if len(composer.attachments) == 0 {
|
|
grid.AddChild(ui.NewText("(none)",
|
|
uiConfig.GetStyle(config.STYLE_DEFAULT))).At(2, 0)
|
|
} else {
|
|
for i, a := range composer.attachments {
|
|
grid.AddChild(ui.NewText(a, uiConfig.GetStyle(config.STYLE_DEFAULT))).
|
|
At(i+2, 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
return &reviewMessage{
|
|
composer: composer,
|
|
grid: grid,
|
|
}
|
|
}
|
|
|
|
func (rm *reviewMessage) Invalidate() {
|
|
rm.grid.Invalidate()
|
|
}
|
|
|
|
func (rm *reviewMessage) OnInvalidate(fn func(ui.Drawable)) {
|
|
rm.grid.OnInvalidate(func(_ ui.Drawable) {
|
|
fn(rm)
|
|
})
|
|
}
|
|
|
|
func (rm *reviewMessage) Draw(ctx *ui.Context) {
|
|
rm.grid.Draw(ctx)
|
|
}
|