diff options
author | Robin Jarry <robin@jarry.cc> | 2023-10-09 13:52:20 +0200 |
---|---|---|
committer | Robin Jarry <robin@jarry.cc> | 2023-10-10 11:37:56 +0200 |
commit | 598e4a5803578ab3e291f232d6aad31b4efd8ea4 (patch) | |
tree | c55e16d60e2c3eea2d6de27d1bac18db5670ec77 /app | |
parent | 61bca76423ee87bd59084a146eca71c6bae085e1 (diff) | |
download | aerc-598e4a5803578ab3e291f232d6aad31b4efd8ea4.tar.gz |
widgets: rename package to app
This is the central point of all aerc. Having it named widgets is
confusing. Rename it to app. It will make a cleaner transition when
making the app.Aerc object available globally in the next commit.
Signed-off-by: Robin Jarry <robin@jarry.cc>
Acked-by: Moritz Poldrack <moritz@poldrack.dev>
Diffstat (limited to 'app')
-rw-r--r-- | app/account-wizard.go | 891 | ||||
-rw-r--r-- | app/account.go | 649 | ||||
-rw-r--r-- | app/aerc.go | 908 | ||||
-rw-r--r-- | app/authinfo.go | 88 | ||||
-rw-r--r-- | app/compose.go | 1975 | ||||
-rw-r--r-- | app/dialog.go | 24 | ||||
-rw-r--r-- | app/dirlist.go | 532 | ||||
-rw-r--r-- | app/dirtree.go | 495 | ||||
-rw-r--r-- | app/exline.go | 120 | ||||
-rw-r--r-- | app/getpasswd.go | 68 | ||||
-rw-r--r-- | app/headerlayout.go | 44 | ||||
-rw-r--r-- | app/listbox.go | 299 | ||||
-rw-r--r-- | app/msglist.go | 497 | ||||
-rw-r--r-- | app/msgviewer.go | 927 | ||||
-rw-r--r-- | app/pgpinfo.go | 98 | ||||
-rw-r--r-- | app/providesmessage.go | 30 | ||||
-rw-r--r-- | app/scrollable.go | 67 | ||||
-rw-r--r-- | app/selector.go | 263 | ||||
-rw-r--r-- | app/spinner.go | 86 | ||||
-rw-r--r-- | app/status.go | 166 | ||||
-rw-r--r-- | app/tabhost.go | 15 | ||||
-rw-r--r-- | app/terminal.go | 178 |
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) +} |