From 598e4a5803578ab3e291f232d6aad31b4efd8ea4 Mon Sep 17 00:00:00 2001 From: Robin Jarry Date: Mon, 9 Oct 2023 13:52:20 +0200 Subject: 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 Acked-by: Moritz Poldrack --- app/account-wizard.go | 891 +++++++++++++ app/account.go | 649 +++++++++ app/aerc.go | 908 +++++++++++++ app/authinfo.go | 88 ++ app/compose.go | 1975 ++++++++++++++++++++++++++++ app/dialog.go | 24 + app/dirlist.go | 532 ++++++++ app/dirtree.go | 495 +++++++ app/exline.go | 120 ++ app/getpasswd.go | 68 + app/headerlayout.go | 44 + app/listbox.go | 299 +++++ app/msglist.go | 497 +++++++ app/msgviewer.go | 927 +++++++++++++ app/pgpinfo.go | 98 ++ app/providesmessage.go | 30 + app/scrollable.go | 67 + app/selector.go | 263 ++++ app/spinner.go | 86 ++ app/status.go | 166 +++ app/tabhost.go | 15 + app/terminal.go | 178 +++ commands/account/cf.go | 6 +- commands/account/check-mail.go | 6 +- commands/account/clear.go | 6 +- commands/account/compose.go | 8 +- commands/account/connection.go | 6 +- commands/account/expand-folder.go | 6 +- commands/account/export-mbox.go | 8 +- commands/account/import-mbox.go | 8 +- commands/account/mkdir.go | 6 +- commands/account/next-folder.go | 6 +- commands/account/next-result.go | 6 +- commands/account/next.go | 8 +- commands/account/recover.go | 8 +- commands/account/rmdir.go | 6 +- commands/account/search.go | 8 +- commands/account/select.go | 6 +- commands/account/sort.go | 6 +- commands/account/split.go | 6 +- commands/account/view.go | 8 +- commands/cd.go | 6 +- commands/choose.go | 10 +- commands/commands.go | 22 +- commands/completion_helpers.go | 4 +- commands/compose/abort.go | 8 +- commands/compose/attach-key.go | 8 +- commands/compose/attach.go | 20 +- commands/compose/cc-bcc.go | 8 +- commands/compose/detach.go | 10 +- commands/compose/edit.go | 8 +- commands/compose/encrypt.go | 8 +- commands/compose/header.go | 8 +- commands/compose/multipart.go | 8 +- commands/compose/next-field.go | 8 +- commands/compose/postpone.go | 10 +- commands/compose/send.go | 14 +- commands/compose/sign.go | 8 +- commands/compose/switch.go | 10 +- commands/ct.go | 6 +- commands/eml.go | 12 +- commands/exec.go | 10 +- commands/help.go | 10 +- commands/move-tab.go | 6 +- commands/msg/archive.go | 8 +- commands/msg/copy.go | 6 +- commands/msg/delete.go | 10 +- commands/msg/envelope.go | 10 +- commands/msg/fold.go | 6 +- commands/msg/forward.go | 14 +- commands/msg/invite.go | 10 +- commands/msg/mark.go | 6 +- commands/msg/modify-labels.go | 6 +- commands/msg/move.go | 14 +- commands/msg/pipe.go | 16 +- commands/msg/read.go | 6 +- commands/msg/recall.go | 14 +- commands/msg/reply.go | 18 +- commands/msg/toggle-thread-context.go | 6 +- commands/msg/toggle-threads.go | 6 +- commands/msg/unsubscribe.go | 22 +- commands/msg/utils.go | 10 +- commands/msgview/close.go | 8 +- commands/msgview/next-part.go | 8 +- commands/msgview/next.go | 10 +- commands/msgview/open-link.go | 8 +- commands/msgview/open.go | 8 +- commands/msgview/save.go | 14 +- commands/msgview/toggle-headers.go | 8 +- commands/msgview/toggle-key-passthrough.go | 8 +- commands/new-account.go | 8 +- commands/next-tab.go | 6 +- commands/pin-tab.go | 6 +- commands/prompt.go | 6 +- commands/pwd.go | 6 +- commands/quit.go | 6 +- commands/term.go | 10 +- commands/terminal/close.go | 8 +- commands/util.go | 8 +- commands/z.go | 6 +- doc/aerc-templates.7.scd | 2 +- main.go | 18 +- widgets/account-wizard.go | 891 ------------- widgets/account.go | 649 --------- widgets/aerc.go | 908 ------------- widgets/authinfo.go | 88 -- widgets/compose.go | 1975 ---------------------------- widgets/dialog.go | 24 - widgets/dirlist.go | 532 -------- widgets/dirtree.go | 495 ------- widgets/exline.go | 120 -- widgets/getpasswd.go | 68 - widgets/headerlayout.go | 44 - widgets/listbox.go | 299 ----- widgets/msglist.go | 497 ------- widgets/msgviewer.go | 927 ------------- widgets/pgpinfo.go | 98 -- widgets/providesmessage.go | 30 - widgets/scrollable.go | 67 - widgets/selector.go | 263 ---- widgets/spinner.go | 86 -- widgets/status.go | 166 --- widgets/tabhost.go | 15 - widgets/terminal.go | 178 --- 124 files changed, 8770 insertions(+), 8770 deletions(-) create mode 100644 app/account-wizard.go create mode 100644 app/account.go create mode 100644 app/aerc.go create mode 100644 app/authinfo.go create mode 100644 app/compose.go create mode 100644 app/dialog.go create mode 100644 app/dirlist.go create mode 100644 app/dirtree.go create mode 100644 app/exline.go create mode 100644 app/getpasswd.go create mode 100644 app/headerlayout.go create mode 100644 app/listbox.go create mode 100644 app/msglist.go create mode 100644 app/msgviewer.go create mode 100644 app/pgpinfo.go create mode 100644 app/providesmessage.go create mode 100644 app/scrollable.go create mode 100644 app/selector.go create mode 100644 app/spinner.go create mode 100644 app/status.go create mode 100644 app/tabhost.go create mode 100644 app/terminal.go delete mode 100644 widgets/account-wizard.go delete mode 100644 widgets/account.go delete mode 100644 widgets/aerc.go delete mode 100644 widgets/authinfo.go delete mode 100644 widgets/compose.go delete mode 100644 widgets/dialog.go delete mode 100644 widgets/dirlist.go delete mode 100644 widgets/dirtree.go delete mode 100644 widgets/exline.go delete mode 100644 widgets/getpasswd.go delete mode 100644 widgets/headerlayout.go delete mode 100644 widgets/listbox.go delete mode 100644 widgets/msglist.go delete mode 100644 widgets/msgviewer.go delete mode 100644 widgets/pgpinfo.go delete mode 100644 widgets/providesmessage.go delete mode 100644 widgets/scrollable.go delete mode 100644 widgets/selector.go delete mode 100644 widgets/spinner.go delete mode 100644 widgets/status.go delete mode 100644 widgets/tabhost.go delete mode 100644 widgets/terminal.go 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: + + , or Next field + , or Previous field + 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 ':'. + // Force a '//' opaque suffix so that it is rendered as '://'. + 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 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", "Send", ""}, + {":edit", "Edit", ""}, + {":attach", "Add attachment", ""}, + {":detach", "Remove attachment", ""}, + {":postpone", "Postpone", ""}, + {":preview", "Preview message", ""}, + {":abort", "Abort (discard message, no confirmation)", ""}, + {":choose -o d discard abort -o p postpone postpone", "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", "Open using the system handler"}, + {":save", "Save to file"}, + {":pipe", "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", "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) +} diff --git a/commands/account/cf.go b/commands/account/cf.go index f0fa3b67..38841474 100644 --- a/commands/account/cf.go +++ b/commands/account/cf.go @@ -4,9 +4,9 @@ import ( "errors" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/lib/state" - "git.sr.ht/~rjarry/aerc/widgets" ) var history map[string]string @@ -22,11 +22,11 @@ func (ChangeFolder) Aliases() []string { return []string{"cf"} } -func (ChangeFolder) Complete(aerc *widgets.Aerc, args []string) []string { +func (ChangeFolder) Complete(aerc *app.Aerc, args []string) []string { return commands.GetFolders(aerc, args) } -func (ChangeFolder) Execute(aerc *widgets.Aerc, args []string) error { +func (ChangeFolder) Execute(aerc *app.Aerc, args []string) error { if len(args) == 1 { return errors.New("Usage: cf ") } diff --git a/commands/account/check-mail.go b/commands/account/check-mail.go index eb57c0c0..2b6a06f9 100644 --- a/commands/account/check-mail.go +++ b/commands/account/check-mail.go @@ -3,7 +3,7 @@ package account import ( "errors" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type CheckMail struct{} @@ -16,11 +16,11 @@ func (CheckMail) Aliases() []string { return []string{"check-mail"} } -func (CheckMail) Complete(aerc *widgets.Aerc, args []string) []string { +func (CheckMail) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (CheckMail) Execute(aerc *widgets.Aerc, args []string) error { +func (CheckMail) Execute(aerc *app.Aerc, args []string) error { acct := aerc.SelectedAccount() if acct == nil { return errors.New("No account selected") diff --git a/commands/account/clear.go b/commands/account/clear.go index a383b621..547c26c4 100644 --- a/commands/account/clear.go +++ b/commands/account/clear.go @@ -3,8 +3,8 @@ package account import ( "errors" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/state" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" ) @@ -18,11 +18,11 @@ func (Clear) Aliases() []string { return []string{"clear"} } -func (Clear) Complete(aerc *widgets.Aerc, args []string) []string { +func (Clear) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Clear) Execute(aerc *widgets.Aerc, args []string) error { +func (Clear) Execute(aerc *app.Aerc, args []string) error { acct := aerc.SelectedAccount() if acct == nil { return errors.New("No account selected") diff --git a/commands/account/compose.go b/commands/account/compose.go index 5da0f163..e2812251 100644 --- a/commands/account/compose.go +++ b/commands/account/compose.go @@ -10,8 +10,8 @@ import ( "github.com/emersion/go-message/mail" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/config" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" ) @@ -25,11 +25,11 @@ func (Compose) Aliases() []string { return []string{"compose"} } -func (Compose) Complete(aerc *widgets.Aerc, args []string) []string { +func (Compose) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Compose) Execute(aerc *widgets.Aerc, args []string) error { +func (Compose) Execute(aerc *app.Aerc, args []string) error { body, template, editHeaders, err := buildBody(args) if err != nil { return err @@ -50,7 +50,7 @@ func (Compose) Execute(aerc *widgets.Aerc, args []string) error { } headers := mail.HeaderFromMap(msg.Header) - composer, err := widgets.NewComposer(aerc, acct, + composer, err := app.NewComposer(aerc, acct, acct.AccountConfig(), acct.Worker(), editHeaders, template, &headers, nil, msg.Body) if err != nil { diff --git a/commands/account/connection.go b/commands/account/connection.go index 0a67b2fe..2704d9bb 100644 --- a/commands/account/connection.go +++ b/commands/account/connection.go @@ -3,8 +3,8 @@ package account import ( "errors" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/state" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -18,11 +18,11 @@ func (Connection) Aliases() []string { return []string{"connect", "disconnect"} } -func (Connection) Complete(aerc *widgets.Aerc, args []string) []string { +func (Connection) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Connection) Execute(aerc *widgets.Aerc, args []string) error { +func (Connection) Execute(aerc *app.Aerc, args []string) error { acct := aerc.SelectedAccount() if acct == nil { return errors.New("No account selected") diff --git a/commands/account/expand-folder.go b/commands/account/expand-folder.go index 3eafaa09..8a7a8e93 100644 --- a/commands/account/expand-folder.go +++ b/commands/account/expand-folder.go @@ -4,7 +4,7 @@ import ( "errors" "fmt" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type ExpandCollapseFolder struct{} @@ -17,11 +17,11 @@ func (ExpandCollapseFolder) Aliases() []string { return []string{"expand-folder", "collapse-folder"} } -func (ExpandCollapseFolder) Complete(aerc *widgets.Aerc, args []string) []string { +func (ExpandCollapseFolder) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (ExpandCollapseFolder) Execute(aerc *widgets.Aerc, args []string) error { +func (ExpandCollapseFolder) Execute(aerc *app.Aerc, args []string) error { if len(args) > 1 { return expandCollapseFolderUsage(args[0]) } diff --git a/commands/account/export-mbox.go b/commands/account/export-mbox.go index 8981261b..c227bdf9 100644 --- a/commands/account/export-mbox.go +++ b/commands/account/export-mbox.go @@ -8,9 +8,9 @@ import ( "sync" "time" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~rjarry/aerc/widgets" mboxer "git.sr.ht/~rjarry/aerc/worker/mbox" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -25,11 +25,11 @@ func (ExportMbox) Aliases() []string { return []string{"export-mbox"} } -func (ExportMbox) Complete(aerc *widgets.Aerc, args []string) []string { +func (ExportMbox) Complete(aerc *app.Aerc, args []string) []string { return commands.CompletePath(filepath.Join(args...)) } -func (ExportMbox) Execute(aerc *widgets.Aerc, args []string) error { +func (ExportMbox) Execute(aerc *app.Aerc, args []string) error { if len(args) != 2 { return exportFolderUsage(args[0]) } @@ -59,7 +59,7 @@ func (ExportMbox) Execute(aerc *widgets.Aerc, args []string) error { var uids []uint32 // check if something is marked - we export that then - msgProvider, ok := aerc.SelectedTabContent().(widgets.ProvidesMessages) + msgProvider, ok := aerc.SelectedTabContent().(app.ProvidesMessages) if !ok { msgProvider = aerc.SelectedAccount() } diff --git a/commands/account/import-mbox.go b/commands/account/import-mbox.go index 85e9a341..2a441737 100644 --- a/commands/account/import-mbox.go +++ b/commands/account/import-mbox.go @@ -10,10 +10,10 @@ import ( "sync/atomic" "time" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" mboxer "git.sr.ht/~rjarry/aerc/worker/mbox" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -28,11 +28,11 @@ func (ImportMbox) Aliases() []string { return []string{"import-mbox"} } -func (ImportMbox) Complete(aerc *widgets.Aerc, args []string) []string { +func (ImportMbox) Complete(aerc *app.Aerc, args []string) []string { return commands.CompletePath(filepath.Join(args...)) } -func (ImportMbox) Execute(aerc *widgets.Aerc, args []string) error { +func (ImportMbox) Execute(aerc *app.Aerc, args []string) error { if len(args) != 2 { return importFolderUsage(args[0]) } @@ -129,7 +129,7 @@ func (ImportMbox) Execute(aerc *widgets.Aerc, args []string) error { } if len(store.Uids()) > 0 { - confirm := widgets.NewSelectorDialog( + confirm := app.NewSelectorDialog( "Selected directory is not empty", fmt.Sprintf("Import mbox file to %s anyways?", folder), []string{"No", "Yes"}, 0, aerc.SelectedAccountUiConfig(), diff --git a/commands/account/mkdir.go b/commands/account/mkdir.go index 02d997e4..79e0a4fa 100644 --- a/commands/account/mkdir.go +++ b/commands/account/mkdir.go @@ -5,7 +5,7 @@ import ( "strings" "time" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -19,7 +19,7 @@ func (MakeDir) Aliases() []string { return []string{"mkdir"} } -func (MakeDir) Complete(aerc *widgets.Aerc, args []string) []string { +func (MakeDir) Complete(aerc *app.Aerc, args []string) []string { if len(args) == 0 { return nil } @@ -41,7 +41,7 @@ func (MakeDir) Complete(aerc *widgets.Aerc, args []string) []string { return inboxes } -func (MakeDir) Execute(aerc *widgets.Aerc, args []string) error { +func (MakeDir) Execute(aerc *app.Aerc, args []string) error { if len(args) == 0 { return errors.New("Usage: :mkdir ") } diff --git a/commands/account/next-folder.go b/commands/account/next-folder.go index e3541e52..b0657ff1 100644 --- a/commands/account/next-folder.go +++ b/commands/account/next-folder.go @@ -5,7 +5,7 @@ import ( "fmt" "strconv" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type NextPrevFolder struct{} @@ -18,11 +18,11 @@ func (NextPrevFolder) Aliases() []string { return []string{"next-folder", "prev-folder"} } -func (NextPrevFolder) Complete(aerc *widgets.Aerc, args []string) []string { +func (NextPrevFolder) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (NextPrevFolder) Execute(aerc *widgets.Aerc, args []string) error { +func (NextPrevFolder) Execute(aerc *app.Aerc, args []string) error { if len(args) > 2 { return nextPrevFolderUsage(args[0]) } diff --git a/commands/account/next-result.go b/commands/account/next-result.go index 922f95a1..06478f0c 100644 --- a/commands/account/next-result.go +++ b/commands/account/next-result.go @@ -4,8 +4,8 @@ import ( "errors" "fmt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/ui" - "git.sr.ht/~rjarry/aerc/widgets" ) type NextPrevResult struct{} @@ -18,11 +18,11 @@ func (NextPrevResult) Aliases() []string { return []string{"next-result", "prev-result"} } -func (NextPrevResult) Complete(aerc *widgets.Aerc, args []string) []string { +func (NextPrevResult) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (NextPrevResult) Execute(aerc *widgets.Aerc, args []string) error { +func (NextPrevResult) Execute(aerc *app.Aerc, args []string) error { if len(args) > 1 { return nextPrevResultUsage(args[0]) } diff --git a/commands/account/next.go b/commands/account/next.go index 15dc5363..224534b9 100644 --- a/commands/account/next.go +++ b/commands/account/next.go @@ -6,8 +6,8 @@ import ( "strconv" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/ui" - "git.sr.ht/~rjarry/aerc/widgets" ) type NextPrevMsg struct{} @@ -20,11 +20,11 @@ func (NextPrevMsg) Aliases() []string { return []string{"next", "next-message", "prev", "prev-message"} } -func (NextPrevMsg) Complete(aerc *widgets.Aerc, args []string) []string { +func (NextPrevMsg) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (NextPrevMsg) Execute(aerc *widgets.Aerc, args []string) error { +func (NextPrevMsg) Execute(aerc *app.Aerc, args []string) error { n, pct, err := ParseNextPrevMessage(args) if err != nil { return err @@ -58,7 +58,7 @@ func ParseNextPrevMessage(args []string) (int, bool, error) { return n, pct, nil } -func ExecuteNextPrevMessage(args []string, acct *widgets.AccountView, pct bool, n int) error { +func ExecuteNextPrevMessage(args []string, acct *app.AccountView, pct bool, n int) error { if pct { n = int(float64(acct.Messages().Height()) * (float64(n) / 100.0)) } diff --git a/commands/account/recover.go b/commands/account/recover.go index 9fdaa3e9..1bb1aceb 100644 --- a/commands/account/recover.go +++ b/commands/account/recover.go @@ -7,9 +7,9 @@ import ( "os" "path/filepath" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/config" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" ) @@ -27,7 +27,7 @@ func (Recover) Options() string { return "feE" } -func (r Recover) Complete(aerc *widgets.Aerc, args []string) []string { +func (r Recover) Complete(aerc *app.Aerc, args []string) []string { // file name of temp file is hard-coded in the NewComposer() function files, err := filepath.Glob( filepath.Join(os.TempDir(), "aerc-compose-*.eml"), @@ -39,7 +39,7 @@ func (r Recover) Complete(aerc *widgets.Aerc, args []string) []string { commands.Operands(args, r.Options())) } -func (r Recover) Execute(aerc *widgets.Aerc, args []string) error { +func (r Recover) Execute(aerc *app.Aerc, args []string) error { // Complete() expects to be passed only the arguments, not including the command name if len(Recover{}.Complete(aerc, args[1:])) == 0 { return errors.New("No messages to recover.") @@ -89,7 +89,7 @@ func (r Recover) Execute(aerc *widgets.Aerc, args []string) error { return err } - composer, err := widgets.NewComposer(aerc, acct, + composer, err := app.NewComposer(aerc, acct, acct.AccountConfig(), acct.Worker(), editHeaders, "", nil, nil, bytes.NewReader(data)) if err != nil { diff --git a/commands/account/rmdir.go b/commands/account/rmdir.go index 9f6fedeb..eca8b59f 100644 --- a/commands/account/rmdir.go +++ b/commands/account/rmdir.go @@ -6,7 +6,7 @@ import ( "git.sr.ht/~sircmpwn/getopt" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -20,11 +20,11 @@ func (RemoveDir) Aliases() []string { return []string{"rmdir"} } -func (RemoveDir) Complete(aerc *widgets.Aerc, args []string) []string { +func (RemoveDir) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (RemoveDir) Execute(aerc *widgets.Aerc, args []string) error { +func (RemoveDir) Execute(aerc *app.Aerc, args []string) error { acct := aerc.SelectedAccount() if acct == nil { return errors.New("No account selected") diff --git a/commands/account/search.go b/commands/account/search.go index d7884f15..04f7ddc3 100644 --- a/commands/account/search.go +++ b/commands/account/search.go @@ -4,11 +4,11 @@ import ( "errors" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "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/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -27,7 +27,7 @@ func (SearchFilter) Aliases() []string { } func (s SearchFilter) CompleteOption( - aerc *widgets.Aerc, + aerc *app.Aerc, r rune, search string, ) []string { @@ -44,11 +44,11 @@ func (s SearchFilter) CompleteOption( return commands.CompletionFromList(aerc, valid, []string{search}) } -func (SearchFilter) Complete(aerc *widgets.Aerc, args []string) []string { +func (SearchFilter) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (SearchFilter) Execute(aerc *widgets.Aerc, args []string) error { +func (SearchFilter) Execute(aerc *app.Aerc, args []string) error { acct := aerc.SelectedAccount() if acct == nil { return errors.New("No account selected") diff --git a/commands/account/select.go b/commands/account/select.go index 28aedfa5..aeb584f4 100644 --- a/commands/account/select.go +++ b/commands/account/select.go @@ -4,7 +4,7 @@ import ( "errors" "strconv" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type SelectMessage struct{} @@ -17,11 +17,11 @@ func (SelectMessage) Aliases() []string { return []string{"select", "select-message"} } -func (SelectMessage) Complete(aerc *widgets.Aerc, args []string) []string { +func (SelectMessage) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (SelectMessage) Execute(aerc *widgets.Aerc, args []string) error { +func (SelectMessage) Execute(aerc *app.Aerc, args []string) error { if len(args) != 2 { return errors.New("Usage: :select-message ") } diff --git a/commands/account/sort.go b/commands/account/sort.go index cabe10ec..8624ff12 100644 --- a/commands/account/sort.go +++ b/commands/account/sort.go @@ -4,10 +4,10 @@ import ( "errors" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/lib/sort" "git.sr.ht/~rjarry/aerc/lib/state" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -21,7 +21,7 @@ func (Sort) Aliases() []string { return []string{"sort"} } -func (Sort) Complete(aerc *widgets.Aerc, args []string) []string { +func (Sort) Complete(aerc *app.Aerc, args []string) []string { supportedCriteria := []string{ "arrival", "cc", @@ -62,7 +62,7 @@ func (Sort) Complete(aerc *widgets.Aerc, args []string) []string { return completions } -func (Sort) Execute(aerc *widgets.Aerc, args []string) error { +func (Sort) Execute(aerc *app.Aerc, args []string) error { acct := aerc.SelectedAccount() if acct == nil { return errors.New("No account selected.") diff --git a/commands/account/split.go b/commands/account/split.go index 7a5acc47..82870d60 100644 --- a/commands/account/split.go +++ b/commands/account/split.go @@ -5,7 +5,7 @@ import ( "strconv" "strings" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Split struct{} @@ -18,11 +18,11 @@ func (Split) Aliases() []string { return []string{"split", "vsplit"} } -func (Split) Complete(aerc *widgets.Aerc, args []string) []string { +func (Split) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Split) Execute(aerc *widgets.Aerc, args []string) error { +func (Split) Execute(aerc *app.Aerc, args []string) error { if len(args) > 2 { return errors.New("Usage: [v]split n") } diff --git a/commands/account/view.go b/commands/account/view.go index f48d3bc3..cbf2ce3f 100644 --- a/commands/account/view.go +++ b/commands/account/view.go @@ -3,8 +3,8 @@ package account import ( "errors" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" ) @@ -18,11 +18,11 @@ func (ViewMessage) Aliases() []string { return []string{"view-message", "view"} } -func (ViewMessage) Complete(aerc *widgets.Aerc, args []string) []string { +func (ViewMessage) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (ViewMessage) Execute(aerc *widgets.Aerc, args []string) error { +func (ViewMessage) Execute(aerc *app.Aerc, args []string) error { peek := false opts, optind, err := getopt.Getopts(args, "p") if err != nil { @@ -65,7 +65,7 @@ func (ViewMessage) Execute(aerc *widgets.Aerc, args []string) error { aerc.PushError(err.Error()) return } - viewer := widgets.NewMessageViewer(acct, view) + viewer := app.NewMessageViewer(acct, view) aerc.NewTab(viewer, msg.Envelope.Subject) }) return nil diff --git a/commands/cd.go b/commands/cd.go index 8c0191c2..67891589 100644 --- a/commands/cd.go +++ b/commands/cd.go @@ -5,8 +5,8 @@ import ( "os" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/xdg" - "git.sr.ht/~rjarry/aerc/widgets" ) var previousDir string @@ -21,7 +21,7 @@ func (ChangeDirectory) Aliases() []string { return []string{"cd"} } -func (ChangeDirectory) Complete(aerc *widgets.Aerc, args []string) []string { +func (ChangeDirectory) Complete(aerc *app.Aerc, args []string) []string { path := strings.Join(args, " ") completions := CompletePath(path) @@ -36,7 +36,7 @@ func (ChangeDirectory) Complete(aerc *widgets.Aerc, args []string) []string { return dirs } -func (ChangeDirectory) Execute(aerc *widgets.Aerc, args []string) error { +func (ChangeDirectory) Execute(aerc *app.Aerc, args []string) error { if len(args) < 1 { return errors.New("Usage: cd [directory]") } diff --git a/commands/choose.go b/commands/choose.go index 3b3af794..64535ea6 100644 --- a/commands/choose.go +++ b/commands/choose.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Choose struct{} @@ -17,21 +17,21 @@ func (Choose) Aliases() []string { return []string{"choose"} } -func (Choose) Complete(aerc *widgets.Aerc, args []string) []string { +func (Choose) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Choose) Execute(aerc *widgets.Aerc, args []string) error { +func (Choose) Execute(aerc *app.Aerc, args []string) error { if len(args) < 5 || len(args)%4 != 1 { return chooseUsage(args[0]) } - choices := []widgets.Choice{} + choices := []app.Choice{} for i := 0; i+4 < len(args); i += 4 { if args[i+1] != "-o" { return chooseUsage(args[0]) } - choices = append(choices, widgets.Choice{ + choices = append(choices, app.Choice{ Key: args[i+2], Text: args[i+3], Command: strings.Split(args[i+4], " "), diff --git a/commands/commands.go b/commands/commands.go index 9366be9c..0971d478 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -9,18 +9,18 @@ import ( "github.com/google/shlex" + "git.sr.ht/~rjarry/aerc/app" "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/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" ) type Command interface { Aliases() []string - Execute(*widgets.Aerc, []string) error - Complete(*widgets.Aerc, []string) []string + Execute(*app.Aerc, []string) error + Complete(*app.Aerc, []string) []string } type OptionsProvider interface { @@ -30,7 +30,7 @@ type OptionsProvider interface { type OptionCompleter interface { OptionsProvider - CompleteOption(*widgets.Aerc, rune, string) []string + CompleteOption(*app.Aerc, rune, string) []string } type Commands map[string]Command @@ -81,7 +81,7 @@ type CommandSource interface { } func templateData( - aerc *widgets.Aerc, + aerc *app.Aerc, cfg *config.AccountConfig, msg *models.MessageInfo, ) models.TemplateData { @@ -112,7 +112,7 @@ func templateData( } func (cmds *Commands) ExecuteCommand( - aerc *widgets.Aerc, + aerc *app.Aerc, origArgs []string, account *config.AccountConfig, msg *models.MessageInfo, @@ -178,7 +178,7 @@ func expand(data models.TemplateData, origArgs []string) ([]string, error) { } func GetTemplateCompletion( - aerc *widgets.Aerc, cmd string, + aerc *app.Aerc, cmd string, ) ([]string, string, bool) { args, err := splitCmd(cmd) if err != nil || len(args) == 0 { @@ -231,7 +231,7 @@ func GetTemplateCompletion( // GetCompletions returns the completion options and the command prefix func (cmds *Commands) GetCompletions( - aerc *widgets.Aerc, cmd string, + aerc *app.Aerc, cmd string, ) (options []string, prefix string) { log.Tracef("completing command: %s", cmd) @@ -323,7 +323,7 @@ func (cmds *Commands) GetCompletions( return } -func GetFolders(aerc *widgets.Aerc, args []string) []string { +func GetFolders(aerc *app.Aerc, args []string) []string { acct := aerc.SelectedAccount() if acct == nil { return make([]string, 0) @@ -336,14 +336,14 @@ func GetFolders(aerc *widgets.Aerc, args []string) []string { // CompletionFromList provides a convenience wrapper for commands to use in the // Complete function. It simply matches the items provided in valid -func CompletionFromList(aerc *widgets.Aerc, valid []string, args []string) []string { +func CompletionFromList(aerc *app.Aerc, valid []string, args []string) []string { if len(args) == 0 { return valid } return FilterList(valid, args[0], "", aerc.SelectedAccountUiConfig().FuzzyComplete) } -func GetLabels(aerc *widgets.Aerc, args []string) []string { +func GetLabels(aerc *app.Aerc, args []string) []string { acct := aerc.SelectedAccount() if acct == nil { return make([]string, 0) diff --git a/commands/completion_helpers.go b/commands/completion_helpers.go index 96a423ee..c7cb780b 100644 --- a/commands/completion_helpers.go +++ b/commands/completion_helpers.go @@ -5,14 +5,14 @@ import ( "net/mail" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/completer" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~rjarry/aerc/widgets" ) // GetAddress uses the address-book-cmd for address completion -func GetAddress(aerc *widgets.Aerc, search string) []string { +func GetAddress(aerc *app.Aerc, search string) []string { var options []string cmd := aerc.SelectedAccount().AccountConfig().AddressBookCmd diff --git a/commands/compose/abort.go b/commands/compose/abort.go index d6a81b57..d6ceae6d 100644 --- a/commands/compose/abort.go +++ b/commands/compose/abort.go @@ -3,7 +3,7 @@ package compose import ( "errors" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Abort struct{} @@ -16,15 +16,15 @@ func (Abort) Aliases() []string { return []string{"abort"} } -func (Abort) Complete(aerc *widgets.Aerc, args []string) []string { +func (Abort) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Abort) Execute(aerc *widgets.Aerc, args []string) error { +func (Abort) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: abort") } - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) aerc.RemoveTab(composer, true) diff --git a/commands/compose/attach-key.go b/commands/compose/attach-key.go index 208e9fd8..29237374 100644 --- a/commands/compose/attach-key.go +++ b/commands/compose/attach-key.go @@ -3,7 +3,7 @@ package compose import ( "errors" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type AttachKey struct{} @@ -16,16 +16,16 @@ func (AttachKey) Aliases() []string { return []string{"attach-key"} } -func (AttachKey) Complete(aerc *widgets.Aerc, args []string) []string { +func (AttachKey) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (AttachKey) Execute(aerc *widgets.Aerc, args []string) error { +func (AttachKey) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: attach-key") } - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) return composer.SetAttachKey(!composer.AttachKey()) } diff --git a/commands/compose/attach.go b/commands/compose/attach.go index f9ef027f..fd84b4ea 100644 --- a/commands/compose/attach.go +++ b/commands/compose/attach.go @@ -10,13 +10,13 @@ import ( "path/filepath" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" "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/widgets" "github.com/pkg/errors" "git.sr.ht/~sircmpwn/getopt" @@ -32,12 +32,12 @@ func (Attach) Aliases() []string { return []string{"attach"} } -func (Attach) Complete(aerc *widgets.Aerc, args []string) []string { +func (Attach) Complete(aerc *app.Aerc, args []string) []string { path := strings.Join(args, " ") return commands.CompletePath(path) } -func (a Attach) Execute(aerc *widgets.Aerc, args []string) error { +func (a Attach) Execute(aerc *app.Aerc, args []string) error { var ( menu bool read bool @@ -82,7 +82,7 @@ func (a Attach) Execute(aerc *widgets.Aerc, args []string) error { return a.addPath(aerc, strings.Join(args, " ")) } -func (a Attach) addPath(aerc *widgets.Aerc, path string) error { +func (a Attach) addPath(aerc *app.Aerc, path string) error { path = xdg.ExpandHome(path) attachments, err := filepath.Glob(path) if err != nil && errors.Is(err, filepath.ErrBadPattern) { @@ -103,7 +103,7 @@ func (a Attach) addPath(aerc *widgets.Aerc, path string) error { } } - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) for _, attach := range attachments { log.Debugf("attaching '%s'", attach) @@ -129,7 +129,7 @@ func (a Attach) addPath(aerc *widgets.Aerc, path string) error { return nil } -func (a Attach) openMenu(aerc *widgets.Aerc, args []string) error { +func (a Attach) openMenu(aerc *app.Aerc, args []string) error { filePickerCmd := config.Compose.FilePickerCmd if filePickerCmd == "" { return fmt.Errorf("no file-picker-cmd defined") @@ -157,7 +157,7 @@ func (a Attach) openMenu(aerc *widgets.Aerc, args []string) error { filepicker.ExtraFiles = append(filepicker.ExtraFiles, picks) } - t, err := widgets.NewTerminal(filepicker) + t, err := app.NewTerminal(filepicker) if err != nil { return err } @@ -200,7 +200,7 @@ func (a Attach) openMenu(aerc *widgets.Aerc, args []string) error { } } - aerc.AddDialog(widgets.NewDialog( + aerc.AddDialog(app.NewDialog( ui.NewBox(t, "File Picker", "", aerc.SelectedAccountUiConfig()), // start pos on screen func(h int) int { @@ -215,7 +215,7 @@ func (a Attach) openMenu(aerc *widgets.Aerc, args []string) error { return nil } -func (a Attach) readCommand(aerc *widgets.Aerc, name string, args []string) error { +func (a Attach) readCommand(aerc *app.Aerc, name string, args []string) error { args = append([]string{"-c"}, args...) cmd := exec.Command("sh", args...) @@ -233,7 +233,7 @@ func (a Attach) readCommand(aerc *widgets.Aerc, name string, args []string) erro mimeParams["name"] = name - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) err = composer.AddPartAttachment(name, mimeType, mimeParams, reader) if err != nil { return errors.Wrap(err, "AddPartAttachment") diff --git a/commands/compose/cc-bcc.go b/commands/compose/cc-bcc.go index 045f9092..8f8d4aac 100644 --- a/commands/compose/cc-bcc.go +++ b/commands/compose/cc-bcc.go @@ -3,7 +3,7 @@ package compose import ( "strings" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type CC struct{} @@ -16,16 +16,16 @@ func (CC) Aliases() []string { return []string{"cc", "bcc"} } -func (CC) Complete(aerc *widgets.Aerc, args []string) []string { +func (CC) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (CC) Execute(aerc *widgets.Aerc, args []string) error { +func (CC) Execute(aerc *app.Aerc, args []string) error { var addrs string if len(args) > 1 { addrs = strings.Join(args[1:], " ") } - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) switch args[0] { case "cc": diff --git a/commands/compose/detach.go b/commands/compose/detach.go index 487bf225..014301f2 100644 --- a/commands/compose/detach.go +++ b/commands/compose/detach.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Detach struct{} @@ -17,14 +17,14 @@ func (Detach) Aliases() []string { return []string{"detach"} } -func (Detach) Complete(aerc *widgets.Aerc, args []string) []string { - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) +func (Detach) Complete(aerc *app.Aerc, args []string) []string { + composer, _ := aerc.SelectedTabContent().(*app.Composer) return composer.GetAttachments() } -func (Detach) Execute(aerc *widgets.Aerc, args []string) error { +func (Detach) Execute(aerc *app.Aerc, args []string) error { var path string - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) if len(args) > 1 { path = strings.Join(args[1:], " ") diff --git a/commands/compose/edit.go b/commands/compose/edit.go index 1e8e0672..485b3098 100644 --- a/commands/compose/edit.go +++ b/commands/compose/edit.go @@ -3,8 +3,8 @@ package compose import ( "errors" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/config" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" ) @@ -18,12 +18,12 @@ func (Edit) Aliases() []string { return []string{"edit"} } -func (Edit) Complete(aerc *widgets.Aerc, args []string) []string { +func (Edit) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Edit) Execute(aerc *widgets.Aerc, args []string) error { - composer, ok := aerc.SelectedTabContent().(*widgets.Composer) +func (Edit) Execute(aerc *app.Aerc, args []string) error { + composer, ok := aerc.SelectedTabContent().(*app.Composer) if !ok { return errors.New("only valid while composing") } diff --git a/commands/compose/encrypt.go b/commands/compose/encrypt.go index 905bdc4b..3c852dc4 100644 --- a/commands/compose/encrypt.go +++ b/commands/compose/encrypt.go @@ -3,7 +3,7 @@ package compose import ( "errors" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Encrypt struct{} @@ -16,16 +16,16 @@ func (Encrypt) Aliases() []string { return []string{"encrypt"} } -func (Encrypt) Complete(aerc *widgets.Aerc, args []string) []string { +func (Encrypt) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Encrypt) Execute(aerc *widgets.Aerc, args []string) error { +func (Encrypt) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: encrypt") } - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) composer.SetEncrypt(!composer.Encrypt()) return nil diff --git a/commands/compose/header.go b/commands/compose/header.go index 59d01952..e66df149 100644 --- a/commands/compose/header.go +++ b/commands/compose/header.go @@ -4,8 +4,8 @@ import ( "fmt" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" ) @@ -33,11 +33,11 @@ func (Header) Options() string { return "fd" } -func (Header) Complete(aerc *widgets.Aerc, args []string) []string { +func (Header) Complete(aerc *app.Aerc, args []string) []string { return commands.CompletionFromList(aerc, headers, args) } -func (h Header) Execute(aerc *widgets.Aerc, args []string) error { +func (h Header) Execute(aerc *app.Aerc, args []string) error { opts, optind, err := getopt.Getopts(args, h.Options()) args = args[optind:] if err == nil && len(args) < 1 { @@ -58,7 +58,7 @@ func (h Header) Execute(aerc *widgets.Aerc, args []string) error { } } - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) name := strings.TrimRight(args[0], ":") diff --git a/commands/compose/multipart.go b/commands/compose/multipart.go index 32801965..13ee69e5 100644 --- a/commands/compose/multipart.go +++ b/commands/compose/multipart.go @@ -4,9 +4,9 @@ import ( "bytes" "fmt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/config" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" ) @@ -20,7 +20,7 @@ func (Multipart) Aliases() []string { return []string{"multipart"} } -func (Multipart) Complete(aerc *widgets.Aerc, args []string) []string { +func (Multipart) Complete(aerc *app.Aerc, args []string) []string { var completions []string completions = append(completions, "-d") for mime := range config.Converters { @@ -29,8 +29,8 @@ func (Multipart) Complete(aerc *widgets.Aerc, args []string) []string { return commands.CompletionFromList(aerc, completions, args) } -func (a Multipart) Execute(aerc *widgets.Aerc, args []string) error { - composer, ok := aerc.SelectedTabContent().(*widgets.Composer) +func (a Multipart) Execute(aerc *app.Aerc, args []string) error { + composer, ok := aerc.SelectedTabContent().(*app.Composer) if !ok { return fmt.Errorf(":multipart is only available on the compose::review screen") } diff --git a/commands/compose/next-field.go b/commands/compose/next-field.go index ec4db582..d029b50a 100644 --- a/commands/compose/next-field.go +++ b/commands/compose/next-field.go @@ -3,7 +3,7 @@ package compose import ( "fmt" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type NextPrevField struct{} @@ -16,15 +16,15 @@ func (NextPrevField) Aliases() []string { return []string{"next-field", "prev-field"} } -func (NextPrevField) Complete(aerc *widgets.Aerc, args []string) []string { +func (NextPrevField) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (NextPrevField) Execute(aerc *widgets.Aerc, args []string) error { +func (NextPrevField) Execute(aerc *app.Aerc, args []string) error { if len(args) > 2 { return nextPrevFieldUsage(args[0]) } - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) if args[0] == "prev-field" { composer.PrevField() } else { diff --git a/commands/compose/postpone.go b/commands/compose/postpone.go index fd59cc11..e33c9ab7 100644 --- a/commands/compose/postpone.go +++ b/commands/compose/postpone.go @@ -8,10 +8,10 @@ import ( "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -29,7 +29,7 @@ func (Postpone) Options() string { return "t:" } -func (Postpone) CompleteOption(aerc *widgets.Aerc, r rune, arg string) []string { +func (Postpone) CompleteOption(aerc *app.Aerc, r rune, arg string) []string { var valid []string if r == 't' { valid = commands.GetFolders(aerc, []string{arg}) @@ -37,11 +37,11 @@ func (Postpone) CompleteOption(aerc *widgets.Aerc, r rune, arg string) []string return commands.CompletionFromList(aerc, valid, []string{arg}) } -func (Postpone) Complete(aerc *widgets.Aerc, args []string) []string { +func (Postpone) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (p Postpone) Execute(aerc *widgets.Aerc, args []string) error { +func (p Postpone) Execute(aerc *app.Aerc, args []string) error { opts, optind, err := getopt.Getopts(args, p.Options()) if err != nil { return err @@ -55,7 +55,7 @@ func (p Postpone) Execute(aerc *widgets.Aerc, args []string) error { if tab == nil { return errors.New("No tab selected") } - composer, _ := tab.Content.(*widgets.Composer) + composer, _ := tab.Content.(*app.Composer) config := composer.Config() tabName := tab.Name diff --git a/commands/compose/send.go b/commands/compose/send.go index e53b01a3..c9b843a7 100644 --- a/commands/compose/send.go +++ b/commands/compose/send.go @@ -16,11 +16,11 @@ import ( "github.com/google/shlex" "github.com/pkg/errors" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands/mode" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" "github.com/emersion/go-message/mail" "golang.org/x/oauth2" @@ -36,11 +36,11 @@ func (Send) Aliases() []string { return []string{"send"} } -func (Send) Complete(aerc *widgets.Aerc, args []string) []string { +func (Send) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Send) Execute(aerc *widgets.Aerc, args []string) error { +func (Send) Execute(aerc *app.Aerc, args []string) error { opts, optind, err := getopt.Getopts(args, "a:") if err != nil { return err @@ -58,7 +58,7 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error { if tab == nil { return errors.New("No selected tab") } - composer, _ := tab.Content.(*widgets.Composer) + composer, _ := tab.Content.(*app.Composer) tabName := tab.Name config := composer.Config() @@ -125,7 +125,7 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error { msg = "You may have forgotten an attachment." } - prompt := widgets.NewPrompt( + prompt := app.NewPrompt( msg+" Abort send? [Y/n] ", func(text string) { if text == "n" || text == "N" { @@ -148,7 +148,7 @@ func (Send) Execute(aerc *widgets.Aerc, args []string) error { return nil } -func send(aerc *widgets.Aerc, composer *widgets.Composer, ctx sendCtx, +func send(aerc *app.Aerc, composer *app.Composer, ctx sendCtx, header *mail.Header, tabName string, archive string, ) { // we don't want to block the UI thread while we are sending @@ -518,7 +518,7 @@ func connectSmtps(host string) (*smtp.Client, error) { } func newJmapSender( - composer *widgets.Composer, header *mail.Header, ctx sendCtx, + composer *app.Composer, header *mail.Header, ctx sendCtx, ) (io.WriteCloser, error) { var writer io.WriteCloser done := make(chan error) diff --git a/commands/compose/sign.go b/commands/compose/sign.go index e6afd98e..f44c33ec 100644 --- a/commands/compose/sign.go +++ b/commands/compose/sign.go @@ -4,7 +4,7 @@ import ( "errors" "time" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Sign struct{} @@ -17,16 +17,16 @@ func (Sign) Aliases() []string { return []string{"sign"} } -func (Sign) Complete(aerc *widgets.Aerc, args []string) []string { +func (Sign) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Sign) Execute(aerc *widgets.Aerc, args []string) error { +func (Sign) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: sign") } - composer, _ := aerc.SelectedTabContent().(*widgets.Composer) + composer, _ := aerc.SelectedTabContent().(*app.Composer) err := composer.SetSign(!composer.Sign()) if err != nil { diff --git a/commands/compose/switch.go b/commands/compose/switch.go index 2b6aadb5..f442d6b0 100644 --- a/commands/compose/switch.go +++ b/commands/compose/switch.go @@ -4,12 +4,12 @@ import ( "errors" "fmt" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~sircmpwn/getopt" ) type AccountSwitcher interface { - SwitchAccount(*widgets.AccountView) error + SwitchAccount(*app.AccountView) error } type SwitchAccount struct{} @@ -22,11 +22,11 @@ func (SwitchAccount) Aliases() []string { return []string{"switch-account"} } -func (SwitchAccount) Complete(aerc *widgets.Aerc, args []string) []string { +func (SwitchAccount) Complete(aerc *app.Aerc, args []string) []string { return aerc.AccountNames() } -func (SwitchAccount) Execute(aerc *widgets.Aerc, args []string) error { +func (SwitchAccount) Execute(aerc *app.Aerc, args []string) error { opts, optind, err := getopt.Getopts(args, "np") if err != nil { return err @@ -57,7 +57,7 @@ func (SwitchAccount) Execute(aerc *widgets.Aerc, args []string) error { return errors.New("this tab cannot switch accounts") } - var acct *widgets.AccountView + var acct *app.AccountView switch { case prev: diff --git a/commands/ct.go b/commands/ct.go index 3bd3428e..e6b29b58 100644 --- a/commands/ct.go +++ b/commands/ct.go @@ -6,7 +6,7 @@ import ( "strconv" "strings" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type ChangeTab struct{} @@ -19,7 +19,7 @@ func (ChangeTab) Aliases() []string { return []string{"ct", "change-tab"} } -func (ChangeTab) Complete(aerc *widgets.Aerc, args []string) []string { +func (ChangeTab) Complete(aerc *app.Aerc, args []string) []string { if len(args) == 0 { return aerc.TabNames() } @@ -27,7 +27,7 @@ func (ChangeTab) Complete(aerc *widgets.Aerc, args []string) []string { return FilterList(aerc.TabNames(), joinedArgs, "", aerc.SelectedAccountUiConfig().FuzzyComplete) } -func (ChangeTab) Execute(aerc *widgets.Aerc, args []string) error { +func (ChangeTab) Execute(aerc *app.Aerc, args []string) error { if len(args) == 1 { return fmt.Errorf("Usage: %s ", args[0]) } diff --git a/commands/eml.go b/commands/eml.go index 00380763..81e578d3 100644 --- a/commands/eml.go +++ b/commands/eml.go @@ -7,8 +7,8 @@ import ( "os" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib" - "git.sr.ht/~rjarry/aerc/widgets" ) type Eml struct{} @@ -21,11 +21,11 @@ func (Eml) Aliases() []string { return []string{"eml", "preview"} } -func (Eml) Complete(aerc *widgets.Aerc, args []string) []string { +func (Eml) Complete(aerc *app.Aerc, args []string) []string { return CompletePath(strings.Join(args, " ")) } -func (Eml) Execute(aerc *widgets.Aerc, args []string) error { +func (Eml) Execute(aerc *app.Aerc, args []string) error { acct := aerc.SelectedAccount() if acct == nil { return fmt.Errorf("no account selected") @@ -43,7 +43,7 @@ func (Eml) Execute(aerc *widgets.Aerc, args []string) error { aerc.PushError(err.Error()) return } - msgView := widgets.NewMessageViewer(acct, view) + msgView := app.NewMessageViewer(acct, view) aerc.NewTab(msgView, view.MessageInfo().Envelope.Subject) }) @@ -51,10 +51,10 @@ func (Eml) Execute(aerc *widgets.Aerc, args []string) error { if len(args) == 1 { switch tab := aerc.SelectedTabContent().(type) { - case *widgets.MessageViewer: + case *app.MessageViewer: part := tab.SelectedMessagePart() tab.MessageView().FetchBodyPart(part.Index, showEml) - case *widgets.Composer: + case *app.Composer: var buf bytes.Buffer h, err := tab.PrepareHeader() if err != nil { diff --git a/commands/exec.go b/commands/exec.go index 37274116..a38c8789 100644 --- a/commands/exec.go +++ b/commands/exec.go @@ -7,8 +7,8 @@ import ( "os/exec" "time" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~rjarry/aerc/widgets" ) type ExecCmd struct{} @@ -21,11 +21,11 @@ func (ExecCmd) Aliases() []string { return []string{"exec"} } -func (ExecCmd) Complete(aerc *widgets.Aerc, args []string) []string { +func (ExecCmd) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (ExecCmd) Execute(aerc *widgets.Aerc, args []string) error { +func (ExecCmd) Execute(aerc *app.Aerc, args []string) error { if len(args) < 2 { return errors.New("Usage: exec [cmd...]") } @@ -34,10 +34,10 @@ func (ExecCmd) Execute(aerc *widgets.Aerc, args []string) error { env := os.Environ() switch view := aerc.SelectedTabContent().(type) { - case *widgets.AccountView: + case *app.AccountView: env = append(env, fmt.Sprintf("account=%s", view.AccountConfig().Name)) env = append(env, fmt.Sprintf("folder=%s", view.Directories().Selected())) - case *widgets.MessageViewer: + case *app.MessageViewer: acct := view.SelectedAccount() env = append(env, fmt.Sprintf("account=%s", acct.AccountConfig().Name)) env = append(env, fmt.Sprintf("folder=%s", acct.Directories().Selected())) diff --git a/commands/help.go b/commands/help.go index b2654ab5..312c3318 100644 --- a/commands/help.go +++ b/commands/help.go @@ -3,7 +3,7 @@ package commands import ( "errors" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Help struct{} @@ -33,11 +33,11 @@ func (Help) Aliases() []string { return []string{"help"} } -func (Help) Complete(aerc *widgets.Aerc, args []string) []string { +func (Help) Complete(aerc *app.Aerc, args []string) []string { return CompletionFromList(aerc, pages, args) } -func (Help) Execute(aerc *widgets.Aerc, args []string) error { +func (Help) Execute(aerc *app.Aerc, args []string) error { page := "aerc" if len(args) == 2 && args[1] != "aerc" { page = "aerc-" + args[1] @@ -46,8 +46,8 @@ func (Help) Execute(aerc *widgets.Aerc, args []string) error { } if page == "aerc-keys" { - aerc.AddDialog(widgets.NewDialog( - widgets.NewListBox( + aerc.AddDialog(app.NewDialog( + app.NewListBox( "Bindings: Press or to close. "+ "Start typing to filter bindings.", aerc.HumanReadableBindings(), diff --git a/commands/move-tab.go b/commands/move-tab.go index 76e5348d..d85f3b2f 100644 --- a/commands/move-tab.go +++ b/commands/move-tab.go @@ -5,7 +5,7 @@ import ( "strconv" "strings" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type MoveTab struct{} @@ -18,11 +18,11 @@ func (MoveTab) Aliases() []string { return []string{"move-tab"} } -func (MoveTab) Complete(aerc *widgets.Aerc, args []string) []string { +func (MoveTab) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (MoveTab) Execute(aerc *widgets.Aerc, args []string) error { +func (MoveTab) Execute(aerc *app.Aerc, args []string) error { if len(args) == 1 { return fmt.Errorf("Usage: %s [+|-]", args[0]) } diff --git a/commands/msg/archive.go b/commands/msg/archive.go index 1c9d7929..9753f664 100644 --- a/commands/msg/archive.go +++ b/commands/msg/archive.go @@ -6,10 +6,10 @@ import ( "strings" "sync" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -29,12 +29,12 @@ func (Archive) Aliases() []string { return []string{"archive"} } -func (Archive) Complete(aerc *widgets.Aerc, args []string) []string { +func (Archive) Complete(aerc *app.Aerc, args []string) []string { valid := []string{"flat", "year", "month"} return commands.CompletionFromList(aerc, valid, args) } -func (Archive) Execute(aerc *widgets.Aerc, args []string) error { +func (Archive) Execute(aerc *app.Aerc, args []string) error { if len(args) != 2 { return errors.New("Usage: archive ") } @@ -47,7 +47,7 @@ func (Archive) Execute(aerc *widgets.Aerc, args []string) error { return err } -func archive(aerc *widgets.Aerc, msgs []*models.MessageInfo, archiveType string) error { +func archive(aerc *app.Aerc, msgs []*models.MessageInfo, archiveType string) error { h := newHelper(aerc) acct, err := h.account() if err != nil { diff --git a/commands/msg/copy.go b/commands/msg/copy.go index 7118e4f8..3f3498a2 100644 --- a/commands/msg/copy.go +++ b/commands/msg/copy.go @@ -7,8 +7,8 @@ import ( "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -22,11 +22,11 @@ func (Copy) Aliases() []string { return []string{"cp", "copy"} } -func (Copy) Complete(aerc *widgets.Aerc, args []string) []string { +func (Copy) Complete(aerc *app.Aerc, args []string) []string { return commands.GetFolders(aerc, args) } -func (Copy) Execute(aerc *widgets.Aerc, args []string) error { +func (Copy) Execute(aerc *app.Aerc, args []string) error { if len(args) == 1 { return errors.New("Usage: cp [-p] ") } diff --git a/commands/msg/delete.go b/commands/msg/delete.go index 6d3fb4a3..37103da3 100644 --- a/commands/msg/delete.go +++ b/commands/msg/delete.go @@ -4,11 +4,11 @@ import ( "errors" "time" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/lib/ui" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -22,11 +22,11 @@ func (Delete) Aliases() []string { return []string{"delete", "delete-message"} } -func (Delete) Complete(aerc *widgets.Aerc, args []string) []string { +func (Delete) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Delete) Execute(aerc *widgets.Aerc, args []string) error { +func (Delete) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: :delete") } @@ -53,7 +53,7 @@ func (Delete) Execute(aerc *widgets.Aerc, args []string) error { switch msg := msg.(type) { case *types.Done: aerc.PushStatus("Messages deleted.", 10*time.Second) - mv, isMsgView := h.msgProvider.(*widgets.MessageViewer) + mv, isMsgView := h.msgProvider.(*app.MessageViewer) if isMsgView { if !config.Ui.NextMessageOnDelete { aerc.RemoveTab(h.msgProvider, true) @@ -72,7 +72,7 @@ func (Delete) Execute(aerc *widgets.Aerc, args []string) error { aerc.PushError(err.Error()) return } - nextMv := widgets.NewMessageViewer(acct, view) + nextMv := app.NewMessageViewer(acct, view) aerc.ReplaceTab(mv, nextMv, next.Envelope.Subject, true) }) } diff --git a/commands/msg/envelope.go b/commands/msg/envelope.go index 1b16d16f..f5e50358 100644 --- a/commands/msg/envelope.go +++ b/commands/msg/envelope.go @@ -5,10 +5,10 @@ import ( "fmt" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/format" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" "github.com/emersion/go-message/mail" ) @@ -23,11 +23,11 @@ func (Envelope) Aliases() []string { return []string{"envelope"} } -func (Envelope) Complete(aerc *widgets.Aerc, args []string) []string { +func (Envelope) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Envelope) Execute(aerc *widgets.Aerc, args []string) error { +func (Envelope) Execute(aerc *app.Aerc, args []string) error { header := false fmtStr := "%-20.20s: %s" opts, _, err := getopt.Getopts(args, "hs:") @@ -65,8 +65,8 @@ func (Envelope) Execute(aerc *widgets.Aerc, args []string) error { } n := len(list) - aerc.AddDialog(widgets.NewDialog( - widgets.NewListBox( + aerc.AddDialog(app.NewDialog( + app.NewListBox( "Message Envelope. Press or to close. "+ "Start typing to filter.", list, diff --git a/commands/msg/fold.go b/commands/msg/fold.go index 14d00f17..755a292f 100644 --- a/commands/msg/fold.go +++ b/commands/msg/fold.go @@ -5,8 +5,8 @@ import ( "fmt" "strings" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/ui" - "git.sr.ht/~rjarry/aerc/widgets" ) type Fold struct{} @@ -19,11 +19,11 @@ func (Fold) Aliases() []string { return []string{"fold", "unfold"} } -func (Fold) Complete(aerc *widgets.Aerc, args []string) []string { +func (Fold) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Fold) Execute(aerc *widgets.Aerc, args []string) error { +func (Fold) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return fmt.Errorf("Usage: %s", args[0]) } diff --git a/commands/msg/forward.go b/commands/msg/forward.go index 86c52059..d1abbc5b 100644 --- a/commands/msg/forward.go +++ b/commands/msg/forward.go @@ -12,12 +12,12 @@ import ( "strings" "sync" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/lib/format" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" "github.com/emersion/go-message/mail" @@ -34,11 +34,11 @@ func (forward) Aliases() []string { return []string{"forward"} } -func (forward) Complete(aerc *widgets.Aerc, args []string) []string { +func (forward) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (forward) Execute(aerc *widgets.Aerc, args []string) error { +func (forward) Execute(aerc *app.Aerc, args []string) error { opts, optind, err := getopt.Getopts(args, "AFT:eE") if err != nil { return err @@ -69,7 +69,7 @@ func (forward) Execute(aerc *widgets.Aerc, args []string) error { return errors.New("Options -A and -F are mutually exclusive") } - widget := aerc.SelectedTabContent().(widgets.ProvidesMessage) + widget := aerc.SelectedTabContent().(app.ProvidesMessage) acct := widget.SelectedAccount() if acct == nil { return errors.New("No account selected") @@ -106,8 +106,8 @@ func (forward) Execute(aerc *widgets.Aerc, args []string) error { RFC822Headers: msg.RFC822Headers, } - addTab := func() (*widgets.Composer, error) { - composer, err := widgets.NewComposer(aerc, acct, + addTab := func() (*app.Composer, error) { + composer, err := app.NewComposer(aerc, acct, acct.AccountConfig(), acct.Worker(), editHeaders, template, h, &original, nil) if err != nil { @@ -153,7 +153,7 @@ func (forward) Execute(aerc *widgets.Aerc, args []string) error { return } composer.AddAttachment(tmpFileName) - composer.OnClose(func(_ *widgets.Composer) { + composer.OnClose(func(_ *app.Composer) { os.RemoveAll(tmpDir) }) }) diff --git a/commands/msg/invite.go b/commands/msg/invite.go index 309fe643..ceb043bb 100644 --- a/commands/msg/invite.go +++ b/commands/msg/invite.go @@ -5,13 +5,13 @@ import ( "fmt" "io" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/lib/calendar" "git.sr.ht/~rjarry/aerc/lib/format" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" "github.com/emersion/go-message/mail" ) @@ -26,11 +26,11 @@ func (invite) Aliases() []string { return []string{"accept", "accept-tentative", "decline"} } -func (invite) Complete(aerc *widgets.Aerc, args []string) []string { +func (invite) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (invite) Execute(aerc *widgets.Aerc, args []string) error { +func (invite) Execute(aerc *app.Aerc, args []string) error { acct := aerc.SelectedAccount() if acct == nil { return errors.New("no account selected") @@ -155,7 +155,7 @@ func (invite) Execute(aerc *widgets.Aerc, args []string) error { } addTab := func(cr *calendar.Reply) error { - composer, err := widgets.NewComposer(aerc, acct, + composer, err := app.NewComposer(aerc, acct, acct.AccountConfig(), acct.Worker(), editHeaders, "", h, &original, cr.PlainText) if err != nil { @@ -170,7 +170,7 @@ func (invite) Execute(aerc *widgets.Aerc, args []string) error { composer.Tab = aerc.NewTab(composer, subject) - composer.OnClose(func(c *widgets.Composer) { + composer.OnClose(func(c *app.Composer) { if c.Sent() { store.Answered([]uint32{msg.Uid}, true, nil) } diff --git a/commands/msg/mark.go b/commands/msg/mark.go index 51aa1eb4..27677609 100644 --- a/commands/msg/mark.go +++ b/commands/msg/mark.go @@ -3,7 +3,7 @@ package msg import ( "fmt" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~sircmpwn/getopt" ) @@ -17,11 +17,11 @@ func (Mark) Aliases() []string { return []string{"mark", "unmark", "remark"} } -func (Mark) Complete(aerc *widgets.Aerc, args []string) []string { +func (Mark) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Mark) Execute(aerc *widgets.Aerc, args []string) error { +func (Mark) Execute(aerc *app.Aerc, args []string) error { h := newHelper(aerc) OnSelectedMessage := func(fn func(uint32)) error { if fn == nil { diff --git a/commands/msg/modify-labels.go b/commands/msg/modify-labels.go index 02eed520..d61dc23b 100644 --- a/commands/msg/modify-labels.go +++ b/commands/msg/modify-labels.go @@ -4,8 +4,8 @@ import ( "errors" "time" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -19,11 +19,11 @@ func (ModifyLabels) Aliases() []string { return []string{"modify-labels", "tag"} } -func (ModifyLabels) Complete(aerc *widgets.Aerc, args []string) []string { +func (ModifyLabels) Complete(aerc *app.Aerc, args []string) []string { return commands.GetLabels(aerc, args) } -func (ModifyLabels) Execute(aerc *widgets.Aerc, args []string) error { +func (ModifyLabels) Execute(aerc *app.Aerc, args []string) error { changes := args[1:] if len(changes) == 0 { return errors.New("Usage: modify-labels <[+-]label> ...") diff --git a/commands/msg/move.go b/commands/msg/move.go index e8661a61..847fa549 100644 --- a/commands/msg/move.go +++ b/commands/msg/move.go @@ -5,12 +5,12 @@ import ( "strings" "time" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/lib/ui" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" "git.sr.ht/~sircmpwn/getopt" ) @@ -25,11 +25,11 @@ func (Move) Aliases() []string { return []string{"mv", "move"} } -func (Move) Complete(aerc *widgets.Aerc, args []string) []string { +func (Move) Complete(aerc *app.Aerc, args []string) []string { return commands.GetFolders(aerc, args) } -func (Move) Execute(aerc *widgets.Aerc, args []string) error { +func (Move) Execute(aerc *app.Aerc, args []string) error { if len(args) == 1 { return errors.New("Usage: mv [-p] ") } @@ -82,15 +82,15 @@ func (Move) Execute(aerc *widgets.Aerc, args []string) error { } func handleDone( - aerc *widgets.Aerc, - acct *widgets.AccountView, + aerc *app.Aerc, + acct *app.AccountView, next *models.MessageInfo, message string, store *lib.MessageStore, ) { h := newHelper(aerc) aerc.PushStatus(message, 10*time.Second) - mv, isMsgView := h.msgProvider.(*widgets.MessageViewer) + mv, isMsgView := h.msgProvider.(*app.MessageViewer) switch { case isMsgView && !config.Ui.NextMessageOnDelete: aerc.RemoveTab(h.msgProvider, true) @@ -108,7 +108,7 @@ func handleDone( aerc.PushError(err.Error()) return } - nextMv := widgets.NewMessageViewer(acct, view) + nextMv := app.NewMessageViewer(acct, view) aerc.ReplaceTab(mv, nextMv, next.Envelope.Subject, true) }) default: diff --git a/commands/msg/pipe.go b/commands/msg/pipe.go index fc1ac8f8..e8c1e277 100644 --- a/commands/msg/pipe.go +++ b/commands/msg/pipe.go @@ -9,9 +9,9 @@ import ( "sort" "time" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~rjarry/aerc/widgets" mboxer "git.sr.ht/~rjarry/aerc/worker/mbox" "git.sr.ht/~rjarry/aerc/worker/types" @@ -28,11 +28,11 @@ func (Pipe) Aliases() []string { return []string{"pipe"} } -func (Pipe) Complete(aerc *widgets.Aerc, args []string) []string { +func (Pipe) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Pipe) Execute(aerc *widgets.Aerc, args []string) error { +func (Pipe) Execute(aerc *app.Aerc, args []string) error { var ( background bool pipeFull bool @@ -64,11 +64,11 @@ func (Pipe) Execute(aerc *widgets.Aerc, args []string) error { return errors.New("Usage: pipe [-mp] [args...]") } - provider := aerc.SelectedTabContent().(widgets.ProvidesMessage) + provider := aerc.SelectedTabContent().(app.ProvidesMessage) if !pipeFull && !pipePart { - if _, ok := provider.(*widgets.MessageViewer); ok { + if _, ok := provider.(*app.MessageViewer); ok { pipePart = true - } else if _, ok := provider.(*widgets.AccountView); ok { + } else if _, ok := provider.(*app.AccountView); ok { pipeFull = true } else { return errors.New( @@ -123,7 +123,7 @@ func (Pipe) Execute(aerc *widgets.Aerc, args []string) error { h := newHelper(aerc) store, err := h.store() if err != nil { - if mv, ok := provider.(*widgets.MessageViewer); ok { + if mv, ok := provider.(*app.MessageViewer); ok { mv.MessageView().FetchFull(func(reader io.Reader) { if background { doExec(reader) @@ -209,7 +209,7 @@ func (Pipe) Execute(aerc *widgets.Aerc, args []string) error { } }() } else if pipePart { - mv, ok := provider.(*widgets.MessageViewer) + mv, ok := provider.(*app.MessageViewer) if !ok { return fmt.Errorf("can only pipe message part from a message view") } diff --git a/commands/msg/read.go b/commands/msg/read.go index cffd2218..10a874e3 100644 --- a/commands/msg/read.go +++ b/commands/msg/read.go @@ -6,8 +6,8 @@ import ( "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -21,7 +21,7 @@ func (FlagMsg) Aliases() []string { return []string{"flag", "unflag", "read", "unread"} } -func (FlagMsg) Complete(aerc *widgets.Aerc, args []string) []string { +func (FlagMsg) Complete(aerc *app.Aerc, args []string) []string { return nil } @@ -32,7 +32,7 @@ func (FlagMsg) Complete(aerc *widgets.Aerc, args []string) []string { // // If this was called as 'read' or 'unread', it has the same effect as // 'flag' or 'unflag', respectively, but the 'Seen' flag is affected. -func (FlagMsg) Execute(aerc *widgets.Aerc, args []string) error { +func (FlagMsg) Execute(aerc *app.Aerc, args []string) error { // The flag to change var flag models.Flags // User-readable name of the flag to change diff --git a/commands/msg/recall.go b/commands/msg/recall.go index e7579ca1..4a08df29 100644 --- a/commands/msg/recall.go +++ b/commands/msg/recall.go @@ -10,10 +10,10 @@ import ( _ "github.com/emersion/go-message/charset" "github.com/pkg/errors" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" "git.sr.ht/~sircmpwn/getopt" ) @@ -28,11 +28,11 @@ func (Recall) Aliases() []string { return []string{"recall"} } -func (Recall) Complete(aerc *widgets.Aerc, args []string) []string { +func (Recall) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Recall) Execute(aerc *widgets.Aerc, args []string) error { +func (Recall) Execute(aerc *app.Aerc, args []string) error { force := false editHeaders := config.Compose.EditHeaders @@ -54,7 +54,7 @@ func (Recall) Execute(aerc *widgets.Aerc, args []string) error { return errors.New("Usage: recall [-f] [-e|-E]") } - widget := aerc.SelectedTabContent().(widgets.ProvidesMessage) + widget := aerc.SelectedTabContent().(app.ProvidesMessage) acct := widget.SelectedAccount() if acct == nil { return errors.New("No account selected") @@ -74,13 +74,13 @@ func (Recall) Execute(aerc *widgets.Aerc, args []string) error { } log.Debugf("Recalling message <%s>", msgInfo.Envelope.MessageId) - addTab := func(composer *widgets.Composer) { + addTab := func(composer *app.Composer) { subject := msgInfo.Envelope.Subject if subject == "" { subject = "Recalled email" } composer.Tab = aerc.NewTab(composer, subject) - composer.OnClose(func(composer *widgets.Composer) { + composer.OnClose(func(composer *app.Composer) { worker := composer.Worker() uids := []uint32{msgInfo.Uid} @@ -116,7 +116,7 @@ func (Recall) Execute(aerc *widgets.Aerc, args []string) error { } msg.FetchBodyPart(path, func(reader io.Reader) { - composer, err := widgets.NewComposer(aerc, acct, + composer, err := app.NewComposer(aerc, acct, acct.AccountConfig(), acct.Worker(), editHeaders, "", msgInfo.RFC822Headers, nil, reader) if err != nil { diff --git a/commands/msg/reply.go b/commands/msg/reply.go index b2a61a80..035e6aa3 100644 --- a/commands/msg/reply.go +++ b/commands/msg/reply.go @@ -11,6 +11,7 @@ import ( "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands/account" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" @@ -19,7 +20,6 @@ import ( "git.sr.ht/~rjarry/aerc/lib/parse" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "github.com/emersion/go-message/mail" ) @@ -33,11 +33,11 @@ func (reply) Aliases() []string { return []string{"reply"} } -func (reply) Complete(aerc *widgets.Aerc, args []string) []string { +func (reply) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (reply) Execute(aerc *widgets.Aerc, args []string) error { +func (reply) Execute(aerc *app.Aerc, args []string) error { opts, optind, err := getopt.Getopts(args, "acqT:eE") if err != nil { return err @@ -69,7 +69,7 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error { } } - widget := aerc.SelectedTabContent().(widgets.ProvidesMessage) + widget := aerc.SelectedTabContent().(app.ProvidesMessage) acct := widget.SelectedAccount() if acct == nil { @@ -177,9 +177,9 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error { RFC822Headers: msg.RFC822Headers, } - mv, _ := aerc.SelectedTabContent().(*widgets.MessageViewer) + mv, _ := aerc.SelectedTabContent().(*app.MessageViewer) addTab := func() error { - composer, err := widgets.NewComposer(aerc, acct, + composer, err := app.NewComposer(aerc, acct, acct.AccountConfig(), acct.Worker(), editHeaders, template, h, &original, nil) if err != nil { @@ -196,7 +196,7 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error { composer.Tab = aerc.NewTab(composer, subject) - composer.OnClose(func(c *widgets.Composer) { + composer.OnClose(func(c *app.Composer) { switch { case c.Sent() && c.Archive() != "": store.Answered([]uint32{msg.Uid}, true, nil) @@ -221,8 +221,8 @@ func (reply) Execute(aerc *widgets.Aerc, args []string) error { } if crypto.IsEncrypted(msg.BodyStructure) { - provider := aerc.SelectedTabContent().(widgets.ProvidesMessage) - mv, ok := provider.(*widgets.MessageViewer) + provider := aerc.SelectedTabContent().(app.ProvidesMessage) + mv, ok := provider.(*app.MessageViewer) if !ok { return fmt.Errorf("message is encrypted. can only quote reply while message is open") } diff --git a/commands/msg/toggle-thread-context.go b/commands/msg/toggle-thread-context.go index 09c60b85..6f8b7bbb 100644 --- a/commands/msg/toggle-thread-context.go +++ b/commands/msg/toggle-thread-context.go @@ -3,8 +3,8 @@ package msg import ( "errors" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/ui" - "git.sr.ht/~rjarry/aerc/widgets" ) type ToggleThreadContext struct{} @@ -17,11 +17,11 @@ func (ToggleThreadContext) Aliases() []string { return []string{"toggle-thread-context"} } -func (ToggleThreadContext) Complete(aerc *widgets.Aerc, args []string) []string { +func (ToggleThreadContext) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (ToggleThreadContext) Execute(aerc *widgets.Aerc, args []string) error { +func (ToggleThreadContext) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: toggle-entire-thread") } diff --git a/commands/msg/toggle-threads.go b/commands/msg/toggle-threads.go index 9508da50..0b85e510 100644 --- a/commands/msg/toggle-threads.go +++ b/commands/msg/toggle-threads.go @@ -3,9 +3,9 @@ package msg import ( "errors" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/state" "git.sr.ht/~rjarry/aerc/lib/ui" - "git.sr.ht/~rjarry/aerc/widgets" ) type ToggleThreads struct{} @@ -18,11 +18,11 @@ func (ToggleThreads) Aliases() []string { return []string{"toggle-threads"} } -func (ToggleThreads) Complete(aerc *widgets.Aerc, args []string) []string { +func (ToggleThreads) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (ToggleThreads) Execute(aerc *widgets.Aerc, args []string) error { +func (ToggleThreads) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: toggle-threads") } diff --git a/commands/msg/unsubscribe.go b/commands/msg/unsubscribe.go index 505392d4..23029244 100644 --- a/commands/msg/unsubscribe.go +++ b/commands/msg/unsubscribe.go @@ -8,10 +8,10 @@ import ( "strings" "time" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" "github.com/emersion/go-message/mail" ) @@ -30,12 +30,12 @@ func (Unsubscribe) Aliases() []string { } // Complete returns a list of completions -func (Unsubscribe) Complete(aerc *widgets.Aerc, args []string) []string { +func (Unsubscribe) Complete(aerc *app.Aerc, args []string) []string { return nil } // Execute runs the Unsubscribe command -func (Unsubscribe) Execute(aerc *widgets.Aerc, args []string) error { +func (Unsubscribe) Execute(aerc *app.Aerc, args []string) error { editHeaders := config.Compose.EditHeaders opts, optind, err := getopt.Getopts(args, "eE") if err != nil { @@ -52,7 +52,7 @@ func (Unsubscribe) Execute(aerc *widgets.Aerc, args []string) error { editHeaders = false } } - widget := aerc.SelectedTabContent().(widgets.ProvidesMessage) + widget := aerc.SelectedTabContent().(app.ProvidesMessage) msg, err := widget.SelectedMessage() if err != nil { return err @@ -97,14 +97,14 @@ func (Unsubscribe) Execute(aerc *widgets.Aerc, args []string) error { options[i] = method.Scheme } - dialog := widgets.NewSelectorDialog( + dialog := app.NewSelectorDialog( title, "Press to confirm or to cancel", options, 0, aerc.SelectedAccountUiConfig(), func(option string, err error) { aerc.CloseDialog() if err != nil { - if errors.Is(err, widgets.ErrNoOptionSelected) { + if errors.Is(err, app.ErrNoOptionSelected) { aerc.PushStatus("Unsubscribe: "+err.Error(), 5*time.Second) } else { @@ -148,8 +148,8 @@ func parseUnsubscribeMethods(header string) (methods []*url.URL) { } } -func unsubscribeMailto(aerc *widgets.Aerc, u *url.URL, editHeaders bool) error { - widget := aerc.SelectedTabContent().(widgets.ProvidesMessage) +func unsubscribeMailto(aerc *app.Aerc, u *url.URL, editHeaders bool) error { + widget := aerc.SelectedTabContent().(app.ProvidesMessage) acct := widget.SelectedAccount() if acct == nil { return errors.New("No account selected") @@ -161,7 +161,7 @@ func unsubscribeMailto(aerc *widgets.Aerc, u *url.URL, editHeaders bool) error { h.SetAddressList("to", to) } - composer, err := widgets.NewComposer( + composer, err := app.NewComposer( aerc, acct, acct.AccountConfig(), @@ -180,8 +180,8 @@ func unsubscribeMailto(aerc *widgets.Aerc, u *url.URL, editHeaders bool) error { return nil } -func unsubscribeHTTP(aerc *widgets.Aerc, u *url.URL) error { - confirm := widgets.NewSelectorDialog( +func unsubscribeHTTP(aerc *app.Aerc, u *url.URL) error { + confirm := app.NewSelectorDialog( "Do you want to open this link?", u.String(), []string{"No", "Yes"}, 0, aerc.SelectedAccountUiConfig(), diff --git a/commands/msg/utils.go b/commands/msg/utils.go index 8210cae1..423be37d 100644 --- a/commands/msg/utils.go +++ b/commands/msg/utils.go @@ -4,19 +4,19 @@ import ( "errors" "time" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" ) type helper struct { - msgProvider widgets.ProvidesMessages + msgProvider app.ProvidesMessages statusInfo func(string) } -func newHelper(aerc *widgets.Aerc) *helper { - msgProvider, ok := aerc.SelectedTabContent().(widgets.ProvidesMessages) +func newHelper(aerc *app.Aerc) *helper { + msgProvider, ok := aerc.SelectedTabContent().(app.ProvidesMessages) if !ok { msgProvider = aerc.SelectedAccount() } @@ -40,7 +40,7 @@ func (h *helper) store() (*lib.MessageStore, error) { return store, nil } -func (h *helper) account() (*widgets.AccountView, error) { +func (h *helper) account() (*app.AccountView, error) { acct := h.msgProvider.SelectedAccount() if acct == nil { return nil, errors.New("No account selected") diff --git a/commands/msgview/close.go b/commands/msgview/close.go index 5e4f3e92..428dc51f 100644 --- a/commands/msgview/close.go +++ b/commands/msgview/close.go @@ -3,7 +3,7 @@ package msgview import ( "errors" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Close struct{} @@ -16,15 +16,15 @@ func (Close) Aliases() []string { return []string{"close"} } -func (Close) Complete(aerc *widgets.Aerc, args []string) []string { +func (Close) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Close) Execute(aerc *widgets.Aerc, args []string) error { +func (Close) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: close") } - mv, _ := aerc.SelectedTabContent().(*widgets.MessageViewer) + mv, _ := aerc.SelectedTabContent().(*app.MessageViewer) aerc.RemoveTab(mv, true) return nil } diff --git a/commands/msgview/next-part.go b/commands/msgview/next-part.go index 652dccb6..6f314991 100644 --- a/commands/msgview/next-part.go +++ b/commands/msgview/next-part.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type NextPrevPart struct{} @@ -17,11 +17,11 @@ func (NextPrevPart) Aliases() []string { return []string{"next-part", "prev-part"} } -func (NextPrevPart) Complete(aerc *widgets.Aerc, args []string) []string { +func (NextPrevPart) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (NextPrevPart) Execute(aerc *widgets.Aerc, args []string) error { +func (NextPrevPart) Execute(aerc *app.Aerc, args []string) error { if len(args) > 2 { return nextPrevPartUsage(args[0]) } @@ -35,7 +35,7 @@ func (NextPrevPart) Execute(aerc *widgets.Aerc, args []string) error { return nextPrevPartUsage(args[0]) } } - mv, _ := aerc.SelectedTabContent().(*widgets.MessageViewer) + mv, _ := aerc.SelectedTabContent().(*app.MessageViewer) for ; n > 0; n-- { if args[0] == "prev-part" { mv.PreviousPart() diff --git a/commands/msgview/next.go b/commands/msgview/next.go index cc7a5479..e647cc0d 100644 --- a/commands/msgview/next.go +++ b/commands/msgview/next.go @@ -4,10 +4,10 @@ import ( "errors" "fmt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands/account" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) @@ -21,16 +21,16 @@ func (NextPrevMsg) Aliases() []string { return []string{"next", "next-message", "prev", "prev-message"} } -func (NextPrevMsg) Complete(aerc *widgets.Aerc, args []string) []string { +func (NextPrevMsg) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (NextPrevMsg) Execute(aerc *widgets.Aerc, args []string) error { +func (NextPrevMsg) Execute(aerc *app.Aerc, args []string) error { n, pct, err := account.ParseNextPrevMessage(args) if err != nil { return err } - mv, _ := aerc.SelectedTabContent().(*widgets.MessageViewer) + mv, _ := aerc.SelectedTabContent().(*app.MessageViewer) acct := mv.SelectedAccount() if acct == nil { return errors.New("No account selected") @@ -51,7 +51,7 @@ func (NextPrevMsg) Execute(aerc *widgets.Aerc, args []string) error { aerc.PushError(err.Error()) return } - nextMv := widgets.NewMessageViewer(acct, view) + nextMv := app.NewMessageViewer(acct, view) aerc.ReplaceTab(mv, nextMv, nextMsg.Envelope.Subject, true) }) diff --git a/commands/msgview/open-link.go b/commands/msgview/open-link.go index da58b717..4052e3a6 100644 --- a/commands/msgview/open-link.go +++ b/commands/msgview/open-link.go @@ -5,10 +5,10 @@ import ( "fmt" "net/url" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~rjarry/aerc/widgets" ) type OpenLink struct{} @@ -21,8 +21,8 @@ func (OpenLink) Aliases() []string { return []string{"open-link"} } -func (OpenLink) Complete(aerc *widgets.Aerc, args []string) []string { - mv := aerc.SelectedTabContent().(*widgets.MessageViewer) +func (OpenLink) Complete(aerc *app.Aerc, args []string) []string { + mv := aerc.SelectedTabContent().(*app.MessageViewer) if mv != nil { if p := mv.SelectedMessagePart(); p != nil { return commands.CompletionFromList(aerc, p.Links, args) @@ -31,7 +31,7 @@ func (OpenLink) Complete(aerc *widgets.Aerc, args []string) []string { return nil } -func (OpenLink) Execute(aerc *widgets.Aerc, args []string) error { +func (OpenLink) Execute(aerc *app.Aerc, args []string) error { if len(args) < 2 { return errors.New("Usage: open-link [program [args...]]") } diff --git a/commands/msgview/open.go b/commands/msgview/open.go index b66456cc..1f74bc7a 100644 --- a/commands/msgview/open.go +++ b/commands/msgview/open.go @@ -9,9 +9,9 @@ import ( "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/log" - "git.sr.ht/~rjarry/aerc/widgets" ) type Open struct{} @@ -28,11 +28,11 @@ func (Open) Aliases() []string { return []string{"open"} } -func (Open) Complete(aerc *widgets.Aerc, args []string) []string { +func (Open) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (o Open) Execute(aerc *widgets.Aerc, args []string) error { +func (o Open) Execute(aerc *app.Aerc, args []string) error { opts, optind, err := getopt.Getopts(args, o.Options()) if err != nil { return err @@ -46,7 +46,7 @@ func (o Open) Execute(aerc *widgets.Aerc, args []string) error { } } - mv := aerc.SelectedTabContent().(*widgets.MessageViewer) + mv := aerc.SelectedTabContent().(*app.MessageViewer) if mv == nil { return errors.New("open only supported selected message parts") } diff --git a/commands/msgview/save.go b/commands/msgview/save.go index 1ffdaf92..4d914e3e 100644 --- a/commands/msgview/save.go +++ b/commands/msgview/save.go @@ -11,12 +11,12 @@ import ( "git.sr.ht/~sircmpwn/getopt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/config" "git.sr.ht/~rjarry/aerc/lib/xdg" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" ) type Save struct{} @@ -33,7 +33,7 @@ func (Save) Aliases() []string { return []string{"save"} } -func (s Save) Complete(aerc *widgets.Aerc, args []string) []string { +func (s Save) Complete(aerc *app.Aerc, args []string) []string { trimmed := commands.Operands(args, s.Options()) path := strings.Join(trimmed, " ") defaultPath := config.General.DefaultSavePath @@ -51,7 +51,7 @@ type saveParams struct { allAttachments bool } -func (s Save) Execute(aerc *widgets.Aerc, args []string) error { +func (s Save) Execute(aerc *app.Aerc, args []string) error { opts, optind, err := getopt.Getopts(args, s.Options()) if err != nil { return err @@ -100,7 +100,7 @@ func (s Save) Execute(aerc *widgets.Aerc, args []string) error { path = xdg.ExpandHome(path) - mv, ok := aerc.SelectedTabContent().(*widgets.MessageViewer) + mv, ok := aerc.SelectedTabContent().(*app.MessageViewer) if !ok { return fmt.Errorf("SelectedTabContent is not a MessageViewer") } @@ -125,10 +125,10 @@ func (s Save) Execute(aerc *widgets.Aerc, args []string) error { } func savePart( - pi *widgets.PartInfo, + pi *app.PartInfo, path string, - mv *widgets.MessageViewer, - aerc *widgets.Aerc, + mv *app.MessageViewer, + aerc *app.Aerc, params *saveParams, names map[string]struct{}, ) error { diff --git a/commands/msgview/toggle-headers.go b/commands/msgview/toggle-headers.go index 1a593494..0bb834a9 100644 --- a/commands/msgview/toggle-headers.go +++ b/commands/msgview/toggle-headers.go @@ -3,7 +3,7 @@ package msgview import ( "fmt" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type ToggleHeaders struct{} @@ -16,15 +16,15 @@ func (ToggleHeaders) Aliases() []string { return []string{"toggle-headers"} } -func (ToggleHeaders) Complete(aerc *widgets.Aerc, args []string) []string { +func (ToggleHeaders) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (ToggleHeaders) Execute(aerc *widgets.Aerc, args []string) error { +func (ToggleHeaders) Execute(aerc *app.Aerc, args []string) error { if len(args) > 1 { return toggleHeadersUsage(args[0]) } - mv, _ := aerc.SelectedTabContent().(*widgets.MessageViewer) + mv, _ := aerc.SelectedTabContent().(*app.MessageViewer) mv.ToggleHeaders() return nil } diff --git a/commands/msgview/toggle-key-passthrough.go b/commands/msgview/toggle-key-passthrough.go index 00a39559..7e24329a 100644 --- a/commands/msgview/toggle-key-passthrough.go +++ b/commands/msgview/toggle-key-passthrough.go @@ -3,8 +3,8 @@ package msgview import ( "errors" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/state" - "git.sr.ht/~rjarry/aerc/widgets" ) type ToggleKeyPassthrough struct{} @@ -17,15 +17,15 @@ func (ToggleKeyPassthrough) Aliases() []string { return []string{"toggle-key-passthrough"} } -func (ToggleKeyPassthrough) Complete(aerc *widgets.Aerc, args []string) []string { +func (ToggleKeyPassthrough) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (ToggleKeyPassthrough) Execute(aerc *widgets.Aerc, args []string) error { +func (ToggleKeyPassthrough) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: toggle-key-passthrough") } - mv, _ := aerc.SelectedTabContent().(*widgets.MessageViewer) + mv, _ := aerc.SelectedTabContent().(*app.MessageViewer) keyPassthroughEnabled := mv.ToggleKeyPassthrough() if acct := mv.SelectedAccount(); acct != nil { acct.SetStatus(state.Passthrough(keyPassthroughEnabled)) diff --git a/commands/new-account.go b/commands/new-account.go index d67b5eca..3170c75c 100644 --- a/commands/new-account.go +++ b/commands/new-account.go @@ -3,7 +3,7 @@ package commands import ( "errors" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~sircmpwn/getopt" ) @@ -17,16 +17,16 @@ func (NewAccount) Aliases() []string { return []string{"new-account"} } -func (NewAccount) Complete(aerc *widgets.Aerc, args []string) []string { +func (NewAccount) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (NewAccount) Execute(aerc *widgets.Aerc, args []string) error { +func (NewAccount) Execute(aerc *app.Aerc, args []string) error { opts, _, err := getopt.Getopts(args, "t") if err != nil { return errors.New("Usage: new-account [-t]") } - wizard := widgets.NewAccountWizard(aerc) + wizard := app.NewAccountWizard(aerc) for _, opt := range opts { if opt.Option == 't' { wizard.ConfigureTemporaryAccount(true) diff --git a/commands/next-tab.go b/commands/next-tab.go index 854353f8..247e7534 100644 --- a/commands/next-tab.go +++ b/commands/next-tab.go @@ -4,7 +4,7 @@ import ( "fmt" "strconv" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type NextPrevTab struct{} @@ -17,11 +17,11 @@ func (NextPrevTab) Aliases() []string { return []string{"next-tab", "prev-tab"} } -func (NextPrevTab) Complete(aerc *widgets.Aerc, args []string) []string { +func (NextPrevTab) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (NextPrevTab) Execute(aerc *widgets.Aerc, args []string) error { +func (NextPrevTab) Execute(aerc *app.Aerc, args []string) error { if len(args) > 2 { return nextPrevTabUsage(args[0]) } diff --git a/commands/pin-tab.go b/commands/pin-tab.go index 9a626614..e2e897ed 100644 --- a/commands/pin-tab.go +++ b/commands/pin-tab.go @@ -3,7 +3,7 @@ package commands import ( "fmt" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type PinTab struct{} @@ -16,11 +16,11 @@ func (PinTab) Aliases() []string { return []string{"pin-tab", "unpin-tab"} } -func (PinTab) Complete(aerc *widgets.Aerc, args []string) []string { +func (PinTab) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (PinTab) Execute(aerc *widgets.Aerc, args []string) error { +func (PinTab) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return fmt.Errorf("Usage: %s", args[0]) } diff --git a/commands/prompt.go b/commands/prompt.go index a93d19a9..d3a9411c 100644 --- a/commands/prompt.go +++ b/commands/prompt.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Prompt struct{} @@ -17,7 +17,7 @@ func (Prompt) Aliases() []string { return []string{"prompt"} } -func (Prompt) Complete(aerc *widgets.Aerc, args []string) []string { +func (Prompt) Complete(aerc *app.Aerc, args []string) []string { argc := len(args) if argc == 0 { return nil @@ -69,7 +69,7 @@ func (Prompt) Complete(aerc *widgets.Aerc, args []string) []string { return rs } -func (Prompt) Execute(aerc *widgets.Aerc, args []string) error { +func (Prompt) Execute(aerc *app.Aerc, args []string) error { if len(args) < 3 { return fmt.Errorf("Usage: %s ", args[0]) } diff --git a/commands/pwd.go b/commands/pwd.go index 624258ce..9082a469 100644 --- a/commands/pwd.go +++ b/commands/pwd.go @@ -5,7 +5,7 @@ import ( "os" "time" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type PrintWorkDir struct{} @@ -18,11 +18,11 @@ func (PrintWorkDir) Aliases() []string { return []string{"pwd"} } -func (PrintWorkDir) Complete(aerc *widgets.Aerc, args []string) []string { +func (PrintWorkDir) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (PrintWorkDir) Execute(aerc *widgets.Aerc, args []string) error { +func (PrintWorkDir) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: pwd") } diff --git a/commands/quit.go b/commands/quit.go index 09791a74..73054f86 100644 --- a/commands/quit.go +++ b/commands/quit.go @@ -4,8 +4,8 @@ import ( "errors" "fmt" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands/mode" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~sircmpwn/getopt" ) @@ -19,7 +19,7 @@ func (Quit) Aliases() []string { return []string{"quit", "exit"} } -func (Quit) Complete(aerc *widgets.Aerc, args []string) []string { +func (Quit) Complete(aerc *app.Aerc, args []string) []string { return nil } @@ -29,7 +29,7 @@ func (err ErrorExit) Error() string { return "exit" } -func (Quit) Execute(aerc *widgets.Aerc, args []string) error { +func (Quit) Execute(aerc *app.Aerc, args []string) error { force := false opts, optind, err := getopt.Getopts(args, "f") if err != nil { diff --git a/commands/term.go b/commands/term.go index 22957635..c5fa5cbd 100644 --- a/commands/term.go +++ b/commands/term.go @@ -5,8 +5,8 @@ import ( "github.com/riywo/loginshell" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib/ui" - "git.sr.ht/~rjarry/aerc/widgets" ) type Term struct{} @@ -19,12 +19,12 @@ func (Term) Aliases() []string { return []string{"terminal", "term"} } -func (Term) Complete(aerc *widgets.Aerc, args []string) []string { +func (Term) Complete(aerc *app.Aerc, args []string) []string { return nil } // The help command is an alias for `term man` thus Term requires a simple func -func TermCore(aerc *widgets.Aerc, args []string) error { +func TermCore(aerc *app.Aerc, args []string) error { if len(args) == 1 { shell, err := loginshell.Shell() if err != nil { @@ -32,7 +32,7 @@ func TermCore(aerc *widgets.Aerc, args []string) error { } args = append(args, shell) } - term, err := widgets.NewTerminal(exec.Command(args[1], args[2:]...)) + term, err := app.NewTerminal(exec.Command(args[1], args[2:]...)) if err != nil { return err } @@ -55,6 +55,6 @@ func TermCore(aerc *widgets.Aerc, args []string) error { return nil } -func (Term) Execute(aerc *widgets.Aerc, args []string) error { +func (Term) Execute(aerc *app.Aerc, args []string) error { return TermCore(aerc, args) } diff --git a/commands/terminal/close.go b/commands/terminal/close.go index 812266c0..0f72292e 100644 --- a/commands/terminal/close.go +++ b/commands/terminal/close.go @@ -3,7 +3,7 @@ package terminal import ( "errors" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Close struct{} @@ -16,15 +16,15 @@ func (Close) Aliases() []string { return []string{"close"} } -func (Close) Complete(aerc *widgets.Aerc, args []string) []string { +func (Close) Complete(aerc *app.Aerc, args []string) []string { return nil } -func (Close) Execute(aerc *widgets.Aerc, args []string) error { +func (Close) Execute(aerc *app.Aerc, args []string) error { if len(args) != 1 { return errors.New("Usage: close") } - term, _ := aerc.SelectedTabContent().(*widgets.Terminal) + term, _ := aerc.SelectedTabContent().(*app.Terminal) term.Close() return nil } diff --git a/commands/util.go b/commands/util.go index aeb18237..fbb20433 100644 --- a/commands/util.go +++ b/commands/util.go @@ -12,24 +12,24 @@ import ( "github.com/lithammer/fuzzysearch/fuzzy" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/lib" "git.sr.ht/~rjarry/aerc/lib/xdg" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" "github.com/gdamore/tcell/v2" ) // QuickTerm is an ephemeral terminal for running a single command and quitting. -func QuickTerm(aerc *widgets.Aerc, args []string, stdin io.Reader) (*widgets.Terminal, error) { +func QuickTerm(aerc *app.Aerc, args []string, stdin io.Reader) (*app.Terminal, error) { cmd := exec.Command(args[0], args[1:]...) pipe, err := cmd.StdinPipe() if err != nil { return nil, err } - term, err := widgets.NewTerminal(cmd) + term, err := app.NewTerminal(cmd) if err != nil { return nil, err } @@ -167,7 +167,7 @@ func listDir(path string, hidden bool) []string { // MarkedOrSelected returns either all marked messages if any are marked or the // selected message instead -func MarkedOrSelected(pm widgets.ProvidesMessages) ([]uint32, error) { +func MarkedOrSelected(pm app.ProvidesMessages) ([]uint32, error) { // marked has priority over the selected message marked, err := pm.MarkedMessages() if err != nil { diff --git a/commands/z.go b/commands/z.go index ca982ba7..aa903738 100644 --- a/commands/z.go +++ b/commands/z.go @@ -6,7 +6,7 @@ import ( "os/exec" "strings" - "git.sr.ht/~rjarry/aerc/widgets" + "git.sr.ht/~rjarry/aerc/app" ) type Zoxide struct{} @@ -36,13 +36,13 @@ func (Zoxide) Aliases() []string { return []string{"z"} } -func (Zoxide) Complete(aerc *widgets.Aerc, args []string) []string { +func (Zoxide) Complete(aerc *app.Aerc, args []string) []string { return ChangeDirectory{}.Complete(aerc, args) } // Execute calls zoxide add and query and delegates actually changing the // directory to ChangeDirectory -func (Zoxide) Execute(aerc *widgets.Aerc, args []string) error { +func (Zoxide) Execute(aerc *app.Aerc, args []string) error { if len(args) < 1 { return errors.New("Usage: z [directory or zoxide query]") } diff --git a/doc/aerc-templates.7.scd b/doc/aerc-templates.7.scd index 17df91b3..c130ee28 100644 --- a/doc/aerc-templates.7.scd +++ b/doc/aerc-templates.7.scd @@ -9,7 +9,7 @@ aerc-templates - template file specification for *aerc*(1) aerc uses the go text/template package for the template parsing. Refer to the go text/template documentation for the general syntax. The template syntax described below can be used for message template files and -for dynamic formatting of some UI widgets. +for dynamic formatting of some UI app. Template files are composed of headers, followed by a newline, followed by the body text. diff --git a/main.go b/main.go index 0f897bd0..1210783f 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/mattn/go-isatty" "github.com/xo/terminfo" + "git.sr.ht/~rjarry/aerc/app" "git.sr.ht/~rjarry/aerc/commands" "git.sr.ht/~rjarry/aerc/commands/account" "git.sr.ht/~rjarry/aerc/commands/compose" @@ -29,30 +30,29 @@ import ( libui "git.sr.ht/~rjarry/aerc/lib/ui" "git.sr.ht/~rjarry/aerc/log" "git.sr.ht/~rjarry/aerc/models" - "git.sr.ht/~rjarry/aerc/widgets" "git.sr.ht/~rjarry/aerc/worker/types" ) func getCommands(selected libui.Drawable) []*commands.Commands { switch selected.(type) { - case *widgets.AccountView: + case *app.AccountView: return []*commands.Commands{ account.AccountCommands, msg.MessageCommands, commands.GlobalCommands, } - case *widgets.Composer: + case *app.Composer: return []*commands.Commands{ compose.ComposeCommands, commands.GlobalCommands, } - case *widgets.MessageViewer: + case *app.MessageViewer: return []*commands.Commands{ msgview.MessageViewCommands, msg.MessageCommands, commands.GlobalCommands, } - case *widgets.Terminal: + case *app.Terminal: return []*commands.Commands{ terminal.TerminalCommands, commands.GlobalCommands, @@ -104,7 +104,7 @@ func expandAbbreviations(cmd []string, sets []*commands.Commands) []string { } func execCommand( - aerc *widgets.Aerc, ui *libui.UI, cmd []string, + aerc *app.Aerc, ui *libui.UI, cmd []string, acct *config.AccountConfig, msg *models.MessageInfo, ) error { cmds := getCommands(aerc.SelectedTabContent()) @@ -129,7 +129,7 @@ func execCommand( return nil } -func getCompletions(aerc *widgets.Aerc, cmd string) ([]string, string) { +func getCompletions(aerc *app.Aerc, cmd string) ([]string, string) { if options, prefix, ok := commands.GetTemplateCompletion(aerc, cmd); ok { return options, prefix } @@ -229,7 +229,7 @@ func main() { log.Infof("Starting up version %s", log.BuildInfo) var ( - aerc *widgets.Aerc + aerc *app.Aerc ui *libui.UI ) @@ -242,7 +242,7 @@ func main() { } defer c.Close() - aerc = widgets.NewAerc(c, func( + aerc = app.NewAerc(c, func( cmd []string, acct *config.AccountConfig, msg *models.MessageInfo, ) error { diff --git a/widgets/account-wizard.go b/widgets/account-wizard.go deleted file mode 100644 index 7bb61079..00000000 --- a/widgets/account-wizard.go +++ /dev/null @@ -1,891 +0,0 @@ -package widgets - -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: - - , or Next field - , or Previous field - 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 ':'. - // Force a '//' opaque suffix so that it is rendered as '://'. - 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/widgets/account.go b/widgets/account.go deleted file mode 100644 index 7e380996..00000000 --- a/widgets/account.go +++ /dev/null @@ -1,649 +0,0 @@ -package widgets - -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/widgets/aerc.go b/widgets/aerc.go deleted file mode 100644 index efa13194..00000000 --- a/widgets/aerc.go +++ /dev/null @@ -1,908 +0,0 @@ -package widgets - -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 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/widgets/authinfo.go b/widgets/authinfo.go deleted file mode 100644 index 2b406a7a..00000000 --- a/widgets/authinfo.go +++ /dev/null @@ -1,88 +0,0 @@ -package widgets - -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/widgets/compose.go b/widgets/compose.go deleted file mode 100644 index 14fce3ce..00000000 --- a/widgets/compose.go +++ /dev/null @@ -1,1975 +0,0 @@ -package widgets - -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", "Send", ""}, - {":edit", "Edit", ""}, - {":attach", "Add attachment", ""}, - {":detach", "Remove attachment", ""}, - {":postpone", "Postpone", ""}, - {":preview", "Preview message", ""}, - {":abort", "Abort (discard message, no confirmation)", ""}, - {":choose -o d discard abort -o p postpone postpone", "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/widgets/dialog.go b/widgets/dialog.go deleted file mode 100644 index 1af4456a..00000000 --- a/widgets/dialog.go +++ /dev/null @@ -1,24 +0,0 @@ -package widgets - -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/widgets/dirlist.go b/widgets/dirlist.go deleted file mode 100644 index e9cec458..00000000 --- a/widgets/dirlist.go +++ /dev/null @@ -1,532 +0,0 @@ -package widgets - -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/widgets/dirtree.go b/widgets/dirtree.go deleted file mode 100644 index 035a0a81..00000000 --- a/widgets/dirtree.go +++ /dev/null @@ -1,495 +0,0 @@ -package widgets - -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/widgets/exline.go b/widgets/exline.go deleted file mode 100644 index 1f2d71e4..00000000 --- a/widgets/exline.go +++ /dev/null @@ -1,120 +0,0 @@ -package widgets - -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/widgets/getpasswd.go b/widgets/getpasswd.go deleted file mode 100644 index 17274626..00000000 --- a/widgets/getpasswd.go +++ /dev/null @@ -1,68 +0,0 @@ -package widgets - -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/widgets/headerlayout.go b/widgets/headerlayout.go deleted file mode 100644 index 8113cf89..00000000 --- a/widgets/headerlayout.go +++ /dev/null @@ -1,44 +0,0 @@ -package widgets - -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/widgets/listbox.go b/widgets/listbox.go deleted file mode 100644 index 9a0a48bc..00000000 --- a/widgets/listbox.go +++ /dev/null @@ -1,299 +0,0 @@ -package widgets - -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/widgets/msglist.go b/widgets/msglist.go deleted file mode 100644 index 3187b5d5..00000000 --- a/widgets/msglist.go +++ /dev/null @@ -1,497 +0,0 @@ -package widgets - -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/widgets/msgviewer.go b/widgets/msgviewer.go deleted file mode 100644 index 90f41167..00000000 --- a/widgets/msgviewer.go +++ /dev/null @@ -1,927 +0,0 @@ -package widgets - -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", "Open using the system handler"}, - {":save", "Save to file"}, - {":pipe", "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", "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/widgets/pgpinfo.go b/widgets/pgpinfo.go deleted file mode 100644 index c64bcfdf..00000000 --- a/widgets/pgpinfo.go +++ /dev/null @@ -1,98 +0,0 @@ -package widgets - -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/widgets/providesmessage.go b/widgets/providesmessage.go deleted file mode 100644 index b0f261d9..00000000 --- a/widgets/providesmessage.go +++ /dev/null @@ -1,30 +0,0 @@ -package widgets - -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/widgets/scrollable.go b/widgets/scrollable.go deleted file mode 100644 index f478f858..00000000 --- a/widgets/scrollable.go +++ /dev/null @@ -1,67 +0,0 @@ -package widgets - -// 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/widgets/selector.go b/widgets/selector.go deleted file mode 100644 index 00479d4f..00000000 --- a/widgets/selector.go +++ /dev/null @@ -1,263 +0,0 @@ -package widgets - -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/widgets/spinner.go b/widgets/spinner.go deleted file mode 100644 index 63eaf11b..00000000 --- a/widgets/spinner.go +++ /dev/null @@ -1,86 +0,0 @@ -package widgets - -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/widgets/status.go b/widgets/status.go deleted file mode 100644 index 6157dd10..00000000 --- a/widgets/status.go +++ /dev/null @@ -1,166 +0,0 @@ -package widgets - -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/widgets/tabhost.go b/widgets/tabhost.go deleted file mode 100644 index c0a9dd53..00000000 --- a/widgets/tabhost.go +++ /dev/null @@ -1,15 +0,0 @@ -package widgets - -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/widgets/terminal.go b/widgets/terminal.go deleted file mode 100644 index 96919515..00000000 --- a/widgets/terminal.go +++ /dev/null @@ -1,178 +0,0 @@ -package widgets - -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) -} -- cgit