aboutsummaryrefslogtreecommitdiffstats
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/account-wizard.go891
-rw-r--r--app/account.go649
-rw-r--r--app/aerc.go908
-rw-r--r--app/authinfo.go88
-rw-r--r--app/compose.go1975
-rw-r--r--app/dialog.go24
-rw-r--r--app/dirlist.go532
-rw-r--r--app/dirtree.go495
-rw-r--r--app/exline.go120
-rw-r--r--app/getpasswd.go68
-rw-r--r--app/headerlayout.go44
-rw-r--r--app/listbox.go299
-rw-r--r--app/msglist.go497
-rw-r--r--app/msgviewer.go927
-rw-r--r--app/pgpinfo.go98
-rw-r--r--app/providesmessage.go30
-rw-r--r--app/scrollable.go67
-rw-r--r--app/selector.go263
-rw-r--r--app/spinner.go86
-rw-r--r--app/status.go166
-rw-r--r--app/tabhost.go15
-rw-r--r--app/terminal.go178
22 files changed, 8420 insertions, 0 deletions
diff --git a/app/account-wizard.go b/app/account-wizard.go
new file mode 100644
index 00000000..db3aae2f
--- /dev/null
+++ b/app/account-wizard.go
@@ -0,0 +1,891 @@
+package app
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "os"
+ "os/exec"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/emersion/go-message/mail"
+ "github.com/gdamore/tcell/v2"
+ "github.com/go-ini/ini"
+ "golang.org/x/sys/unix"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/format"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/lib/xdg"
+ "git.sr.ht/~rjarry/aerc/log"
+)
+
+const (
+ CONFIGURE_BASICS = iota
+ CONFIGURE_SOURCE = iota
+ CONFIGURE_OUTGOING = iota
+ CONFIGURE_COMPLETE = iota
+)
+
+type AccountWizard struct {
+ aerc *Aerc
+ step int
+ steps []*ui.Grid
+ focus int
+ temporary bool
+ // CONFIGURE_BASICS
+ accountName *ui.TextInput
+ email *ui.TextInput
+ discovered map[string]string
+ fullName *ui.TextInput
+ basics []ui.Interactive
+ // CONFIGURE_SOURCE
+ sourceProtocol *Selector
+ sourceTransport *Selector
+
+ sourceUsername *ui.TextInput
+ sourcePassword *ui.TextInput
+ sourceServer *ui.TextInput
+ sourceStr *ui.Text
+ sourceUrl url.URL
+ source []ui.Interactive
+ // CONFIGURE_OUTGOING
+ outgoingProtocol *Selector
+ outgoingTransport *Selector
+
+ outgoingUsername *ui.TextInput
+ outgoingPassword *ui.TextInput
+ outgoingServer *ui.TextInput
+ outgoingStr *ui.Text
+ outgoingUrl url.URL
+ outgoingCopyTo *ui.TextInput
+ outgoing []ui.Interactive
+ // CONFIGURE_COMPLETE
+ complete []ui.Interactive
+}
+
+func showPasswordWarning(aerc *Aerc) {
+ title := "ATTENTION"
+ text := `
+The Wizard will store your passwords as clear text in:
+
+ ~/.config/aerc/accounts.conf
+
+It is recommended to remove the clear text passwords and configure
+'source-cred-cmd' and 'outgoing-cred-cmd' using your own password store
+after the setup.
+`
+ warning := NewSelectorDialog(
+ title, text, []string{"OK"}, 0,
+ aerc.SelectedAccountUiConfig(),
+ func(_ string, _ error) {
+ aerc.CloseDialog()
+ },
+ )
+ aerc.AddDialog(warning)
+}
+
+type configStep struct {
+ introduction string
+ labels []string
+ fields []ui.Drawable
+ interactive *[]ui.Interactive
+}
+
+func NewConfigStep(intro string, interactive *[]ui.Interactive) configStep {
+ return configStep{introduction: intro, interactive: interactive}
+}
+
+func (s *configStep) AddField(label string, field ui.Drawable) {
+ s.labels = append(s.labels, label)
+ s.fields = append(s.fields, field)
+ if i, ok := field.(ui.Interactive); ok {
+ *s.interactive = append(*s.interactive, i)
+ }
+}
+
+func (s *configStep) Grid() *ui.Grid {
+ introduction := strings.TrimSpace(s.introduction)
+ h := strings.Count(introduction, "\n") + 1
+ spec := []ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(h)}, // intro text
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding
+ }
+ for range s.fields {
+ spec = append(spec, []ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // label
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // field
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)}, // padding
+ }...)
+ }
+ justify := ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)}
+ spec = append(spec, justify)
+ grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{justify})
+
+ intro := ui.NewText(introduction, config.Ui.GetStyle(config.STYLE_DEFAULT))
+ fill := ui.NewFill(' ', tcell.StyleDefault)
+
+ grid.AddChild(fill).At(0, 0)
+ grid.AddChild(intro).At(1, 0)
+ grid.AddChild(fill).At(2, 0)
+
+ row := 3
+ for i, field := range s.fields {
+ label := ui.NewText(s.labels[i], config.Ui.GetStyle(config.STYLE_HEADER))
+ grid.AddChild(label).At(row, 0)
+ grid.AddChild(field).At(row+1, 0)
+ grid.AddChild(fill).At(row+2, 0)
+ row += 3
+ }
+
+ grid.AddChild(fill).At(row, 0)
+
+ return grid
+}
+
+const (
+ // protocols
+ IMAP = "IMAP"
+ JMAP = "JMAP"
+ MAILDIR = "Maildir"
+ MAILDIRPP = "Maildir++"
+ NOTMUCH = "notmuch"
+ SMTP = "SMTP"
+ SENDMAIL = "sendmail"
+ // transports
+ SSL_TLS = "SSL/TLS"
+ OAUTH = "SSL/TLS+OAUTHBEARER"
+ XOAUTH = "SSL/TLS+XOAUTH2"
+ STARTTLS = "STARTTLS"
+ INSECURE = "Insecure"
+)
+
+var (
+ sources = []string{IMAP, JMAP, MAILDIR, MAILDIRPP, NOTMUCH}
+ outgoings = []string{SMTP, JMAP, SENDMAIL}
+ transports = []string{SSL_TLS, OAUTH, XOAUTH, STARTTLS, INSECURE}
+)
+
+func NewAccountWizard(aerc *Aerc) *AccountWizard {
+ wizard := &AccountWizard{
+ accountName: ui.NewTextInput("", config.Ui).Prompt("> "),
+ aerc: aerc,
+ temporary: false,
+ email: ui.NewTextInput("", config.Ui).Prompt("> "),
+ fullName: ui.NewTextInput("", config.Ui).Prompt("> "),
+ sourcePassword: ui.NewTextInput("", config.Ui).Prompt("] ").Password(true),
+ sourceServer: ui.NewTextInput("", config.Ui).Prompt("> "),
+ sourceStr: ui.NewText("", config.Ui.GetStyle(config.STYLE_DEFAULT)),
+ sourceUsername: ui.NewTextInput("", config.Ui).Prompt("> "),
+ outgoingPassword: ui.NewTextInput("", config.Ui).Prompt("] ").Password(true),
+ outgoingServer: ui.NewTextInput("", config.Ui).Prompt("> "),
+ outgoingStr: ui.NewText("", config.Ui.GetStyle(config.STYLE_DEFAULT)),
+ outgoingUsername: ui.NewTextInput("", config.Ui).Prompt("> "),
+ outgoingCopyTo: ui.NewTextInput("", config.Ui).Prompt("> "),
+
+ sourceProtocol: NewSelector(sources, 0, config.Ui).Chooser(true),
+ sourceTransport: NewSelector(transports, 0, config.Ui).Chooser(true),
+ outgoingProtocol: NewSelector(outgoings, 0, config.Ui).Chooser(true),
+ outgoingTransport: NewSelector(transports, 0, config.Ui).Chooser(true),
+ }
+
+ // Autofill some stuff for the user
+ wizard.email.OnFocusLost(func(_ *ui.TextInput) {
+ value := wizard.email.String()
+ if wizard.sourceUsername.String() == "" {
+ wizard.sourceUsername.Set(value)
+ }
+ if wizard.outgoingUsername.String() == "" {
+ wizard.outgoingUsername.Set(value)
+ }
+ wizard.sourceUri()
+ wizard.outgoingUri()
+ })
+ wizard.sourceProtocol.OnSelect(func(option string) {
+ wizard.sourceServer.Set("")
+ wizard.autofill()
+ wizard.sourceUri()
+ })
+ wizard.sourceServer.OnChange(func(_ *ui.TextInput) {
+ wizard.sourceUri()
+ })
+ wizard.sourceServer.OnFocusLost(func(_ *ui.TextInput) {
+ src := wizard.sourceServer.String()
+ out := wizard.outgoingServer.String()
+ if out == "" && strings.HasPrefix(src, "imap.") {
+ out = strings.Replace(src, "imap.", "smtp.", 1)
+ wizard.outgoingServer.Set(out)
+ }
+ wizard.outgoingUri()
+ })
+ wizard.sourceUsername.OnChange(func(_ *ui.TextInput) {
+ wizard.sourceUri()
+ })
+ wizard.sourceUsername.OnFocusLost(func(_ *ui.TextInput) {
+ if wizard.outgoingUsername.String() == "" {
+ wizard.outgoingUsername.Set(wizard.sourceUsername.String())
+ wizard.outgoingUri()
+ }
+ })
+ wizard.sourceTransport.OnSelect(func(option string) {
+ wizard.sourceUri()
+ })
+ var once sync.Once
+ wizard.sourcePassword.OnChange(func(_ *ui.TextInput) {
+ wizard.outgoingPassword.Set(wizard.sourcePassword.String())
+ wizard.sourceUri()
+ wizard.outgoingUri()
+ })
+ wizard.sourcePassword.OnFocusLost(func(_ *ui.TextInput) {
+ if wizard.sourcePassword.String() != "" {
+ once.Do(func() {
+ showPasswordWarning(aerc)
+ })
+ }
+ })
+ wizard.outgoingProtocol.OnSelect(func(option string) {
+ wizard.outgoingServer.Set("")
+ wizard.autofill()
+ wizard.outgoingUri()
+ })
+ wizard.outgoingServer.OnChange(func(_ *ui.TextInput) {
+ wizard.outgoingUri()
+ })
+ wizard.outgoingUsername.OnChange(func(_ *ui.TextInput) {
+ wizard.outgoingUri()
+ })
+ wizard.outgoingPassword.OnChange(func(_ *ui.TextInput) {
+ if wizard.outgoingPassword.String() != "" {
+ once.Do(func() {
+ showPasswordWarning(aerc)
+ })
+ }
+ wizard.outgoingUri()
+ })
+ wizard.outgoingTransport.OnSelect(func(option string) {
+ wizard.outgoingUri()
+ })
+
+ // CONFIGURE_BASICS
+ basics := NewConfigStep(
+ `
+Welcome to aerc! Let's configure your account.
+
+Key bindings:
+
+ <Tab>, <Down> or <Ctrl+j> Next field
+ <Shift+Tab>, <Up> or <Ctrl+k> Previous field
+ <Ctrl+q> Exit aerc
+`,
+ &wizard.basics,
+ )
+ basics.AddField(
+ "Name for this account? (e.g. 'Personal' or 'Work')",
+ wizard.accountName,
+ )
+ basics.AddField(
+ "Full name for outgoing emails? (e.g. 'John Doe')",
+ wizard.fullName,
+ )
+ basics.AddField(
+ "Your email address? (e.g. 'john@example.org')",
+ wizard.email,
+ )
+ basics.AddField("", NewSelector([]string{"Next"}, 0, config.Ui).
+ OnChoose(func(option string) {
+ wizard.discoverServices()
+ wizard.autofill()
+ wizard.sourceUri()
+ wizard.outgoingUri()
+ wizard.advance(option)
+ }),
+ )
+
+ // CONFIGURE_SOURCE
+ source := NewConfigStep("Configure email source", &wizard.source)
+ source.AddField("Protocol", wizard.sourceProtocol)
+ source.AddField("Username", wizard.sourceUsername)
+ source.AddField("Password", wizard.sourcePassword)
+ source.AddField(
+ "Server address (or path to email store)",
+ wizard.sourceServer,
+ )
+ source.AddField("Transport security", wizard.sourceTransport)
+ source.AddField("Connection URL", wizard.sourceStr)
+ source.AddField(
+ "", NewSelector([]string{"Previous", "Next"}, 1, config.Ui).
+ OnChoose(wizard.advance),
+ )
+
+ // CONFIGURE_OUTGOING
+ outgoing := NewConfigStep("Configure outgoing mail", &wizard.outgoing)
+ outgoing.AddField("Protocol", wizard.outgoingProtocol)
+ outgoing.AddField("Username", wizard.outgoingUsername)
+ outgoing.AddField("Password", wizard.outgoingPassword)
+ outgoing.AddField(
+ "Server address (or path to sendmail)",
+ wizard.outgoingServer,
+ )
+ outgoing.AddField("Transport security", wizard.outgoingTransport)
+ outgoing.AddField("Connection URL", wizard.outgoingStr)
+ outgoing.AddField(
+ "Copy sent messages to folder (leave empty to disable)",
+ wizard.outgoingCopyTo,
+ )
+ outgoing.AddField(
+ "", NewSelector([]string{"Previous", "Next"}, 1, config.Ui).
+ OnChoose(wizard.advance),
+ )
+
+ // CONFIGURE_COMPLETE
+ complete := NewConfigStep(
+ fmt.Sprintf(`
+Configuration complete!
+
+You can go back and double check your settings, or choose [Finish] to
+save your settings to %s/accounts.conf.
+
+Make sure to review the contents of this file and read the
+aerc-accounts(5) man page for guidance and further tweaking.
+
+To add another account in the future, run ':new-account'.
+`, xdg.TildeHome(xdg.ConfigPath("aerc"))),
+ &wizard.complete,
+ )
+ complete.AddField(
+ "", NewSelector([]string{
+ "Previous",
+ "Finish & open tutorial",
+ "Finish",
+ }, 1, config.Ui).OnChoose(func(option string) {
+ switch option {
+ case "Previous":
+ wizard.advance("Previous")
+ case "Finish & open tutorial":
+ wizard.finish(true)
+ case "Finish":
+ wizard.finish(false)
+ }
+ }),
+ )
+
+ wizard.steps = []*ui.Grid{
+ basics.Grid(), source.Grid(), outgoing.Grid(), complete.Grid(),
+ }
+
+ return wizard
+}
+
+func (wizard *AccountWizard) ConfigureTemporaryAccount(temporary bool) {
+ wizard.temporary = temporary
+}
+
+func (wizard *AccountWizard) errorFor(d ui.Interactive, err error) {
+ if d == nil {
+ wizard.aerc.PushError(err.Error())
+ wizard.Invalidate()
+ return
+ }
+ for step, interactives := range [][]ui.Interactive{
+ wizard.basics,
+ wizard.source,
+ wizard.outgoing,
+ } {
+ for focus, item := range interactives {
+ if item == d {
+ wizard.Focus(false)
+ wizard.step = step
+ wizard.focus = focus
+ wizard.Focus(true)
+ wizard.aerc.PushError(err.Error())
+ wizard.Invalidate()
+ return
+ }
+ }
+ }
+}
+
+func (wizard *AccountWizard) finish(tutorial bool) {
+ accountsConf := xdg.ConfigPath("aerc", "accounts.conf")
+
+ // Validation
+ if wizard.accountName.String() == "" {
+ wizard.errorFor(wizard.accountName,
+ errors.New("Account name is required"))
+ return
+ }
+ if wizard.email.String() == "" {
+ wizard.errorFor(wizard.email,
+ errors.New("Email address is required"))
+ return
+ }
+ if wizard.sourceServer.String() == "" {
+ wizard.errorFor(wizard.sourceServer,
+ errors.New("Email source configuration is required"))
+ return
+ }
+ if wizard.outgoingServer.String() == "" &&
+ wizard.outgoingProtocol.Selected() != JMAP {
+ wizard.errorFor(wizard.outgoingServer,
+ errors.New("Outgoing mail configuration is required"))
+ return
+ }
+ switch wizard.sourceProtocol.Selected() {
+ case MAILDIR, MAILDIRPP, NOTMUCH:
+ path := xdg.ExpandHome(wizard.sourceServer.String())
+ s, err := os.Stat(path)
+ if err == nil && !s.IsDir() {
+ err = fmt.Errorf("%s: Not a directory", s.Name())
+ }
+ if err == nil {
+ err = unix.Access(path, unix.X_OK)
+ }
+ if err != nil {
+ wizard.errorFor(wizard.sourceServer, err)
+ return
+ }
+ }
+ if wizard.outgoingProtocol.Selected() == SENDMAIL {
+ path := xdg.ExpandHome(wizard.outgoingServer.String())
+ s, err := os.Stat(path)
+ if err == nil && !s.Mode().IsRegular() {
+ err = fmt.Errorf("%s: Not a regular file", s.Name())
+ }
+ if err == nil {
+ err = unix.Access(path, unix.X_OK)
+ }
+ if err != nil {
+ wizard.errorFor(wizard.outgoingServer, err)
+ return
+ }
+ }
+
+ file, err := ini.Load(accountsConf)
+ if err != nil {
+ file = ini.Empty()
+ }
+
+ var sec *ini.Section
+ if sec, _ = file.GetSection(wizard.accountName.String()); sec != nil {
+ wizard.errorFor(wizard.accountName,
+ errors.New("An account by this name already exists"))
+ return
+ }
+ sec, _ = file.NewSection(wizard.accountName.String())
+ // these can't fail
+ _, _ = sec.NewKey("source", wizard.sourceUrl.String())
+ _, _ = sec.NewKey("outgoing", wizard.outgoingUrl.String())
+ _, _ = sec.NewKey("default", "INBOX")
+ from := mail.Address{
+ Name: wizard.fullName.String(),
+ Address: wizard.email.String(),
+ }
+ _, _ = sec.NewKey("from", format.AddressForHumans(&from))
+ if wizard.outgoingCopyTo.String() != "" {
+ _, _ = sec.NewKey("copy-to", wizard.outgoingCopyTo.String())
+ }
+
+ switch wizard.sourceProtocol.Selected() {
+ case IMAP:
+ _, _ = sec.NewKey("cache-headers", "true")
+ case JMAP:
+ _, _ = sec.NewKey("use-labels", "true")
+ _, _ = sec.NewKey("cache-state", "true")
+ _, _ = sec.NewKey("cache-blobs", "false")
+ case NOTMUCH:
+ cmd := exec.Command("notmuch", "config", "get", "database.mail_root")
+ out, err := cmd.Output()
+ if err == nil {
+ root := strings.TrimSpace(string(out))
+ _, _ = sec.NewKey("maildir-store", xdg.TildeHome(root))
+ }
+ querymap := ini.Empty()
+ def := querymap.Section("")
+ cmd = exec.Command("notmuch", "config", "list")
+ out, err = cmd.Output()
+ if err == nil {
+ re := regexp.MustCompile(`(?m)^query\.([^=]+)=(.+)$`)
+ for _, m := range re.FindAllStringSubmatch(string(out), -1) {
+ _, _ = def.NewKey(m[1], m[2])
+ }
+ }
+ if len(def.Keys()) == 0 {
+ _, _ = def.NewKey("INBOX", "tag:inbox and not tag:archived")
+ }
+ if !wizard.temporary {
+ qmapPath := xdg.ConfigPath("aerc",
+ wizard.accountName.String()+".qmap")
+ f, err := os.OpenFile(qmapPath, os.O_WRONLY|os.O_CREATE, 0o600)
+ if err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ defer f.Close()
+ if _, err = querymap.WriteTo(f); err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ _, _ = sec.NewKey("query-map", xdg.TildeHome(qmapPath))
+ }
+ }
+
+ if !wizard.temporary {
+ f, err := os.OpenFile(accountsConf, os.O_WRONLY|os.O_CREATE, 0o600)
+ if err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ defer f.Close()
+ if _, err = file.WriteTo(f); err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ }
+
+ account, err := config.ParseAccountConfig(sec.Name(), sec)
+ if err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ config.Accounts = append(config.Accounts, account)
+
+ view, err := NewAccountView(wizard.aerc, account, wizard.aerc, nil)
+ if err != nil {
+ wizard.aerc.NewTab(errorScreen(err.Error()), account.Name)
+ return
+ }
+ wizard.aerc.accounts[account.Name] = view
+ wizard.aerc.NewTab(view, account.Name)
+
+ if tutorial {
+ name := "aerc-tutorial"
+ if _, err := os.Stat("./aerc-tutorial.7"); !os.IsNotExist(err) {
+ // For development
+ name = "./aerc-tutorial.7"
+ }
+ term, err := NewTerminal(exec.Command("man", name))
+ if err != nil {
+ wizard.errorFor(nil, err)
+ return
+ }
+ wizard.aerc.NewTab(term, "Tutorial")
+ term.OnClose = func(err error) {
+ wizard.aerc.RemoveTab(term, false)
+ if err != nil {
+ wizard.aerc.PushError(err.Error())
+ }
+ }
+ }
+
+ wizard.aerc.RemoveTab(wizard, false)
+}
+
+func splitHostPath(server string) (string, string) {
+ host, path, found := strings.Cut(server, "/")
+ if found {
+ path = "/" + path
+ }
+ return host, path
+}
+
+func makeURLs(scheme, host, path, user, pass string) (url.URL, url.URL) {
+ var opaque string
+
+ // If everything is unset, the rendered URL is '<scheme>:'.
+ // Force a '//' opaque suffix so that it is rendered as '<scheme>://'.
+ if scheme != "" && host == "" && path == "" && user == "" && pass == "" {
+ opaque = "//"
+ }
+
+ uri := url.URL{Scheme: scheme, Host: host, Path: path, Opaque: opaque}
+ clean := uri
+
+ switch {
+ case pass != "":
+ uri.User = url.UserPassword(user, pass)
+ clean.User = url.UserPassword(user, strings.Repeat("*", len(pass)))
+ case user != "":
+ uri.User = url.User(user)
+ clean.User = url.User(user)
+ }
+
+ return uri, clean
+}
+
+func (wizard *AccountWizard) sourceUri() url.URL {
+ host, path := splitHostPath(wizard.sourceServer.String())
+ user := wizard.sourceUsername.String()
+ pass := wizard.sourcePassword.String()
+ var scheme string
+ switch wizard.sourceProtocol.Selected() {
+ case IMAP:
+ switch wizard.sourceTransport.Selected() {
+ case STARTTLS:
+ scheme = "imap"
+ case INSECURE:
+ scheme = "imap+insecure"
+ case OAUTH:
+ scheme = "imaps+oauthbearer"
+ case XOAUTH:
+ scheme = "imaps+xoauth2"
+ default:
+ scheme = "imaps"
+ }
+ case JMAP:
+ switch wizard.sourceTransport.Selected() {
+ case OAUTH:
+ scheme = "jmap+oauthbearer"
+ default:
+ scheme = "jmap"
+ }
+ case MAILDIR:
+ scheme = "maildir"
+ case MAILDIRPP:
+ scheme = "maildirpp"
+ case NOTMUCH:
+ scheme = "notmuch"
+ }
+ switch wizard.sourceProtocol.Selected() {
+ case MAILDIR, MAILDIRPP, NOTMUCH:
+ path = host + path
+ host = ""
+ user = ""
+ pass = ""
+ }
+
+ uri, clean := makeURLs(scheme, host, path, user, pass)
+
+ wizard.sourceStr.Text(
+ " " + strings.ReplaceAll(clean.String(), "%2A", "*"))
+ wizard.sourceUrl = uri
+ return uri
+}
+
+func (wizard *AccountWizard) outgoingUri() url.URL {
+ host, path := splitHostPath(wizard.outgoingServer.String())
+ user := wizard.outgoingUsername.String()
+ pass := wizard.outgoingPassword.String()
+ var scheme string
+ switch wizard.outgoingProtocol.Selected() {
+ case SMTP:
+ switch wizard.outgoingTransport.Selected() {
+ case OAUTH:
+ scheme = "smtps+oauthbearer"
+ case XOAUTH:
+ scheme = "smtps+xoauth2"
+ case INSECURE:
+ scheme = "smtp+insecure"
+ case STARTTLS:
+ scheme = "smtp"
+ default:
+ scheme = "smtps"
+ }
+ case JMAP:
+ switch wizard.outgoingTransport.Selected() {
+ case OAUTH:
+ scheme = "jmap+oauthbearer"
+ default:
+ scheme = "jmap"
+ }
+ case SENDMAIL:
+ scheme = ""
+ path = host + path
+ host = ""
+ user = ""
+ pass = ""
+ }
+
+ uri, clean := makeURLs(scheme, host, path, user, pass)
+
+ wizard.outgoingStr.Text(
+ " " + strings.ReplaceAll(clean.String(), "%2A", "*"))
+ wizard.outgoingUrl = uri
+ return uri
+}
+
+func (wizard *AccountWizard) Invalidate() {
+ ui.Invalidate()
+}
+
+func (wizard *AccountWizard) Draw(ctx *ui.Context) {
+ wizard.steps[wizard.step].Draw(ctx)
+}
+
+func (wizard *AccountWizard) getInteractive() []ui.Interactive {
+ switch wizard.step {
+ case CONFIGURE_BASICS:
+ return wizard.basics
+ case CONFIGURE_SOURCE:
+ return wizard.source
+ case CONFIGURE_OUTGOING:
+ return wizard.outgoing
+ case CONFIGURE_COMPLETE:
+ return wizard.complete
+ }
+ return nil
+}
+
+func (wizard *AccountWizard) advance(direction string) {
+ wizard.Focus(false)
+ if direction == "Next" && wizard.step < len(wizard.steps)-1 {
+ wizard.step++
+ }
+ if direction == "Previous" && wizard.step > 0 {
+ wizard.step--
+ }
+ wizard.focus = 0
+ wizard.Focus(true)
+ wizard.Invalidate()
+}
+
+func (wizard *AccountWizard) Focus(focus bool) {
+ if interactive := wizard.getInteractive(); interactive != nil {
+ interactive[wizard.focus].Focus(focus)
+ }
+}
+
+func (wizard *AccountWizard) Event(event tcell.Event) bool {
+ interactive := wizard.getInteractive()
+ if event, ok := event.(*tcell.EventKey); ok {
+ switch event.Key() {
+ case tcell.KeyUp:
+ fallthrough
+ case tcell.KeyBacktab:
+ fallthrough
+ case tcell.KeyCtrlK:
+ if interactive != nil {
+ interactive[wizard.focus].Focus(false)
+ wizard.focus--
+ if wizard.focus < 0 {
+ wizard.focus = len(interactive) - 1
+ }
+ interactive[wizard.focus].Focus(true)
+ }
+ wizard.Invalidate()
+ return true
+ case tcell.KeyDown:
+ fallthrough
+ case tcell.KeyTab:
+ fallthrough
+ case tcell.KeyCtrlJ:
+ if interactive != nil {
+ interactive[wizard.focus].Focus(false)
+ wizard.focus++
+ if wizard.focus >= len(interactive) {
+ wizard.focus = 0
+ }
+ interactive[wizard.focus].Focus(true)
+ }
+ wizard.Invalidate()
+ return true
+ }
+ }
+ if interactive != nil {
+ return interactive[wizard.focus].Event(event)
+ }
+ return false
+}
+
+func (wizard *AccountWizard) discoverServices() {
+ email := wizard.email.String()
+ if !strings.ContainsRune(email, '@') {
+ return
+ }
+ domain := email[strings.IndexRune(email, '@')+1:]
+ var wg sync.WaitGroup
+ type Service struct{ srv, hostport string }
+ services := make(chan Service)
+
+ for _, service := range []string{"imaps", "imap", "submission", "jmap"} {
+ wg.Add(1)
+ go func(srv string) {
+ defer log.PanicHandler()
+ defer wg.Done()
+ _, addrs, err := net.LookupSRV(srv, "tcp", domain)
+ if err != nil {
+ log.Tracef("SRV lookup for _%s._tcp.%s failed: %s",
+ srv, domain, err)
+ } else if addrs[0].Target != "" && addrs[0].Port > 0 {
+ services <- Service{
+ srv: srv,
+ hostport: net.JoinHostPort(
+ strings.TrimSuffix(addrs[0].Target, "."),
+ strconv.Itoa(int(addrs[0].Port))),
+ }
+ }
+ }(service)
+ }
+ go func() {
+ defer log.PanicHandler()
+ wg.Wait()
+ close(services)
+ }()
+
+ wizard.discovered = make(map[string]string)
+ for s := range services {
+ wizard.discovered[s.srv] = s.hostport
+ }
+}
+
+func (wizard *AccountWizard) autofill() {
+ if wizard.sourceServer.String() == "" {
+ switch wizard.sourceProtocol.Selected() {
+ case IMAP:
+ if s, ok := wizard.discovered["imaps"]; ok {
+ wizard.sourceServer.Set(s)
+ wizard.sourceTransport.Select(SSL_TLS)
+ } else if s, ok := wizard.discovered["imap"]; ok {
+ wizard.sourceServer.Set(s)
+ wizard.sourceTransport.Select(STARTTLS)
+ }
+ case JMAP:
+ if s, ok := wizard.discovered["jmap"]; ok {
+ s = strings.TrimSuffix(s, ":443")
+ wizard.sourceServer.Set(s + "/.well-known/jmap")
+ wizard.sourceTransport.Select(SSL_TLS)
+ }
+ case MAILDIR, MAILDIRPP:
+ wizard.sourceServer.Set("~/mail")
+ wizard.sourceUsername.Set("")
+ wizard.sourcePassword.Set("")
+ case NOTMUCH:
+ cmd := exec.Command("notmuch", "config", "get", "database.path")
+ out, err := cmd.Output()
+ if err == nil {
+ db := strings.TrimSpace(string(out))
+ wizard.sourceServer.Set(xdg.TildeHome(db))
+ } else {
+ wizard.sourceServer.Set("~/mail")
+ }
+ wizard.sourceUsername.Set("")
+ wizard.sourcePassword.Set("")
+ }
+ }
+ if wizard.outgoingServer.String() == "" {
+ switch wizard.outgoingProtocol.Selected() {
+ case SMTP:
+ if s, ok := wizard.discovered["submission"]; ok {
+ switch {
+ case strings.HasSuffix(s, ":587"):
+ wizard.outgoingTransport.Select(SSL_TLS)
+ case strings.HasSuffix(s, ":465"):
+ wizard.outgoingTransport.Select(STARTTLS)
+ default:
+ wizard.outgoingTransport.Select(INSECURE)
+ }
+ wizard.outgoingServer.Set(s)
+ }
+ case JMAP:
+ wizard.outgoingTransport.Select(SSL_TLS)
+ case SENDMAIL:
+ wizard.outgoingServer.Set("/usr/sbin/sendmail")
+ wizard.outgoingUsername.Set("")
+ wizard.outgoingPassword.Set("")
+ }
+ }
+}
diff --git a/app/account.go b/app/account.go
new file mode 100644
index 00000000..3ab7fc8f
--- /dev/null
+++ b/app/account.go
@@ -0,0 +1,649 @@
+package app
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/hooks"
+ "git.sr.ht/~rjarry/aerc/lib/marker"
+ "git.sr.ht/~rjarry/aerc/lib/sort"
+ "git.sr.ht/~rjarry/aerc/lib/state"
+ "git.sr.ht/~rjarry/aerc/lib/templates"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rjarry/aerc/worker"
+ "git.sr.ht/~rjarry/aerc/worker/types"
+)
+
+var _ ProvidesMessages = (*AccountView)(nil)
+
+type AccountView struct {
+ sync.Mutex
+ acct *config.AccountConfig
+ aerc *Aerc
+ dirlist DirectoryLister
+ labels []string
+ grid *ui.Grid
+ host TabHost
+ tab *ui.Tab
+ msglist *MessageList
+ worker *types.Worker
+ state state.AccountState
+ newConn bool // True if this is a first run after a new connection/reconnection
+ uiConf *config.UIConfig
+
+ split *MessageViewer
+ splitSize int
+ splitDebounce *time.Timer
+ splitDir string
+
+ // Check-mail ticker
+ ticker *time.Ticker
+ checkingMail bool
+}
+
+func (acct *AccountView) UiConfig() *config.UIConfig {
+ if dirlist := acct.Directories(); dirlist != nil {
+ return dirlist.UiConfig("")
+ }
+ return acct.uiConf
+}
+
+func NewAccountView(
+ aerc *Aerc, acct *config.AccountConfig,
+ host TabHost, deferLoop chan struct{},
+) (*AccountView, error) {
+ acctUiConf := config.Ui.ForAccount(acct.Name)
+
+ view := &AccountView{
+ acct: acct,
+ aerc: aerc,
+ host: host,
+ uiConf: acctUiConf,
+ }
+
+ view.grid = ui.NewGrid().Rows([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: func() int {
+ return view.UiConfig().SidebarWidth
+ }},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ worker, err := worker.NewWorker(acct.Source, acct.Name)
+ if err != nil {
+ host.SetError(fmt.Sprintf("%s: %s", acct.Name, err))
+ log.Errorf("%s: %v", acct.Name, err)
+ return view, err
+ }
+ view.worker = worker
+
+ view.dirlist = NewDirectoryList(acct, worker)
+ if acctUiConf.SidebarWidth > 0 {
+ view.grid.AddChild(ui.NewBordered(view.dirlist, ui.BORDER_RIGHT, acctUiConf))
+ }
+
+ view.msglist = NewMessageList(aerc, view)
+ view.grid.AddChild(view.msglist).At(0, 1)
+
+ view.dirlist.OnVirtualNode(func() {
+ view.msglist.SetStore(nil)
+ view.Invalidate()
+ })
+
+ go func() {
+ defer log.PanicHandler()
+
+ if deferLoop != nil {
+ <-deferLoop
+ }
+
+ worker.Backend.Run()
+ }()
+
+ worker.PostAction(&types.Configure{Config: acct}, nil)
+ worker.PostAction(&types.Connect{}, nil)
+ view.SetStatus(state.ConnectionActivity("Connecting..."))
+ if acct.CheckMail.Minutes() > 0 {
+ view.CheckMailTimer(acct.CheckMail)
+ }
+
+ return view, nil
+}
+
+func (acct *AccountView) SetStatus(setters ...state.SetStateFunc) {
+ for _, fn := range setters {
+ fn(&acct.state, acct.SelectedDirectory())
+ }
+ acct.UpdateStatus()
+}
+
+func (acct *AccountView) UpdateStatus() {
+ if acct.isSelected() {
+ acct.host.UpdateStatus()
+ }
+}
+
+func (acct *AccountView) PushStatus(status string, expiry time.Duration) {
+ acct.aerc.PushStatus(fmt.Sprintf("%s: %s", acct.acct.Name, status), expiry)
+}
+
+func (acct *AccountView) PushError(err error) {
+ acct.aerc.PushError(fmt.Sprintf("%s: %v", acct.acct.Name, err))
+}
+
+func (acct *AccountView) PushWarning(warning string) {
+ acct.aerc.PushWarning(fmt.Sprintf("%s: %s", acct.acct.Name, warning))
+}
+
+func (acct *AccountView) AccountConfig() *config.AccountConfig {
+ return acct.acct
+}
+
+func (acct *AccountView) Worker() *types.Worker {
+ return acct.worker
+}
+
+func (acct *AccountView) Name() string {
+ return acct.acct.Name
+}
+
+func (acct *AccountView) Invalidate() {
+ ui.Invalidate()
+}
+
+func (acct *AccountView) Draw(ctx *ui.Context) {
+ acct.grid.Draw(ctx)
+}
+
+func (acct *AccountView) MouseEvent(localX int, localY int, event tcell.Event) {
+ acct.grid.MouseEvent(localX, localY, event)
+}
+
+func (acct *AccountView) Focus(focus bool) {
+ // TODO: Unfocus children I guess
+}
+
+func (acct *AccountView) Directories() DirectoryLister {
+ return acct.dirlist
+}
+
+func (acct *AccountView) Labels() []string {
+ return acct.labels
+}
+
+func (acct *AccountView) Messages() *MessageList {
+ return acct.msglist
+}
+
+func (acct *AccountView) Store() *lib.MessageStore {
+ if acct.msglist == nil {
+ return nil
+ }
+ return acct.msglist.Store()
+}
+
+func (acct *AccountView) SelectedAccount() *AccountView {
+ return acct
+}
+
+func (acct *AccountView) SelectedDirectory() string {
+ return acct.dirlist.Selected()
+}
+
+func (acct *AccountView) SelectedMessage() (*models.MessageInfo, error) {
+ if acct.msglist == nil || acct.msglist.Store() == nil {
+ return nil, errors.New("init in progress")
+ }
+ if len(acct.msglist.Store().Uids()) == 0 {
+ return nil, errors.New("no message selected")
+ }
+ msg := acct.msglist.Selected()
+ if msg == nil {
+ return nil, errors.New("message not loaded")
+ }
+ return msg, nil
+}
+
+func (acct *AccountView) MarkedMessages() ([]uint32, error) {
+ if store := acct.Store(); store != nil {
+ return store.Marker().Marked(), nil
+ }
+ return nil, errors.New("no store available")
+}
+
+func (acct *AccountView) SelectedMessagePart() *PartInfo {
+ return nil
+}
+
+func (acct *AccountView) isSelected() bool {
+ return acct == acct.aerc.SelectedAccount()
+}
+
+func (acct *AccountView) newStore(name string) *lib.MessageStore {
+ uiConf := acct.dirlist.UiConfig(name)
+ store := lib.NewMessageStore(acct.worker,
+ acct.sortCriteria(uiConf),
+ uiConf.ThreadingEnabled,
+ uiConf.ForceClientThreads,
+ uiConf.ClientThreadsDelay,
+ uiConf.ReverseOrder,
+ uiConf.ReverseThreadOrder,
+ uiConf.SortThreadSiblings,
+ func(msg *models.MessageInfo) {
+ err := hooks.RunHook(&hooks.MailReceived{
+ Account: acct.Name(),
+ Folder: name,
+ MsgInfo: msg,
+ })
+ if err != nil {
+ msg := fmt.Sprintf("mail-received hook: %s", err)
+ acct.aerc.PushError(msg)
+ }
+ }, func() {
+ if uiConf.NewMessageBell {
+ acct.host.Beep()
+ }
+ },
+ acct.updateSplitView,
+ acct.dirlist.UiConfig(name).ThreadContext,
+ )
+ store.SetMarker(marker.New(store))
+ return store
+}
+
+func (acct *AccountView) onMessage(msg types.WorkerMessage) {
+ msg = acct.worker.ProcessMessage(msg)
+ switch msg := msg.(type) {
+ case *types.Done:
+ switch resp := msg.InResponseTo().(type) {
+ case *types.Connect, *types.Reconnect:
+ acct.SetStatus(state.ConnectionActivity("Listing mailboxes..."))
+ log.Infof("[%s] connected.", acct.acct.Name)
+ acct.SetStatus(state.SetConnected(true))
+ log.Tracef("Listing mailboxes...")
+ acct.worker.PostAction(&types.ListDirectories{}, nil)
+ case *types.Disconnect:
+ acct.dirlist.ClearList()
+ acct.msglist.SetStore(nil)
+ log.Infof("[%s] disconnected.", acct.acct.Name)
+ acct.SetStatus(state.SetConnected(false))
+ case *types.OpenDirectory:
+ acct.dirlist.Update(msg)
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ // If we've opened this dir before, we can re-render it from
+ // memory while we wait for the update and the UI feels
+ // snappier. If not, we'll unset the store and show the spinner
+ // while we download the UID list.
+ acct.msglist.SetStore(store)
+ acct.Store().Update(msg.InResponseTo())
+ } else {
+ acct.msglist.SetStore(nil)
+ }
+ case *types.CreateDirectory:
+ store := acct.newStore(resp.Directory)
+ acct.dirlist.SetMsgStore(&models.Directory{
+ Name: resp.Directory,
+ }, store)
+ acct.dirlist.Update(msg)
+ case *types.RemoveDirectory:
+ acct.dirlist.Update(msg)
+ case *types.FetchMessageHeaders:
+ if acct.newConn {
+ acct.checkMailOnStartup()
+ }
+ case *types.ListDirectories:
+ acct.dirlist.Update(msg)
+ if dir := acct.dirlist.Selected(); dir != "" {
+ acct.dirlist.Select(dir)
+ return
+ }
+ // Nothing selected, select based on config
+ dirs := acct.dirlist.List()
+ var dir string
+ for _, _dir := range dirs {
+ if _dir == acct.acct.Default {
+ dir = _dir
+ break
+ }
+ }
+ if dir == "" && len(dirs) > 0 {
+ dir = dirs[0]
+ }
+ if dir != "" {
+ acct.dirlist.Select(dir)
+ }
+ acct.msglist.SetInitDone()
+ acct.newConn = true
+ }
+ case *types.Directory:
+ store, ok := acct.dirlist.MsgStore(msg.Dir.Name)
+ if !ok {
+ store = acct.newStore(msg.Dir.Name)
+ }
+ acct.dirlist.SetMsgStore(msg.Dir, store)
+ case *types.DirectoryInfo:
+ acct.dirlist.Update(msg)
+ case *types.DirectoryContents:
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ if acct.msglist.Store() == nil {
+ acct.msglist.SetStore(store)
+ }
+ store.Update(msg)
+ acct.SetStatus(state.Threading(store.ThreadedView()))
+ }
+ if acct.newConn && len(msg.Uids) == 0 {
+ acct.checkMailOnStartup()
+ }
+ case *types.DirectoryThreaded:
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ if acct.msglist.Store() == nil {
+ acct.msglist.SetStore(store)
+ }
+ store.Update(msg)
+ acct.SetStatus(state.Threading(store.ThreadedView()))
+ }
+ if acct.newConn && len(msg.Threads) == 0 {
+ acct.checkMailOnStartup()
+ }
+ case *types.FullMessage:
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ store.Update(msg)
+ }
+ case *types.MessageInfo:
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ store.Update(msg)
+ }
+ case *types.MessagesDeleted:
+ if dir := acct.dirlist.SelectedDirectory(); dir != nil {
+ dir.Exists -= len(msg.Uids)
+ }
+ if store, ok := acct.dirlist.SelectedMsgStore(); ok {
+ store.Update(msg)
+ }
+ case *types.MessagesCopied:
+ acct.updateDirCounts(msg.Destination, msg.Uids)
+ case *types.MessagesMoved:
+ acct.updateDirCounts(msg.Destination, msg.Uids)
+ case *types.LabelList:
+ acct.labels = msg.Labels
+ case *types.ConnError:
+ log.Errorf("[%s] connection error: %v", acct.acct.Name, msg.Error)
+ acct.SetStatus(state.SetConnected(false))
+ acct.PushError(msg.Error)
+ acct.msglist.SetStore(nil)
+ acct.worker.PostAction(&types.Reconnect{}, nil)
+ case *types.Error:
+ log.Errorf("[%s] unexpected error: %v", acct.acct.Name, msg.Error)
+ acct.PushError(msg.Error)
+ }
+ acct.UpdateStatus()
+ acct.setTitle()
+}
+
+func (acct *AccountView) updateDirCounts(destination string, uids []uint32) {
+ // Only update the destination destDir if it is initialized
+ if destDir := acct.dirlist.Directory(destination); destDir != nil {
+ var recent, unseen int
+ var accurate bool = true
+ for _, uid := range uids {
+ // Get the message from the originating store
+ msg, ok := acct.Store().Messages[uid]
+ if !ok {
+ continue
+ }
+ // If message that was not yet loaded is copied
+ if msg == nil {
+ accurate = false
+ break
+ }
+ seen := msg.Flags.Has(models.SeenFlag)
+ if msg.Flags.Has(models.RecentFlag) {
+ recent++
+ }
+ if !seen {
+ unseen++
+ }
+ }
+ if accurate {
+ destDir.Recent += recent
+ destDir.Unseen += unseen
+ destDir.Exists += len(uids)
+ } else {
+ destDir.Exists += len(uids)
+ }
+ }
+}
+
+func (acct *AccountView) sortCriteria(uiConf *config.UIConfig) []*types.SortCriterion {
+ if uiConf == nil {
+ return nil
+ }
+ if len(uiConf.Sort) == 0 {
+ return nil
+ }
+ criteria, err := sort.GetSortCriteria(uiConf.Sort)
+ if err != nil {
+ acct.PushError(fmt.Errorf("ui sort: %w", err))
+ return nil
+ }
+ return criteria
+}
+
+func (acct *AccountView) GetSortCriteria() []*types.SortCriterion {
+ return acct.sortCriteria(acct.UiConfig())
+}
+
+func (acct *AccountView) CheckMail() {
+ acct.Lock()
+ defer acct.Unlock()
+ if acct.checkingMail {
+ return
+ }
+ // Exclude selected mailbox, per IMAP specification
+ exclude := append(acct.AccountConfig().CheckMailExclude, acct.dirlist.Selected()) //nolint:gocritic // intentional append to different slice
+ dirs := acct.dirlist.List()
+ dirs = acct.dirlist.FilterDirs(dirs, acct.AccountConfig().CheckMailInclude, false)
+ dirs = acct.dirlist.FilterDirs(dirs, exclude, true)
+ log.Debugf("Checking for new mail on account %s", acct.Name())
+ acct.SetStatus(state.ConnectionActivity("Checking for new mail..."))
+ msg := &types.CheckMail{
+ Directories: dirs,
+ Command: acct.acct.CheckMailCmd,
+ Timeout: acct.acct.CheckMailTimeout,
+ }
+ acct.checkingMail = true
+
+ var cb func(types.WorkerMessage)
+ cb = func(response types.WorkerMessage) {
+ dirsMsg, ok := response.(*types.CheckMailDirectories)
+ if ok {
+ checkMailMsg := &types.CheckMail{
+ Directories: dirsMsg.Directories,
+ Command: acct.acct.CheckMailCmd,
+ Timeout: acct.acct.CheckMailTimeout,
+ }
+ acct.worker.PostAction(checkMailMsg, cb)
+ } else { // Done
+ acct.SetStatus(state.ConnectionActivity(""))
+ acct.Lock()
+ acct.checkingMail = false
+ acct.Unlock()
+ }
+ }
+ acct.worker.PostAction(msg, cb)
+}
+
+// CheckMailReset resets the check-mail timer
+func (acct *AccountView) CheckMailReset() {
+ if acct.ticker != nil {
+ d := acct.AccountConfig().CheckMail
+ acct.ticker = time.NewTicker(d)
+ }
+}
+
+func (acct *AccountView) checkMailOnStartup() {
+ if acct.AccountConfig().CheckMail.Minutes() > 0 {
+ acct.newConn = false
+ acct.CheckMail()
+ }
+}
+
+func (acct *AccountView) CheckMailTimer(d time.Duration) {
+ acct.ticker = time.NewTicker(d)
+ go func() {
+ defer log.PanicHandler()
+ for range acct.ticker.C {
+ if !acct.state.Connected {
+ continue
+ }
+ acct.CheckMail()
+ }
+ }()
+}
+
+func (acct *AccountView) closeSplit() {
+ if acct.split != nil {
+ acct.split.Close()
+ }
+ acct.splitSize = 0
+ acct.splitDir = ""
+ acct.split = nil
+ acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: func() int {
+ return acct.UiConfig().SidebarWidth
+ }},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.uiConf))
+ acct.grid.AddChild(acct.msglist).At(0, 1)
+ ui.Invalidate()
+}
+
+func (acct *AccountView) updateSplitView(msg *models.MessageInfo) {
+ if acct.splitSize == 0 {
+ return
+ }
+ if acct.splitDebounce != nil {
+ acct.splitDebounce.Stop()
+ }
+ fn := func() {
+ if acct.split != nil {
+ acct.grid.RemoveChild(acct.split)
+ acct.split.Close()
+ }
+ lib.NewMessageStoreView(msg, false, acct.Store(), acct.aerc.Crypto, acct.aerc.DecryptKeys,
+ func(view lib.MessageView, err error) {
+ if err != nil {
+ acct.aerc.PushError(err.Error())
+ return
+ }
+ acct.split = NewMessageViewer(acct, view)
+ switch acct.splitDir {
+ case "split":
+ acct.grid.AddChild(acct.split).At(1, 1)
+ case "vsplit":
+ acct.grid.AddChild(acct.split).At(0, 2)
+ }
+ })
+ }
+ acct.splitDebounce = time.AfterFunc(100*time.Millisecond, func() {
+ ui.QueueFunc(fn)
+ })
+}
+
+func (acct *AccountView) SplitSize() int {
+ return acct.splitSize
+}
+
+func (acct *AccountView) SetSplitSize(n int) {
+ if n == 0 {
+ acct.closeSplit()
+ }
+ acct.splitSize = n
+}
+
+// Split splits the message list view horizontally. The message list will be n
+// rows high. If n is 0, any existing split is removed
+func (acct *AccountView) Split(n int) error {
+ acct.SetSplitSize(n)
+ if acct.splitDir == "split" || n == 0 {
+ return nil
+ }
+ acct.splitDir = "split"
+ acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
+ // Add 1 so that the splitSize is the number of visible messages
+ {Strategy: ui.SIZE_EXACT, Size: func() int { return acct.SplitSize() + 1 }},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: func() int {
+ return acct.UiConfig().SidebarWidth
+ }},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.uiConf)).Span(2, 1)
+ acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_BOTTOM, acct.uiConf)).At(0, 1)
+ acct.split = NewMessageViewer(acct, nil)
+ acct.grid.AddChild(acct.split).At(1, 1)
+ acct.updateSplitView(acct.msglist.Selected())
+ return nil
+}
+
+// Vsplit splits the message list view vertically. The message list will be n
+// rows wide. If n is 0, any existing split is removed
+func (acct *AccountView) Vsplit(n int) error {
+ acct.SetSplitSize(n)
+ if acct.splitDir == "vsplit" || n == 0 {
+ return nil
+ }
+ acct.splitDir = "vsplit"
+ acct.grid = ui.NewGrid().Rows([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: func() int {
+ return acct.UiConfig().SidebarWidth
+ }},
+ {Strategy: ui.SIZE_EXACT, Size: acct.SplitSize},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.uiConf)).At(0, 0)
+ acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_RIGHT, acct.uiConf)).At(0, 1)
+ acct.split = NewMessageViewer(acct, nil)
+ acct.grid.AddChild(acct.split).At(0, 2)
+ acct.updateSplitView(acct.msglist.Selected())
+ return nil
+}
+
+// setTitle executes the title template and sets the tab title
+func (acct *AccountView) setTitle() {
+ if acct.tab == nil {
+ return
+ }
+
+ data := state.NewDataSetter()
+ data.SetAccount(acct.acct)
+ data.SetFolder(acct.Directories().SelectedDirectory())
+ data.SetRUE(acct.dirlist.List(), acct.dirlist.GetRUECount)
+
+ var buf bytes.Buffer
+ err := templates.Render(acct.uiConf.TabTitleAccount, &buf, data.Data())
+ if err != nil {
+ acct.PushError(err)
+ return
+ }
+ acct.tab.SetTitle(buf.String())
+}
diff --git a/app/aerc.go b/app/aerc.go
new file mode 100644
index 00000000..a1995c2d
--- /dev/null
+++ b/app/aerc.go
@@ -0,0 +1,908 @@
+package app
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "os/exec"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/emersion/go-message/mail"
+ "github.com/gdamore/tcell/v2"
+ "github.com/google/shlex"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/crypto"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rjarry/aerc/worker/types"
+)
+
+type Aerc struct {
+ accounts map[string]*AccountView
+ cmd func([]string, *config.AccountConfig, *models.MessageInfo) error
+ cmdHistory lib.History
+ complete func(cmd string) ([]string, string)
+ focused ui.Interactive
+ grid *ui.Grid
+ simulating int
+ statusbar *ui.Stack
+ statusline *StatusLine
+ pasting bool
+ pendingKeys []config.KeyStroke
+ prompts *ui.Stack
+ tabs *ui.Tabs
+ ui *ui.UI
+ beep func() error
+ dialog ui.DrawableInteractive
+
+ Crypto crypto.Provider
+}
+
+type Choice struct {
+ Key string
+ Text string
+ Command []string
+}
+
+func NewAerc(
+ crypto crypto.Provider,
+ cmd func([]string, *config.AccountConfig, *models.MessageInfo) error,
+ complete func(cmd string) ([]string, string), cmdHistory lib.History,
+ deferLoop chan struct{},
+) *Aerc {
+ tabs := ui.NewTabs(config.Ui)
+
+ statusbar := ui.NewStack(config.Ui)
+ statusline := &StatusLine{}
+ statusbar.Push(statusline)
+
+ grid := ui.NewGrid().Rows([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+ grid.AddChild(tabs.TabStrip)
+ grid.AddChild(tabs.TabContent).At(1, 0)
+ grid.AddChild(statusbar).At(2, 0)
+
+ aerc := &Aerc{
+ accounts: make(map[string]*AccountView),
+ cmd: cmd,
+ cmdHistory: cmdHistory,
+ complete: complete,
+ grid: grid,
+ statusbar: statusbar,
+ statusline: statusline,
+ prompts: ui.NewStack(config.Ui),
+ tabs: tabs,
+ Crypto: crypto,
+ }
+
+ statusline.SetAerc(aerc)
+
+ for _, acct := range config.Accounts {
+ view, err := NewAccountView(aerc, acct, aerc, deferLoop)
+ if err != nil {
+ tabs.Add(errorScreen(err.Error()), acct.Name, nil)
+ } else {
+ aerc.accounts[acct.Name] = view
+ view.tab = tabs.Add(view, acct.Name, view.UiConfig())
+ }
+ }
+
+ if len(config.Accounts) == 0 {
+ wizard := NewAccountWizard(aerc)
+ wizard.Focus(true)
+ aerc.NewTab(wizard, "New account")
+ }
+
+ tabs.Select(0)
+
+ tabs.CloseTab = func(index int) {
+ tab := aerc.tabs.Get(index)
+ if tab == nil {
+ return
+ }
+ switch content := tab.Content.(type) {
+ case *AccountView:
+ return
+ case *AccountWizard:
+ return
+ default:
+ aerc.RemoveTab(content, true)
+ }
+ }
+
+ aerc.showConfigWarnings()
+
+ return aerc
+}
+
+func (aerc *Aerc) showConfigWarnings() {
+ var dialogs []ui.DrawableInteractive
+
+ callback := func(string, error) {
+ aerc.CloseDialog()
+ if len(dialogs) > 0 {
+ d := dialogs[0]
+ dialogs = dialogs[1:]
+ aerc.AddDialog(d)
+ }
+ }
+
+ for _, w := range config.Warnings {
+ dialogs = append(dialogs, NewSelectorDialog(
+ w.Title, w.Body, []string{"OK"}, 0,
+ aerc.SelectedAccountUiConfig(),
+ callback,
+ ))
+ }
+
+ callback("", nil)
+}
+
+func (aerc *Aerc) OnBeep(f func() error) {
+ aerc.beep = f
+}
+
+func (aerc *Aerc) Beep() {
+ if aerc.beep == nil {
+ log.Warnf("should beep, but no beeper")
+ return
+ }
+ if err := aerc.beep(); err != nil {
+ log.Errorf("tried to beep, but could not: %v", err)
+ }
+}
+
+func (aerc *Aerc) HandleMessage(msg types.WorkerMessage) {
+ if acct, ok := aerc.accounts[msg.Account()]; ok {
+ acct.onMessage(msg)
+ }
+}
+
+func (aerc *Aerc) Invalidate() {
+ ui.Invalidate()
+}
+
+func (aerc *Aerc) Focus(focus bool) {
+ // who cares
+}
+
+func (aerc *Aerc) Draw(ctx *ui.Context) {
+ if len(aerc.prompts.Children()) > 0 {
+ previous := aerc.focused
+ prompt := aerc.prompts.Pop().(*ExLine)
+ prompt.finish = func() {
+ aerc.statusbar.Pop()
+ aerc.focus(previous)
+ }
+
+ aerc.statusbar.Push(prompt)
+ aerc.focus(prompt)
+ }
+ aerc.grid.Draw(ctx)
+ if aerc.dialog != nil {
+ if w, h := ctx.Width(), ctx.Height(); w > 8 && h > 4 {
+ if d, ok := aerc.dialog.(Dialog); ok {
+ start, height := d.ContextHeight()
+ aerc.dialog.Draw(
+ ctx.Subcontext(4, start(h),
+ w-8, height(h)))
+ } else {
+ aerc.dialog.Draw(ctx.Subcontext(4, h/2-2, w-8, 4))
+ }
+ }
+ }
+}
+
+func (aerc *Aerc) HumanReadableBindings() []string {
+ var result []string
+ binds := aerc.getBindings()
+ format := func(s string) string {
+ return strings.ReplaceAll(s, "%", "%%")
+ }
+ fmtStr := "%10s %s"
+ for _, bind := range binds.Bindings {
+ result = append(result, fmt.Sprintf(fmtStr,
+ format(config.FormatKeyStrokes(bind.Input)),
+ format(config.FormatKeyStrokes(bind.Output)),
+ ))
+ }
+ if binds.Globals && config.Binds.Global != nil {
+ for _, bind := range config.Binds.Global.Bindings {
+ result = append(result, fmt.Sprintf(fmtStr+" (Globals)",
+ format(config.FormatKeyStrokes(bind.Input)),
+ format(config.FormatKeyStrokes(bind.Output)),
+ ))
+ }
+ }
+ result = append(result, fmt.Sprintf(fmtStr,
+ "$ex",
+ fmt.Sprintf("'%c'", binds.ExKey.Rune),
+ ))
+ result = append(result, fmt.Sprintf(fmtStr,
+ "Globals",
+ fmt.Sprintf("%v", binds.Globals),
+ ))
+ sort.Strings(result)
+ return result
+}
+
+func (aerc *Aerc) getBindings() *config.KeyBindings {
+ selectedAccountName := ""
+ if aerc.SelectedAccount() != nil {
+ selectedAccountName = aerc.SelectedAccount().acct.Name
+ }
+ switch view := aerc.SelectedTabContent().(type) {
+ case *AccountView:
+ binds := config.Binds.MessageList.ForAccount(selectedAccountName)
+ return binds.ForFolder(view.SelectedDirectory())
+ case *AccountWizard:
+ return config.Binds.AccountWizard
+ case *Composer:
+ switch view.Bindings() {
+ case "compose::editor":
+ return config.Binds.ComposeEditor.ForAccount(
+ selectedAccountName)
+ case "compose::review":
+ return config.Binds.ComposeReview.ForAccount(
+ selectedAccountName)
+ default:
+ return config.Binds.Compose.ForAccount(
+ selectedAccountName)
+ }
+ case *MessageViewer:
+ switch view.Bindings() {
+ case "view::passthrough":
+ return config.Binds.MessageViewPassthrough.ForAccount(
+ selectedAccountName)
+ default:
+ return config.Binds.MessageView.ForAccount(
+ selectedAccountName)
+ }
+ case *Terminal:
+ return config.Binds.Terminal
+ default:
+ return config.Binds.Global
+ }
+}
+
+func (aerc *Aerc) simulate(strokes []config.KeyStroke) {
+ aerc.pendingKeys = []config.KeyStroke{}
+ aerc.simulating += 1
+ for _, stroke := range strokes {
+ simulated := tcell.NewEventKey(
+ stroke.Key, stroke.Rune, tcell.ModNone)
+ aerc.Event(simulated)
+ }
+ aerc.simulating -= 1
+ // If we are still focused on the exline, turn on tab complete
+ if exline, ok := aerc.focused.(*ExLine); ok {
+ exline.TabComplete(func(cmd string) ([]string, string) {
+ return aerc.complete(cmd)
+ })
+ // send tab to text input to trigger completion
+ exline.Event(tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone))
+ }
+}
+
+func (aerc *Aerc) Event(event tcell.Event) bool {
+ if aerc.dialog != nil {
+ return aerc.dialog.Event(event)
+ }
+
+ if aerc.focused != nil {
+ return aerc.focused.Event(event)
+ }
+
+ switch event := event.(type) {
+ case *tcell.EventKey:
+ // If we are in a bracketed paste, don't process the keys for
+ // bindings
+ if aerc.pasting {
+ interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
+ if ok {
+ return interactive.Event(event)
+ }
+ return false
+ }
+ aerc.statusline.Expire()
+ aerc.pendingKeys = append(aerc.pendingKeys, config.KeyStroke{
+ Modifiers: event.Modifiers(),
+ Key: event.Key(),
+ Rune: event.Rune(),
+ })
+ ui.Invalidate()
+ bindings := aerc.getBindings()
+ incomplete := false
+ result, strokes := bindings.GetBinding(aerc.pendingKeys)
+ switch result {
+ case config.BINDING_FOUND:
+ aerc.simulate(strokes)
+ return true
+ case config.BINDING_INCOMPLETE:
+ incomplete = true
+ case config.BINDING_NOT_FOUND:
+ }
+ if bindings.Globals {
+ result, strokes = config.Binds.Global.GetBinding(aerc.pendingKeys)
+ switch result {
+ case config.BINDING_FOUND:
+ aerc.simulate(strokes)
+ return true
+ case config.BINDING_INCOMPLETE:
+ incomplete = true
+ case config.BINDING_NOT_FOUND:
+ }
+ }
+ if !incomplete {
+ aerc.pendingKeys = []config.KeyStroke{}
+ exKey := bindings.ExKey
+ if aerc.simulating > 0 {
+ // Keybindings still use : even if you change the ex key
+ exKey = config.Binds.Global.ExKey
+ }
+ if aerc.isExKey(event, exKey) {
+ aerc.BeginExCommand("")
+ return true
+ }
+ interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
+ if ok {
+ return interactive.Event(event)
+ }
+ return false
+ }
+ case *tcell.EventMouse:
+ x, y := event.Position()
+ aerc.grid.MouseEvent(x, y, event)
+ return true
+ case *tcell.EventPaste:
+ if event.Start() {
+ aerc.pasting = true
+ }
+ if event.End() {
+ aerc.pasting = false
+ }
+ interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
+ if ok {
+ return interactive.Event(event)
+ }
+ return false
+ }
+ return false
+}
+
+func (aerc *Aerc) SelectedAccount() *AccountView {
+ return aerc.account(aerc.SelectedTabContent())
+}
+
+func (aerc *Aerc) Account(name string) (*AccountView, error) {
+ if acct, ok := aerc.accounts[name]; ok {
+ return acct, nil
+ }
+ return nil, fmt.Errorf("account <%s> not found", name)
+}
+
+func (aerc *Aerc) PrevAccount() (*AccountView, error) {
+ cur := aerc.SelectedAccount()
+ if cur == nil {
+ return nil, fmt.Errorf("no account selected, cannot get prev")
+ }
+ for i, conf := range config.Accounts {
+ if conf.Name == cur.Name() {
+ i -= 1
+ if i == -1 {
+ i = len(config.Accounts) - 1
+ }
+ conf = config.Accounts[i]
+ return aerc.Account(conf.Name)
+ }
+ }
+ return nil, fmt.Errorf("no prev account")
+}
+
+func (aerc *Aerc) NextAccount() (*AccountView, error) {
+ cur := aerc.SelectedAccount()
+ if cur == nil {
+ return nil, fmt.Errorf("no account selected, cannot get next")
+ }
+ for i, conf := range config.Accounts {
+ if conf.Name == cur.Name() {
+ i += 1
+ if i == len(config.Accounts) {
+ i = 0
+ }
+ conf = config.Accounts[i]
+ return aerc.Account(conf.Name)
+ }
+ }
+ return nil, fmt.Errorf("no next account")
+}
+
+func (aerc *Aerc) AccountNames() []string {
+ results := make([]string, 0)
+ for name := range aerc.accounts {
+ results = append(results, name)
+ }
+ return results
+}
+
+func (aerc *Aerc) account(d ui.Drawable) *AccountView {
+ switch tab := d.(type) {
+ case *AccountView:
+ return tab
+ case *MessageViewer:
+ return tab.SelectedAccount()
+ case *Composer:
+ return tab.Account()
+ }
+ return nil
+}
+
+func (aerc *Aerc) SelectedAccountUiConfig() *config.UIConfig {
+ acct := aerc.SelectedAccount()
+ if acct == nil {
+ return config.Ui
+ }
+ return acct.UiConfig()
+}
+
+func (aerc *Aerc) SelectedTabContent() ui.Drawable {
+ tab := aerc.tabs.Selected()
+ if tab == nil {
+ return nil
+ }
+ return tab.Content
+}
+
+func (aerc *Aerc) SelectedTab() *ui.Tab {
+ return aerc.tabs.Selected()
+}
+
+func (aerc *Aerc) NewTab(clickable ui.Drawable, name string) *ui.Tab {
+ uiConf := config.Ui
+ if acct := aerc.account(clickable); acct != nil {
+ uiConf = acct.UiConfig()
+ }
+ tab := aerc.tabs.Add(clickable, name, uiConf)
+ aerc.UpdateStatus()
+ return tab
+}
+
+func (aerc *Aerc) RemoveTab(tab ui.Drawable, closeContent bool) {
+ aerc.tabs.Remove(tab)
+ aerc.UpdateStatus()
+ if content, ok := tab.(ui.Closeable); ok && closeContent {
+ content.Close()
+ }
+}
+
+func (aerc *Aerc) ReplaceTab(tabSrc ui.Drawable, tabTarget ui.Drawable, name string, closeSrc bool) {
+ aerc.tabs.Replace(tabSrc, tabTarget, name)
+ if content, ok := tabSrc.(ui.Closeable); ok && closeSrc {
+ content.Close()
+ }
+}
+
+func (aerc *Aerc) MoveTab(i int, relative bool) {
+ aerc.tabs.MoveTab(i, relative)
+}
+
+func (aerc *Aerc) PinTab() {
+ aerc.tabs.PinTab()
+}
+
+func (aerc *Aerc) UnpinTab() {
+ aerc.tabs.UnpinTab()
+}
+
+func (aerc *Aerc) NextTab() {
+ aerc.tabs.NextTab()
+}
+
+func (aerc *Aerc) PrevTab() {
+ aerc.tabs.PrevTab()
+}
+
+func (aerc *Aerc) SelectTab(name string) bool {
+ ok := aerc.tabs.SelectName(name)
+ if ok {
+ aerc.UpdateStatus()
+ }
+ return ok
+}
+
+func (aerc *Aerc) SelectTabIndex(index int) bool {
+ ok := aerc.tabs.Select(index)
+ if ok {
+ aerc.UpdateStatus()
+ }
+ return ok
+}
+
+func (aerc *Aerc) TabNames() []string {
+ return aerc.tabs.Names()
+}
+
+func (aerc *Aerc) SelectPreviousTab() bool {
+ return aerc.tabs.SelectPrevious()
+}
+
+func (aerc *Aerc) UpdateStatus() {
+ if acct := aerc.SelectedAccount(); acct != nil {
+ aerc.statusline.Update(acct)
+ } else {
+ aerc.statusline.Clear()
+ }
+}
+
+func (aerc *Aerc) SetError(err string) {
+ aerc.statusline.SetError(err)
+}
+
+func (aerc *Aerc) PushStatus(text string, expiry time.Duration) *StatusMessage {
+ return aerc.statusline.Push(text, expiry)
+}
+
+func (aerc *Aerc) PushError(text string) *StatusMessage {
+ return aerc.statusline.PushError(text)
+}
+
+func (aerc *Aerc) PushWarning(text string) *StatusMessage {
+ return aerc.statusline.PushWarning(text)
+}
+
+func (aerc *Aerc) PushSuccess(text string) *StatusMessage {
+ return aerc.statusline.PushSuccess(text)
+}
+
+func (aerc *Aerc) focus(item ui.Interactive) {
+ if aerc.focused == item {
+ return
+ }
+ if aerc.focused != nil {
+ aerc.focused.Focus(false)
+ }
+ aerc.focused = item
+ interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
+ if item != nil {
+ item.Focus(true)
+ if ok {
+ interactive.Focus(false)
+ }
+ } else if ok {
+ interactive.Focus(true)
+ }
+}
+
+func (aerc *Aerc) BeginExCommand(cmd string) {
+ previous := aerc.focused
+ var tabComplete func(string) ([]string, string)
+ if aerc.simulating != 0 {
+ // Don't try to draw completions for simulated events
+ tabComplete = nil
+ } else {
+ tabComplete = func(cmd string) ([]string, string) {
+ return aerc.complete(cmd)
+ }
+ }
+ exline := NewExLine(cmd, func(cmd string) {
+ parts, err := shlex.Split(cmd)
+ if err != nil {
+ aerc.PushError(err.Error())
+ }
+ err = aerc.cmd(parts, nil, nil)
+ if err != nil {
+ aerc.PushError(err.Error())
+ }
+ // only add to history if this is an unsimulated command,
+ // ie one not executed from a keybinding
+ if aerc.simulating == 0 {
+ aerc.cmdHistory.Add(cmd)
+ }
+ }, func() {
+ aerc.statusbar.Pop()
+ aerc.focus(previous)
+ }, tabComplete, aerc.cmdHistory)
+ aerc.statusbar.Push(exline)
+ aerc.focus(exline)
+}
+
+func (aerc *Aerc) PushPrompt(prompt *ExLine) {
+ aerc.prompts.Push(prompt)
+}
+
+func (aerc *Aerc) RegisterPrompt(prompt string, cmd []string) {
+ p := NewPrompt(prompt, func(text string) {
+ if text != "" {
+ cmd = append(cmd, text)
+ }
+ err := aerc.cmd(cmd, nil, nil)
+ if err != nil {
+ aerc.PushError(err.Error())
+ }
+ }, func(cmd string) ([]string, string) {
+ return nil, "" // TODO: completions
+ })
+ aerc.prompts.Push(p)
+}
+
+func (aerc *Aerc) RegisterChoices(choices []Choice) {
+ cmds := make(map[string][]string)
+ texts := []string{}
+ for _, c := range choices {
+ text := fmt.Sprintf("[%s] %s", c.Key, c.Text)
+ if strings.Contains(c.Text, c.Key) {
+ text = strings.Replace(c.Text, c.Key, "["+c.Key+"]", 1)
+ }
+ texts = append(texts, text)
+ cmds[c.Key] = c.Command
+ }
+ prompt := strings.Join(texts, ", ") + "? "
+ p := NewPrompt(prompt, func(text string) {
+ cmd, ok := cmds[text]
+ if !ok {
+ return
+ }
+ err := aerc.cmd(cmd, nil, nil)
+ if err != nil {
+ aerc.PushError(err.Error())
+ }
+ }, func(cmd string) ([]string, string) {
+ return nil, "" // TODO: completions
+ })
+ aerc.prompts.Push(p)
+}
+
+func (aerc *Aerc) Mailto(addr *url.URL) error {
+ var subject string
+ var body string
+ var acctName string
+ var attachments []string
+ h := &mail.Header{}
+ to, err := mail.ParseAddressList(addr.Opaque)
+ if err != nil && addr.Opaque != "" {
+ return fmt.Errorf("Could not parse to: %w", err)
+ }
+ h.SetAddressList("to", to)
+ template := config.Templates.NewMessage
+ for key, vals := range addr.Query() {
+ switch strings.ToLower(key) {
+ case "account":
+ acctName = strings.Join(vals, "")
+ case "bcc":
+ list, err := mail.ParseAddressList(strings.Join(vals, ","))
+ if err != nil {
+ break
+ }
+ h.SetAddressList("Bcc", list)
+ case "body":
+ body = strings.Join(vals, "\n")
+ case "cc":
+ list, err := mail.ParseAddressList(strings.Join(vals, ","))
+ if err != nil {
+ break
+ }
+ h.SetAddressList("Cc", list)
+ case "in-reply-to":
+ for i, msgID := range vals {
+ if len(msgID) > 1 && msgID[0] == '<' &&
+ msgID[len(msgID)-1] == '>' {
+ vals[i] = msgID[1 : len(msgID)-1]
+ }
+ }
+ h.SetMsgIDList("In-Reply-To", vals)
+ case "subject":
+ subject = strings.Join(vals, ",")
+ h.SetText("Subject", subject)
+ case "template":
+ template = strings.Join(vals, "")
+ log.Tracef("template set to %s", template)
+ case "attach":
+ for _, path := range vals {
+ // remove a potential file:// prefix.
+ attachments = append(attachments, strings.TrimPrefix(path, "file://"))
+ }
+ default:
+ // any other header gets ignored on purpose to avoid control headers
+ // being injected
+ }
+ }
+
+ acct := aerc.SelectedAccount()
+ if acctName != "" {
+ if a, ok := aerc.accounts[acctName]; ok && a != nil {
+ acct = a
+ }
+ }
+
+ if acct == nil {
+ return errors.New("No account selected")
+ }
+
+ defer ui.Invalidate()
+
+ composer, err := NewComposer(aerc, acct,
+ acct.AccountConfig(), acct.Worker(),
+ config.Compose.EditHeaders, template, h, nil,
+ strings.NewReader(body))
+ if err != nil {
+ return err
+ }
+ composer.FocusEditor("subject")
+ title := "New email"
+ if subject != "" {
+ title = subject
+ composer.FocusTerminal()
+ }
+ if to == nil {
+ composer.FocusEditor("to")
+ }
+ composer.Tab = aerc.NewTab(composer, title)
+
+ for _, file := range attachments {
+ composer.AddAttachment(file)
+ }
+ return nil
+}
+
+func (aerc *Aerc) Mbox(source string) error {
+ acctConf := config.AccountConfig{}
+ if selectedAcct := aerc.SelectedAccount(); selectedAcct != nil {
+ acctConf = *selectedAcct.acct
+ info := fmt.Sprintf("Loading outgoing mbox mail settings from account [%s]", selectedAcct.Name())
+ aerc.PushStatus(info, 10*time.Second)
+ log.Debugf(info)
+ } else {
+ acctConf.From = &mail.Address{Address: "user@localhost"}
+ }
+ acctConf.Name = "mbox"
+ acctConf.Source = source
+ acctConf.Default = "INBOX"
+ acctConf.Archive = "Archive"
+ acctConf.Postpone = "Drafts"
+ acctConf.CopyTo = "Sent"
+
+ defer ui.Invalidate()
+
+ mboxView, err := NewAccountView(aerc, &acctConf, aerc, nil)
+ if err != nil {
+ aerc.NewTab(errorScreen(err.Error()), acctConf.Name)
+ } else {
+ aerc.accounts[acctConf.Name] = mboxView
+ aerc.NewTab(mboxView, acctConf.Name)
+ }
+ return nil
+}
+
+func (aerc *Aerc) Command(args []string) error {
+ defer ui.Invalidate()
+ return aerc.cmd(args, nil, nil)
+}
+
+func (aerc *Aerc) CloseBackends() error {
+ var returnErr error
+ for _, acct := range aerc.accounts {
+ var raw interface{} = acct.worker.Backend
+ c, ok := raw.(io.Closer)
+ if !ok {
+ continue
+ }
+ err := c.Close()
+ if err != nil {
+ returnErr = err
+ log.Errorf("Closing backend failed for %s: %v", acct.Name(), err)
+ }
+ }
+ return returnErr
+}
+
+func (aerc *Aerc) AddDialog(d ui.DrawableInteractive) {
+ aerc.dialog = d
+ aerc.Invalidate()
+}
+
+func (aerc *Aerc) CloseDialog() {
+ aerc.dialog = nil
+ aerc.Invalidate()
+}
+
+func (aerc *Aerc) GetPassword(title string, prompt string) (chText chan string, chErr chan error) {
+ chText = make(chan string, 1)
+ chErr = make(chan error, 1)
+ getPasswd := NewGetPasswd(title, prompt, func(pw string, err error) {
+ defer func() {
+ close(chErr)
+ close(chText)
+ aerc.CloseDialog()
+ }()
+ if err != nil {
+ chErr <- err
+ return
+ }
+ chErr <- nil
+ chText <- pw
+ })
+ aerc.AddDialog(getPasswd)
+
+ return
+}
+
+func (aerc *Aerc) Initialize(ui *ui.UI) {
+ aerc.ui = ui
+}
+
+func (aerc *Aerc) DecryptKeys(keys []openpgp.Key, symmetric bool) (b []byte, err error) {
+ for _, key := range keys {
+ ident := key.Entity.PrimaryIdentity()
+ chPass, chErr := aerc.GetPassword("Decrypt PGP private key",
+ fmt.Sprintf("Enter password for %s (%8X)\nPress <ESC> to cancel",
+ ident.Name, key.PublicKey.KeyId))
+
+ for err := range chErr {
+ if err != nil {
+ return nil, err
+ }
+ pass := <-chPass
+ err = key.PrivateKey.Decrypt([]byte(pass))
+ return nil, err
+ }
+ }
+ return nil, err
+}
+
+// errorScreen is a widget that draws an error in the middle of the context
+func errorScreen(s string) ui.Drawable {
+ errstyle := config.Ui.GetStyle(config.STYLE_ERROR)
+ text := ui.NewText(s, errstyle).Strategy(ui.TEXT_CENTER)
+ grid := ui.NewGrid().Rows([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+ grid.AddChild(ui.NewFill(' ', tcell.StyleDefault)).At(0, 0)
+ grid.AddChild(text).At(1, 0)
+ grid.AddChild(ui.NewFill(' ', tcell.StyleDefault)).At(2, 0)
+ return grid
+}
+
+func (aerc *Aerc) isExKey(event *tcell.EventKey, exKey config.KeyStroke) bool {
+ if event.Key() == tcell.KeyRune {
+ // Compare runes if it's a KeyRune
+ return event.Modifiers() == exKey.Modifiers && event.Rune() == exKey.Rune
+ }
+ return event.Modifiers() == exKey.Modifiers && event.Key() == exKey.Key
+}
+
+// CmdFallbackSearch checks cmds for the first executable availabe in PATH. An error is
+// returned if none are found
+func (aerc *Aerc) CmdFallbackSearch(cmds []string) (string, error) {
+ var tried []string
+ for _, cmd := range cmds {
+ if cmd == "" {
+ continue
+ }
+ params := strings.Split(cmd, " ")
+ _, err := exec.LookPath(params[0])
+ if err != nil {
+ tried = append(tried, cmd)
+ warn := fmt.Sprintf("cmd '%s' not found in PATH, using fallback", cmd)
+ aerc.PushWarning(warn)
+ continue
+ }
+ return cmd, nil
+ }
+ return "", fmt.Errorf("no command found in PATH: %s", tried)
+}
diff --git a/app/authinfo.go b/app/authinfo.go
new file mode 100644
index 00000000..982edcb2
--- /dev/null
+++ b/app/authinfo.go
@@ -0,0 +1,88 @@
+package app
+
+import (
+ "fmt"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/auth"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "github.com/gdamore/tcell/v2"
+ "github.com/mattn/go-runewidth"
+)
+
+type AuthInfo struct {
+ authdetails *auth.Details
+ showInfo bool
+ uiConfig *config.UIConfig
+}
+
+func NewAuthInfo(auth *auth.Details, showInfo bool, uiConfig *config.UIConfig) *AuthInfo {
+ return &AuthInfo{authdetails: auth, showInfo: showInfo, uiConfig: uiConfig}
+}
+
+func (a *AuthInfo) Draw(ctx *ui.Context) {
+ defaultStyle := a.uiConfig.GetStyle(config.STYLE_DEFAULT)
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
+ var text string
+ switch {
+ case a.authdetails == nil:
+ text = "(no header)"
+ ctx.Printf(0, 0, defaultStyle, text)
+ case a.authdetails.Err != nil:
+ style := a.uiConfig.GetStyle(config.STYLE_ERROR)
+ text = a.authdetails.Err.Error()
+ ctx.Printf(0, 0, style, text)
+ default:
+ checkBounds := func(x int) bool {
+ return x < ctx.Width()
+ }
+ setResult := func(result auth.Result) (string, tcell.Style) {
+ switch result {
+ case auth.ResultNone:
+ return "none", defaultStyle
+ case auth.ResultNeutral:
+ return "neutral", a.uiConfig.GetStyle(config.STYLE_WARNING)
+ case auth.ResultPolicy:
+ return "policy", a.uiConfig.GetStyle(config.STYLE_WARNING)
+ case auth.ResultPass:
+ return "✓", a.uiConfig.GetStyle(config.STYLE_SUCCESS)
+ case auth.ResultFail:
+ return "✗", a.uiConfig.GetStyle(config.STYLE_ERROR)
+ default:
+ return string(result), a.uiConfig.GetStyle(config.STYLE_ERROR)
+ }
+ }
+ x := 1
+ for i := 0; i < len(a.authdetails.Results); i++ {
+ if checkBounds(x) {
+ text, style := setResult(a.authdetails.Results[i])
+ if i > 0 {
+ text = " " + text
+ }
+ x += ctx.Printf(x, 0, style, text)
+ }
+ }
+ if a.showInfo {
+ infoText := ""
+ for i := 0; i < len(a.authdetails.Infos); i++ {
+ if i > 0 {
+ infoText += ","
+ }
+ infoText += a.authdetails.Infos[i]
+ if reason := a.authdetails.Reasons[i]; reason != "" {
+ infoText += reason
+ }
+ }
+ if checkBounds(x) && infoText != "" {
+ if trunc := ctx.Width() - x - 3; trunc > 0 {
+ text = runewidth.Truncate(infoText, trunc, "…")
+ ctx.Printf(x, 0, defaultStyle, fmt.Sprintf(" (%s)", text))
+ }
+ }
+ }
+ }
+}
+
+func (a *AuthInfo) Invalidate() {
+ ui.Invalidate()
+}
diff --git a/app/compose.go b/app/compose.go
new file mode 100644
index 00000000..6eda2b0d
--- /dev/null
+++ b/app/compose.go
@@ -0,0 +1,1975 @@
+package app
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "io"
+ "net/textproto"
+ "os"
+ "os/exec"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/emersion/go-message/mail"
+ "github.com/gdamore/tcell/v2"
+ "github.com/mattn/go-runewidth"
+ "github.com/pkg/errors"
+
+ "git.sr.ht/~rjarry/aerc/completer"
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/format"
+ "git.sr.ht/~rjarry/aerc/lib/state"
+ "git.sr.ht/~rjarry/aerc/lib/templates"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/lib/xdg"
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rjarry/aerc/worker/types"
+)
+
+type Composer struct {
+ sync.Mutex
+ 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
+ acct *AccountView
+ aerc *Aerc
+
+ attachments []lib.Attachment
+ editor *Terminal
+ email *os.File
+ grid atomic.Value
+ heditors atomic.Value // from, to, cc display a user can jump to
+ review *reviewMessage
+ worker *types.Worker
+ completer *completer.Completer
+ crypto *cryptoStatus
+ sign bool
+ encrypt bool
+ attachKey bool
+ editHeaders bool
+
+ layout HeaderLayout
+ focusable []ui.MouseableDrawableInteractive
+ focused int
+ sent bool
+ archive string
+
+ recalledFrom string
+ postponed bool
+
+ onClose []func(ti *Composer)
+
+ width int
+
+ textParts []*lib.Part
+ Tab *ui.Tab
+}
+
+func NewComposer(
+ aerc *Aerc, acct *AccountView, acctConfig *config.AccountConfig,
+ worker *types.Worker, editHeaders bool, template string,
+ h *mail.Header, orig *models.OriginalMail, body io.Reader,
+) (*Composer, error) {
+ if h == nil {
+ h = new(mail.Header)
+ }
+
+ email, err := os.CreateTemp("", "aerc-compose-*.eml")
+ if err != nil {
+ // TODO: handle this better
+ return nil, err
+ }
+
+ c := &Composer{
+ acct: acct,
+ acctConfig: acctConfig,
+ aerc: aerc,
+ 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: nil,
+
+ editHeaders: editHeaders,
+ }
+
+ data := state.NewDataSetter()
+ data.SetAccount(acct.acct)
+ data.SetFolder(acct.Directories().SelectedDirectory())
+ data.SetHeaders(h, orig)
+ data.SetComposer(c)
+ if err := c.addTemplate(template, data.Data(), body); err != nil {
+ return nil, err
+ }
+ if sig, err := c.HasSignature(); !sig && err == nil {
+ c.AddSignature()
+ } else if err != nil {
+ return nil, err
+ }
+ if err := c.setupFor(acct); err != nil {
+ return nil, err
+ }
+
+ if err := c.ShowTerminal(editHeaders); err != nil {
+ return nil, err
+ }
+
+ return c, nil
+}
+
+func (c *Composer) SwitchAccount(newAcct *AccountView) error {
+ if c.acct == newAcct {
+ log.Tracef("same accounts: no switch")
+ return nil
+ }
+ // sync the header with the editors
+ for _, editor := range c.editors {
+ editor.storeValue()
+ }
+ // ensure that from header is updated, so remove it
+ c.header.Del("from")
+ c.header.Del("message-id")
+ // update entire composer with new the account
+ if err := c.setupFor(newAcct); err != nil {
+ return err
+ }
+ // sync the header with the editors
+ for _, editor := range c.editors {
+ editor.loadValue()
+ }
+ c.resetReview()
+ c.Invalidate()
+ log.Debugf("account successfully switched")
+ return nil
+}
+
+func (c *Composer) setupFor(view *AccountView) error {
+ c.Lock()
+ defer c.Unlock()
+ // set new account
+ c.acct = view
+ c.worker = view.Worker()
+ c.acctConfig = c.acct.AccountConfig()
+ // Set from header if not already in header
+ 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
+ if cmd == "" {
+ cmd = config.Compose.AddressBookCmd
+ }
+ cmpl := completer.New(cmd, func(err error) {
+ c.aerc.PushError(
+ fmt.Sprintf("could not complete header: %v", err))
+ log.Errorf("could not complete header: %v", err)
+ })
+ c.completer = cmpl
+
+ // if editor already exists, we have to get it from the focusable slice
+ // because this will be rebuild during buildComposeHeader()
+ var focusEditor ui.MouseableDrawableInteractive
+ if c.editor != nil && len(c.focusable) > 0 {
+ focusEditor = c.focusable[len(c.focusable)-1]
+ }
+
+ // rebuild editors and focusable slice
+ c.buildComposeHeader(c.aerc, cmpl)
+
+ // restore the editor in the focusable list
+ if focusEditor != nil {
+ c.focusable = append(c.focusable, focusEditor)
+ }
+ if c.focused >= len(c.focusable) {
+ c.focused = len(c.focusable) - 1
+ }
+
+ // update the crypto parts
+ c.crypto = nil
+ c.sign = false
+ if c.acct.acct.PgpAutoSign {
+ err := c.SetSign(true)
+ log.Warnf("failed to enable message signing: %v", err)
+ }
+ c.encrypt = false
+ if c.acct.acct.PgpOpportunisticEncrypt {
+ c.SetEncrypt(true)
+ }
+ err := c.updateCrypto()
+ if err != nil {
+ log.Warnf("failed to update crypto: %v", err)
+ }
+
+ // redraw the grid
+ c.updateGrid()
+
+ return nil
+}
+
+func (c *Composer) buildComposeHeader(aerc *Aerc, cmpl *completer.Completer) {
+ c.layout = config.Compose.HeaderLayout
+ c.editors = make(map[string]*headerEditor)
+ c.focusable = make([]ui.MouseableDrawableInteractive, 0)
+ uiConfig := c.acct.UiConfig()
+
+ 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, uiConfig)
+ if uiConfig.CompletionPopovers {
+ e.input.TabComplete(
+ cmpl.ForHeader(h),
+ uiConfig.CompletionDelay,
+ uiConfig.CompletionMinChars,
+ )
+ }
+ 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)
+ }
+ e.OnChange(func() {
+ c.setTitle()
+ ui.Invalidate()
+ })
+ e.OnFocusLost(func() {
+ c.PrepareHeader() //nolint:errcheck // tab title only, fine if it's not valid yet
+ c.setTitle()
+ ui.Invalidate()
+ })
+ }
+ }
+
+ // 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, uiConfig)
+ if uiConfig.CompletionPopovers {
+ e.input.TabComplete(
+ cmpl.ForHeader(h),
+ uiConfig.CompletionDelay,
+ uiConfig.CompletionMinChars,
+ )
+ }
+ 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) 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
+}
+
+func (c *Composer) Sent() bool {
+ return c.sent
+}
+
+func (c *Composer) SetPostponed() {
+ c.postponed = true
+}
+
+func (c *Composer) Postponed() bool {
+ return c.postponed
+}
+
+func (c *Composer) SetRecalledFrom(folder string) {
+ c.recalledFrom = folder
+}
+
+func (c *Composer) RecalledFrom() string {
+ return c.recalledFrom
+}
+
+func (c *Composer) Archive() string {
+ return c.archive
+}
+
+func (c *Composer) SetAttachKey(attach bool) error {
+ if !attach {
+ name := c.crypto.signKey + ".asc"
+ found := false
+ for _, a := range c.attachments {
+ if a.Name() == name {
+ found = true
+ }
+ }
+ if found {
+ err := c.DeleteAttachment(name)
+ if err != nil {
+ return fmt.Errorf("failed to delete attachment '%s: %w", name, err)
+ }
+ } else {
+ attach = !attach
+ }
+ }
+ if attach {
+ var s string
+ var err error
+ if c.crypto.signKey == "" {
+ if c.acctConfig.PgpKeyId != "" {
+ s = c.acctConfig.PgpKeyId
+ } else {
+ s = c.acctConfig.From.Address
+ }
+ c.crypto.signKey, err = c.aerc.Crypto.GetSignerKeyId(s)
+ if err != nil {
+ return err
+ }
+ }
+
+ r, err := c.aerc.Crypto.ExportKey(c.crypto.signKey)
+ if err != nil {
+ return err
+ }
+
+ newPart, err := lib.NewPart(
+ "application/pgp-keys",
+ map[string]string{"charset": "UTF-8"},
+ r,
+ )
+ if err != nil {
+ return err
+ }
+ c.attachments = append(c.attachments,
+ lib.NewPartAttachment(
+ newPart,
+ c.crypto.signKey+".asc",
+ ),
+ )
+
+ }
+
+ c.attachKey = attach
+
+ c.resetReview()
+ return nil
+}
+
+func (c *Composer) AttachKey() bool {
+ return c.attachKey
+}
+
+func (c *Composer) SetSign(sign bool) error {
+ c.sign = sign
+ err := c.updateCrypto()
+ if err != nil {
+ c.sign = !sign
+ return fmt.Errorf("Cannot sign message: %w", err)
+ }
+ return nil
+}
+
+func (c *Composer) Sign() bool {
+ return c.sign
+}
+
+func (c *Composer) SetEncrypt(encrypt bool) *Composer {
+ if !encrypt {
+ c.encrypt = encrypt
+ err := c.updateCrypto()
+ if err != nil {
+ log.Warnf("failed to update crypto: %v", err)
+ }
+ return c
+ }
+ // Check on any attempt to encrypt, and any lost focus of "to", "cc", or
+ // "bcc" field. Use OnFocusLost instead of OnChange to limit keyring checks
+ c.encrypt = c.checkEncryptionKeys("")
+ if c.crypto.setEncOneShot {
+ // Prevent registering a lot of callbacks
+ c.OnFocusLost("to", c.checkEncryptionKeys)
+ c.OnFocusLost("cc", c.checkEncryptionKeys)
+ c.OnFocusLost("bcc", c.checkEncryptionKeys)
+ c.crypto.setEncOneShot = false
+ }
+ return c
+}
+
+func (c *Composer) Encrypt() bool {
+ return c.encrypt
+}
+
+func (c *Composer) updateCrypto() error {
+ if c.crypto == nil {
+ uiConfig := c.acct.UiConfig()
+ c.crypto = newCryptoStatus(uiConfig)
+ }
+ if c.sign {
+ cp := c.aerc.Crypto
+ s, err := c.Signer()
+ if err != nil {
+ return errors.Wrap(err, "Signer")
+ }
+ c.crypto.signKey, err = cp.GetSignerKeyId(s)
+ if err != nil {
+ return err
+ }
+ }
+
+ st := ""
+ switch {
+ case c.sign && c.encrypt:
+ st = fmt.Sprintf("Sign (%s) & Encrypt", c.crypto.signKey)
+ case c.sign:
+ st = fmt.Sprintf("Sign (%s)", c.crypto.signKey)
+ case c.encrypt:
+ st = "Encrypt"
+ }
+ c.crypto.status.Text(st)
+
+ c.updateGrid()
+
+ return nil
+}
+
+func (c *Composer) writeEml(reader io.Reader) error {
+ // .eml files must always use '\r\n' line endings, but some editors
+ // don't support these, so if they are using one of those, the
+ // line-endings are transformed
+ lineEnding := "\r\n"
+ if config.Compose.LFEditor {
+ lineEnding = "\n"
+ }
+
+ scanner := bufio.NewScanner(reader)
+ for scanner.Scan() {
+ _, err := c.email.WriteString(scanner.Text() + lineEnding)
+ if err != nil {
+ return err
+ }
+ }
+ if scanner.Err() != nil {
+ return scanner.Err()
+ }
+ return c.email.Sync()
+}
+
+// Note: this does not reload the editor. You must call this before the first
+// Draw() call.
+func (c *Composer) setContents(reader io.Reader) error {
+ _, err := c.email.Seek(0, io.SeekStart)
+ if err != nil {
+ return err
+ }
+ err = c.email.Truncate(0)
+ if err != nil {
+ return err
+ }
+ lineEnding := "\r\n"
+ if config.Compose.LFEditor {
+ lineEnding = "\n"
+ }
+
+ if c.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, ","+lineEnding+"\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"+lineEnding, key, value)
+ if err != nil {
+ return err
+ }
+ }
+ _, err = c.email.WriteString(lineEnding)
+ if err != nil {
+ return err
+ }
+ }
+ return c.writeEml(reader)
+}
+
+func (c *Composer) appendContents(reader io.Reader) error {
+ _, err := c.email.Seek(0, io.SeekEnd)
+ if err != nil {
+ return err
+ }
+ return c.writeEml(reader)
+}
+
+func (c *Composer) AppendPart(mimetype string, params map[string]string, body io.Reader) error {
+ if !strings.HasPrefix(mimetype, "text") {
+ return fmt.Errorf("can only append text mimetypes")
+ }
+ for _, part := range c.textParts {
+ if part.MimeType == mimetype {
+ return fmt.Errorf("%s part already exists", mimetype)
+ }
+ }
+ newPart, err := lib.NewPart(mimetype, params, body)
+ if err != nil {
+ return err
+ }
+ c.textParts = append(c.textParts, newPart)
+ c.resetReview()
+ return nil
+}
+
+func (c *Composer) RemovePart(mimetype string) error {
+ if mimetype == "text/plain" {
+ return fmt.Errorf("cannot remove text/plain parts")
+ }
+ for i, part := range c.textParts {
+ if part.MimeType != mimetype {
+ continue
+ }
+ c.textParts = append(c.textParts[:i], c.textParts[i+1:]...)
+ c.resetReview()
+ return nil
+ }
+ return fmt.Errorf("%s part not found", mimetype)
+}
+
+func (c *Composer) addTemplate(
+ template string, data models.TemplateData, body io.Reader,
+) error {
+ var readers []io.Reader
+
+ if template != "" {
+ templateText, err := templates.ParseTemplateFromFile(
+ template, config.Templates.TemplateDirs, data)
+ if err != nil {
+ return err
+ }
+ 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 {
+ return nil
+ }
+
+ buf, err := io.ReadAll(io.MultiReader(readers...))
+ if err != nil {
+ return err
+ }
+
+ mr, err := mail.CreateReader(bytes.NewReader(buf))
+ if err != nil {
+ // no headers in the template nor body
+ return c.setContents(bytes.NewReader(buf))
+ }
+
+ // 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("NextPart: %w", err)
+ }
+
+ return c.setContents(part.Body)
+}
+
+func (c *Composer) HasSignature() (bool, error) {
+ buf, err := c.GetBody()
+ if err != nil {
+ return false, err
+ }
+ found := false
+ scanner := bufio.NewScanner(buf)
+ for scanner.Scan() {
+ if scanner.Text() == "-- " {
+ found = true
+ break
+ }
+ }
+ return found, scanner.Err()
+}
+
+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()
+ }
+ if len(bytes.TrimSpace(signature)) == 0 {
+ return
+ }
+ signature = ensureSignatureDelimiter(signature)
+ err := c.appendContents(bytes.NewReader(signature))
+ if err != nil {
+ log.Errorf("appendContents: %s", err)
+ }
+}
+
+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 = xdg.ExpandHome(sigFile)
+ signature, err := os.ReadFile(sigFile)
+ if err != nil {
+ c.aerc.PushError(
+ fmt.Sprintf(" Error loading signature from file: %v", sigFile))
+ return nil
+ }
+ return signature
+}
+
+func ensureSignatureDelimiter(signature []byte) []byte {
+ buf := bytes.NewBuffer(signature)
+ scanner := bufio.NewScanner(buf)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if line == "-- " {
+ // signature contains standard delimiter, we're good
+ return signature
+ }
+ }
+ // signature does not contain standard delimiter, prepend one
+ sig := "\n\n-- \n" + strings.TrimLeft(string(signature), " \t\r\n")
+ return []byte(sig)
+}
+
+func (c *Composer) GetBody() (*bytes.Buffer, error) {
+ _, err := c.email.Seek(0, io.SeekStart)
+ if err != nil {
+ return nil, err
+ }
+ scanner := bufio.NewScanner(c.email)
+ if c.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() {
+ buf.WriteString(scanner.Text() + "\r\n")
+ }
+ err = scanner.Err()
+ if err != nil {
+ return nil, err
+ }
+ return buf, nil
+}
+
+func (c *Composer) FocusTerminal() *Composer {
+ c.Lock()
+ defer c.Unlock()
+ return c.focusTerminalPriv()
+}
+
+func (c *Composer) focusTerminalPriv() *Composer {
+ if c.editor == nil {
+ return c
+ }
+ c.focusActiveWidget(false)
+ c.focused = len(c.focusable) - 1
+ c.focusActiveWidget(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())
+ })
+ }
+}
+
+// OnFocusLost registers an OnFocusLost callback for the specified header.
+func (c *Composer) OnFocusLost(header string, fn func(input string) bool) {
+ if editor, ok := c.editors[strings.ToLower(header)]; ok {
+ editor.OnFocusLost(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.setTitle()
+ c.width = ctx.Width()
+ c.grid.Load().(*ui.Grid).Draw(ctx)
+}
+
+func (c *Composer) Invalidate() {
+ ui.Invalidate()
+}
+
+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 {
+ c.Lock()
+ defer c.Unlock()
+ switch c.editor {
+ case nil:
+ return "compose::review"
+ case c.focusedWidget():
+ return "compose::editor"
+ default:
+ return "compose"
+ }
+}
+
+func (c *Composer) focusedWidget() ui.MouseableDrawableInteractive {
+ if c.focused < 0 || c.focused >= len(c.focusable) {
+ return nil
+ }
+ return c.focusable[c.focused]
+}
+
+func (c *Composer) focusActiveWidget(focus bool) {
+ if w := c.focusedWidget(); w != nil {
+ w.Focus(focus)
+ }
+}
+
+func (c *Composer) Event(event tcell.Event) bool {
+ c.Lock()
+ defer c.Unlock()
+ if w := c.focusedWidget(); c.editor != nil && w != nil {
+ return w.Event(event)
+ }
+ return false
+}
+
+func (c *Composer) MouseEvent(localX int, localY int, event tcell.Event) {
+ c.Lock()
+ for _, e := range c.focusable {
+ he, ok := e.(*headerEditor)
+ if ok && he.focused {
+ he.focused = false
+ }
+ }
+ c.Unlock()
+ c.grid.Load().(*ui.Grid).MouseEvent(localX, localY, event)
+ c.Lock()
+ defer c.Unlock()
+ for i, e := range c.focusable {
+ he, ok := e.(*headerEditor)
+ if ok && he.focused {
+ c.focusActiveWidget(false)
+ c.focused = i
+ c.focusActiveWidget(true)
+ return
+ }
+ }
+}
+
+func (c *Composer) Focus(focus bool) {
+ c.Lock()
+ c.focusActiveWidget(focus)
+ c.Unlock()
+}
+
+func (c *Composer) Show(visible bool) {
+ c.Lock()
+ if w := c.focusedWidget(); w != nil {
+ if vis, ok := w.(ui.Visible); ok {
+ vis.Show(visible)
+ }
+ }
+ c.Unlock()
+}
+
+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") {
+ hostname, err := getMessageIdHostname(c)
+ if err != nil {
+ return nil, err
+ }
+ if err := c.header.GenerateMessageIDWithHostname(hostname); err != nil {
+ return nil, err
+ }
+ }
+
+ // update the "Date" header every time PrepareHeader is called
+ if c.acctConfig.SendAsUTC {
+ c.header.SetDate(time.Now().UTC())
+ } else {
+ c.header.SetDate(time.Now())
+ }
+
+ return c.header, nil
+}
+
+func getMessageIdHostname(c *Composer) (string, error) {
+ if c.acctConfig.SendWithHostname {
+ return os.Hostname()
+ }
+ addrs, err := c.header.AddressList("from")
+ if err != nil {
+ return "", err
+ }
+ _, domain, found := strings.Cut(addrs[0].Address, "@")
+ if !found {
+ return "", fmt.Errorf("Invalid address %q", addrs[0])
+ }
+ return domain, nil
+}
+
+func (c *Composer) parseEmbeddedHeader() (*mail.Header, error) {
+ _, err := c.email.Seek(0, io.SeekStart)
+ if err != nil {
+ return nil, errors.Wrap(err, "Seek")
+ }
+
+ buf := bytes.NewBuffer([]byte{})
+ _, err = io.Copy(buf, c.email)
+ if err != nil {
+ return nil, fmt.Errorf("mail.ReadMessageCopy: %w", err)
+ }
+ if config.Compose.LFEditor {
+ bytes.ReplaceAll(buf.Bytes(), []byte{'\n'}, []byte{'\r', '\n'})
+ }
+
+ msg, err := mail.CreateReader(buf)
+ 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 {
+ return nil, errors.Wrap(err, "PrepareHeader")
+ }
+
+ // collect all 'recipients' from header (to:, cc:, bcc:)
+ rcpts := make(map[string]bool)
+ for _, key := range []string{"to", "cc", "bcc"} {
+ list, err := h.AddressList(key)
+ if err != nil {
+ continue
+ }
+ for _, entry := range list {
+ if entry != nil {
+ rcpts[entry.Address] = true
+ }
+ }
+ }
+
+ // return email addresses as string slice
+ results := []string{}
+ for email := range rcpts {
+ results = append(results, email)
+ }
+ return results, nil
+}
+
+func (c *Composer) Signer() (string, error) {
+ signer := ""
+
+ if c.acctConfig.PgpKeyId != "" {
+ // get key from explicitly set keyid
+ signer = c.acctConfig.PgpKeyId
+ } else {
+ // get signer from `from` header
+ from, err := c.header.AddressList("from")
+ if err != nil {
+ return "", err
+ }
+
+ if len(from) > 0 {
+ signer = from[0].Address
+ } else {
+ // fall back to address from config
+ signer = c.acctConfig.From.Address
+ }
+ }
+
+ return signer, nil
+}
+
+func (c *Composer) WriteMessage(header *mail.Header, writer io.Writer) error {
+ if c.sign || c.encrypt {
+
+ var signedHeader mail.Header
+ signedHeader.SetContentType("text/plain", nil)
+
+ var buf bytes.Buffer
+ var cleartext io.WriteCloser
+ var err error
+
+ signer := ""
+ if c.sign {
+ signer, err = c.Signer()
+ if err != nil {
+ return errors.Wrap(err, "Signer")
+ }
+ }
+
+ if c.encrypt {
+ rcpts, err := getRecipientsEmail(c)
+ if err != nil {
+ return err
+ }
+ cleartext, err = c.aerc.Crypto.Encrypt(&buf, rcpts, signer, c.aerc.DecryptKeys, header)
+ if err != nil {
+ return err
+ }
+ } else {
+ cleartext, err = c.aerc.Crypto.Sign(&buf, signer, c.aerc.DecryptKeys, header)
+ if err != nil {
+ return err
+ }
+ }
+
+ err = writeMsgImpl(c, &signedHeader, cleartext)
+ if err != nil {
+ return err
+ }
+ err = cleartext.Close()
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(writer, &buf)
+ if err != nil {
+ return fmt.Errorf("failed to write message: %w", err)
+ }
+ return nil
+
+ } else {
+ return writeMsgImpl(c, header, writer)
+ }
+}
+
+func (c *Composer) ShouldWarnAttachment() bool {
+ regex := config.Compose.NoAttachmentWarning
+
+ if regex == nil || len(c.attachments) > 0 {
+ return false
+ }
+
+ body, err := c.GetBody()
+ if err != nil {
+ log.Warnf("failed to check for a forgotten attachment: %v", err)
+ return true
+ }
+
+ return regex.Match(body.Bytes())
+}
+
+func (c *Composer) ShouldWarnSubject() bool {
+ if !config.Compose.EmptySubjectWarning {
+ return false
+ }
+
+ // ignore errors because the raw header field is sufficient here
+ subject, _ := c.header.Subject()
+ return len(subject) == 0
+}
+
+func writeMsgImpl(c *Composer, header *mail.Header, writer io.Writer) error {
+ mimeParams := map[string]string{"Charset": "UTF-8"}
+ if config.Compose.FormatFlowed {
+ mimeParams["Format"] = "Flowed"
+ }
+ body, err := c.GetBody()
+ if err != nil {
+ return err
+ }
+ if len(c.attachments) == 0 && len(c.textParts) == 0 {
+ // no attachments
+ return writeInlineBody(header, body, writer, mimeParams)
+ } else {
+ // with attachments
+ w, err := mail.CreateWriter(writer, *header)
+ if err != nil {
+ return errors.Wrap(err, "CreateWriter")
+ }
+ newPart, err := lib.NewPart("text/plain", mimeParams, body)
+ if err != nil {
+ return err
+ }
+ parts := []*lib.Part{newPart}
+ if err := writeMultipartBody(append(parts, c.textParts...), w); err != nil {
+ return errors.Wrap(err, "writeMultipartBody")
+ }
+ for _, a := range c.attachments {
+ if err := a.WriteTo(w); err != nil {
+ return errors.Wrap(err, "writeAttachment")
+ }
+ }
+ w.Close()
+ }
+ return nil
+}
+
+func writeInlineBody(
+ header *mail.Header,
+ body io.Reader,
+ writer io.Writer,
+ mimeParams map[string]string,
+) error {
+ header.SetContentType("text/plain", mimeParams)
+ 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(parts []*lib.Part, w *mail.Writer) error {
+ bi, err := w.CreateInline()
+ if err != nil {
+ return errors.Wrap(err, "CreateInline")
+ }
+ defer bi.Close()
+
+ for _, part := range parts {
+ bh := mail.InlineHeader{}
+ bh.SetContentType(part.MimeType, part.Params)
+ bw, err := bi.CreatePart(bh)
+ if err != nil {
+ return errors.Wrap(err, "CreatePart")
+ }
+ defer bw.Close()
+ if _, err := io.Copy(bw, part.NewReader()); err != nil {
+ return errors.Wrap(err, "io.Copy")
+ }
+ }
+
+ return nil
+}
+
+func (c *Composer) GetAttachments() []string {
+ var names []string
+ for _, a := range c.attachments {
+ names = append(names, a.Name())
+ }
+ return names
+}
+
+func (c *Composer) AddAttachment(path string) {
+ c.attachments = append(c.attachments, lib.NewFileAttachment(path))
+ c.resetReview()
+}
+
+func (c *Composer) AddPartAttachment(name string, mimetype string,
+ params map[string]string, body io.Reader,
+) error {
+ p, err := lib.NewPart(mimetype, params, body)
+ if err != nil {
+ return err
+ }
+ c.attachments = append(c.attachments, lib.NewPartAttachment(
+ p, name,
+ ))
+ c.resetReview()
+ return nil
+}
+
+func (c *Composer) DeleteAttachment(name string) error {
+ for i, a := range c.attachments {
+ if a.Name() == name {
+ 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.Load().(*ui.Grid).RemoveChild(c.review)
+ c.review = newReviewMessage(c, nil)
+ c.grid.Load().(*ui.Grid).AddChild(c.review).At(3, 0)
+ }
+}
+
+func (c *Composer) termEvent(event tcell.Event) bool {
+ if event, ok := event.(*tcell.EventMouse); ok {
+ if event.Buttons() == tcell.Button1 {
+ c.FocusTerminal()
+ return true
+ }
+ }
+ return false
+}
+
+func (c *Composer) reopenEmailFile() error {
+ name := c.email.Name()
+ f, err := os.OpenFile(name, os.O_RDWR, 0o600)
+ if err != nil {
+ return err
+ }
+ err = c.email.Close()
+ c.email = f
+ return err
+}
+
+func (c *Composer) termClosed(err error) {
+ c.Lock()
+ defer c.Unlock()
+ if c.editor == nil {
+ return
+ }
+ if e := c.reopenEmailFile(); e != nil {
+ c.aerc.PushError("Failed to reopen email file: " + e.Error())
+ }
+ 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
+ }
+
+ if c.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
+ }
+ // delete previous headers first
+ for _, h := range c.headerOrder() {
+ c.delEditor(h)
+ }
+ hf := embedHeader.Fields()
+ for hf.Next() {
+ if hf.Value() != "" {
+ // add new header values in order
+ c.addEditor(hf.Key(), hf.Value(), false)
+ }
+ }
+ }
+
+ // prepare review window
+ c.review = newReviewMessage(c, err)
+ c.updateGrid()
+}
+
+func (c *Composer) ShowTerminal(editHeaders bool) error {
+ c.Lock()
+ defer c.Unlock()
+ if c.editor != nil {
+ return nil
+ }
+ body, err := c.GetBody()
+ if err != nil {
+ return err
+ }
+ c.editHeaders = editHeaders
+ err = c.setContents(body)
+ if err != nil {
+ return err
+ }
+ return c.showTerminal()
+}
+
+func (c *Composer) showTerminal() error {
+ if c.editor != nil {
+ c.editor.Destroy()
+ }
+ cmds := []string{
+ config.Compose.Editor,
+ os.Getenv("EDITOR"),
+ "vi",
+ "nano",
+ }
+ editorName, err := c.aerc.CmdFallbackSearch(cmds)
+ if err != nil {
+ c.acct.PushError(fmt.Errorf("could not start editor: %w", err))
+ }
+ editor := exec.Command("/bin/sh", "-c", editorName+" "+c.email.Name())
+ c.editor, err = NewTerminal(editor)
+ if err != nil {
+ return err
+ }
+ c.editor.OnEvent = c.termEvent
+ c.editor.OnClose = c.termClosed
+ c.focusable = append(c.focusable, c.editor)
+ c.review = nil
+ c.updateGrid()
+ if c.editHeaders {
+ c.focusTerminalPriv()
+ }
+ return nil
+}
+
+func (c *Composer) PrevField() {
+ c.Lock()
+ defer c.Unlock()
+ if c.editHeaders && c.editor != nil {
+ return
+ }
+ c.focusActiveWidget(false)
+ c.focused--
+ if c.focused == -1 {
+ c.focused = len(c.focusable) - 1
+ }
+ c.focusActiveWidget(true)
+}
+
+func (c *Composer) NextField() {
+ c.Lock()
+ defer c.Unlock()
+ if c.editHeaders && c.editor != nil {
+ return
+ }
+ c.focusActiveWidget(false)
+ c.focused = (c.focused + 1) % len(c.focusable)
+ c.focusActiveWidget(true)
+}
+
+func (c *Composer) FocusEditor(editor string) {
+ c.Lock()
+ defer c.Unlock()
+ if c.editHeaders && c.editor != nil {
+ return
+ }
+ c.focusEditor(editor)
+}
+
+func (c *Composer) focusEditor(editor string) {
+ editor = strings.ToLower(editor)
+ c.focusActiveWidget(false)
+ for i, f := range c.focusable {
+ e := f.(*headerEditor)
+ if strings.ToLower(e.name) == editor {
+ c.focused = i
+ break
+ }
+ }
+ c.focusActiveWidget(true)
+}
+
+// AddEditor appends a new header editor to the compose window.
+func (c *Composer) AddEditor(header string, value string, appendHeader bool) error {
+ c.Lock()
+ defer c.Unlock()
+ if c.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(header)
+ }
+ 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 {
+ e.storeValue() // flush modifications from the user to the header
+ editor = e
+ } else {
+ uiConfig := c.acct.UiConfig()
+ e := newHeaderEditor(header, c.header, uiConfig)
+ if uiConfig.CompletionPopovers {
+ e.input.TabComplete(
+ c.completer.ForHeader(header),
+ uiConfig.CompletionDelay,
+ uiConfig.CompletionMinChars,
+ )
+ }
+ c.editors[header] = e
+ c.layout = append(c.layout, []string{header})
+ switch {
+ case len(c.focusable) == 0:
+ c.focusable = []ui.MouseableDrawableInteractive{e}
+ case c.editor != nil:
+ // Insert focus of new editor before terminal editor
+ c.focusable = append(
+ c.focusable[:len(c.focusable)-1],
+ e,
+ c.focusable[len(c.focusable)-1],
+ )
+ default:
+ c.focusable = append(
+ c.focusable[:len(c.focusable)-1],
+ e,
+ )
+ }
+ 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()
+ }
+ return value
+}
+
+// DelEditor removes a header editor from the compose window.
+func (c *Composer) DelEditor(header string) error {
+ c.Lock()
+ defer c.Unlock()
+ if c.editHeaders && c.editor != nil {
+ return errors.New("header should be removed directly in the text editor")
+ }
+ c.delEditor(header)
+ c.updateGrid()
+ return nil
+}
+
+func (c *Composer) delEditor(header string) {
+ header = strings.ToLower(header)
+ c.header.Del(header)
+ editor, ok := c.editors[header]
+ if !ok {
+ return
+ }
+
+ 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)
+ }
+ }
+ c.focusable = focusable
+ c.focusActiveWidget(true)
+
+ 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 c.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]
+ },
+ )
+
+ crHeight := 0
+ if c.sign || c.encrypt {
+ crHeight = 1
+ }
+ 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)},
+ })
+
+ borderStyle := c.acct.UiConfig().GetStyle(config.STYLE_BORDER)
+ borderChar := c.acct.UiConfig().BorderCharHorizontal
+ 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 {
+ 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))
+ ui.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":
+ if strings.TrimSpace(val) == "" {
+ // if header is empty, delete it
+ he.header.Del(he.name)
+ return
+ }
+ 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)
+ }
+ default:
+ he.header.SetText(he.name, val)
+ }
+ if strings.ToLower(he.name) == "from" {
+ he.header.Del("message-id")
+ }
+}
+
+func (he *headerEditor) Draw(ctx *ui.Context) {
+ name := textproto.CanonicalMIMEHeaderKey(he.name)
+ // Extra character to put a blank cell between the header and the input
+ size := runewidth.StringWidth(name+":") + 1
+ 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) {
+ if event, ok := event.(*tcell.EventMouse); ok {
+ if event.Buttons() == tcell.Button1 {
+ he.focused = true
+ }
+
+ width := runewidth.StringWidth(he.name + " ")
+ if localX >= width {
+ he.input.MouseEvent(localX-width, localY, event)
+ }
+ }
+}
+
+func (he *headerEditor) Invalidate() {
+ ui.Invalidate()
+}
+
+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()
+ })
+}
+
+func (he *headerEditor) OnFocusLost(fn func()) {
+ he.input.OnFocusLost(func(_ *ui.TextInput) {
+ fn()
+ })
+}
+
+type reviewMessage struct {
+ composer *Composer
+ grid *ui.Grid
+}
+
+func newReviewMessage(composer *Composer, err error) *reviewMessage {
+ bindings := config.Binds.ComposeReview.ForAccount(
+ composer.acctConfig.Name,
+ )
+
+ reviewCommands := [][]string{
+ {":send<enter>", "Send", ""},
+ {":edit<enter>", "Edit", ""},
+ {":attach<space>", "Add attachment", ""},
+ {":detach<space>", "Remove attachment", ""},
+ {":postpone<enter>", "Postpone", ""},
+ {":preview<enter>", "Preview message", ""},
+ {":abort<enter>", "Abort (discard message, no confirmation)", ""},
+ {":choose -o d discard abort -o p postpone postpone<enter>", "Abort or postpone", ""},
+ }
+ knownCommands := len(reviewCommands)
+ var actions []string
+ for _, binding := range bindings.Bindings {
+ inputs := config.FormatKeyStrokes(binding.Input)
+ outputs := config.FormatKeyStrokes(binding.Output)
+ found := false
+ for i, rcmd := range reviewCommands {
+ if outputs == rcmd[0] {
+ found = true
+ if reviewCommands[i][2] == "" {
+ reviewCommands[i][2] = inputs
+ } else {
+ reviewCommands[i][2] += ", " + inputs
+ }
+ break
+ }
+ }
+ if !found {
+ rcmd := []string{outputs, "", inputs}
+ reviewCommands = append(reviewCommands, rcmd)
+ }
+ }
+ unknownCommands := reviewCommands[knownCommands:]
+ sort.Slice(unknownCommands, func(i, j int) bool {
+ return unknownCommands[i][2] < unknownCommands[j][2]
+ })
+
+ longest := 0
+ for _, rcmd := range reviewCommands {
+ if len(rcmd[2]) > longest {
+ longest = len(rcmd[2])
+ }
+ }
+
+ width := longest
+ if longest < 6 {
+ width = 6
+ }
+ widthstr := strconv.Itoa(width)
+
+ for _, rcmd := range reviewCommands {
+ if rcmd[2] != "" {
+ actions = append(actions, fmt.Sprintf(" %-"+widthstr+"s %-40s %s",
+ rcmd[2], rcmd[1], rcmd[0]))
+ }
+ }
+
+ spec := []ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
+ }
+ for i := 0; i < len(actions)-1; i++ {
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
+ }
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(2)})
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
+ for i := 0; i < len(composer.attachments)-1; i++ {
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
+ }
+ if len(composer.textParts) > 0 {
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
+ for i := 0; i < len(composer.textParts); i++ {
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
+ }
+ }
+ // make the last element fill remaining space
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)})
+
+ grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ uiConfig := composer.acct.UiConfig()
+
+ 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 {
+ grid.AddChild(ui.NewText("Send this email?",
+ uiConfig.GetStyle(config.STYLE_TITLE))).At(0, 0)
+ i := 1
+ for _, action := range actions {
+ grid.AddChild(ui.NewText(action,
+ uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
+ i += 1
+ }
+ grid.AddChild(ui.NewText("Attachments:",
+ uiConfig.GetStyle(config.STYLE_TITLE))).At(i, 0)
+ i += 1
+ if len(composer.attachments) == 0 {
+ grid.AddChild(ui.NewText("(none)",
+ uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
+ i += 1
+ } else {
+ for _, a := range composer.attachments {
+ grid.AddChild(ui.NewText(a.Name(), uiConfig.GetStyle(config.STYLE_DEFAULT))).
+ At(i, 0)
+ i += 1
+ }
+ }
+ if len(composer.textParts) > 0 {
+ grid.AddChild(ui.NewText("Parts:",
+ uiConfig.GetStyle(config.STYLE_TITLE))).At(i, 0)
+ i += 1
+ grid.AddChild(ui.NewText("text/plain", uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
+ i += 1
+ for _, p := range composer.textParts {
+ err := composer.updateMultipart(p)
+ if err != nil {
+ msg := fmt.Sprintf("%s error: %s", p.MimeType, err)
+ grid.AddChild(ui.NewText(msg,
+ uiConfig.GetStyle(config.STYLE_ERROR))).At(i, 0)
+ } else {
+ grid.AddChild(ui.NewText(p.MimeType,
+ uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
+ }
+ i += 1
+ }
+
+ }
+ }
+
+ return &reviewMessage{
+ composer: composer,
+ grid: grid,
+ }
+}
+
+func (c *Composer) updateMultipart(p *lib.Part) error {
+ command, found := config.Converters[p.MimeType]
+ if !found {
+ // unreachable
+ return fmt.Errorf("no command defined for mime/type")
+ }
+ // reset part body to avoid it leaving outdated if the command fails
+ p.Data = nil
+ body, err := c.GetBody()
+ if err != nil {
+ return errors.Wrap(err, "GetBody")
+ }
+ cmd := exec.Command("sh", "-c", command)
+ cmd.Stdin = body
+ out, err := cmd.Output()
+ if err != nil {
+ var stderr string
+ var ee *exec.ExitError
+ if errors.As(err, &ee) {
+ // append the first 30 chars of stderr if any
+ stderr = strings.Trim(string(ee.Stderr), " \t\n\r")
+ stderr = strings.ReplaceAll(stderr, "\n", "; ")
+ if stderr != "" {
+ stderr = fmt.Sprintf(": %.30s", stderr)
+ }
+ }
+ return fmt.Errorf("%s: %w%s", command, err, stderr)
+ }
+ p.Data = out
+ return nil
+}
+
+func (rm *reviewMessage) Invalidate() {
+ ui.Invalidate()
+}
+
+func (rm *reviewMessage) Draw(ctx *ui.Context) {
+ rm.grid.Draw(ctx)
+}
+
+type cryptoStatus struct {
+ title string
+ status *ui.Text
+ uiConfig *config.UIConfig
+ signKey string
+ setEncOneShot bool
+}
+
+func newCryptoStatus(uiConfig *config.UIConfig) *cryptoStatus {
+ defaultStyle := uiConfig.GetStyle(config.STYLE_DEFAULT)
+ return &cryptoStatus{
+ title: "Security",
+ status: ui.NewText("", defaultStyle),
+ uiConfig: uiConfig,
+ signKey: "",
+ setEncOneShot: true,
+ }
+}
+
+func (cs *cryptoStatus) Draw(ctx *ui.Context) {
+ // Extra character to put a blank cell between the header and the input
+ size := runewidth.StringWidth(cs.title+":") + 1
+ defaultStyle := cs.uiConfig.GetStyle(config.STYLE_DEFAULT)
+ titleStyle := cs.uiConfig.GetStyle(config.STYLE_HEADER)
+ ctx.Fill(0, 0, size, ctx.Height(), ' ', defaultStyle)
+ ctx.Printf(0, 0, titleStyle, "%s:", cs.title)
+ cs.status.Draw(ctx.Subcontext(size, 0, ctx.Width()-size, 1))
+}
+
+func (cs *cryptoStatus) Invalidate() {
+ ui.Invalidate()
+}
+
+func (c *Composer) checkEncryptionKeys(_ string) bool {
+ rcpts, err := getRecipientsEmail(c)
+ if err != nil {
+ // checkEncryptionKeys gets registered as a callback and must
+ // explicitly call c.SetEncrypt(false) when encryption is not possible
+ c.SetEncrypt(false)
+ st := fmt.Sprintf("Cannot encrypt: %v", err)
+ c.aerc.statusline.PushError(st)
+ return false
+ }
+ var mk []string
+ for _, rcpt := range rcpts {
+ key, err := c.aerc.Crypto.GetKeyId(rcpt)
+ if err != nil || key == "" {
+ mk = append(mk, rcpt)
+ }
+ }
+
+ encrypt := true
+ switch {
+ case len(mk) > 0:
+ c.SetEncrypt(false)
+ st := fmt.Sprintf("Cannot encrypt, missing keys: %s", strings.Join(mk, ", "))
+ if c.Config().PgpOpportunisticEncrypt {
+ switch c.Config().PgpErrorLevel {
+ case config.PgpErrorLevelWarn:
+ c.aerc.statusline.PushWarning(st)
+ return false
+ case config.PgpErrorLevelNone:
+ return false
+ case config.PgpErrorLevelError:
+ // Continue to the default
+ }
+ }
+ c.aerc.statusline.PushError(st)
+ encrypt = false
+ case len(rcpts) == 0:
+ encrypt = false
+ }
+
+ // If callbacks were registered, encrypt will be set when user removes
+ // recipients with missing keys
+ c.encrypt = encrypt
+ err = c.updateCrypto()
+ if err != nil {
+ log.Warnf("failed update crypto: %v", err)
+ }
+ return true
+}
+
+// setTitle executes the title template and sets the tab title
+func (c *Composer) setTitle() {
+ if c.Tab == nil {
+ return
+ }
+
+ header := c.header.Copy()
+ // Get subject direct from the textinput
+ subject, ok := c.editors["subject"]
+ if ok {
+ header.SetSubject(subject.input.String())
+ }
+ if header.Get("subject") == "" {
+ header.SetSubject("New Email")
+ }
+
+ data := state.NewDataSetter()
+ data.SetAccount(c.acctConfig)
+ data.SetFolder(c.acct.Directories().SelectedDirectory())
+ data.SetHeaders(&header, c.parent)
+
+ var buf bytes.Buffer
+ err := templates.Render(c.acct.UiConfig().TabTitleComposer, &buf,
+ data.Data())
+ if err != nil {
+ c.acct.PushError(err)
+ return
+ }
+ c.Tab.SetTitle(buf.String())
+}
diff --git a/app/dialog.go b/app/dialog.go
new file mode 100644
index 00000000..8ec6405a
--- /dev/null
+++ b/app/dialog.go
@@ -0,0 +1,24 @@
+package app
+
+import (
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+)
+
+type Dialog interface {
+ ui.DrawableInteractive
+ ContextHeight() (func(int) int, func(int) int)
+}
+
+type dialog struct {
+ ui.DrawableInteractive
+ y func(int) int
+ h func(int) int
+}
+
+func (d *dialog) ContextHeight() (func(int) int, func(int) int) {
+ return d.y, d.h
+}
+
+func NewDialog(d ui.DrawableInteractive, y func(int) int, h func(int) int) Dialog {
+ return &dialog{DrawableInteractive: d, y: y, h: h}
+}
diff --git a/app/dirlist.go b/app/dirlist.go
new file mode 100644
index 00000000..d77ace42
--- /dev/null
+++ b/app/dirlist.go
@@ -0,0 +1,532 @@
+package app
+
+import (
+ "bytes"
+ "context"
+ "math"
+ "regexp"
+ "sort"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/parse"
+ "git.sr.ht/~rjarry/aerc/lib/state"
+ "git.sr.ht/~rjarry/aerc/lib/templates"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rjarry/aerc/worker/types"
+)
+
+type DirectoryLister interface {
+ ui.Drawable
+
+ Selected() string
+ Select(string)
+
+ Update(types.WorkerMessage)
+ List() []string
+ ClearList()
+
+ OnVirtualNode(func())
+
+ NextPrev(int)
+
+ CollapseFolder()
+ ExpandFolder()
+
+ SelectedMsgStore() (*lib.MessageStore, bool)
+ MsgStore(string) (*lib.MessageStore, bool)
+ SelectedDirectory() *models.Directory
+ Directory(string) *models.Directory
+ SetMsgStore(*models.Directory, *lib.MessageStore)
+
+ FilterDirs([]string, []string, bool) []string
+ GetRUECount(string) (int, int, int)
+
+ UiConfig(string) *config.UIConfig
+}
+
+type DirectoryList struct {
+ Scrollable
+ acctConf *config.AccountConfig
+ store *lib.DirStore
+ dirs []string
+ selecting string
+ selected string
+ spinner *Spinner
+ worker *types.Worker
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+func NewDirectoryList(acctConf *config.AccountConfig,
+ worker *types.Worker,
+) DirectoryLister {
+ ctx, cancel := context.WithCancel(context.Background())
+
+ dirlist := &DirectoryList{
+ acctConf: acctConf,
+ store: lib.NewDirStore(),
+ worker: worker,
+ ctx: ctx,
+ cancel: cancel,
+ }
+ uiConf := dirlist.UiConfig("")
+ dirlist.spinner = NewSpinner(uiConf)
+ dirlist.spinner.Start()
+
+ if uiConf.DirListTree {
+ return NewDirectoryTree(dirlist)
+ }
+
+ return dirlist
+}
+
+func (dirlist *DirectoryList) UiConfig(dir string) *config.UIConfig {
+ if dir == "" {
+ dir = dirlist.Selected()
+ }
+ return config.Ui.ForAccount(dirlist.acctConf.Name).ForFolder(dir)
+}
+
+func (dirlist *DirectoryList) List() []string {
+ return dirlist.dirs
+}
+
+func (dirlist *DirectoryList) ClearList() {
+ dirlist.dirs = []string{}
+}
+
+func (dirlist *DirectoryList) OnVirtualNode(_ func()) {
+}
+
+func (dirlist *DirectoryList) Update(msg types.WorkerMessage) {
+ switch msg := msg.(type) {
+ case *types.Done:
+ switch msg := msg.InResponseTo().(type) {
+ case *types.OpenDirectory:
+ dirlist.selected = msg.Directory
+ dirlist.filterDirsByFoldersConfig()
+ hasSelected := false
+ for _, d := range dirlist.dirs {
+ if d == dirlist.selected {
+ hasSelected = true
+ break
+ }
+ }
+ if !hasSelected && dirlist.selected != "" {
+ dirlist.dirs = append(dirlist.dirs, dirlist.selected)
+ }
+ if dirlist.acctConf.EnableFoldersSort {
+ sort.Strings(dirlist.dirs)
+ }
+ dirlist.sortDirsByFoldersSortConfig()
+ store, ok := dirlist.SelectedMsgStore()
+ if !ok {
+ return
+ }
+ store.SetContext(msg.Context)
+ case *types.ListDirectories:
+ dirlist.filterDirsByFoldersConfig()
+ dirlist.sortDirsByFoldersSortConfig()
+ dirlist.spinner.Stop()
+ dirlist.Invalidate()
+ case *types.RemoveDirectory:
+ dirlist.store.Remove(msg.Directory)
+ dirlist.filterDirsByFoldersConfig()
+ dirlist.sortDirsByFoldersSortConfig()
+ case *types.CreateDirectory:
+ dirlist.filterDirsByFoldersConfig()
+ dirlist.sortDirsByFoldersSortConfig()
+ dirlist.Invalidate()
+ }
+ case *types.DirectoryInfo:
+ dir := dirlist.Directory(msg.Info.Name)
+ if dir == nil {
+ return
+ }
+ dir.Exists = msg.Info.Exists
+ dir.Recent = msg.Info.Recent
+ dir.Unseen = msg.Info.Unseen
+ if msg.Refetch {
+ store, ok := dirlist.SelectedMsgStore()
+ if ok {
+ store.Sort(store.GetCurrentSortCriteria(), nil)
+ }
+ }
+ default:
+ return
+ }
+}
+
+func (dirlist *DirectoryList) CollapseFolder() {
+ // no effect for the DirectoryList
+}
+
+func (dirlist *DirectoryList) ExpandFolder() {
+ // no effect for the DirectoryList
+}
+
+func (dirlist *DirectoryList) Select(name string) {
+ dirlist.selecting = name
+
+ dirlist.cancel()
+ dirlist.ctx, dirlist.cancel = context.WithCancel(context.Background())
+ delay := dirlist.UiConfig(name).DirListDelay
+
+ go func(ctx context.Context) {
+ defer log.PanicHandler()
+
+ select {
+ case <-time.After(delay):
+ dirlist.worker.PostAction(&types.OpenDirectory{
+ Context: ctx,
+ Directory: name,
+ },
+ func(msg types.WorkerMessage) {
+ switch msg := msg.(type) {
+ case *types.Error:
+ dirlist.selecting = ""
+ log.Errorf("(%s) couldn't open directory %s: %v",
+ dirlist.acctConf.Name,
+ name,
+ msg.Error)
+ case *types.Cancelled:
+ log.Debugf("OpenDirectory cancelled")
+ }
+ })
+ case <-ctx.Done():
+ log.Tracef("dirlist: skip %s", name)
+ return
+ }
+ }(dirlist.ctx)
+}
+
+func (dirlist *DirectoryList) Selected() string {
+ return dirlist.selected
+}
+
+func (dirlist *DirectoryList) Invalidate() {
+ ui.Invalidate()
+}
+
+// Returns the Recent, Unread, and Exist counts for the named directory
+func (dirlist *DirectoryList) GetRUECount(name string) (int, int, int) {
+ dir := dirlist.Directory(name)
+ if dir == nil {
+ return 0, 0, 0
+ }
+ return dir.Recent, dir.Unseen, dir.Exists
+}
+
+func (dirlist *DirectoryList) Draw(ctx *ui.Context) {
+ uiConfig := dirlist.UiConfig("")
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
+ uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT))
+
+ if dirlist.spinner.IsRunning() {
+ dirlist.spinner.Draw(ctx)
+ return
+ }
+
+ if len(dirlist.dirs) == 0 {
+ style := uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT)
+ ctx.Printf(0, 0, style, uiConfig.EmptyDirlist)
+ return
+ }
+
+ dirlist.UpdateScroller(ctx.Height(), len(dirlist.dirs))
+ dirlist.EnsureScroll(findString(dirlist.dirs, dirlist.selecting))
+
+ textWidth := ctx.Width()
+ if dirlist.NeedScrollbar() {
+ textWidth -= 1
+ }
+ if textWidth < 0 {
+ return
+ }
+
+ listCtx := ctx.Subcontext(0, 0, textWidth, ctx.Height())
+
+ data := state.NewDataSetter()
+ data.SetAccount(dirlist.acctConf)
+
+ for i, name := range dirlist.dirs {
+ if i < dirlist.Scroll() {
+ continue
+ }
+ row := i - dirlist.Scroll()
+ if row >= ctx.Height() {
+ break
+ }
+
+ data.SetFolder(dirlist.Directory(name))
+ data.SetRUE([]string{name}, dirlist.GetRUECount)
+ left, right, style := dirlist.renderDir(
+ name, uiConfig, data.Data(),
+ name == dirlist.selecting, listCtx.Width(),
+ )
+ listCtx.Printf(0, row, style, "%s %s", left, right)
+ }
+
+ if dirlist.NeedScrollbar() {
+ scrollBarCtx := ctx.Subcontext(ctx.Width()-1, 0, 1, ctx.Height())
+ dirlist.drawScrollbar(scrollBarCtx)
+ }
+}
+
+func (dirlist *DirectoryList) renderDir(
+ path string, conf *config.UIConfig, data models.TemplateData,
+ selected bool, width int,
+) (string, string, tcell.Style) {
+ var left, right string
+ var buf bytes.Buffer
+
+ var styles []config.StyleObject
+ var style tcell.Style
+
+ r, u, _ := dirlist.GetRUECount(path)
+ if u > 0 {
+ styles = append(styles, config.STYLE_DIRLIST_UNREAD)
+ }
+ if r > 0 {
+ styles = append(styles, config.STYLE_DIRLIST_RECENT)
+ }
+ conf = conf.ForFolder(path)
+ if selected {
+ style = conf.GetComposedStyleSelected(
+ config.STYLE_DIRLIST_DEFAULT, styles)
+ } else {
+ style = conf.GetComposedStyle(
+ config.STYLE_DIRLIST_DEFAULT, styles)
+ }
+
+ err := templates.Render(conf.DirListLeft, &buf, data)
+ if err != nil {
+ log.Errorf("dirlist-left: %s", err)
+ left = err.Error()
+ style = conf.GetStyle(config.STYLE_ERROR)
+ } else {
+ left = buf.String()
+ }
+ buf.Reset()
+ err = templates.Render(conf.DirListRight, &buf, data)
+ if err != nil {
+ log.Errorf("dirlist-right: %s", err)
+ right = err.Error()
+ style = conf.GetStyle(config.STYLE_ERROR)
+ } else {
+ right = buf.String()
+ }
+ buf.Reset()
+
+ lbuf := parse.ParseANSI(left)
+ lbuf.ApplyAttrs(style)
+ lwidth := lbuf.Len()
+ rbuf := parse.ParseANSI(right)
+ rbuf.ApplyAttrs(style)
+ rwidth := rbuf.Len()
+
+ if lwidth+rwidth+1 > width {
+ if rwidth > 3*width/4 {
+ rwidth = 3 * width / 4
+ }
+ lwidth = width - rwidth - 1
+ right = rbuf.TruncateHead(rwidth, '…')
+ left = lbuf.Truncate(lwidth-1, '…')
+ } else {
+ for i := 0; i < (width - lwidth - rwidth - 1); i += 1 {
+ lbuf.Write(' ', tcell.StyleDefault)
+ }
+ left = lbuf.String()
+ right = rbuf.String()
+ }
+
+ return left, right, style
+}
+
+func (dirlist *DirectoryList) drawScrollbar(ctx *ui.Context) {
+ gutterStyle := tcell.StyleDefault
+ pillStyle := tcell.StyleDefault.Reverse(true)
+
+ // gutter
+ ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle)
+
+ // pill
+ pillSize := int(math.Ceil(float64(ctx.Height()) * dirlist.PercentVisible()))
+ pillOffset := int(math.Floor(float64(ctx.Height()) * dirlist.PercentScrolled()))
+ ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
+}
+
+func (dirlist *DirectoryList) MouseEvent(localX int, localY int, event tcell.Event) {
+ if event, ok := event.(*tcell.EventMouse); ok {
+ switch event.Buttons() {
+ case tcell.Button1:
+ clickedDir, ok := dirlist.Clicked(localX, localY)
+ if ok {
+ dirlist.Select(clickedDir)
+ }
+ case tcell.WheelDown:
+ dirlist.Next()
+ case tcell.WheelUp:
+ dirlist.Prev()
+ }
+ }
+}
+
+func (dirlist *DirectoryList) Clicked(x int, y int) (string, bool) {
+ if dirlist.dirs == nil || len(dirlist.dirs) == 0 {
+ return "", false
+ }
+ for i, name := range dirlist.dirs {
+ if i == y {
+ return name, true
+ }
+ }
+ return "", false
+}
+
+func (dirlist *DirectoryList) NextPrev(delta int) {
+ curIdx := findString(dirlist.dirs, dirlist.selecting)
+ if curIdx == len(dirlist.dirs) {
+ return
+ }
+ newIdx := curIdx + delta
+ ndirs := len(dirlist.dirs)
+
+ if ndirs == 0 {
+ return
+ }
+
+ if newIdx < 0 {
+ newIdx = ndirs - 1
+ } else if newIdx >= ndirs {
+ newIdx = 0
+ }
+
+ dirlist.Select(dirlist.dirs[newIdx])
+}
+
+func (dirlist *DirectoryList) Next() {
+ dirlist.NextPrev(1)
+}
+
+func (dirlist *DirectoryList) Prev() {
+ dirlist.NextPrev(-1)
+}
+
+func folderMatches(folder string, pattern string) bool {
+ if len(pattern) == 0 {
+ return false
+ }
+ if pattern[0] == '~' {
+ r, err := regexp.Compile(pattern[1:])
+ if err != nil {
+ return false
+ }
+ return r.Match([]byte(folder))
+ }
+ return pattern == folder
+}
+
+// sortDirsByFoldersSortConfig sets dirlist.dirs to be sorted based on the
+// AccountConfig.FoldersSort option. Folders not included in the option
+// will be appended at the end in alphabetical order
+func (dirlist *DirectoryList) sortDirsByFoldersSortConfig() {
+ if !dirlist.acctConf.EnableFoldersSort {
+ return
+ }
+
+ sort.Slice(dirlist.dirs, func(i, j int) bool {
+ foldersSort := dirlist.acctConf.FoldersSort
+ iInFoldersSort := findString(foldersSort, dirlist.dirs[i])
+ jInFoldersSort := findString(foldersSort, dirlist.dirs[j])
+ if iInFoldersSort >= 0 && jInFoldersSort >= 0 {
+ return iInFoldersSort < jInFoldersSort
+ }
+ if iInFoldersSort >= 0 {
+ return true
+ }
+ if jInFoldersSort >= 0 {
+ return false
+ }
+ return dirlist.dirs[i] < dirlist.dirs[j]
+ })
+}
+
+// filterDirsByFoldersConfig sets dirlist.dirs to the filtered subset of the
+// dirstore, based on AccountConfig.Folders (inclusion) and
+// AccountConfig.FoldersExclude (exclusion), in that order.
+func (dirlist *DirectoryList) filterDirsByFoldersConfig() {
+ dirlist.dirs = dirlist.store.List()
+
+ // 'folders' (if available) is used to make the initial list and
+ // 'folders-exclude' removes from that list.
+ configFolders := dirlist.acctConf.Folders
+ dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFolders, false)
+
+ configFoldersExclude := dirlist.acctConf.FoldersExclude
+ dirlist.dirs = dirlist.FilterDirs(dirlist.dirs, configFoldersExclude, true)
+}
+
+// FilterDirs filters directories by the supplied filter. If exclude is false,
+// the filter will only include directories from orig which exist in filters.
+// If exclude is true, the directories in filters are removed from orig
+func (dirlist *DirectoryList) FilterDirs(orig, filters []string, exclude bool) []string {
+ if len(filters) == 0 {
+ return orig
+ }
+ var dest []string
+ for _, folder := range orig {
+ // When excluding, include things by default, and vice-versa
+ include := exclude
+ for _, f := range filters {
+ if folderMatches(folder, f) {
+ // If matched an exclusion, don't include
+ // If matched an inclusion, do include
+ include = !exclude
+ break
+ }
+ }
+ if include {
+ dest = append(dest, folder)
+ }
+ }
+ return dest
+}
+
+func (dirlist *DirectoryList) SelectedMsgStore() (*lib.MessageStore, bool) {
+ return dirlist.store.MessageStore(dirlist.selected)
+}
+
+func (dirlist *DirectoryList) MsgStore(name string) (*lib.MessageStore, bool) {
+ return dirlist.store.MessageStore(name)
+}
+
+func (dirlist *DirectoryList) SelectedDirectory() *models.Directory {
+ return dirlist.store.Directory(dirlist.selected)
+}
+
+func (dirlist *DirectoryList) Directory(name string) *models.Directory {
+ return dirlist.store.Directory(name)
+}
+
+func (dirlist *DirectoryList) SetMsgStore(dir *models.Directory, msgStore *lib.MessageStore) {
+ dirlist.store.SetMessageStore(dir, msgStore)
+ msgStore.OnUpdateDirs(func() {
+ dirlist.Invalidate()
+ })
+}
+
+func findString(slice []string, str string) int {
+ for i, s := range slice {
+ if str == s {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/app/dirtree.go b/app/dirtree.go
new file mode 100644
index 00000000..24e776d8
--- /dev/null
+++ b/app/dirtree.go
@@ -0,0 +1,495 @@
+package app
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/state"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rjarry/aerc/worker/types"
+ "github.com/gdamore/tcell/v2"
+)
+
+type DirectoryTree struct {
+ *DirectoryList
+
+ listIdx int
+ list []*types.Thread
+
+ treeDirs []string
+
+ virtual bool
+ virtualCb func()
+}
+
+func NewDirectoryTree(dirlist *DirectoryList) DirectoryLister {
+ dt := &DirectoryTree{
+ DirectoryList: dirlist,
+ listIdx: -1,
+ list: make([]*types.Thread, 0),
+ virtualCb: func() {},
+ }
+ return dt
+}
+
+func (dt *DirectoryTree) OnVirtualNode(cb func()) {
+ dt.virtualCb = cb
+}
+
+func (dt *DirectoryTree) ClearList() {
+ dt.list = make([]*types.Thread, 0)
+}
+
+func (dt *DirectoryTree) Update(msg types.WorkerMessage) {
+ switch msg := msg.(type) {
+
+ case *types.Done:
+ switch msg.InResponseTo().(type) {
+ case *types.RemoveDirectory, *types.ListDirectories, *types.CreateDirectory:
+ dt.DirectoryList.Update(msg)
+ dt.buildTree()
+ dt.Invalidate()
+ default:
+ dt.DirectoryList.Update(msg)
+ }
+ default:
+ dt.DirectoryList.Update(msg)
+ }
+}
+
+func (dt *DirectoryTree) Draw(ctx *ui.Context) {
+ uiConfig := dt.UiConfig("")
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
+ uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT))
+
+ if dt.DirectoryList.spinner.IsRunning() {
+ dt.DirectoryList.spinner.Draw(ctx)
+ return
+ }
+
+ n := dt.countVisible(dt.list)
+ if n == 0 || dt.listIdx < 0 {
+ style := uiConfig.GetStyle(config.STYLE_DIRLIST_DEFAULT)
+ ctx.Printf(0, 0, style, uiConfig.EmptyDirlist)
+ return
+ }
+
+ dt.UpdateScroller(ctx.Height(), n)
+ dt.EnsureScroll(dt.countVisible(dt.list[:dt.listIdx]))
+
+ needScrollbar := true
+ percentVisible := float64(ctx.Height()) / float64(n)
+ if percentVisible >= 1.0 {
+ needScrollbar = false
+ }
+
+ textWidth := ctx.Width()
+ if needScrollbar {
+ textWidth -= 1
+ }
+ if textWidth < 0 {
+ return
+ }
+
+ treeCtx := ctx.Subcontext(0, 0, textWidth, ctx.Height())
+
+ data := state.NewDataSetter()
+ data.SetAccount(dt.acctConf)
+
+ n = 0
+ for i, node := range dt.list {
+ if n > treeCtx.Height() {
+ break
+ }
+ rowNr := dt.countVisible(dt.list[:i])
+ if rowNr < dt.Scroll() || !isVisible(node) {
+ continue
+ }
+
+ path := dt.getDirectory(node)
+ dir := dt.Directory(path)
+ treeDir := &models.Directory{
+ Name: dt.displayText(node),
+ }
+ if dir != nil {
+ treeDir.Role = dir.Role
+ }
+ data.SetFolder(treeDir)
+ data.SetRUE([]string{path}, dt.GetRUECount)
+
+ left, right, style := dt.renderDir(
+ path, uiConfig, data.Data(),
+ i == dt.listIdx, treeCtx.Width(),
+ )
+
+ treeCtx.Printf(0, n, style, "%s %s", left, right)
+ n++
+ }
+
+ if dt.NeedScrollbar() {
+ scrollBarCtx := ctx.Subcontext(ctx.Width()-1, 0, 1, ctx.Height())
+ dt.drawScrollbar(scrollBarCtx)
+ }
+}
+
+func (dt *DirectoryTree) MouseEvent(localX int, localY int, event tcell.Event) {
+ if event, ok := event.(*tcell.EventMouse); ok {
+ switch event.Buttons() {
+ case tcell.Button1:
+ clickedDir, ok := dt.Clicked(localX, localY)
+ if ok {
+ dt.Select(clickedDir)
+ }
+ case tcell.WheelDown:
+ dt.NextPrev(1)
+ case tcell.WheelUp:
+ dt.NextPrev(-1)
+ }
+ }
+}
+
+func (dt *DirectoryTree) Clicked(x int, y int) (string, bool) {
+ if dt.list == nil || len(dt.list) == 0 || dt.countVisible(dt.list) < y+dt.Scroll() {
+ return "", false
+ }
+ visible := 0
+ for _, node := range dt.list {
+ if isVisible(node) {
+ visible++
+ }
+ if visible == y+dt.Scroll()+1 {
+ if path := dt.getDirectory(node); path != "" {
+ return path, true
+ }
+ node.Hidden = !node.Hidden
+ dt.Invalidate()
+ return "", false
+ }
+ }
+ return "", false
+}
+
+func (dt *DirectoryTree) SelectedMsgStore() (*lib.MessageStore, bool) {
+ if dt.virtual {
+ return nil, false
+ }
+ if findString(dt.treeDirs, dt.selected) < 0 {
+ dt.buildTree()
+ if idx := findString(dt.treeDirs, dt.selected); idx >= 0 {
+ selIdx, node := dt.getTreeNode(uint32(idx))
+ if node != nil {
+ makeVisible(node)
+ dt.listIdx = selIdx
+ }
+ }
+ }
+ return dt.DirectoryList.SelectedMsgStore()
+}
+
+func (dt *DirectoryTree) Select(name string) {
+ idx := findString(dt.treeDirs, name)
+ if idx >= 0 {
+ selIdx, node := dt.getTreeNode(uint32(idx))
+ if node != nil {
+ makeVisible(node)
+ dt.listIdx = selIdx
+ }
+ }
+
+ if name == "" {
+ return
+ }
+
+ dt.DirectoryList.Select(name)
+}
+
+func (dt *DirectoryTree) NextPrev(delta int) {
+ newIdx := dt.listIdx
+ ndirs := len(dt.list)
+ if newIdx == ndirs {
+ return
+ }
+
+ if ndirs == 0 {
+ return
+ }
+
+ step := 1
+ if delta < 0 {
+ step = -1
+ delta *= -1
+ }
+
+ for i := 0; i < delta; {
+ newIdx += step
+ if newIdx < 0 {
+ newIdx = ndirs - 1
+ } else if newIdx >= ndirs {
+ newIdx = 0
+ }
+ if isVisible(dt.list[newIdx]) {
+ i++
+ }
+ }
+
+ dt.listIdx = newIdx
+ if path := dt.getDirectory(dt.list[dt.listIdx]); path != "" {
+ dt.virtual = false
+ dt.Select(path)
+ } else {
+ dt.virtual = true
+ dt.virtualCb()
+ }
+}
+
+func (dt *DirectoryTree) CollapseFolder() {
+ if dt.listIdx >= 0 && dt.listIdx < len(dt.list) {
+ if node := dt.list[dt.listIdx]; node != nil {
+ if node.Parent != nil && (node.Hidden || node.FirstChild == nil) {
+ node.Parent.Hidden = true
+ // highlight parent node and select it
+ for i, t := range dt.list {
+ if t == node.Parent {
+ dt.listIdx = i
+ if path := dt.getDirectory(dt.list[dt.listIdx]); path != "" {
+ dt.Select(path)
+ }
+ }
+ }
+ } else {
+ node.Hidden = true
+ }
+ dt.Invalidate()
+ }
+ }
+}
+
+func (dt *DirectoryTree) ExpandFolder() {
+ if dt.listIdx >= 0 && dt.listIdx < len(dt.list) {
+ dt.list[dt.listIdx].Hidden = false
+ dt.Invalidate()
+ }
+}
+
+func (dt *DirectoryTree) countVisible(list []*types.Thread) (n int) {
+ for _, node := range list {
+ if isVisible(node) {
+ n++
+ }
+ }
+ return
+}
+
+func (dt *DirectoryTree) displayText(node *types.Thread) string {
+ elems := strings.Split(dt.treeDirs[getAnyUid(node)], dt.DirectoryList.worker.PathSeparator())
+ return fmt.Sprintf("%s%s%s", threadPrefix(node, false, false), getFlag(node), elems[countLevels(node)])
+}
+
+func (dt *DirectoryTree) getDirectory(node *types.Thread) string {
+ if uid := node.Uid; int(uid) < len(dt.treeDirs) {
+ return dt.treeDirs[uid]
+ }
+ return ""
+}
+
+func (dt *DirectoryTree) getTreeNode(uid uint32) (int, *types.Thread) {
+ var found *types.Thread
+ var idx int
+ for i, node := range dt.list {
+ if node.Uid == uid {
+ found = node
+ idx = i
+ }
+ }
+ return idx, found
+}
+
+func (dt *DirectoryTree) hiddenDirectories() map[string]bool {
+ hidden := make(map[string]bool, 0)
+ for _, node := range dt.list {
+ if node.Hidden && node.FirstChild != nil {
+ elems := strings.Split(dt.treeDirs[getAnyUid(node)], dt.DirectoryList.worker.PathSeparator())
+ if levels := countLevels(node); levels < len(elems) {
+ if node.FirstChild != nil && (levels+1) < len(elems) {
+ levels += 1
+ }
+ if dirStr := strings.Join(elems[:levels], dt.DirectoryList.worker.PathSeparator()); dirStr != "" {
+ hidden[dirStr] = true
+ }
+ }
+ }
+ }
+ return hidden
+}
+
+func (dt *DirectoryTree) setHiddenDirectories(hiddenDirs map[string]bool) {
+ for _, node := range dt.list {
+ elems := strings.Split(dt.treeDirs[getAnyUid(node)], dt.DirectoryList.worker.PathSeparator())
+ if levels := countLevels(node); levels < len(elems) {
+ if node.FirstChild != nil && (levels+1) < len(elems) {
+ levels += 1
+ }
+ strDir := strings.Join(elems[:levels], dt.DirectoryList.worker.PathSeparator())
+ if hidden, ok := hiddenDirs[strDir]; hidden && ok {
+ node.Hidden = true
+ }
+ }
+ }
+}
+
+func (dt *DirectoryTree) buildTree() {
+ if len(dt.list) != 0 {
+ hiddenDirs := dt.hiddenDirectories()
+ defer func() {
+ dt.setHiddenDirectories(hiddenDirs)
+ }()
+ }
+
+ sTree := make([][]string, 0)
+ for i, dir := range dt.dirs {
+ elems := strings.Split(dir, dt.DirectoryList.worker.PathSeparator())
+ if len(elems) == 0 {
+ continue
+ }
+ elems = append(elems, fmt.Sprintf("%d", i))
+ sTree = append(sTree, elems)
+ }
+
+ dt.treeDirs = make([]string, len(dt.dirs))
+ copy(dt.treeDirs, dt.dirs)
+
+ root := &types.Thread{Uid: 0}
+ dt.buildTreeNode(root, sTree, 0xFFFFFF, 1)
+
+ threads := make([]*types.Thread, 0)
+
+ for iter := root.FirstChild; iter != nil; iter = iter.NextSibling {
+ iter.Parent = nil
+ threads = append(threads, iter)
+ }
+
+ // folders-sort
+ if dt.DirectoryList.acctConf.EnableFoldersSort {
+ toStr := func(t *types.Thread) string {
+ if elems := strings.Split(dt.treeDirs[getAnyUid(t)], dt.DirectoryList.worker.PathSeparator()); len(elems) > 0 {
+ return elems[0]
+ }
+ return ""
+ }
+ sort.Slice(threads, func(i, j int) bool {
+ foldersSort := dt.DirectoryList.acctConf.FoldersSort
+ iInFoldersSort := findString(foldersSort, toStr(threads[i]))
+ jInFoldersSort := findString(foldersSort, toStr(threads[j]))
+ if iInFoldersSort >= 0 && jInFoldersSort >= 0 {
+ return iInFoldersSort < jInFoldersSort
+ }
+ if iInFoldersSort >= 0 {
+ return true
+ }
+ if jInFoldersSort >= 0 {
+ return false
+ }
+ return toStr(threads[i]) < toStr(threads[j])
+ })
+ }
+
+ dt.list = make([]*types.Thread, 0)
+ for _, node := range threads {
+ err := node.Walk(func(t *types.Thread, lvl int, err error) error {
+ dt.list = append(dt.list, t)
+ return nil
+ })
+ if err != nil {
+ log.Warnf("failed to walk tree: %v", err)
+ }
+ }
+}
+
+func (dt *DirectoryTree) buildTreeNode(node *types.Thread, stree [][]string, defaultUid uint32, depth int) {
+ m := make(map[string][][]string)
+ for _, branch := range stree {
+ if len(branch) > 1 {
+ next := append(m[branch[0]], branch[1:]) //nolint:gocritic // intentional append to different slice
+ m[branch[0]] = next
+ }
+ }
+ keys := make([]string, 0)
+ for key := range m {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ path := dt.getDirectory(node)
+ for _, key := range keys {
+ next := m[key]
+ var uid uint32 = defaultUid
+ for _, testStr := range next {
+ if len(testStr) == 1 {
+ if uidI, err := strconv.Atoi(next[0][0]); err == nil {
+ uid = uint32(uidI)
+ }
+ }
+ }
+ nextNode := &types.Thread{Uid: uid}
+ node.AddChild(nextNode)
+ if dt.UiConfig(path).DirListCollapse != 0 {
+ node.Hidden = depth > dt.UiConfig(path).DirListCollapse
+ }
+ dt.buildTreeNode(nextNode, next, defaultUid, depth+1)
+ }
+}
+
+func makeVisible(node *types.Thread) {
+ if node == nil {
+ return
+ }
+ for iter := node.Parent; iter != nil; iter = iter.Parent {
+ iter.Hidden = false
+ }
+}
+
+func isVisible(node *types.Thread) bool {
+ isVisible := true
+ for iter := node.Parent; iter != nil; iter = iter.Parent {
+ if iter.Hidden {
+ isVisible = false
+ break
+ }
+ }
+ return isVisible
+}
+
+func getAnyUid(node *types.Thread) (uid uint32) {
+ err := node.Walk(func(t *types.Thread, l int, err error) error {
+ if t.FirstChild == nil {
+ uid = t.Uid
+ }
+ return nil
+ })
+ if err != nil {
+ log.Warnf("failed to get uid: %v", err)
+ }
+ return
+}
+
+func countLevels(node *types.Thread) (level int) {
+ for iter := node.Parent; iter != nil; iter = iter.Parent {
+ level++
+ }
+ return
+}
+
+func getFlag(node *types.Thread) string {
+ if node == nil && node.FirstChild == nil {
+ return ""
+ }
+ if node.Hidden {
+ return "+"
+ }
+ return ""
+}
diff --git a/app/exline.go b/app/exline.go
new file mode 100644
index 00000000..b941b100
--- /dev/null
+++ b/app/exline.go
@@ -0,0 +1,120 @@
+package app
+
+import (
+ "github.com/gdamore/tcell/v2"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+)
+
+type ExLine struct {
+ commit func(cmd string)
+ finish func()
+ tabcomplete func(cmd string) ([]string, string)
+ cmdHistory lib.History
+ input *ui.TextInput
+}
+
+func NewExLine(cmd string, commit func(cmd string), finish func(),
+ tabcomplete func(cmd string) ([]string, string),
+ cmdHistory lib.History,
+) *ExLine {
+ input := ui.NewTextInput("", config.Ui).Prompt(":").Set(cmd)
+ if config.Ui.CompletionPopovers {
+ input.TabComplete(
+ tabcomplete,
+ config.Ui.CompletionDelay,
+ config.Ui.CompletionMinChars,
+ )
+ }
+ exline := &ExLine{
+ commit: commit,
+ finish: finish,
+ tabcomplete: tabcomplete,
+ cmdHistory: cmdHistory,
+ input: input,
+ }
+ return exline
+}
+
+func (x *ExLine) TabComplete(tabComplete func(string) ([]string, string)) {
+ x.input.TabComplete(
+ tabComplete,
+ config.Ui.CompletionDelay,
+ config.Ui.CompletionMinChars,
+ )
+}
+
+func NewPrompt(prompt string, commit func(text string),
+ tabcomplete func(cmd string) ([]string, string),
+) *ExLine {
+ input := ui.NewTextInput("", config.Ui).Prompt(prompt)
+ if config.Ui.CompletionPopovers {
+ input.TabComplete(
+ tabcomplete,
+ config.Ui.CompletionDelay,
+ config.Ui.CompletionMinChars,
+ )
+ }
+ exline := &ExLine{
+ commit: commit,
+ tabcomplete: tabcomplete,
+ cmdHistory: &nullHistory{input: input},
+ input: input,
+ }
+ return exline
+}
+
+func (ex *ExLine) Invalidate() {
+ ui.Invalidate()
+}
+
+func (ex *ExLine) Draw(ctx *ui.Context) {
+ ex.input.Draw(ctx)
+}
+
+func (ex *ExLine) Focus(focus bool) {
+ ex.input.Focus(focus)
+}
+
+func (ex *ExLine) Event(event tcell.Event) bool {
+ if event, ok := event.(*tcell.EventKey); ok {
+ switch event.Key() {
+ case tcell.KeyEnter, tcell.KeyCtrlJ:
+ cmd := ex.input.String()
+ ex.input.Focus(false)
+ ex.commit(cmd)
+ ex.finish()
+ case tcell.KeyUp:
+ ex.input.Set(ex.cmdHistory.Prev())
+ ex.Invalidate()
+ case tcell.KeyDown:
+ ex.input.Set(ex.cmdHistory.Next())
+ ex.Invalidate()
+ case tcell.KeyEsc, tcell.KeyCtrlC:
+ ex.input.Focus(false)
+ ex.cmdHistory.Reset()
+ ex.finish()
+ default:
+ return ex.input.Event(event)
+ }
+ }
+ return true
+}
+
+type nullHistory struct {
+ input *ui.TextInput
+}
+
+func (*nullHistory) Add(string) {}
+
+func (h *nullHistory) Next() string {
+ return h.input.String()
+}
+
+func (h *nullHistory) Prev() string {
+ return h.input.String()
+}
+
+func (*nullHistory) Reset() {}
diff --git a/app/getpasswd.go b/app/getpasswd.go
new file mode 100644
index 00000000..9a3dbf3b
--- /dev/null
+++ b/app/getpasswd.go
@@ -0,0 +1,68 @@
+package app
+
+import (
+ "fmt"
+
+ "github.com/gdamore/tcell/v2"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+)
+
+type GetPasswd struct {
+ callback func(string, error)
+ title string
+ prompt string
+ input *ui.TextInput
+}
+
+func NewGetPasswd(
+ title string, prompt string, cb func(string, error),
+) *GetPasswd {
+ getpasswd := &GetPasswd{
+ callback: cb,
+ title: title,
+ prompt: prompt,
+ input: ui.NewTextInput("", config.Ui).Password(true).Prompt("Password: "),
+ }
+ getpasswd.input.Focus(true)
+ return getpasswd
+}
+
+func (gp *GetPasswd) Draw(ctx *ui.Context) {
+ defaultStyle := config.Ui.GetStyle(config.STYLE_DEFAULT)
+ titleStyle := config.Ui.GetStyle(config.STYLE_TITLE)
+
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
+ ctx.Fill(0, 0, ctx.Width(), 1, ' ', titleStyle)
+ ctx.Printf(1, 0, titleStyle, "%s", gp.title)
+ ctx.Printf(1, 1, defaultStyle, gp.prompt)
+ gp.input.Draw(ctx.Subcontext(1, 3, ctx.Width()-2, 1))
+}
+
+func (gp *GetPasswd) Invalidate() {
+ ui.Invalidate()
+}
+
+func (gp *GetPasswd) Event(event tcell.Event) bool {
+ switch event := event.(type) {
+ case *tcell.EventKey:
+ switch event.Key() {
+ case tcell.KeyEnter:
+ gp.input.Focus(false)
+ gp.callback(gp.input.String(), nil)
+ case tcell.KeyEsc:
+ gp.input.Focus(false)
+ gp.callback("", fmt.Errorf("no password provided"))
+ default:
+ gp.input.Event(event)
+ }
+ default:
+ gp.input.Event(event)
+ }
+ return true
+}
+
+func (gp *GetPasswd) Focus(f bool) {
+ // Who cares
+}
diff --git a/app/headerlayout.go b/app/headerlayout.go
new file mode 100644
index 00000000..d9304dea
--- /dev/null
+++ b/app/headerlayout.go
@@ -0,0 +1,44 @@
+package app
+
+import (
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+type HeaderLayout [][]string
+
+type HeaderLayoutFilter struct {
+ layout HeaderLayout
+ keep func(msg *models.MessageInfo, header string) bool // filter criteria
+}
+
+// forMessage returns a filtered header layout, removing rows whose headers
+// do not appear in the provided message.
+func (filter HeaderLayoutFilter) forMessage(msg *models.MessageInfo) HeaderLayout {
+ result := make(HeaderLayout, 0, len(filter.layout))
+ for _, row := range filter.layout {
+ // To preserve layout alignment, only hide rows if all columns are empty
+ for _, col := range row {
+ if filter.keep(msg, 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)
+ 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)
+ }
+ return grid, rowCount
+}
diff --git a/app/listbox.go b/app/listbox.go
new file mode 100644
index 00000000..5a80261e
--- /dev/null
+++ b/app/listbox.go
@@ -0,0 +1,299 @@
+package app
+
+import (
+ "math"
+ "strings"
+ "sync"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+ "github.com/gdamore/tcell/v2"
+ "github.com/mattn/go-runewidth"
+)
+
+type ListBox struct {
+ Scrollable
+ title string
+ lines []string
+ selected string
+ cursorPos int
+ horizPos int
+ jump int
+ showCursor bool
+ showFilter bool
+ filterMutex sync.Mutex
+ filter *ui.TextInput
+ uiConfig *config.UIConfig
+ cb func(string)
+}
+
+func NewListBox(title string, lines []string, uiConfig *config.UIConfig, cb func(string)) *ListBox {
+ lb := &ListBox{
+ title: title,
+ lines: lines,
+ cursorPos: -1,
+ jump: -1,
+ uiConfig: uiConfig,
+ cb: cb,
+ filter: ui.NewTextInput("", uiConfig),
+ }
+ lb.filter.OnChange(func(ti *ui.TextInput) {
+ var show bool
+ if ti.String() == "" {
+ show = false
+ } else {
+ show = true
+ }
+ lb.setShowFilterField(show)
+ lb.filter.Focus(show)
+ lb.Invalidate()
+ })
+ lb.dedup()
+ return lb
+}
+
+func (lb *ListBox) dedup() {
+ dedupped := make([]string, 0, len(lb.lines))
+ dedup := make(map[string]struct{})
+ for _, line := range lb.lines {
+ if _, dup := dedup[line]; dup {
+ log.Warnf("ignore duplicate: %s", line)
+ continue
+ }
+ dedup[line] = struct{}{}
+ dedupped = append(dedupped, line)
+ }
+ lb.lines = dedupped
+}
+
+func (lb *ListBox) setShowFilterField(b bool) {
+ lb.filterMutex.Lock()
+ defer lb.filterMutex.Unlock()
+ lb.showFilter = b
+}
+
+func (lb *ListBox) showFilterField() bool {
+ lb.filterMutex.Lock()
+ defer lb.filterMutex.Unlock()
+ return lb.showFilter
+}
+
+func (lb *ListBox) Draw(ctx *ui.Context) {
+ defaultStyle := lb.uiConfig.GetStyle(config.STYLE_DEFAULT)
+ titleStyle := lb.uiConfig.GetStyle(config.STYLE_TITLE)
+ w, h := ctx.Width(), ctx.Height()
+ ctx.Fill(0, 0, w, h, ' ', defaultStyle)
+ ctx.Fill(0, 0, w, 1, ' ', titleStyle)
+ ctx.Printf(0, 0, titleStyle, "%s", lb.title)
+
+ y := 0
+ if lb.showFilterField() {
+ y = 1
+ x := ctx.Printf(0, y, defaultStyle, "Filter: ")
+ lb.filter.Draw(ctx.Subcontext(x, y, w-x, 1))
+ }
+
+ lb.drawBox(ctx.Subcontext(0, y+1, w, h-(y+1)))
+}
+
+func (lb *ListBox) moveCursor(delta int) {
+ list := lb.filtered()
+ if len(list) == 0 {
+ return
+ }
+ lb.cursorPos += delta
+ if lb.cursorPos < 0 {
+ lb.cursorPos = 0
+ }
+ if lb.cursorPos >= len(list) {
+ lb.cursorPos = len(list) - 1
+ }
+ lb.selected = list[lb.cursorPos]
+ lb.showCursor = true
+ lb.horizPos = 0
+}
+
+func (lb *ListBox) moveHorizontal(delta int) {
+ lb.horizPos += delta
+ if lb.horizPos > len(lb.selected) {
+ lb.horizPos = len(lb.selected)
+ }
+ if lb.horizPos < 0 {
+ lb.horizPos = 0
+ }
+}
+
+func (lb *ListBox) filtered() []string {
+ list := []string{}
+ filterTerm := lb.filter.String()
+ for _, line := range lb.lines {
+ if strings.Contains(line, filterTerm) {
+ list = append(list, line)
+ }
+ }
+ return list
+}
+
+func (lb *ListBox) drawBox(ctx *ui.Context) {
+ defaultStyle := lb.uiConfig.GetStyle(config.STYLE_DEFAULT)
+ selectedStyle := lb.uiConfig.GetComposedStyleSelected(config.STYLE_MSGLIST_DEFAULT, nil)
+
+ w, h := ctx.Width(), ctx.Height()
+ lb.jump = h
+ list := lb.filtered()
+
+ lb.UpdateScroller(ctx.Height(), len(list))
+ scroll := 0
+ lb.cursorPos = -1
+ for i := 0; i < len(list); i++ {
+ if lb.selected == list[i] {
+ scroll = i
+ lb.cursorPos = i
+ break
+ }
+ }
+ lb.EnsureScroll(scroll)
+
+ needScrollbar := lb.NeedScrollbar()
+ if needScrollbar {
+ w -= 1
+ if w < 0 {
+ w = 0
+ }
+ }
+
+ if lb.lines == nil || len(list) == 0 {
+ return
+ }
+
+ y := 0
+ for i := lb.Scroll(); i < len(list) && y < h; i++ {
+ style := defaultStyle
+ line := runewidth.Truncate(list[i], w-1, "❯")
+ if lb.selected == list[i] && lb.showCursor {
+ style = selectedStyle
+ if len(list[i]) > w {
+ if len(list[i])-lb.horizPos < w {
+ lb.horizPos = len(list[i]) - w + 1
+ }
+ rest := list[i][lb.horizPos:]
+ line = runewidth.Truncate(rest,
+ w-1, "❯")
+ if lb.horizPos > 0 && len(line) > 0 {
+ line = "❮" + line[1:]
+ }
+ }
+ }
+ ctx.Printf(1, y, style, line)
+ y += 1
+ }
+
+ if needScrollbar {
+ scrollBarCtx := ctx.Subcontext(w, 0, 1, ctx.Height())
+ lb.drawScrollbar(scrollBarCtx)
+ }
+}
+
+func (lb *ListBox) drawScrollbar(ctx *ui.Context) {
+ gutterStyle := tcell.StyleDefault
+ pillStyle := tcell.StyleDefault.Reverse(true)
+
+ // gutter
+ h := ctx.Height()
+ ctx.Fill(0, 0, 1, h, ' ', gutterStyle)
+
+ // pill
+ pillSize := int(math.Ceil(float64(h) * lb.PercentVisible()))
+ pillOffset := int(math.Floor(float64(h) * lb.PercentScrolled()))
+ ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
+}
+
+func (lb *ListBox) Invalidate() {
+ ui.Invalidate()
+}
+
+func (lb *ListBox) Event(event tcell.Event) bool {
+ if event, ok := event.(*tcell.EventKey); ok {
+ switch event.Key() {
+ case tcell.KeyLeft:
+ lb.moveHorizontal(-1)
+ lb.Invalidate()
+ return true
+ case tcell.KeyRight:
+ lb.moveHorizontal(+1)
+ lb.Invalidate()
+ return true
+ case tcell.KeyCtrlB:
+ line := lb.selected[:lb.horizPos]
+ fds := strings.Fields(line)
+ if len(fds) > 1 {
+ lb.moveHorizontal(
+ strings.LastIndex(line,
+ fds[len(fds)-1]) - lb.horizPos - 1)
+ } else {
+ lb.horizPos = 0
+ }
+ lb.Invalidate()
+ return true
+ case tcell.KeyCtrlW:
+ line := lb.selected[lb.horizPos+1:]
+ fds := strings.Fields(line)
+ if len(fds) > 1 {
+ lb.moveHorizontal(strings.Index(line, fds[1]))
+ }
+ lb.Invalidate()
+ return true
+ case tcell.KeyCtrlA, tcell.KeyHome:
+ lb.horizPos = 0
+ lb.Invalidate()
+ return true
+ case tcell.KeyCtrlE, tcell.KeyEnd:
+ lb.horizPos = len(lb.selected)
+ lb.Invalidate()
+ return true
+ case tcell.KeyCtrlP, tcell.KeyUp:
+ lb.moveCursor(-1)
+ lb.Invalidate()
+ return true
+ case tcell.KeyCtrlN, tcell.KeyDown:
+ lb.moveCursor(+1)
+ lb.Invalidate()
+ return true
+ case tcell.KeyPgUp:
+ if lb.jump >= 0 {
+ lb.moveCursor(-lb.jump)
+ lb.Invalidate()
+ }
+ return true
+ case tcell.KeyPgDn:
+ if lb.jump >= 0 {
+ lb.moveCursor(+lb.jump)
+ lb.Invalidate()
+ }
+ return true
+ case tcell.KeyEnter:
+ return lb.quit(lb.selected)
+ case tcell.KeyEsc:
+ return lb.quit("")
+ }
+ }
+ if lb.filter != nil {
+ handled := lb.filter.Event(event)
+ lb.Invalidate()
+ return handled
+ }
+ return false
+}
+
+func (lb *ListBox) quit(s string) bool {
+ lb.filter.Focus(false)
+ if lb.cb != nil {
+ lb.cb(s)
+ }
+ return true
+}
+
+func (lb *ListBox) Focus(f bool) {
+ lb.filter.Focus(f)
+}
diff --git a/app/msglist.go b/app/msglist.go
new file mode 100644
index 00000000..57771ae8
--- /dev/null
+++ b/app/msglist.go
@@ -0,0 +1,497 @@
+package app
+
+import (
+ "bytes"
+ "fmt"
+ "math"
+ "strings"
+
+ sortthread "github.com/emersion/go-imap-sortthread"
+ "github.com/emersion/go-message/mail"
+ "github.com/gdamore/tcell/v2"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/state"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+ "git.sr.ht/~rjarry/aerc/worker/types"
+)
+
+type MessageList struct {
+ Scrollable
+ height int
+ width int
+ nmsgs int
+ spinner *Spinner
+ store *lib.MessageStore
+ isInitalizing bool
+ aerc *Aerc
+}
+
+func NewMessageList(aerc *Aerc, account *AccountView) *MessageList {
+ ml := &MessageList{
+ spinner: NewSpinner(account.uiConf),
+ isInitalizing: true,
+ aerc: aerc,
+ }
+ // TODO: stop spinner, probably
+ ml.spinner.Start()
+ return ml
+}
+
+func (ml *MessageList) Invalidate() {
+ ui.Invalidate()
+}
+
+type messageRowParams struct {
+ uid uint32
+ needsHeaders bool
+ uiConfig *config.UIConfig
+ styles []config.StyleObject
+ headers *mail.Header
+}
+
+func (ml *MessageList) Draw(ctx *ui.Context) {
+ ml.height = ctx.Height()
+ ml.width = ctx.Width()
+ uiConfig := ml.aerc.SelectedAccountUiConfig()
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
+ uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT))
+
+ acct := ml.aerc.SelectedAccount()
+ store := ml.Store()
+ if store == nil || acct == nil || len(store.Uids()) == 0 {
+ if ml.isInitalizing {
+ ml.spinner.Draw(ctx)
+ } else {
+ ml.spinner.Stop()
+ ml.drawEmptyMessage(ctx)
+ }
+ return
+ }
+
+ ml.UpdateScroller(ml.height, len(store.Uids()))
+ iter := store.UidsIterator()
+ for i := 0; iter.Next(); i++ {
+ if store.SelectedUid() == iter.Value().(uint32) {
+ ml.EnsureScroll(i)
+ break
+ }
+ }
+
+ store.UpdateScroll(ml.Scroll(), ml.height)
+
+ textWidth := ctx.Width()
+ if ml.NeedScrollbar() {
+ textWidth -= 1
+ }
+ if textWidth <= 0 {
+ return
+ }
+
+ var needsHeaders []uint32
+
+ data := state.NewDataSetter()
+ data.SetAccount(acct.acct)
+ data.SetFolder(acct.Directories().SelectedDirectory())
+
+ customDraw := func(t *ui.Table, r int, c *ui.Context) bool {
+ row := &t.Rows[r]
+ params, _ := row.Priv.(messageRowParams)
+ if params.needsHeaders {
+ needsHeaders = append(needsHeaders, params.uid)
+ ml.spinner.Draw(ctx.Subcontext(0, r, c.Width(), 1))
+ return true
+ }
+ return false
+ }
+
+ getRowStyle := func(t *ui.Table, r int) tcell.Style {
+ var style tcell.Style
+ row := &t.Rows[r]
+ params, _ := row.Priv.(messageRowParams)
+ if params.uid == store.SelectedUid() {
+ style = params.uiConfig.MsgComposedStyleSelected(
+ config.STYLE_MSGLIST_DEFAULT, params.styles,
+ params.headers)
+ } else {
+ style = params.uiConfig.MsgComposedStyle(
+ config.STYLE_MSGLIST_DEFAULT, params.styles,
+ params.headers)
+ }
+ return style
+ }
+
+ table := ui.NewTable(
+ ml.height,
+ uiConfig.IndexColumns,
+ uiConfig.ColumnSeparator,
+ customDraw,
+ getRowStyle,
+ )
+
+ showThreads := store.ThreadedView()
+ threadView := newThreadView(store)
+ iter = store.UidsIterator()
+ for i := 0; iter.Next(); i++ {
+ if i < ml.Scroll() {
+ continue
+ }
+ uid := iter.Value().(uint32)
+ if showThreads {
+ threadView.Update(data, uid)
+ }
+ if addMessage(store, uid, &table, data, uiConfig) {
+ break
+ }
+ }
+
+ table.Draw(ctx.Subcontext(0, 0, textWidth, ctx.Height()))
+
+ if ml.NeedScrollbar() {
+ scrollbarCtx := ctx.Subcontext(textWidth, 0, 1, ctx.Height())
+ ml.drawScrollbar(scrollbarCtx)
+ }
+
+ if len(store.Uids()) == 0 {
+ if store.Sorting {
+ ml.spinner.Start()
+ ml.spinner.Draw(ctx)
+ return
+ } else {
+ ml.drawEmptyMessage(ctx)
+ }
+ }
+
+ if len(needsHeaders) != 0 {
+ store.FetchHeaders(needsHeaders, nil)
+ ml.spinner.Start()
+ } else {
+ ml.spinner.Stop()
+ }
+}
+
+func addMessage(
+ store *lib.MessageStore, uid uint32,
+ table *ui.Table, data state.DataSetter,
+ uiConfig *config.UIConfig,
+) bool {
+ msg := store.Messages[uid]
+
+ cells := make([]string, len(table.Columns))
+ params := messageRowParams{uid: uid, uiConfig: uiConfig}
+
+ if msg == nil || msg.Envelope == nil {
+ params.needsHeaders = true
+ return table.AddRow(cells, params)
+ }
+
+ if msg.Flags.Has(models.SeenFlag) {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_READ)
+ } else {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_UNREAD)
+ }
+ if msg.Flags.Has(models.AnsweredFlag) {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_ANSWERED)
+ }
+ if msg.Flags.Has(models.FlaggedFlag) {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_FLAGGED)
+ }
+ // deleted message
+ if _, ok := store.Deleted[msg.Uid]; ok {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_DELETED)
+ }
+ // search result
+ if store.IsResult(msg.Uid) {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_RESULT)
+ }
+ // folded thread
+ templateData, ok := data.(models.TemplateData)
+ if ok {
+ if templateData.ThreadFolded() {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_FOLDED)
+ }
+ if templateData.ThreadContext() {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_CONTEXT)
+ }
+ }
+ // marked message
+ marked := store.Marker().IsMarked(msg.Uid)
+ if marked {
+ params.styles = append(params.styles, config.STYLE_MSGLIST_MARKED)
+ }
+
+ data.SetInfo(msg, len(table.Rows), marked)
+
+ for c, col := range table.Columns {
+ var buf bytes.Buffer
+ err := col.Def.Template.Execute(&buf, data.Data())
+ if err != nil {
+ log.Errorf("<%s> %s", msg.Envelope.MessageId, err)
+ cells[c] = err.Error()
+ } else {
+ cells[c] = buf.String()
+ }
+ }
+
+ params.headers = msg.RFC822Headers
+
+ return table.AddRow(cells, params)
+}
+
+func (ml *MessageList) drawScrollbar(ctx *ui.Context) {
+ uiConfig := ml.aerc.SelectedAccountUiConfig()
+ gutterStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_GUTTER)
+ pillStyle := uiConfig.GetStyle(config.STYLE_MSGLIST_PILL)
+
+ // gutter
+ ctx.Fill(0, 0, 1, ctx.Height(), ' ', gutterStyle)
+
+ // pill
+ pillSize := int(math.Ceil(float64(ctx.Height()) * ml.PercentVisible()))
+ pillOffset := int(math.Floor(float64(ctx.Height()) * ml.PercentScrolled()))
+ ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
+}
+
+func (ml *MessageList) MouseEvent(localX int, localY int, event tcell.Event) {
+ if event, ok := event.(*tcell.EventMouse); ok {
+ switch event.Buttons() {
+ case tcell.Button1:
+ if ml.aerc == nil {
+ return
+ }
+ selectedMsg, ok := ml.Clicked(localX, localY)
+ if ok {
+ ml.Select(selectedMsg)
+ acct := ml.aerc.SelectedAccount()
+ if acct == nil || acct.Messages().Empty() {
+ return
+ }
+ store := acct.Messages().Store()
+ msg := acct.Messages().Selected()
+ if msg == nil {
+ return
+ }
+ lib.NewMessageStoreView(msg, acct.UiConfig().AutoMarkRead,
+ store, ml.aerc.Crypto, ml.aerc.DecryptKeys,
+ func(view lib.MessageView, err error) {
+ if err != nil {
+ ml.aerc.PushError(err.Error())
+ return
+ }
+ viewer := NewMessageViewer(acct, view)
+ ml.aerc.NewTab(viewer, msg.Envelope.Subject)
+ })
+ }
+ case tcell.WheelDown:
+ if ml.store != nil {
+ ml.store.Next()
+ }
+ ml.Invalidate()
+ case tcell.WheelUp:
+ if ml.store != nil {
+ ml.store.Prev()
+ }
+ ml.Invalidate()
+ }
+ }
+}
+
+func (ml *MessageList) Clicked(x, y int) (int, bool) {
+ store := ml.Store()
+ if store == nil || ml.nmsgs == 0 || y >= ml.nmsgs {
+ return 0, false
+ }
+ return y + ml.Scroll(), true
+}
+
+func (ml *MessageList) Height() int {
+ return ml.height
+}
+
+func (ml *MessageList) Width() int {
+ return ml.width
+}
+
+func (ml *MessageList) storeUpdate(store *lib.MessageStore) {
+ if ml.Store() != store {
+ return
+ }
+ ml.Invalidate()
+}
+
+func (ml *MessageList) SetStore(store *lib.MessageStore) {
+ if ml.Store() != store {
+ ml.Scrollable = Scrollable{}
+ }
+ ml.store = store
+ if store != nil {
+ ml.spinner.Stop()
+ uids := store.Uids()
+ ml.nmsgs = len(uids)
+ store.OnUpdate(ml.storeUpdate)
+ store.OnFilterChange(func(store *lib.MessageStore) {
+ if ml.Store() != store {
+ return
+ }
+ ml.nmsgs = len(store.Uids())
+ })
+ } else {
+ ml.spinner.Start()
+ }
+ ml.Invalidate()
+}
+
+func (ml *MessageList) SetInitDone() {
+ ml.isInitalizing = false
+}
+
+func (ml *MessageList) Store() *lib.MessageStore {
+ return ml.store
+}
+
+func (ml *MessageList) Empty() bool {
+ store := ml.Store()
+ return store == nil || len(store.Uids()) == 0
+}
+
+func (ml *MessageList) Selected() *models.MessageInfo {
+ return ml.Store().Selected()
+}
+
+func (ml *MessageList) Select(index int) {
+ // Note that the msgstore.Select function expects a uid as argument
+ // whereas the msglist.Select expects the message number
+ store := ml.Store()
+ uids := store.Uids()
+ if len(uids) == 0 {
+ store.Select(lib.MagicUid)
+ return
+ }
+
+ iter := store.UidsIterator()
+
+ var uid uint32
+ if index < 0 {
+ uid = uids[iter.EndIndex()]
+ } else {
+ uid = uids[iter.StartIndex()]
+ for i := 0; iter.Next(); i++ {
+ if i >= index {
+ uid = iter.Value().(uint32)
+ break
+ }
+ }
+ }
+ store.Select(uid)
+
+ ml.Invalidate()
+}
+
+func (ml *MessageList) drawEmptyMessage(ctx *ui.Context) {
+ uiConfig := ml.aerc.SelectedAccountUiConfig()
+ msg := uiConfig.EmptyMessage
+ ctx.Printf((ctx.Width()/2)-(len(msg)/2), 0,
+ uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT), "%s", msg)
+}
+
+func countThreads(thread *types.Thread) (ctr int) {
+ if thread == nil {
+ return
+ }
+ _ = thread.Walk(func(t *types.Thread, _ int, _ error) error {
+ ctr++
+ return nil
+ })
+ return
+}
+
+func threadPrefix(t *types.Thread, reverse bool, point bool) string {
+ var arrow string
+ if t.Parent != nil {
+ switch {
+ case t.NextSibling != nil:
+ arrow = "├─"
+ case reverse:
+ arrow = "┌─"
+ default:
+ arrow = "└─"
+ }
+ if point {
+ arrow += ">"
+ }
+ }
+ var prefix []string
+ for n := t; n.Parent != nil; n = n.Parent {
+ switch {
+ case n.Parent.NextSibling != nil && point:
+ prefix = append(prefix, "│ ")
+ case n.Parent.NextSibling != nil:
+ prefix = append(prefix, "│ ")
+ case point:
+ prefix = append(prefix, " ")
+ default:
+ prefix = append(prefix, " ")
+ }
+ }
+ // prefix is now in a reverse order (inside --> outside), so turn it
+ for i, j := 0, len(prefix)-1; i < j; i, j = i+1, j-1 {
+ prefix[i], prefix[j] = prefix[j], prefix[i]
+ }
+
+ // we don't want to indent the first child, hence we strip that level
+ if len(prefix) > 0 {
+ prefix = prefix[1:]
+ }
+ ps := strings.Join(prefix, "")
+ return fmt.Sprintf("%v%v", ps, arrow)
+}
+
+func sameParent(left, right *types.Thread) bool {
+ return left.Root() == right.Root()
+}
+
+func isParent(t *types.Thread) bool {
+ return t == t.Root()
+}
+
+func threadSubject(store *lib.MessageStore, thread *types.Thread) string {
+ msg, found := store.Messages[thread.Uid]
+ if !found || msg == nil || msg.Envelope == nil {
+ return ""
+ }
+ subject, _ := sortthread.GetBaseSubject(msg.Envelope.Subject)
+ return subject
+}
+
+type threadView struct {
+ store *lib.MessageStore
+ reverse bool
+ prev *types.Thread
+ prevSubj string
+}
+
+func newThreadView(store *lib.MessageStore) *threadView {
+ return &threadView{
+ store: store,
+ reverse: store.ReverseThreadOrder(),
+ }
+}
+
+func (t *threadView) Update(data state.DataSetter, uid uint32) {
+ prefix, same, count, folded, context := "", false, 0, false, false
+ thread, err := t.store.Thread(uid)
+ if thread != nil && err == nil {
+ prefix = threadPrefix(thread, t.reverse, true)
+ subject := threadSubject(t.store, thread)
+ same = subject == t.prevSubj && sameParent(thread, t.prev) && !isParent(thread)
+ t.prev = thread
+ t.prevSubj = subject
+ count = countThreads(thread)
+ folded = thread.FirstChild != nil && thread.FirstChild.Hidden
+ context = thread.Context
+ }
+ data.SetThreading(prefix, same, count, folded, context)
+}
diff --git a/app/msgviewer.go b/app/msgviewer.go
new file mode 100644
index 00000000..2d261c3f
--- /dev/null
+++ b/app/msgviewer.go
@@ -0,0 +1,927 @@
+package app
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "sync/atomic"
+
+ "github.com/danwakefield/fnmatch"
+ "github.com/emersion/go-message/textproto"
+ "github.com/gdamore/tcell/v2"
+ "github.com/google/shlex"
+ "github.com/mattn/go-runewidth"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/auth"
+ "git.sr.ht/~rjarry/aerc/lib/format"
+ "git.sr.ht/~rjarry/aerc/lib/parse"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+var _ ProvidesMessages = (*MessageViewer)(nil)
+
+type MessageViewer struct {
+ acct *AccountView
+ err error
+ grid *ui.Grid
+ switcher *PartSwitcher
+ msg lib.MessageView
+ uiConfig *config.UIConfig
+}
+
+type PartSwitcher struct {
+ parts []*PartViewer
+ selected int
+ alwaysShowMime bool
+
+ height int
+ mv *MessageViewer
+}
+
+func NewMessageViewer(
+ acct *AccountView, msg lib.MessageView,
+) *MessageViewer {
+ if msg == nil {
+ return &MessageViewer{
+ acct: acct,
+ err: fmt.Errorf("(no message selected)"),
+ }
+ }
+ hf := HeaderLayoutFilter{
+ layout: HeaderLayout(config.Viewer.HeaderLayout),
+ keep: func(msg *models.MessageInfo, header string) bool {
+ return fmtHeader(msg, header, "2", "3", "4", "5") != ""
+ },
+ }
+ layout := hf.forMessage(msg.MessageInfo())
+ header, headerHeight := layout.grid(
+ func(header string) ui.Drawable {
+ hv := &HeaderView{
+ Name: header,
+ Value: fmtHeader(
+ msg.MessageInfo(),
+ header,
+ acct.UiConfig().MessageViewTimestampFormat,
+ acct.UiConfig().MessageViewThisDayTimeFormat,
+ acct.UiConfig().MessageViewThisWeekTimeFormat,
+ acct.UiConfig().MessageViewThisYearTimeFormat,
+ ),
+ uiConfig: acct.UiConfig(),
+ }
+ showInfo := false
+ if i := strings.IndexRune(header, '+'); i > 0 {
+ header = header[:i]
+ hv.Name = header
+ showInfo = true
+ }
+ if parser := auth.New(header); parser != nil && msg.MessageInfo().Error == nil {
+ details, err := parser(msg.MessageInfo().RFC822Headers, acct.AccountConfig().TrustedAuthRes)
+ if err != nil {
+ hv.Value = err.Error()
+ } else {
+ hv.ValueField = NewAuthInfo(details, showInfo, acct.UiConfig())
+ }
+ hv.Invalidate()
+ }
+ return hv
+ },
+ )
+
+ rows := []ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(headerHeight)},
+ }
+
+ if msg.MessageDetails() != nil || acct.UiConfig().IconUnencrypted != "" {
+ height := 1
+ if msg.MessageDetails() != nil && msg.MessageDetails().IsSigned && msg.MessageDetails().IsEncrypted {
+ height = 2
+ }
+ rows = append(rows, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(height)})
+ }
+
+ rows = append(rows, []ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }...)
+
+ grid := ui.NewGrid().Rows(rows).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ switcher := &PartSwitcher{}
+ err := createSwitcher(acct, switcher, msg)
+ if err != nil {
+ return &MessageViewer{
+ acct: acct,
+ err: err,
+ grid: grid,
+ msg: msg,
+ uiConfig: acct.UiConfig(),
+ }
+ }
+
+ borderStyle := acct.UiConfig().GetStyle(config.STYLE_BORDER)
+ borderChar := acct.UiConfig().BorderCharHorizontal
+
+ grid.AddChild(header).At(0, 0)
+ if msg.MessageDetails() != nil || acct.UiConfig().IconUnencrypted != "" {
+ grid.AddChild(NewPGPInfo(msg.MessageDetails(), acct.UiConfig())).At(1, 0)
+ grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(2, 0)
+ grid.AddChild(switcher).At(3, 0)
+ } else {
+ grid.AddChild(ui.NewFill(borderChar, borderStyle)).At(1, 0)
+ grid.AddChild(switcher).At(2, 0)
+ }
+
+ mv := &MessageViewer{
+ acct: acct,
+ grid: grid,
+ msg: msg,
+ switcher: switcher,
+ uiConfig: acct.UiConfig(),
+ }
+ switcher.mv = mv
+
+ return mv
+}
+
+func fmtHeader(msg *models.MessageInfo, header string,
+ timefmt string, todayFormat string, thisWeekFormat string, thisYearFormat string,
+) string {
+ if msg == nil || msg.Envelope == nil {
+ return "error: no envelope for this message"
+ }
+
+ if v := auth.New(header); v != nil {
+ return "Fetching.."
+ }
+
+ switch header {
+ case "From":
+ return format.FormatAddresses(msg.Envelope.From)
+ case "To":
+ return format.FormatAddresses(msg.Envelope.To)
+ case "Cc":
+ return format.FormatAddresses(msg.Envelope.Cc)
+ case "Bcc":
+ return format.FormatAddresses(msg.Envelope.Bcc)
+ case "Date":
+ return format.DummyIfZeroDate(
+ msg.Envelope.Date.Local(),
+ timefmt,
+ todayFormat,
+ thisWeekFormat,
+ thisYearFormat,
+ )
+ case "Subject":
+ return msg.Envelope.Subject
+ case "Labels":
+ return strings.Join(msg.Labels, ", ")
+ default:
+ return msg.RFC822Headers.Get(header)
+ }
+}
+
+func enumerateParts(
+ acct *AccountView, msg lib.MessageView,
+ body *models.BodyStructure, index []int,
+) ([]*PartViewer, error) {
+ var parts []*PartViewer
+ for i, part := range body.Parts {
+ curindex := append(index, i+1) //nolint:gocritic // intentional append to different slice
+ if part.MIMEType == "multipart" {
+ // Multipart meta-parts are faked
+ pv := &PartViewer{part: part}
+ parts = append(parts, pv)
+ subParts, err := enumerateParts(
+ acct, msg, part, curindex)
+ if err != nil {
+ return nil, err
+ }
+ parts = append(parts, subParts...)
+ continue
+ }
+ pv, err := NewPartViewer(acct, msg, part, curindex)
+ if err != nil {
+ return nil, err
+ }
+ parts = append(parts, pv)
+ }
+ return parts, nil
+}
+
+func createSwitcher(
+ acct *AccountView, switcher *PartSwitcher, msg lib.MessageView,
+) error {
+ var err error
+ switcher.selected = -1
+ switcher.alwaysShowMime = config.Viewer.AlwaysShowMime
+
+ if msg.MessageInfo().Error != nil {
+ return fmt.Errorf("could not view message: %w", msg.MessageInfo().Error)
+ }
+
+ if len(msg.BodyStructure().Parts) == 0 {
+ switcher.selected = 0
+ pv, err := NewPartViewer(acct, msg, msg.BodyStructure(), nil)
+ if err != nil {
+ return err
+ }
+ switcher.parts = []*PartViewer{pv}
+ } else {
+ switcher.parts, err = enumerateParts(acct, msg,
+ msg.BodyStructure(), []int{})
+ if err != nil {
+ return err
+ }
+ selectedPriority := -1
+ log.Tracef("Selecting best message from %v", config.Viewer.Alternatives)
+ for i, pv := range switcher.parts {
+ // Switch to user's preferred mimetype
+ if switcher.selected == -1 && pv.part.MIMEType != "multipart" {
+ switcher.selected = i
+ }
+ mime := pv.part.FullMIMEType()
+ for idx, m := range config.Viewer.Alternatives {
+ if m != mime {
+ continue
+ }
+ priority := len(config.Viewer.Alternatives) - idx
+ if priority > selectedPriority {
+ selectedPriority = priority
+ switcher.selected = i
+ }
+ }
+ }
+ }
+ return nil
+}
+
+func (mv *MessageViewer) Draw(ctx *ui.Context) {
+ if mv.err != nil {
+ style := mv.acct.UiConfig().GetStyle(config.STYLE_DEFAULT)
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
+ ctx.Printf(0, 0, style, "%s", mv.err.Error())
+ return
+ }
+ mv.grid.Draw(ctx)
+}
+
+func (mv *MessageViewer) MouseEvent(localX int, localY int, event tcell.Event) {
+ if mv.err != nil {
+ return
+ }
+ mv.grid.MouseEvent(localX, localY, event)
+}
+
+func (mv *MessageViewer) Invalidate() {
+ ui.Invalidate()
+}
+
+func (mv *MessageViewer) Store() *lib.MessageStore {
+ return mv.msg.Store()
+}
+
+func (mv *MessageViewer) SelectedAccount() *AccountView {
+ return mv.acct
+}
+
+func (mv *MessageViewer) MessageView() lib.MessageView {
+ return mv.msg
+}
+
+func (mv *MessageViewer) SelectedMessage() (*models.MessageInfo, error) {
+ if mv.msg == nil {
+ return nil, errors.New("no message selected")
+ }
+ return mv.msg.MessageInfo(), nil
+}
+
+func (mv *MessageViewer) MarkedMessages() ([]uint32, error) {
+ return mv.acct.MarkedMessages()
+}
+
+func (mv *MessageViewer) ToggleHeaders() {
+ switcher := mv.switcher
+ switcher.Cleanup()
+ config.Viewer.ShowHeaders = !config.Viewer.ShowHeaders
+ err := createSwitcher(mv.acct, switcher, mv.msg)
+ if err != nil {
+ log.Errorf("cannot create switcher: %v", err)
+ }
+ switcher.Invalidate()
+}
+
+func (mv *MessageViewer) ToggleKeyPassthrough() bool {
+ config.Viewer.KeyPassthrough = !config.Viewer.KeyPassthrough
+ return config.Viewer.KeyPassthrough
+}
+
+func (mv *MessageViewer) SelectedMessagePart() *PartInfo {
+ switcher := mv.switcher
+ part := switcher.parts[switcher.selected]
+
+ return &PartInfo{
+ Index: part.index,
+ Msg: part.msg.MessageInfo(),
+ Part: part.part,
+ Links: part.links,
+ }
+}
+
+func (mv *MessageViewer) AttachmentParts(all bool) []*PartInfo {
+ var attachments []*PartInfo
+
+ for _, p := range mv.switcher.parts {
+ if p.part.Disposition == "attachment" || (all && p.part.FileName() != "") {
+ pi := &PartInfo{
+ Index: p.index,
+ Msg: p.msg.MessageInfo(),
+ Part: p.part,
+ }
+ attachments = append(attachments, pi)
+ }
+ }
+
+ return attachments
+}
+
+func (mv *MessageViewer) PreviousPart() {
+ switcher := mv.switcher
+ for {
+ switcher.selected--
+ if switcher.selected < 0 {
+ switcher.selected = len(switcher.parts) - 1
+ }
+ if switcher.parts[switcher.selected].part.MIMEType != "multipart" {
+ break
+ }
+ }
+ mv.Invalidate()
+}
+
+func (mv *MessageViewer) NextPart() {
+ switcher := mv.switcher
+ for {
+ switcher.selected++
+ if switcher.selected >= len(switcher.parts) {
+ switcher.selected = 0
+ }
+ if switcher.parts[switcher.selected].part.MIMEType != "multipart" {
+ break
+ }
+ }
+ mv.Invalidate()
+}
+
+func (mv *MessageViewer) Bindings() string {
+ if config.Viewer.KeyPassthrough {
+ return "view::passthrough"
+ } else {
+ return "view"
+ }
+}
+
+func (mv *MessageViewer) Close() {
+ if mv.switcher != nil {
+ mv.switcher.Cleanup()
+ }
+}
+
+func (ps *PartSwitcher) Invalidate() {
+ ui.Invalidate()
+}
+
+func (ps *PartSwitcher) Focus(focus bool) {
+ if ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.Focus(focus)
+ }
+}
+
+func (ps *PartSwitcher) Show(visible bool) {
+ if ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.Show(visible)
+ }
+}
+
+func (ps *PartSwitcher) Event(event tcell.Event) bool {
+ return ps.parts[ps.selected].Event(event)
+}
+
+func (ps *PartSwitcher) Draw(ctx *ui.Context) {
+ height := len(ps.parts)
+ if height == 1 && !config.Viewer.AlwaysShowMime {
+ ps.parts[ps.selected].Draw(ctx)
+ return
+ }
+
+ var styleSwitcher, styleFile, styleMime tcell.Style
+
+ // TODO: cap height and add scrolling for messages with many parts
+ ps.height = ctx.Height()
+ y := ctx.Height() - height
+ for i, part := range ps.parts {
+ if ps.selected == i {
+ styleSwitcher = ps.mv.uiConfig.GetStyleSelected(config.STYLE_PART_SWITCHER)
+ styleFile = ps.mv.uiConfig.GetStyleSelected(config.STYLE_PART_FILENAME)
+ styleMime = ps.mv.uiConfig.GetStyleSelected(config.STYLE_PART_MIMETYPE)
+ } else {
+ styleSwitcher = ps.mv.uiConfig.GetStyle(config.STYLE_PART_SWITCHER)
+ styleFile = ps.mv.uiConfig.GetStyle(config.STYLE_PART_FILENAME)
+ styleMime = ps.mv.uiConfig.GetStyle(config.STYLE_PART_MIMETYPE)
+ }
+ ctx.Fill(0, y+i, ctx.Width(), 1, ' ', styleSwitcher)
+ left := len(part.index) * 2
+ if part.part.FileName() != "" {
+ name := runewidth.Truncate(part.part.FileName(),
+ ctx.Width()-left-1, "…")
+ left += ctx.Printf(left, y+i, styleFile, "%s ", name)
+ }
+ t := "(" + part.part.FullMIMEType() + ")"
+ t = runewidth.Truncate(t, ctx.Width()-left, "…")
+ ctx.Printf(left, y+i, styleMime, "%s", t)
+ }
+ ps.parts[ps.selected].Draw(ctx.Subcontext(
+ 0, 0, ctx.Width(), ctx.Height()-height))
+}
+
+func (ps *PartSwitcher) MouseEvent(localX int, localY int, event tcell.Event) {
+ if event, ok := event.(*tcell.EventMouse); ok {
+ switch event.Buttons() {
+ case tcell.Button1:
+ height := len(ps.parts)
+ y := ps.height - height
+ if localY < y && ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
+ }
+ for i := range ps.parts {
+ if localY != y+i {
+ continue
+ }
+ if ps.parts[i].part.MIMEType == "multipart" {
+ continue
+ }
+ if ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.Focus(false)
+ }
+ ps.selected = i
+ ps.Invalidate()
+ if ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.Focus(true)
+ }
+ }
+ case tcell.WheelDown:
+ height := len(ps.parts)
+ y := ps.height - height
+ if localY < y && ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
+ }
+ if ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.Focus(false)
+ }
+ ps.mv.NextPart()
+ if ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.Focus(true)
+ }
+ case tcell.WheelUp:
+ height := len(ps.parts)
+ y := ps.height - height
+ if localY < y && ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
+ }
+ if ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.Focus(false)
+ }
+ ps.mv.PreviousPart()
+ if ps.parts[ps.selected].term != nil {
+ ps.parts[ps.selected].term.Focus(true)
+ }
+ }
+ }
+}
+
+func (ps *PartSwitcher) Cleanup() {
+ for _, partViewer := range ps.parts {
+ partViewer.Cleanup()
+ }
+}
+
+func (mv *MessageViewer) Event(event tcell.Event) bool {
+ return mv.switcher.Event(event)
+}
+
+func (mv *MessageViewer) Focus(focus bool) {
+ mv.switcher.Focus(focus)
+}
+
+func (mv *MessageViewer) Show(visible bool) {
+ mv.switcher.Show(visible)
+}
+
+type PartViewer struct {
+ acctConfig *config.AccountConfig
+ err error
+ fetched bool
+ filter *exec.Cmd
+ index []int
+ msg lib.MessageView
+ pager *exec.Cmd
+ pagerin io.WriteCloser
+ part *models.BodyStructure
+ source io.Reader
+ term *Terminal
+ grid *ui.Grid
+ noFilter *ui.Grid
+ uiConfig *config.UIConfig
+ copying int32
+
+ links []string
+}
+
+const copying int32 = 1
+
+func NewPartViewer(
+ acct *AccountView, msg lib.MessageView, part *models.BodyStructure,
+ curindex []int,
+) (*PartViewer, error) {
+ var (
+ filter *exec.Cmd
+ pager *exec.Cmd
+ pagerin io.WriteCloser
+ term *Terminal
+ )
+ cmds := []string{
+ config.Viewer.Pager,
+ os.Getenv("PAGER"),
+ "less -Rc",
+ }
+ pagerCmd, err := acct.aerc.CmdFallbackSearch(cmds)
+ if err != nil {
+ acct.PushError(fmt.Errorf("could not start pager: %w", err))
+ return nil, err
+ }
+ cmd, err := shlex.Split(pagerCmd)
+ if err != nil {
+ return nil, err
+ }
+
+ pager = exec.Command(cmd[0], cmd[1:]...)
+
+ info := msg.MessageInfo()
+ mime := part.FullMIMEType()
+
+ for _, f := range config.Filters {
+ switch f.Type {
+ case config.FILTER_MIMETYPE:
+ if fnmatch.Match(f.Filter, mime, 0) {
+ filter = exec.Command("sh", "-c", f.Command)
+ }
+ case config.FILTER_HEADER:
+ var header string
+ switch f.Header {
+ case "subject":
+ header = info.Envelope.Subject
+ case "from":
+ header = format.FormatAddresses(info.Envelope.From)
+ case "to":
+ header = format.FormatAddresses(info.Envelope.To)
+ case "cc":
+ header = format.FormatAddresses(info.Envelope.Cc)
+ default:
+ header = msg.MessageInfo().RFC822Headers.Get(f.Header)
+ }
+ if f.Regex.Match([]byte(header)) {
+ filter = exec.Command("sh", "-c", f.Command)
+ }
+ }
+ if filter != nil {
+ break
+ }
+ }
+ var noFilter *ui.Grid
+ if filter != nil {
+ path, _ := os.LookupEnv("PATH")
+ var paths []string
+ for _, dir := range config.SearchDirs {
+ paths = append(paths, dir+"/filters")
+ }
+ paths = append(paths, path)
+ path = strings.Join(paths, ":")
+ filter.Env = os.Environ()
+ filter.Env = append(filter.Env, fmt.Sprintf("PATH=%s", path))
+ filter.Env = append(filter.Env,
+ fmt.Sprintf("AERC_MIME_TYPE=%s", mime))
+ filter.Env = append(filter.Env,
+ fmt.Sprintf("AERC_FILENAME=%s", part.FileName()))
+ if flowed, ok := part.Params["format"]; ok {
+ filter.Env = append(filter.Env,
+ fmt.Sprintf("AERC_FORMAT=%s", flowed))
+ }
+ filter.Env = append(filter.Env,
+ fmt.Sprintf("AERC_SUBJECT=%s", info.Envelope.Subject))
+ filter.Env = append(filter.Env, fmt.Sprintf("AERC_FROM=%s",
+ format.FormatAddresses(info.Envelope.From)))
+ filter.Env = append(filter.Env, fmt.Sprintf("AERC_STYLESET=%s",
+ acct.UiConfig().StyleSetPath()))
+ if config.General.EnableOSC8 {
+ filter.Env = append(filter.Env, "AERC_OSC8_URLS=1")
+ }
+ log.Debugf("<%s> part=%v %s: %v | %v",
+ info.Envelope.MessageId, curindex, mime, filter, pager)
+ if pagerin, err = pager.StdinPipe(); err != nil {
+ return nil, err
+ }
+ if term, err = NewTerminal(pager); err != nil {
+ return nil, err
+ }
+ } else {
+ noFilter = newNoFilterConfigured(acct.Name(), part)
+ }
+
+ grid := ui.NewGrid().Rows([]ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(3)}, // Message
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ }).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ index := make([]int, len(curindex))
+ copy(index, curindex)
+
+ pv := &PartViewer{
+ acctConfig: acct.AccountConfig(),
+ filter: filter,
+ index: index,
+ msg: msg,
+ pager: pager,
+ pagerin: pagerin,
+ part: part,
+ term: term,
+ grid: grid,
+ noFilter: noFilter,
+ uiConfig: acct.UiConfig(),
+ }
+
+ if term != nil {
+ term.OnStart = func() {
+ pv.attemptCopy()
+ }
+ }
+
+ return pv, nil
+}
+
+func (pv *PartViewer) SetSource(reader io.Reader) {
+ pv.source = reader
+ pv.attemptCopy()
+}
+
+func (pv *PartViewer) attemptCopy() {
+ if pv.source == nil ||
+ pv.filter == nil ||
+ atomic.LoadInt32(&pv.copying) == copying {
+ return
+ }
+ atomic.StoreInt32(&pv.copying, copying)
+ pv.writeMailHeaders()
+ if strings.EqualFold(pv.part.MIMEType, "text") {
+ pv.source = parse.StripAnsi(pv.hyperlinks(pv.source))
+ }
+ pv.filter.Stdin = pv.source
+ pv.filter.Stdout = pv.pagerin
+ pv.filter.Stderr = pv.pagerin
+ err := pv.filter.Start()
+ if err != nil {
+ log.Errorf("error running filter: %v", err)
+ return
+ }
+ go func() {
+ defer log.PanicHandler()
+ defer atomic.StoreInt32(&pv.copying, 0)
+ err = pv.filter.Wait()
+ if err != nil {
+ log.Errorf("error waiting for filter: %v", err)
+ return
+ }
+ err = pv.pagerin.Close()
+ if err != nil {
+ log.Errorf("error closing pager pipe: %v", err)
+ return
+ }
+ }()
+}
+
+func (pv *PartViewer) writeMailHeaders() {
+ info := pv.msg.MessageInfo()
+ if config.Viewer.ShowHeaders && info.RFC822Headers != nil {
+ var file io.WriteCloser
+
+ for _, f := range config.Filters {
+ if f.Type != config.FILTER_HEADERS {
+ continue
+ }
+ log.Debugf("<%s> piping headers in filter: %s",
+ info.Envelope.MessageId, f.Command)
+ filter := exec.Command("sh", "-c", f.Command)
+ if pv.filter != nil {
+ // inherit from filter env
+ filter.Env = pv.filter.Env
+ }
+
+ stdin, err := filter.StdinPipe()
+ if err == nil {
+ filter.Stdout = pv.pagerin
+ filter.Stderr = pv.pagerin
+ err := filter.Start()
+ if err == nil {
+ //nolint:errcheck // who cares?
+ defer filter.Wait()
+ file = stdin
+ } else {
+ log.Errorf(
+ "failed to start header filter: %v",
+ err)
+ }
+ } else {
+ log.Errorf("failed to create pipe: %v", err)
+ }
+ break
+ }
+ if file == nil {
+ file = pv.pagerin
+ } else {
+ defer file.Close()
+ }
+
+ var buf bytes.Buffer
+ err := textproto.WriteHeader(&buf, info.RFC822Headers.Header.Header)
+ if err != nil {
+ log.Errorf("failed to format headers: %v", err)
+ }
+ _, err = file.Write(bytes.TrimRight(buf.Bytes(), "\r\n"))
+ if err != nil {
+ log.Errorf("failed to write headers: %v", err)
+ }
+
+ // virtual header
+ if len(info.Labels) != 0 {
+ labels := fmtHeader(info, "Labels", "", "", "", "")
+ _, err := file.Write([]byte(fmt.Sprintf("\r\nLabels: %s", labels)))
+ if err != nil {
+ log.Errorf("failed to write to labels: %v", err)
+ }
+ }
+ _, err = file.Write([]byte{'\r', '\n', '\r', '\n'})
+ if err != nil {
+ log.Errorf("failed to write empty line: %v", err)
+ }
+ }
+}
+
+func (pv *PartViewer) hyperlinks(r io.Reader) (reader io.Reader) {
+ if !config.Viewer.ParseHttpLinks {
+ return r
+ }
+ reader, pv.links = parse.HttpLinks(r)
+ return reader
+}
+
+var noFilterConfiguredCommands = [][]string{
+ {":open<enter>", "Open using the system handler"},
+ {":save<space>", "Save to file"},
+ {":pipe<space>", "Pipe to shell command"},
+}
+
+func newNoFilterConfigured(account string, part *models.BodyStructure) *ui.Grid {
+ bindings := config.Binds.MessageView.ForAccount(account)
+
+ var actions []string
+
+ configured := noFilterConfiguredCommands
+ if strings.Contains(strings.ToLower(part.MIMEType), "message") {
+ configured = append(configured, []string{
+ ":eml<Enter>", "View message attachment",
+ })
+ }
+
+ for _, command := range configured {
+ cmd := command[0]
+ name := command[1]
+ strokes, _ := config.ParseKeyStrokes(cmd)
+ var inputs []string
+ for _, input := range bindings.GetReverseBindings(strokes) {
+ inputs = append(inputs, config.FormatKeyStrokes(input))
+ }
+ actions = append(actions, fmt.Sprintf(" %-6s %-29s %s",
+ strings.Join(inputs, ", "), name, cmd))
+ }
+
+ spec := []ui.GridSpec{
+ {Strategy: ui.SIZE_EXACT, Size: ui.Const(2)},
+ }
+ for i := 0; i < len(actions)-1; i++ {
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
+ }
+ // make the last element fill remaining space
+ spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)})
+
+ grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{
+ {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
+ })
+
+ uiConfig := config.Ui.ForAccount(account)
+
+ noFilter := fmt.Sprintf(`No filter configured for this mimetype ('%s')
+What would you like to do?`, part.FullMIMEType())
+ grid.AddChild(ui.NewText(noFilter,
+ uiConfig.GetStyle(config.STYLE_TITLE))).At(0, 0)
+ for i, action := range actions {
+ grid.AddChild(ui.NewText(action,
+ uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i+1, 0)
+ }
+
+ return grid
+}
+
+func (pv *PartViewer) Invalidate() {
+ ui.Invalidate()
+}
+
+func (pv *PartViewer) Draw(ctx *ui.Context) {
+ style := pv.uiConfig.GetStyle(config.STYLE_DEFAULT)
+ if pv.filter == nil {
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
+ pv.noFilter.Draw(ctx)
+ return
+ }
+ if !pv.fetched {
+ pv.msg.FetchBodyPart(pv.index, pv.SetSource)
+ pv.fetched = true
+ }
+ if pv.err != nil {
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
+ ctx.Printf(0, 0, style, "%s", pv.err.Error())
+ return
+ }
+ if pv.term != nil {
+ pv.term.Draw(ctx)
+ }
+}
+
+func (pv *PartViewer) Cleanup() {
+ if pv.term != nil {
+ pv.term.Close()
+ }
+}
+
+func (pv *PartViewer) Event(event tcell.Event) bool {
+ if pv.term != nil {
+ return pv.term.Event(event)
+ }
+ return false
+}
+
+type HeaderView struct {
+ Name string
+ Value string
+ ValueField ui.Drawable
+ uiConfig *config.UIConfig
+}
+
+func (hv *HeaderView) Draw(ctx *ui.Context) {
+ name := hv.Name
+ size := runewidth.StringWidth(name + ":")
+ lim := ctx.Width() - size - 1
+ if lim <= 0 || ctx.Height() <= 0 {
+ return
+ }
+ value := runewidth.Truncate(" "+hv.Value, lim, "…")
+
+ vstyle := hv.uiConfig.GetStyle(config.STYLE_DEFAULT)
+ hstyle := hv.uiConfig.GetStyle(config.STYLE_HEADER)
+
+ // TODO: Make this more robust and less dumb
+ if hv.Name == "PGP" {
+ vstyle = hv.uiConfig.GetStyle(config.STYLE_SUCCESS)
+ }
+
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', vstyle)
+ ctx.Printf(0, 0, hstyle, "%s:", name)
+ if hv.ValueField == nil {
+ ctx.Printf(size, 0, vstyle, "%s", value)
+ } else {
+ hv.ValueField.Draw(ctx.Subcontext(size, 0, lim, 1))
+ }
+}
+
+func (hv *HeaderView) Invalidate() {
+ ui.Invalidate()
+}
diff --git a/app/pgpinfo.go b/app/pgpinfo.go
new file mode 100644
index 00000000..d5bbc696
--- /dev/null
+++ b/app/pgpinfo.go
@@ -0,0 +1,98 @@
+package app
+
+import (
+ "fmt"
+ "strings"
+ "unicode/utf8"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/models"
+ "github.com/gdamore/tcell/v2"
+)
+
+type PGPInfo struct {
+ details *models.MessageDetails
+ uiConfig *config.UIConfig
+}
+
+func NewPGPInfo(details *models.MessageDetails, uiConfig *config.UIConfig) *PGPInfo {
+ return &PGPInfo{details: details, uiConfig: uiConfig}
+}
+
+func (p *PGPInfo) DrawSignature(ctx *ui.Context) {
+ errorStyle := p.uiConfig.GetStyle(config.STYLE_ERROR)
+ warningStyle := p.uiConfig.GetStyle(config.STYLE_WARNING)
+ validStyle := p.uiConfig.GetStyle(config.STYLE_SUCCESS)
+ defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)
+
+ var icon string
+ var indicatorStyle, textstyle tcell.Style
+ textstyle = defaultStyle
+ var indicatorText, messageText string
+ // TODO: Nicer prompt for TOFU, fetch from keyserver, etc
+ switch p.details.SignatureValidity {
+ case models.UnknownEntity:
+ icon = p.uiConfig.IconUnknown
+ indicatorStyle = warningStyle
+ indicatorText = "Unknown"
+ messageText = fmt.Sprintf("Signed with unknown key (%8X); authenticity unknown", p.details.SignedByKeyId)
+ case models.Valid:
+ icon = p.uiConfig.IconSigned
+ if p.details.IsEncrypted && p.uiConfig.IconSignedEncrypted != "" {
+ icon = p.uiConfig.IconSignedEncrypted
+ }
+ indicatorStyle = validStyle
+ indicatorText = "Authentic"
+ messageText = fmt.Sprintf("Signature from %s (%8X)", p.details.SignedBy, p.details.SignedByKeyId)
+ default:
+ icon = p.uiConfig.IconInvalid
+ indicatorStyle = errorStyle
+ indicatorText = "Invalid signature!"
+ messageText = fmt.Sprintf("This message may have been tampered with! (%s)", p.details.SignatureError)
+ }
+
+ x := ctx.Printf(0, 0, indicatorStyle, "%s %s ", icon, indicatorText)
+ ctx.Printf(x, 0, textstyle, messageText)
+}
+
+func (p *PGPInfo) DrawEncryption(ctx *ui.Context, y int) {
+ warningStyle := p.uiConfig.GetStyle(config.STYLE_WARNING)
+ validStyle := p.uiConfig.GetStyle(config.STYLE_SUCCESS)
+ defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)
+
+ // if a sign-encrypt combination icon is set, use that
+ icon := p.uiConfig.IconEncrypted
+ if p.details.IsSigned && p.details.SignatureValidity == models.Valid && p.uiConfig.IconSignedEncrypted != "" {
+ icon = strings.Repeat(" ", utf8.RuneCountInString(p.uiConfig.IconSignedEncrypted))
+ }
+
+ x := ctx.Printf(0, y, validStyle, "%s Encrypted", icon)
+ x += ctx.Printf(x+1, y, defaultStyle, "To %s (%8X) ", p.details.DecryptedWith, p.details.DecryptedWithKeyId)
+ if !p.details.IsSigned {
+ ctx.Printf(x, y, warningStyle, "(message not signed!)")
+ }
+}
+
+func (p *PGPInfo) Draw(ctx *ui.Context) {
+ warningStyle := p.uiConfig.GetStyle(config.STYLE_WARNING)
+ defaultStyle := p.uiConfig.GetStyle(config.STYLE_DEFAULT)
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
+
+ switch {
+ case p.details == nil && p.uiConfig.IconUnencrypted != "":
+ x := ctx.Printf(0, 0, warningStyle, "%s ", p.uiConfig.IconUnencrypted)
+ ctx.Printf(x, 0, defaultStyle, "message unencrypted and unsigned")
+ case p.details.IsSigned && p.details.IsEncrypted:
+ p.DrawSignature(ctx)
+ p.DrawEncryption(ctx, 1)
+ case p.details.IsSigned:
+ p.DrawSignature(ctx)
+ case p.details.IsEncrypted:
+ p.DrawEncryption(ctx, 0)
+ }
+}
+
+func (p *PGPInfo) Invalidate() {
+ ui.Invalidate()
+}
diff --git a/app/providesmessage.go b/app/providesmessage.go
new file mode 100644
index 00000000..c89c811f
--- /dev/null
+++ b/app/providesmessage.go
@@ -0,0 +1,30 @@
+package app
+
+import (
+ "git.sr.ht/~rjarry/aerc/lib"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/models"
+)
+
+type PartInfo struct {
+ Index []int
+ Msg *models.MessageInfo
+ Part *models.BodyStructure
+ Links []string
+}
+
+type ProvidesMessage interface {
+ ui.Drawable
+ Store() *lib.MessageStore
+ SelectedAccount() *AccountView
+ SelectedMessage() (*models.MessageInfo, error)
+ SelectedMessagePart() *PartInfo
+}
+
+type ProvidesMessages interface {
+ ui.Drawable
+ Store() *lib.MessageStore
+ SelectedAccount() *AccountView
+ SelectedMessage() (*models.MessageInfo, error)
+ MarkedMessages() ([]uint32, error)
+}
diff --git a/app/scrollable.go b/app/scrollable.go
new file mode 100644
index 00000000..bca20232
--- /dev/null
+++ b/app/scrollable.go
@@ -0,0 +1,67 @@
+package app
+
+// Scrollable implements vertical scrolling
+type Scrollable struct {
+ scroll int
+ height int
+ elems int
+}
+
+func (s *Scrollable) Scroll() int {
+ return s.scroll
+}
+
+func (s *Scrollable) PercentVisible() float64 {
+ if s.elems <= 0 {
+ return 1.0
+ }
+ return float64(s.height) / float64(s.elems)
+}
+
+func (s *Scrollable) PercentScrolled() float64 {
+ if s.elems <= 0 {
+ return 1.0
+ }
+ return float64(s.scroll) / float64(s.elems)
+}
+
+func (s *Scrollable) NeedScrollbar() bool {
+ needScrollbar := true
+ if s.PercentVisible() >= 1.0 {
+ needScrollbar = false
+ }
+ return needScrollbar
+}
+
+func (s *Scrollable) UpdateScroller(height, elems int) {
+ s.height = height
+ s.elems = elems
+}
+
+func (s *Scrollable) EnsureScroll(selectingIdx int) {
+ if selectingIdx < 0 {
+ return
+ }
+
+ maxScroll := s.elems - s.height
+ if maxScroll < 0 {
+ maxScroll = 0
+ }
+
+ if selectingIdx >= s.scroll && selectingIdx < s.scroll+s.height {
+ if s.scroll > maxScroll {
+ s.scroll = maxScroll
+ }
+ return
+ }
+
+ if selectingIdx >= s.scroll+s.height {
+ s.scroll = selectingIdx - s.height + 1
+ } else if selectingIdx < s.scroll {
+ s.scroll = selectingIdx
+ }
+
+ if s.scroll > maxScroll {
+ s.scroll = maxScroll
+ }
+}
diff --git a/app/selector.go b/app/selector.go
new file mode 100644
index 00000000..7f4730d0
--- /dev/null
+++ b/app/selector.go
@@ -0,0 +1,263 @@
+package app
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/mattn/go-runewidth"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+)
+
+type Selector struct {
+ chooser bool
+ focused bool
+ focus int
+ options []string
+ uiConfig *config.UIConfig
+
+ onChoose func(option string)
+ onSelect func(option string)
+}
+
+func NewSelector(options []string, focus int, uiConfig *config.UIConfig) *Selector {
+ return &Selector{
+ focus: focus,
+ options: options,
+ uiConfig: uiConfig,
+ }
+}
+
+func (sel *Selector) Chooser(chooser bool) *Selector {
+ sel.chooser = chooser
+ return sel
+}
+
+func (sel *Selector) Invalidate() {
+ ui.Invalidate()
+}
+
+func (sel *Selector) Draw(ctx *ui.Context) {
+ defaultSelectorStyle := sel.uiConfig.GetStyle(config.STYLE_SELECTOR_DEFAULT)
+ w, h := ctx.Width(), ctx.Height()
+ ctx.Fill(0, 0, w, h, ' ', defaultSelectorStyle)
+
+ if w < 5 || h < 1 {
+ // if width and height are that small, don't even try to draw
+ // something
+ return
+ }
+
+ y := 1
+ if h == 1 {
+ y = 0
+ }
+
+ format := "[%s]"
+
+ calculateWidth := func(space int) int {
+ neededWidth := 2
+ for i, option := range sel.options {
+ neededWidth += runewidth.StringWidth(fmt.Sprintf(format, option))
+ if i < len(sel.options)-1 {
+ neededWidth += space
+ }
+ }
+ return neededWidth - space
+ }
+
+ space := 5
+ for ; space > 0; space-- {
+ if w > calculateWidth(space) {
+ break
+ }
+ }
+
+ x := 2
+ for i, option := range sel.options {
+ style := defaultSelectorStyle
+ if sel.focus == i {
+ if sel.focused {
+ style = sel.uiConfig.GetStyle(config.STYLE_SELECTOR_FOCUSED)
+ } else if sel.chooser {
+ style = sel.uiConfig.GetStyle(config.STYLE_SELECTOR_CHOOSER)
+ }
+ }
+
+ if space == 0 {
+ if sel.focus == i {
+ leftArrow, rightArrow := ' ', ' '
+ if i > 0 {
+ leftArrow = '❮'
+ }
+ if i < len(sel.options)-1 {
+ rightArrow = '❯'
+ }
+
+ s := runewidth.Truncate(option,
+ w-runewidth.RuneWidth(leftArrow)-runewidth.RuneWidth(rightArrow)-runewidth.StringWidth(fmt.Sprintf(format, "")),
+ "…")
+
+ nextPos := 0
+ nextPos += ctx.Printf(nextPos, y, defaultSelectorStyle, "%c", leftArrow)
+ nextPos += ctx.Printf(nextPos, y, style, format, s)
+ ctx.Printf(nextPos, y, defaultSelectorStyle, "%c", rightArrow)
+ }
+ } else {
+ x += ctx.Printf(x, y, style, format, option)
+ x += space
+ }
+ }
+}
+
+func (sel *Selector) OnChoose(fn func(option string)) *Selector {
+ sel.onChoose = fn
+ return sel
+}
+
+func (sel *Selector) OnSelect(fn func(option string)) *Selector {
+ sel.onSelect = fn
+ return sel
+}
+
+func (sel *Selector) Select(option string) {
+ for i, opt := range sel.options {
+ if option == opt {
+ sel.focus = i
+ if sel.onSelect != nil {
+ sel.onSelect(opt)
+ }
+ break
+ }
+ }
+}
+
+func (sel *Selector) Selected() string {
+ return sel.options[sel.focus]
+}
+
+func (sel *Selector) Focus(focus bool) {
+ sel.focused = focus
+ sel.Invalidate()
+}
+
+func (sel *Selector) Event(event tcell.Event) bool {
+ if event, ok := event.(*tcell.EventKey); ok {
+ switch event.Key() {
+ case tcell.KeyCtrlH:
+ fallthrough
+ case tcell.KeyLeft:
+ if sel.focus > 0 {
+ sel.focus--
+ sel.Invalidate()
+ }
+ if sel.onSelect != nil {
+ sel.onSelect(sel.Selected())
+ }
+ case tcell.KeyCtrlL:
+ fallthrough
+ case tcell.KeyRight:
+ if sel.focus < len(sel.options)-1 {
+ sel.focus++
+ sel.Invalidate()
+ }
+ if sel.onSelect != nil {
+ sel.onSelect(sel.Selected())
+ }
+ case tcell.KeyEnter:
+ if sel.onChoose != nil {
+ sel.onChoose(sel.Selected())
+ }
+ }
+ }
+ return false
+}
+
+var ErrNoOptionSelected = fmt.Errorf("no option selected")
+
+type SelectorDialog struct {
+ callback func(string, error)
+ title string
+ prompt string
+ uiConfig *config.UIConfig
+ selector *Selector
+}
+
+func NewSelectorDialog(title string, prompt string, options []string, focus int,
+ uiConfig *config.UIConfig, cb func(string, error),
+) *SelectorDialog {
+ sd := &SelectorDialog{
+ callback: cb,
+ title: title,
+ prompt: strings.TrimSpace(prompt),
+ uiConfig: uiConfig,
+ selector: NewSelector(options, focus, uiConfig).Chooser(true),
+ }
+ sd.selector.Focus(true)
+ return sd
+}
+
+func (gp *SelectorDialog) Draw(ctx *ui.Context) {
+ defaultStyle := gp.uiConfig.GetStyle(config.STYLE_DEFAULT)
+ titleStyle := gp.uiConfig.GetStyle(config.STYLE_TITLE)
+
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', defaultStyle)
+ ctx.Fill(0, 0, ctx.Width(), 1, ' ', titleStyle)
+ ctx.Printf(1, 0, titleStyle, "%s", gp.title)
+ var i int
+ lines := strings.Split(gp.prompt, "\n")
+ for i = 0; i < len(lines); i++ {
+ ctx.Printf(1, 2+i, defaultStyle, "%s", lines[i])
+ }
+ gp.selector.Draw(ctx.Subcontext(1, ctx.Height()-1, ctx.Width()-2, 1))
+}
+
+func (gp *SelectorDialog) ContextHeight() (func(int) int, func(int) int) {
+ totalHeight := 2 // title + empty line
+ totalHeight += strings.Count(gp.prompt, "\n") + 1
+ totalHeight += 2 // empty line + selector
+ start := func(h int) int {
+ s := h/2 - totalHeight/2
+ if s < 0 {
+ s = 0
+ }
+ return s
+ }
+ height := func(h int) int {
+ if totalHeight > h {
+ return h
+ } else {
+ return totalHeight
+ }
+ }
+ return start, height
+}
+
+func (gp *SelectorDialog) Invalidate() {
+ ui.Invalidate()
+}
+
+func (gp *SelectorDialog) Event(event tcell.Event) bool {
+ switch event := event.(type) {
+ case *tcell.EventKey:
+ switch event.Key() {
+ case tcell.KeyEnter:
+ gp.selector.Focus(false)
+ gp.callback(gp.selector.Selected(), nil)
+ case tcell.KeyEsc:
+ gp.selector.Focus(false)
+ gp.callback("", ErrNoOptionSelected)
+ default:
+ gp.selector.Event(event)
+ }
+ default:
+ gp.selector.Event(event)
+ }
+ return true
+}
+
+func (gp *SelectorDialog) Focus(f bool) {
+ gp.selector.Focus(f)
+}
diff --git a/app/spinner.go b/app/spinner.go
new file mode 100644
index 00000000..bcf8168a
--- /dev/null
+++ b/app/spinner.go
@@ -0,0 +1,86 @@
+package app
+
+import (
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+)
+
+type Spinner struct {
+ frame int64 // access via atomic
+ frames []string
+ interval time.Duration
+ stop chan struct{}
+ style tcell.Style
+}
+
+func NewSpinner(uiConf *config.UIConfig) *Spinner {
+ spinner := Spinner{
+ stop: make(chan struct{}),
+ frame: -1,
+ interval: uiConf.SpinnerInterval,
+ frames: strings.Split(uiConf.Spinner, uiConf.SpinnerDelimiter),
+ style: uiConf.GetStyle(config.STYLE_SPINNER),
+ }
+ return &spinner
+}
+
+func (s *Spinner) Start() {
+ if s.IsRunning() {
+ return
+ }
+
+ atomic.StoreInt64(&s.frame, 0)
+
+ go func() {
+ defer log.PanicHandler()
+
+ for {
+ select {
+ case <-s.stop:
+ atomic.StoreInt64(&s.frame, -1)
+ s.stop <- struct{}{}
+ return
+ case <-time.After(s.interval):
+ atomic.AddInt64(&s.frame, 1)
+ ui.Invalidate()
+ }
+ }
+ }()
+}
+
+func (s *Spinner) Stop() {
+ if !s.IsRunning() {
+ return
+ }
+
+ s.stop <- struct{}{}
+ <-s.stop
+ s.Invalidate()
+}
+
+func (s *Spinner) IsRunning() bool {
+ return atomic.LoadInt64(&s.frame) != -1
+}
+
+func (s *Spinner) Draw(ctx *ui.Context) {
+ if !s.IsRunning() {
+ s.Start()
+ }
+
+ cur := int(atomic.LoadInt64(&s.frame) % int64(len(s.frames)))
+
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', s.style)
+ col := ctx.Width()/2 - len(s.frames[0])/2 + 1
+ ctx.Printf(col, 0, s.style, "%s", s.frames[cur])
+}
+
+func (s *Spinner) Invalidate() {
+ ui.Invalidate()
+}
diff --git a/app/status.go b/app/status.go
new file mode 100644
index 00000000..f6919e29
--- /dev/null
+++ b/app/status.go
@@ -0,0 +1,166 @@
+package app
+
+import (
+ "bytes"
+ "sync"
+ "time"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/mattn/go-runewidth"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/state"
+ "git.sr.ht/~rjarry/aerc/lib/templates"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+)
+
+type StatusLine struct {
+ sync.Mutex
+ stack []*StatusMessage
+ aerc *Aerc
+ acct *AccountView
+ err string
+}
+
+type StatusMessage struct {
+ style tcell.Style
+ message string
+}
+
+func (status *StatusLine) Invalidate() {
+ ui.Invalidate()
+}
+
+func (status *StatusLine) Draw(ctx *ui.Context) {
+ status.Lock()
+ defer status.Unlock()
+ style := status.uiConfig().GetStyle(config.STYLE_STATUSLINE_DEFAULT)
+ ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
+ switch {
+ case len(status.stack) != 0:
+ line := status.stack[len(status.stack)-1]
+ msg := runewidth.Truncate(line.message, ctx.Width(), "")
+ msg = runewidth.FillRight(msg, ctx.Width())
+ ctx.Printf(0, 0, line.style, "%s", msg)
+ case status.err != "":
+ msg := runewidth.Truncate(status.err, ctx.Width(), "")
+ msg = runewidth.FillRight(msg, ctx.Width())
+ style := status.uiConfig().GetStyle(config.STYLE_STATUSLINE_ERROR)
+ ctx.Printf(0, 0, style, "%s", msg)
+ case status.aerc != nil && status.acct != nil:
+ data := state.NewDataSetter()
+ data.SetPendingKeys(status.aerc.pendingKeys)
+ data.SetState(&status.acct.state)
+ data.SetAccount(status.acct.acct)
+ data.SetFolder(status.acct.Directories().SelectedDirectory())
+ msg, _ := status.acct.SelectedMessage()
+ data.SetInfo(msg, 0, false)
+ table := ui.NewTable(
+ ctx.Height(),
+ config.Statusline.StatusColumns,
+ config.Statusline.ColumnSeparator,
+ nil,
+ func(*ui.Table, int) tcell.Style { return style },
+ )
+ var buf bytes.Buffer
+ cells := make([]string, len(table.Columns))
+ for c, col := range table.Columns {
+ err := templates.Render(col.Def.Template, &buf,
+ data.Data())
+ if err != nil {
+ log.Errorf("%s", err)
+ cells[c] = err.Error()
+ } else {
+ cells[c] = buf.String()
+ }
+ buf.Reset()
+ }
+ table.AddRow(cells, nil)
+ table.Draw(ctx)
+ }
+}
+
+func (status *StatusLine) Update(acct *AccountView) {
+ status.acct = acct
+ status.Invalidate()
+}
+
+func (status *StatusLine) SetError(err string) {
+ prev := status.err
+ status.err = err
+ if prev != status.err {
+ status.Invalidate()
+ }
+}
+
+func (status *StatusLine) Clear() {
+ status.SetError("")
+ status.acct = nil
+}
+
+func (status *StatusLine) Push(text string, expiry time.Duration) *StatusMessage {
+ status.Lock()
+ defer status.Unlock()
+ log.Debugf(text)
+ msg := &StatusMessage{
+ style: status.uiConfig().GetStyle(config.STYLE_STATUSLINE_DEFAULT),
+ message: text,
+ }
+ status.stack = append(status.stack, msg)
+ go (func() {
+ defer log.PanicHandler()
+
+ time.Sleep(expiry)
+ status.Lock()
+ defer status.Unlock()
+ for i, m := range status.stack {
+ if m == msg {
+ status.stack = append(status.stack[:i], status.stack[i+1:]...)
+ break
+ }
+ }
+ status.Invalidate()
+ })()
+ status.Invalidate()
+ return msg
+}
+
+func (status *StatusLine) PushError(text string) *StatusMessage {
+ log.Errorf(text)
+ msg := status.Push(text, 10*time.Second)
+ msg.Color(status.uiConfig().GetStyle(config.STYLE_STATUSLINE_ERROR))
+ return msg
+}
+
+func (status *StatusLine) PushWarning(text string) *StatusMessage {
+ log.Warnf(text)
+ msg := status.Push(text, 10*time.Second)
+ msg.Color(status.uiConfig().GetStyle(config.STYLE_STATUSLINE_WARNING))
+ return msg
+}
+
+func (status *StatusLine) PushSuccess(text string) *StatusMessage {
+ log.Tracef(text)
+ msg := status.Push(text, 10*time.Second)
+ msg.Color(status.uiConfig().GetStyle(config.STYLE_STATUSLINE_SUCCESS))
+ return msg
+}
+
+func (status *StatusLine) Expire() {
+ status.Lock()
+ defer status.Unlock()
+ status.stack = nil
+}
+
+func (status *StatusLine) uiConfig() *config.UIConfig {
+ return status.aerc.SelectedAccountUiConfig()
+}
+
+func (status *StatusLine) SetAerc(aerc *Aerc) {
+ status.aerc = aerc
+}
+
+func (msg *StatusMessage) Color(style tcell.Style) {
+ msg.style = style
+}
diff --git a/app/tabhost.go b/app/tabhost.go
new file mode 100644
index 00000000..9a206084
--- /dev/null
+++ b/app/tabhost.go
@@ -0,0 +1,15 @@
+package app
+
+import (
+ "time"
+)
+
+type TabHost interface {
+ BeginExCommand(cmd string)
+ UpdateStatus()
+ SetError(err string)
+ PushStatus(text string, expiry time.Duration) *StatusMessage
+ PushError(text string) *StatusMessage
+ PushSuccess(text string) *StatusMessage
+ Beep()
+}
diff --git a/app/terminal.go b/app/terminal.go
new file mode 100644
index 00000000..1b177f3e
--- /dev/null
+++ b/app/terminal.go
@@ -0,0 +1,178 @@
+package app
+
+import (
+ "os/exec"
+ "sync/atomic"
+
+ "git.sr.ht/~rjarry/aerc/config"
+ "git.sr.ht/~rjarry/aerc/lib/ui"
+ "git.sr.ht/~rjarry/aerc/log"
+ tcellterm "git.sr.ht/~rockorager/tcell-term"
+
+ "github.com/gdamore/tcell/v2"
+)
+
+type Terminal struct {
+ closed int32
+ cmd *exec.Cmd
+ ctx *ui.Context
+ focus bool
+ visible bool
+ vterm *tcellterm.VT
+ running bool
+
+ OnClose func(err error)
+ OnEvent func(event tcell.Event) bool
+ OnStart func()
+ OnTitle func(title string)
+}
+
+func NewTerminal(cmd *exec.Cmd) (*Terminal, error) {
+ term := &Terminal{
+ cmd: cmd,
+ vterm: tcellterm.New(),
+ visible: true,
+ }
+ term.vterm.OSC8 = config.General.EnableOSC8
+ term.vterm.TERM = config.General.Term
+ return term, nil
+}
+
+func (term *Terminal) Close() {
+ term.closeErr(nil)
+}
+
+// TODO: replace with atomic.Bool when min go version will have it (1.19+)
+const closed int32 = 1
+
+func (term *Terminal) isClosed() bool {
+ return atomic.LoadInt32(&term.closed) == closed
+}
+
+func (term *Terminal) closeErr(err error) {
+ if atomic.SwapInt32(&term.closed, closed) == closed {
+ return
+ }
+ if term.vterm != nil {
+ // Stop receiving events
+ term.vterm.Detach()
+ term.vterm.Close()
+ }
+ if term.OnClose != nil {
+ term.OnClose(err)
+ }
+ ui.Invalidate()
+}
+
+func (term *Terminal) Destroy() {
+ // If we destroy, we don't want to call the OnClose callback
+ term.OnClose = nil
+ term.closeErr(nil)
+}
+
+func (term *Terminal) Invalidate() {
+ ui.Invalidate()
+}
+
+func (term *Terminal) Draw(ctx *ui.Context) {
+ term.vterm.SetSurface(ctx.View())
+
+ w, h := ctx.View().Size()
+ if !term.isClosed() && term.ctx != nil {
+ ow, oh := term.ctx.View().Size()
+ if w != ow || h != oh {
+ term.vterm.Resize(w, h)
+ }
+ }
+ term.ctx = ctx
+ if !term.running && term.cmd != nil {
+ term.vterm.Attach(term.HandleEvent)
+ if err := term.vterm.Start(term.cmd); err != nil {
+ log.Errorf("error running terminal: %v", err)
+ term.closeErr(err)
+ return
+ }
+ term.running = true
+ if term.OnStart != nil {
+ term.OnStart()
+ }
+ }
+ term.vterm.Draw()
+ if term.focus {
+ y, x, style, vis := term.vterm.Cursor()
+ if vis && !term.isClosed() {
+ ctx.SetCursor(x, y)
+ ctx.SetCursorStyle(style)
+ } else {
+ ctx.HideCursor()
+ }
+ }
+}
+
+func (term *Terminal) Show(visible bool) {
+ term.visible = visible
+}
+
+func (term *Terminal) MouseEvent(localX int, localY int, event tcell.Event) {
+ ev, ok := event.(*tcell.EventMouse)
+ if !ok {
+ return
+ }
+ if term.OnEvent != nil {
+ term.OnEvent(ev)
+ }
+ if term.isClosed() {
+ return
+ }
+ e := tcell.NewEventMouse(localX, localY, ev.Buttons(), ev.Modifiers())
+ term.vterm.HandleEvent(e)
+}
+
+func (term *Terminal) Focus(focus bool) {
+ if term.isClosed() {
+ return
+ }
+ term.focus = focus
+ if term.ctx != nil {
+ if !term.focus {
+ term.ctx.HideCursor()
+ } else {
+ y, x, style, _ := term.vterm.Cursor()
+ term.ctx.SetCursor(x, y)
+ term.ctx.SetCursorStyle(style)
+ term.Invalidate()
+ }
+ }
+}
+
+// HandleEvent is used to watch the underlying terminal events
+func (term *Terminal) HandleEvent(ev tcell.Event) {
+ if term.isClosed() {
+ return
+ }
+ switch ev := ev.(type) {
+ case *tcellterm.EventRedraw:
+ if term.visible {
+ ui.Invalidate()
+ }
+ case *tcellterm.EventTitle:
+ if term.OnTitle != nil {
+ term.OnTitle(ev.Title())
+ }
+ case *tcellterm.EventClosed:
+ term.Close()
+ ui.Invalidate()
+ }
+}
+
+func (term *Terminal) Event(event tcell.Event) bool {
+ if term.OnEvent != nil {
+ if term.OnEvent(event) {
+ return true
+ }
+ }
+ if term.isClosed() {
+ return false
+ }
+ return term.vterm.HandleEvent(event)
+}