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