aboutsummaryrefslogtreecommitdiffstats
path: root/widgets
diff options
context:
space:
mode:
authorRobin Jarry <robin@jarry.cc>2023-10-09 13:52:20 +0200
committerRobin Jarry <robin@jarry.cc>2023-10-10 11:37:56 +0200
commit598e4a5803578ab3e291f232d6aad31b4efd8ea4 (patch)
treec55e16d60e2c3eea2d6de27d1bac18db5670ec77 /widgets
parent61bca76423ee87bd59084a146eca71c6bae085e1 (diff)
downloadaerc-598e4a5803578ab3e291f232d6aad31b4efd8ea4.tar.gz
widgets: rename package to app
This is the central point of all aerc. Having it named widgets is confusing. Rename it to app. It will make a cleaner transition when making the app.Aerc object available globally in the next commit. Signed-off-by: Robin Jarry <robin@jarry.cc> Acked-by: Moritz Poldrack <moritz@poldrack.dev>
Diffstat (limited to 'widgets')
-rw-r--r--widgets/account-wizard.go891
-rw-r--r--widgets/account.go649
-rw-r--r--widgets/aerc.go908
-rw-r--r--widgets/authinfo.go88
-rw-r--r--widgets/compose.go1975
-rw-r--r--widgets/dialog.go24
-rw-r--r--widgets/dirlist.go532
-rw-r--r--widgets/dirtree.go495
-rw-r--r--widgets/exline.go120
-rw-r--r--widgets/getpasswd.go68
-rw-r--r--widgets/headerlayout.go44
-rw-r--r--widgets/listbox.go299
-rw-r--r--widgets/msglist.go497
-rw-r--r--widgets/msgviewer.go927
-rw-r--r--widgets/pgpinfo.go98
-rw-r--r--widgets/providesmessage.go30
-rw-r--r--widgets/scrollable.go67
-rw-r--r--widgets/selector.go263
-rw-r--r--widgets/spinner.go86
-rw-r--r--widgets/status.go166
-rw-r--r--widgets/tabhost.go15
-rw-r--r--widgets/terminal.go178
22 files changed, 0 insertions, 8420 deletions
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:
-
- <Tab>, <Down> or <Ctrl+j> Next field
- <Shift+Tab>, <Up> or <Ctrl+k> Previous field
- <Ctrl+q> Exit aerc
-`,
- &wizard.basics,
- )
- basics.AddField(
- "Name for this account? (e.g. 'Personal' or 'Work')",
- wizard.accountName,
- )
- basics.AddField(
- "Full name for outgoing emails? (e.g. 'John Doe')",
- wizard.fullName,
- )
- basics.AddField(
- "Your email address? (e.g. 'john@example.org')",
- wizard.email,
- )
- basics.AddField("", NewSelector([]string{"Next"}, 0, config.Ui).
- OnChoose(func(option string) {
- wizard.discoverServices()
- wizard.autofill()
- wizard.sourceUri()
- wizard.outgoingUri()
- wizard.advance(option)
- }),
- )
-
- // CONFIGURE_SOURCE
- source := NewConfigStep("Configure email source", &wizard.source)
- source.AddField("Protocol", wizard.sourceProtocol)
- source.AddField("Username", wizard.sourceUsername)
- source.AddField("Password", wizard.sourcePassword)
- source.AddField(
- "Server address (or path to email store)",
- wizard.sourceServer,
- )
- source.AddField("Transport security", wizard.sourceTransport)
- source.AddField("Connection URL", wizard.sourceStr)
- source.AddField(
- "", NewSelector([]string{"Previous", "Next"}, 1, config.Ui).
- OnChoose(wizard.advance),
- )
-
- // CONFIGURE_OUTGOING
- outgoing := NewConfigStep("Configure outgoing mail", &wizard.outgoing)
- outgoing.AddField("Protocol", wizard.outgoingProtocol)
- outgoing.AddField("Username", wizard.outgoingUsername)
- outgoing.AddField("Password", wizard.outgoingPassword)
- outgoing.AddField(
- "Server address (or path to sendmail)",
- wizard.outgoingServer,
- )
- outgoing.AddField("Transport security", wizard.outgoingTransport)
- outgoing.AddField("Connection URL", wizard.outgoingStr)
- outgoing.AddField(
- "Copy sent messages to folder (leave empty to disable)",
- wizard.outgoingCopyTo,
- )
- outgoing.AddField(
- "", NewSelector([]string{"Previous", "Next"}, 1, config.Ui).
- OnChoose(wizard.advance),
- )
-
- // CONFIGURE_COMPLETE
- complete := NewConfigStep(
- fmt.Sprintf(`
-Configuration complete!
-
-You can go back and double check your settings, or choose [Finish] to
-save your settings to %s/accounts.conf.
-
-Make sure to review the contents of this file and read the
-aerc-accounts(5) man page for guidance and further tweaking.
-
-To add another account in the future, run ':new-account'.
-`, xdg.TildeHome(xdg.ConfigPath("aerc"))),
- &wizard.complete,
- )
- complete.AddField(
- "", NewSelector([]string{
- "Previous",
- "Finish & open tutorial",
- "Finish",
- }, 1, config.Ui).OnChoose(func(option string) {
- switch option {
- case "Previous":
- wizard.advance("Previous")
- case "Finish & open tutorial":
- wizard.finish(true)
- case "Finish":
- wizard.finish(false)
- }
- }),
- )
-
- wizard.steps = []*ui.Grid{
- basics.Grid(), source.Grid(), outgoing.Grid(), complete.Grid(),
- }
-
- return wizard
-}
-
-func (wizard *AccountWizard) ConfigureTemporaryAccount(temporary bool) {
- wizard.temporary = temporary
-}
-
-func (wizard *AccountWizard) errorFor(d ui.Interactive, err error) {
- if d == nil {
- wizard.aerc.PushError(err.Error())
- wizard.Invalidate()
- return
- }
- for step, interactives := range [][]ui.Interactive{
- wizard.basics,
- wizard.source,
- wizard.outgoing,
- } {
- for focus, item := range interactives {
- if item == d {
- wizard.Focus(false)
- wizard.step = step
- wizard.focus = focus
- wizard.Focus(true)
- wizard.aerc.PushError(err.Error())
- wizard.Invalidate()
- return
- }
- }
- }
-}
-
-func (wizard *AccountWizard) finish(tutorial bool) {
- accountsConf := xdg.ConfigPath("aerc", "accounts.conf")
-
- // Validation
- if wizard.accountName.String() == "" {
- wizard.errorFor(wizard.accountName,
- errors.New("Account name is required"))
- return
- }
- if wizard.email.String() == "" {
- wizard.errorFor(wizard.email,
- errors.New("Email address is required"))
- return
- }
- if wizard.sourceServer.String() == "" {
- wizard.errorFor(wizard.sourceServer,
- errors.New("Email source configuration is required"))
- return
- }
- if wizard.outgoingServer.String() == "" &&
- wizard.outgoingProtocol.Selected() != JMAP {
- wizard.errorFor(wizard.outgoingServer,
- errors.New("Outgoing mail configuration is required"))
- return
- }
- switch wizard.sourceProtocol.Selected() {
- case MAILDIR, MAILDIRPP, NOTMUCH:
- path := xdg.ExpandHome(wizard.sourceServer.String())
- s, err := os.Stat(path)
- if err == nil && !s.IsDir() {
- err = fmt.Errorf("%s: Not a directory", s.Name())
- }
- if err == nil {
- err = unix.Access(path, unix.X_OK)
- }
- if err != nil {
- wizard.errorFor(wizard.sourceServer, err)
- return
- }
- }
- if wizard.outgoingProtocol.Selected() == SENDMAIL {
- path := xdg.ExpandHome(wizard.outgoingServer.String())
- s, err := os.Stat(path)
- if err == nil && !s.Mode().IsRegular() {
- err = fmt.Errorf("%s: Not a regular file", s.Name())
- }
- if err == nil {
- err = unix.Access(path, unix.X_OK)
- }
- if err != nil {
- wizard.errorFor(wizard.outgoingServer, err)
- return
- }
- }
-
- file, err := ini.Load(accountsConf)
- if err != nil {
- file = ini.Empty()
- }
-
- var sec *ini.Section
- if sec, _ = file.GetSection(wizard.accountName.String()); sec != nil {
- wizard.errorFor(wizard.accountName,
- errors.New("An account by this name already exists"))
- return
- }
- sec, _ = file.NewSection(wizard.accountName.String())
- // these can't fail
- _, _ = sec.NewKey("source", wizard.sourceUrl.String())
- _, _ = sec.NewKey("outgoing", wizard.outgoingUrl.String())
- _, _ = sec.NewKey("default", "INBOX")
- from := mail.Address{
- Name: wizard.fullName.String(),
- Address: wizard.email.String(),
- }
- _, _ = sec.NewKey("from", format.AddressForHumans(&from))
- if wizard.outgoingCopyTo.String() != "" {
- _, _ = sec.NewKey("copy-to", wizard.outgoingCopyTo.String())
- }
-
- switch wizard.sourceProtocol.Selected() {
- case IMAP:
- _, _ = sec.NewKey("cache-headers", "true")
- case JMAP:
- _, _ = sec.NewKey("use-labels", "true")
- _, _ = sec.NewKey("cache-state", "true")
- _, _ = sec.NewKey("cache-blobs", "false")
- case NOTMUCH:
- cmd := exec.Command("notmuch", "config", "get", "database.mail_root")
- out, err := cmd.Output()
- if err == nil {
- root := strings.TrimSpace(string(out))
- _, _ = sec.NewKey("maildir-store", xdg.TildeHome(root))
- }
- querymap := ini.Empty()
- def := querymap.Section("")
- cmd = exec.Command("notmuch", "config", "list")
- out, err = cmd.Output()
- if err == nil {
- re := regexp.MustCompile(`(?m)^query\.([^=]+)=(.+)$`)
- for _, m := range re.FindAllStringSubmatch(string(out), -1) {
- _, _ = def.NewKey(m[1], m[2])
- }
- }
- if len(def.Keys()) == 0 {
- _, _ = def.NewKey("INBOX", "tag:inbox and not tag:archived")
- }
- if !wizard.temporary {
- qmapPath := xdg.ConfigPath("aerc",
- wizard.accountName.String()+".qmap")
- f, err := os.OpenFile(qmapPath, os.O_WRONLY|os.O_CREATE, 0o600)
- if err != nil {
- wizard.errorFor(nil, err)
- return
- }
- defer f.Close()
- if _, err = querymap.WriteTo(f); err != nil {
- wizard.errorFor(nil, err)
- return
- }
- _, _ = sec.NewKey("query-map", xdg.TildeHome(qmapPath))
- }
- }
-
- if !wizard.temporary {
- f, err := os.OpenFile(accountsConf, os.O_WRONLY|os.O_CREATE, 0o600)
- if err != nil {
- wizard.errorFor(nil, err)
- return
- }
- defer f.Close()
- if _, err = file.WriteTo(f); err != nil {
- wizard.errorFor(nil, err)
- return
- }
- }
-
- account, err := config.ParseAccountConfig(sec.Name(), sec)
- if err != nil {
- wizard.errorFor(nil, err)
- return
- }
- config.Accounts = append(config.Accounts, account)
-
- view, err := NewAccountView(wizard.aerc, account, wizard.aerc, nil)
- if err != nil {
- wizard.aerc.NewTab(errorScreen(err.Error()), account.Name)
- return
- }
- wizard.aerc.accounts[account.Name] = view
- wizard.aerc.NewTab(view, account.Name)
-
- if tutorial {
- name := "aerc-tutorial"
- if _, err := os.Stat("./aerc-tutorial.7"); !os.IsNotExist(err) {
- // For development
- name = "./aerc-tutorial.7"
- }
- term, err := NewTerminal(exec.Command("man", name))
- if err != nil {
- wizard.errorFor(nil, err)
- return
- }
- wizard.aerc.NewTab(term, "Tutorial")
- term.OnClose = func(err error) {
- wizard.aerc.RemoveTab(term, false)
- if err != nil {
- wizard.aerc.PushError(err.Error())
- }
- }
- }
-
- wizard.aerc.RemoveTab(wizard, false)
-}
-
-func splitHostPath(server string) (string, string) {
- host, path, found := strings.Cut(server, "/")
- if found {
- path = "/" + path
- }
- return host, path
-}
-
-func makeURLs(scheme, host, path, user, pass string) (url.URL, url.URL) {
- var opaque string
-
- // If everything is unset, the rendered URL is '<scheme>:'.
- // Force a '//' opaque suffix so that it is rendered as '<scheme>://'.
- if scheme != "" && host == "" && path == "" && user == "" && pass == "" {
- opaque = "//"
- }
-
- uri := url.URL{Scheme: scheme, Host: host, Path: path, Opaque: opaque}
- clean := uri
-
- switch {
- case pass != "":
- uri.User = url.UserPassword(user, pass)
- clean.User = url.UserPassword(user, strings.Repeat("*", len(pass)))
- case user != "":
- uri.User = url.User(user)
- clean.User = url.User(user)
- }
-
- return uri, clean
-}
-
-func (wizard *AccountWizard) sourceUri() url.URL {
- host, path := splitHostPath(wizard.sourceServer.String())
- user := wizard.sourceUsername.String()
- pass := wizard.sourcePassword.String()
- var scheme string
- switch wizard.sourceProtocol.Selected() {
- case IMAP:
- switch wizard.sourceTransport.Selected() {
- case STARTTLS:
- scheme = "imap"
- case INSECURE:
- scheme = "imap+insecure"
- case OAUTH:
- scheme = "imaps+oauthbearer"
- case XOAUTH:
- scheme = "imaps+xoauth2"
- default:
- scheme = "imaps"
- }
- case JMAP:
- switch wizard.sourceTransport.Selected() {
- case OAUTH:
- scheme = "jmap+oauthbearer"
- default:
- scheme = "jmap"
- }
- case MAILDIR:
- scheme = "maildir"
- case MAILDIRPP:
- scheme = "maildirpp"
- case NOTMUCH:
- scheme = "notmuch"
- }
- switch wizard.sourceProtocol.Selected() {
- case MAILDIR, MAILDIRPP, NOTMUCH:
- path = host + path
- host = ""
- user = ""
- pass = ""
- }
-
- uri, clean := makeURLs(scheme, host, path, user, pass)
-
- wizard.sourceStr.Text(
- " " + strings.ReplaceAll(clean.String(), "%2A", "*"))
- wizard.sourceUrl = uri
- return uri
-}
-
-func (wizard *AccountWizard) outgoingUri() url.URL {
- host, path := splitHostPath(wizard.outgoingServer.String())
- user := wizard.outgoingUsername.String()
- pass := wizard.outgoingPassword.String()
- var scheme string
- switch wizard.outgoingProtocol.Selected() {
- case SMTP:
- switch wizard.outgoingTransport.Selected() {
- case OAUTH:
- scheme = "smtps+oauthbearer"
- case XOAUTH:
- scheme = "smtps+xoauth2"
- case INSECURE:
- scheme = "smtp+insecure"
- case STARTTLS:
- scheme = "smtp"
- default:
- scheme = "smtps"
- }
- case JMAP:
- switch wizard.outgoingTransport.Selected() {
- case OAUTH:
- scheme = "jmap+oauthbearer"
- default:
- scheme = "jmap"
- }
- case SENDMAIL:
- scheme = ""
- path = host + path
- host = ""
- user = ""
- pass = ""
- }
-
- uri, clean := makeURLs(scheme, host, path, user, pass)
-
- wizard.outgoingStr.Text(
- " " + strings.ReplaceAll(clean.String(), "%2A", "*"))
- wizard.outgoingUrl = uri
- return uri
-}
-
-func (wizard *AccountWizard) Invalidate() {
- ui.Invalidate()
-}
-
-func (wizard *AccountWizard) Draw(ctx *ui.Context) {
- wizard.steps[wizard.step].Draw(ctx)
-}
-
-func (wizard *AccountWizard) getInteractive() []ui.Interactive {
- switch wizard.step {
- case CONFIGURE_BASICS:
- return wizard.basics
- case CONFIGURE_SOURCE:
- return wizard.source
- case CONFIGURE_OUTGOING:
- return wizard.outgoing
- case CONFIGURE_COMPLETE:
- return wizard.complete
- }
- return nil
-}
-
-func (wizard *AccountWizard) advance(direction string) {
- wizard.Focus(false)
- if direction == "Next" && wizard.step < len(wizard.steps)-1 {
- wizard.step++
- }
- if direction == "Previous" && wizard.step > 0 {
- wizard.step--
- }
- wizard.focus = 0
- wizard.Focus(true)
- wizard.Invalidate()
-}
-
-func (wizard *AccountWizard) Focus(focus bool) {
- if interactive := wizard.getInteractive(); interactive != nil {
- interactive[wizard.focus].Focus(focus)
- }
-}
-
-func (wizard *AccountWizard) Event(event tcell.Event) bool {
- interactive := wizard.getInteractive()
- if event, ok := event.(*tcell.EventKey); ok {
- switch event.Key() {
- case tcell.KeyUp:
- fallthrough
- case tcell.KeyBacktab:
- fallthrough
- case tcell.KeyCtrlK:
- if interactive != nil {
- interactive[wizard.focus].Focus(false)
- wizard.focus--
- if wizard.focus < 0 {
- wizard.focus = len(interactive) - 1
- }
- interactive[wizard.focus].Focus(true)
- }
- wizard.Invalidate()
- return true
- case tcell.KeyDown:
- fallthrough
- case tcell.KeyTab:
- fallthrough
- case tcell.KeyCtrlJ:
- if interactive != nil {
- interactive[wizard.focus].Focus(false)
- wizard.focus++
- if wizard.focus >= len(interactive) {
- wizard.focus = 0
- }
- interactive[wizard.focus].Focus(true)
- }
- wizard.Invalidate()
- return true
- }
- }
- if interactive != nil {
- return interactive[wizard.focus].Event(event)
- }
- return false
-}
-
-func (wizard *AccountWizard) discoverServices() {
- email := wizard.email.String()
- if !strings.ContainsRune(email, '@') {
- return
- }
- domain := email[strings.IndexRune(email, '@')+1:]
- var wg sync.WaitGroup
- type Service struct{ srv, hostport string }
- services := make(chan Service)
-
- for _, service := range []string{"imaps", "imap", "submission", "jmap"} {
- wg.Add(1)
- go func(srv string) {
- defer log.PanicHandler()
- defer wg.Done()
- _, addrs, err := net.LookupSRV(srv, "tcp", domain)
- if err != nil {
- log.Tracef("SRV lookup for _%s._tcp.%s failed: %s",
- srv, domain, err)
- } else if addrs[0].Target != "" && addrs[0].Port > 0 {
- services <- Service{
- srv: srv,
- hostport: net.JoinHostPort(
- strings.TrimSuffix(addrs[0].Target, "."),
- strconv.Itoa(int(addrs[0].Port))),
- }
- }
- }(service)
- }
- go func() {
- defer log.PanicHandler()
- wg.Wait()
- close(services)
- }()
-
- wizard.discovered = make(map[string]string)
- for s := range services {
- wizard.discovered[s.srv] = s.hostport
- }
-}
-
-func (wizard *AccountWizard) autofill() {
- if wizard.sourceServer.String() == "" {
- switch wizard.sourceProtocol.Selected() {
- case IMAP:
- if s, ok := wizard.discovered["imaps"]; ok {
- wizard.sourceServer.Set(s)
- wizard.sourceTransport.Select(SSL_TLS)
- } else if s, ok := wizard.discovered["imap"]; ok {
- wizard.sourceServer.Set(s)
- wizard.sourceTransport.Select(STARTTLS)
- }
- case JMAP:
- if s, ok := wizard.discovered["jmap"]; ok {
- s = strings.TrimSuffix(s, ":443")
- wizard.sourceServer.Set(s + "/.well-known/jmap")
- wizard.sourceTransport.Select(SSL_TLS)
- }
- case MAILDIR, MAILDIRPP:
- wizard.sourceServer.Set("~/mail")
- wizard.sourceUsername.Set("")
- wizard.sourcePassword.Set("")
- case NOTMUCH:
- cmd := exec.Command("notmuch", "config", "get", "database.path")
- out, err := cmd.Output()
- if err == nil {
- db := strings.TrimSpace(string(out))
- wizard.sourceServer.Set(xdg.TildeHome(db))
- } else {
- wizard.sourceServer.Set("~/mail")
- }
- wizard.sourceUsername.Set("")
- wizard.sourcePassword.Set("")
- }
- }
- if wizard.outgoingServer.String() == "" {
- switch wizard.outgoingProtocol.Selected() {
- case SMTP:
- if s, ok := wizard.discovered["submission"]; ok {
- switch {
- case strings.HasSuffix(s, ":587"):
- wizard.outgoingTransport.Select(SSL_TLS)
- case strings.HasSuffix(s, ":465"):
- wizard.outgoingTransport.Select(STARTTLS)
- default:
- wizard.outgoingTransport.Select(INSECURE)
- }
- wizard.outgoingServer.Set(s)
- }
- case JMAP:
- wizard.outgoingTransport.Select(SSL_TLS)
- case SENDMAIL:
- wizard.outgoingServer.Set("/usr/sbin/sendmail")
- wizard.outgoingUsername.Set("")
- wizard.outgoingPassword.Set("")
- }
- }
-}
diff --git a/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 <ESC> to cancel",
- ident.Name, key.PublicKey.KeyId))
-
- for err := range chErr {
- if err != nil {
- return nil, err
- }
- pass := <-chPass
- err = key.PrivateKey.Decrypt([]byte(pass))
- return nil, err
- }
- }
- return nil, err
-}
-
-// errorScreen is a widget that draws an error in the middle of the context
-func errorScreen(s string) ui.Drawable {
- errstyle := config.Ui.GetStyle(config.STYLE_ERROR)
- text := ui.NewText(s, errstyle).Strategy(ui.TEXT_CENTER)
- grid := ui.NewGrid().Rows([]ui.GridSpec{
- {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
- {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
- {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
- }).Columns([]ui.GridSpec{
- {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
- })
- grid.AddChild(ui.NewFill(' ', tcell.StyleDefault)).At(0, 0)
- grid.AddChild(text).At(1, 0)
- grid.AddChild(ui.NewFill(' ', tcell.StyleDefault)).At(2, 0)
- return grid
-}
-
-func (aerc *Aerc) isExKey(event *tcell.EventKey, exKey config.KeyStroke) bool {
- if event.Key() == tcell.KeyRune {
- // Compare runes if it's a KeyRune
- return event.Modifiers() == exKey.Modifiers && event.Rune() == exKey.Rune
- }
- return event.Modifiers() == exKey.Modifiers && event.Key() == exKey.Key
-}
-
-// CmdFallbackSearch checks cmds for the first executable availabe in PATH. An error is
-// returned if none are found
-func (aerc *Aerc) CmdFallbackSearch(cmds []string) (string, error) {
- var tried []string
- for _, cmd := range cmds {
- if cmd == "" {
- continue
- }
- params := strings.Split(cmd, " ")
- _, err := exec.LookPath(params[0])
- if err != nil {
- tried = append(tried, cmd)
- warn := fmt.Sprintf("cmd '%s' not found in PATH, using fallback", cmd)
- aerc.PushWarning(warn)
- continue
- }
- return cmd, nil
- }
- return "", fmt.Errorf("no command found in PATH: %s", tried)
-}
diff --git a/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<enter>", "Send", ""},
- {":edit<enter>", "Edit", ""},
- {":attach<space>", "Add attachment", ""},
- {":detach<space>", "Remove attachment", ""},
- {":postpone<enter>", "Postpone", ""},
- {":preview<enter>", "Preview message", ""},
- {":abort<enter>", "Abort (discard message, no confirmation)", ""},
- {":choose -o d discard abort -o p postpone postpone<enter>", "Abort or postpone", ""},
- }
- knownCommands := len(reviewCommands)
- var actions []string
- for _, binding := range bindings.Bindings {
- inputs := config.FormatKeyStrokes(binding.Input)
- outputs := config.FormatKeyStrokes(binding.Output)
- found := false
- for i, rcmd := range reviewCommands {
- if outputs == rcmd[0] {
- found = true
- if reviewCommands[i][2] == "" {
- reviewCommands[i][2] = inputs
- } else {
- reviewCommands[i][2] += ", " + inputs
- }
- break
- }
- }
- if !found {
- rcmd := []string{outputs, "", inputs}
- reviewCommands = append(reviewCommands, rcmd)
- }
- }
- unknownCommands := reviewCommands[knownCommands:]
- sort.Slice(unknownCommands, func(i, j int) bool {
- return unknownCommands[i][2] < unknownCommands[j][2]
- })
-
- longest := 0
- for _, rcmd := range reviewCommands {
- if len(rcmd[2]) > longest {
- longest = len(rcmd[2])
- }
- }
-
- width := longest
- if longest < 6 {
- width = 6
- }
- widthstr := strconv.Itoa(width)
-
- for _, rcmd := range reviewCommands {
- if rcmd[2] != "" {
- actions = append(actions, fmt.Sprintf(" %-"+widthstr+"s %-40s %s",
- rcmd[2], rcmd[1], rcmd[0]))
- }
- }
-
- spec := []ui.GridSpec{
- {Strategy: ui.SIZE_EXACT, Size: ui.Const(1)},
- }
- for i := 0; i < len(actions)-1; i++ {
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
- }
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(2)})
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
- for i := 0; i < len(composer.attachments)-1; i++ {
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
- }
- if len(composer.textParts) > 0 {
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
- for i := 0; i < len(composer.textParts); i++ {
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
- }
- }
- // make the last element fill remaining space
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)})
-
- grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{
- {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
- })
-
- uiConfig := composer.acct.UiConfig()
-
- if err != nil {
- grid.AddChild(ui.NewText(err.Error(), uiConfig.GetStyle(config.STYLE_ERROR)))
- grid.AddChild(ui.NewText("Press [q] to close this tab.",
- uiConfig.GetStyle(config.STYLE_DEFAULT))).At(1, 0)
- } else {
- grid.AddChild(ui.NewText("Send this email?",
- uiConfig.GetStyle(config.STYLE_TITLE))).At(0, 0)
- i := 1
- for _, action := range actions {
- grid.AddChild(ui.NewText(action,
- uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
- i += 1
- }
- grid.AddChild(ui.NewText("Attachments:",
- uiConfig.GetStyle(config.STYLE_TITLE))).At(i, 0)
- i += 1
- if len(composer.attachments) == 0 {
- grid.AddChild(ui.NewText("(none)",
- uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
- i += 1
- } else {
- for _, a := range composer.attachments {
- grid.AddChild(ui.NewText(a.Name(), uiConfig.GetStyle(config.STYLE_DEFAULT))).
- At(i, 0)
- i += 1
- }
- }
- if len(composer.textParts) > 0 {
- grid.AddChild(ui.NewText("Parts:",
- uiConfig.GetStyle(config.STYLE_TITLE))).At(i, 0)
- i += 1
- grid.AddChild(ui.NewText("text/plain", uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
- i += 1
- for _, p := range composer.textParts {
- err := composer.updateMultipart(p)
- if err != nil {
- msg := fmt.Sprintf("%s error: %s", p.MimeType, err)
- grid.AddChild(ui.NewText(msg,
- uiConfig.GetStyle(config.STYLE_ERROR))).At(i, 0)
- } else {
- grid.AddChild(ui.NewText(p.MimeType,
- uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i, 0)
- }
- i += 1
- }
-
- }
- }
-
- return &reviewMessage{
- composer: composer,
- grid: grid,
- }
-}
-
-func (c *Composer) updateMultipart(p *lib.Part) error {
- command, found := config.Converters[p.MimeType]
- if !found {
- // unreachable
- return fmt.Errorf("no command defined for mime/type")
- }
- // reset part body to avoid it leaving outdated if the command fails
- p.Data = nil
- body, err := c.GetBody()
- if err != nil {
- return errors.Wrap(err, "GetBody")
- }
- cmd := exec.Command("sh", "-c", command)
- cmd.Stdin = body
- out, err := cmd.Output()
- if err != nil {
- var stderr string
- var ee *exec.ExitError
- if errors.As(err, &ee) {
- // append the first 30 chars of stderr if any
- stderr = strings.Trim(string(ee.Stderr), " \t\n\r")
- stderr = strings.ReplaceAll(stderr, "\n", "; ")
- if stderr != "" {
- stderr = fmt.Sprintf(": %.30s", stderr)
- }
- }
- return fmt.Errorf("%s: %w%s", command, err, stderr)
- }
- p.Data = out
- return nil
-}
-
-func (rm *reviewMessage) Invalidate() {
- ui.Invalidate()
-}
-
-func (rm *reviewMessage) Draw(ctx *ui.Context) {
- rm.grid.Draw(ctx)
-}
-
-type cryptoStatus struct {
- title string
- status *ui.Text
- uiConfig *config.UIConfig
- signKey string
- setEncOneShot bool
-}
-
-func newCryptoStatus(uiConfig *config.UIConfig) *cryptoStatus {
- defaultStyle := uiConfig.GetStyle(config.STYLE_DEFAULT)
- return &cryptoStatus{
- title: "Security",
- status: ui.NewText("", defaultStyle),
- uiConfig: uiConfig,
- signKey: "",
- setEncOneShot: true,
- }
-}
-
-func (cs *cryptoStatus) Draw(ctx *ui.Context) {
- // Extra character to put a blank cell between the header and the input
- size := runewidth.StringWidth(cs.title+":") + 1
- defaultStyle := cs.uiConfig.GetStyle(config.STYLE_DEFAULT)
- titleStyle := cs.uiConfig.GetStyle(config.STYLE_HEADER)
- ctx.Fill(0, 0, size, ctx.Height(), ' ', defaultStyle)
- ctx.Printf(0, 0, titleStyle, "%s:", cs.title)
- cs.status.Draw(ctx.Subcontext(size, 0, ctx.Width()-size, 1))
-}
-
-func (cs *cryptoStatus) Invalidate() {
- ui.Invalidate()
-}
-
-func (c *Composer) checkEncryptionKeys(_ string) bool {
- rcpts, err := getRecipientsEmail(c)
- if err != nil {
- // checkEncryptionKeys gets registered as a callback and must
- // explicitly call c.SetEncrypt(false) when encryption is not possible
- c.SetEncrypt(false)
- st := fmt.Sprintf("Cannot encrypt: %v", err)
- c.aerc.statusline.PushError(st)
- return false
- }
- var mk []string
- for _, rcpt := range rcpts {
- key, err := c.aerc.Crypto.GetKeyId(rcpt)
- if err != nil || key == "" {
- mk = append(mk, rcpt)
- }
- }
-
- encrypt := true
- switch {
- case len(mk) > 0:
- c.SetEncrypt(false)
- st := fmt.Sprintf("Cannot encrypt, missing keys: %s", strings.Join(mk, ", "))
- if c.Config().PgpOpportunisticEncrypt {
- switch c.Config().PgpErrorLevel {
- case config.PgpErrorLevelWarn:
- c.aerc.statusline.PushWarning(st)
- return false
- case config.PgpErrorLevelNone:
- return false
- case config.PgpErrorLevelError:
- // Continue to the default
- }
- }
- c.aerc.statusline.PushError(st)
- encrypt = false
- case len(rcpts) == 0:
- encrypt = false
- }
-
- // If callbacks were registered, encrypt will be set when user removes
- // recipients with missing keys
- c.encrypt = encrypt
- err = c.updateCrypto()
- if err != nil {
- log.Warnf("failed update crypto: %v", err)
- }
- return true
-}
-
-// setTitle executes the title template and sets the tab title
-func (c *Composer) setTitle() {
- if c.Tab == nil {
- return
- }
-
- header := c.header.Copy()
- // Get subject direct from the textinput
- subject, ok := c.editors["subject"]
- if ok {
- header.SetSubject(subject.input.String())
- }
- if header.Get("subject") == "" {
- header.SetSubject("New Email")
- }
-
- data := state.NewDataSetter()
- data.SetAccount(c.acctConfig)
- data.SetFolder(c.acct.Directories().SelectedDirectory())
- data.SetHeaders(&header, c.parent)
-
- var buf bytes.Buffer
- err := templates.Render(c.acct.UiConfig().TabTitleComposer, &buf,
- data.Data())
- if err != nil {
- c.acct.PushError(err)
- return
- }
- c.Tab.SetTitle(buf.String())
-}
diff --git a/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<enter>", "Open using the system handler"},
- {":save<space>", "Save to file"},
- {":pipe<space>", "Pipe to shell command"},
-}
-
-func newNoFilterConfigured(account string, part *models.BodyStructure) *ui.Grid {
- bindings := config.Binds.MessageView.ForAccount(account)
-
- var actions []string
-
- configured := noFilterConfiguredCommands
- if strings.Contains(strings.ToLower(part.MIMEType), "message") {
- configured = append(configured, []string{
- ":eml<Enter>", "View message attachment",
- })
- }
-
- for _, command := range configured {
- cmd := command[0]
- name := command[1]
- strokes, _ := config.ParseKeyStrokes(cmd)
- var inputs []string
- for _, input := range bindings.GetReverseBindings(strokes) {
- inputs = append(inputs, config.FormatKeyStrokes(input))
- }
- actions = append(actions, fmt.Sprintf(" %-6s %-29s %s",
- strings.Join(inputs, ", "), name, cmd))
- }
-
- spec := []ui.GridSpec{
- {Strategy: ui.SIZE_EXACT, Size: ui.Const(2)},
- }
- for i := 0; i < len(actions)-1; i++ {
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_EXACT, Size: ui.Const(1)})
- }
- // make the last element fill remaining space
- spec = append(spec, ui.GridSpec{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)})
-
- grid := ui.NewGrid().Rows(spec).Columns([]ui.GridSpec{
- {Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
- })
-
- uiConfig := config.Ui.ForAccount(account)
-
- noFilter := fmt.Sprintf(`No filter configured for this mimetype ('%s')
-What would you like to do?`, part.FullMIMEType())
- grid.AddChild(ui.NewText(noFilter,
- uiConfig.GetStyle(config.STYLE_TITLE))).At(0, 0)
- for i, action := range actions {
- grid.AddChild(ui.NewText(action,
- uiConfig.GetStyle(config.STYLE_DEFAULT))).At(i+1, 0)
- }
-
- return grid
-}
-
-func (pv *PartViewer) Invalidate() {
- ui.Invalidate()
-}
-
-func (pv *PartViewer) Draw(ctx *ui.Context) {
- style := pv.uiConfig.GetStyle(config.STYLE_DEFAULT)
- if pv.filter == nil {
- ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
- pv.noFilter.Draw(ctx)
- return
- }
- if !pv.fetched {
- pv.msg.FetchBodyPart(pv.index, pv.SetSource)
- pv.fetched = true
- }
- if pv.err != nil {
- ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
- ctx.Printf(0, 0, style, "%s", pv.err.Error())
- return
- }
- if pv.term != nil {
- pv.term.Draw(ctx)
- }
-}
-
-func (pv *PartViewer) Cleanup() {
- if pv.term != nil {
- pv.term.Close()
- }
-}
-
-func (pv *PartViewer) Event(event tcell.Event) bool {
- if pv.term != nil {
- return pv.term.Event(event)
- }
- return false
-}
-
-type HeaderView struct {
- Name string
- Value string
- ValueField ui.Drawable
- uiConfig *config.UIConfig
-}
-
-func (hv *HeaderView) Draw(ctx *ui.Context) {
- name := hv.Name
- size := runewidth.StringWidth(name + ":")
- lim := ctx.Width() - size - 1
- if lim <= 0 || ctx.Height() <= 0 {
- return
- }
- value := runewidth.Truncate(" "+hv.Value, lim, "…")
-
- vstyle := hv.uiConfig.GetStyle(config.STYLE_DEFAULT)
- hstyle := hv.uiConfig.GetStyle(config.STYLE_HEADER)
-
- // TODO: Make this more robust and less dumb
- if hv.Name == "PGP" {
- vstyle = hv.uiConfig.GetStyle(config.STYLE_SUCCESS)
- }
-
- ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', vstyle)
- ctx.Printf(0, 0, hstyle, "%s:", name)
- if hv.ValueField == nil {
- ctx.Printf(size, 0, vstyle, "%s", value)
- } else {
- hv.ValueField.Draw(ctx.Subcontext(size, 0, lim, 1))
- }
-}
-
-func (hv *HeaderView) Invalidate() {
- ui.Invalidate()
-}
diff --git a/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)
-}