init: pristine aerc 0.20.0 source

This commit is contained in:
Mortdecai
2026-04-07 19:54:54 -04:00
commit 083402a548
502 changed files with 68722 additions and 0 deletions
+886
View File
@@ -0,0 +1,886 @@
package app
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"github.com/emersion/go-message/mail"
"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/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/lib/xdg"
"git.sr.ht/~rockorager/vaxis"
)
const (
CONFIGURE_BASICS = iota
CONFIGURE_SOURCE = iota
CONFIGURE_OUTGOING = iota
CONFIGURE_COMPLETE = iota
)
type AccountWizard struct {
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() {
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,
SelectedAccountUiConfig(),
func(_ string, _ error) {
CloseDialog()
},
)
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(' ', vaxis.Style{})
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() *AccountWizard {
wizard := &AccountWizard{
accountName: ui.NewTextInput("", config.Ui).Prompt("> "),
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()
})
}
})
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()
})
}
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 {
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)
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(account, nil)
if err != nil {
NewTab(errorScreen(err.Error()), account.Name)
return
}
aerc.accounts[account.Name] = view
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
}
NewTab(term, "Tutorial")
term.OnClose = func(err error) {
RemoveTab(term, false)
if err != nil {
PushError(err.Error())
}
}
}
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 vaxis.Event) bool {
interactive := wizard.getInteractive()
if key, ok := event.(vaxis.Key); ok {
switch {
case key.Matches('k', vaxis.ModCtrl),
key.Matches(vaxis.KeyTab, vaxis.ModShift),
key.Matches(vaxis.KeyUp):
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 key.Matches('j', vaxis.ModCtrl),
key.Matches(vaxis.KeyTab),
key.Matches(vaxis.KeyDown):
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("")
}
}
}
+776
View File
@@ -0,0 +1,776 @@
package app
import (
"bytes"
"errors"
"fmt"
"strings"
"sync"
"time"
"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/log"
"git.sr.ht/~rjarry/aerc/lib/marker"
"git.sr.ht/~rjarry/aerc/lib/pama"
"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/models"
"git.sr.ht/~rjarry/aerc/worker"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rockorager/vaxis"
)
var _ ProvidesMessages = (*AccountView)(nil)
type AccountView struct {
sync.Mutex
acct *config.AccountConfig
dirlist DirectoryLister
labels []string
grid *ui.Grid
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
split *MessageViewer
splitSize int
splitDebounce *time.Timer
splitDir config.SplitDirection
splitLoaded bool
// Check-mail ticker
ticker *time.Ticker
checkingMail bool
}
func (acct *AccountView) UiConfig() *config.UIConfig {
if dirlist := acct.Directories(); dirlist != nil {
return dirlist.UiConfig("")
}
return config.Ui.ForAccount(acct.acct.Name)
}
func NewAccountView(
acct *config.AccountConfig, deferLoop chan struct{},
) (*AccountView, error) {
view := &AccountView{
acct: acct,
}
worker, err := worker.NewWorker(acct.Source, acct.Name)
if err != nil {
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)
view.msglist = NewMessageList(view)
view.Configure()
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) Configure() {
acct.dirlist.OnVirtualNode(func() {
acct.msglist.SetStore(nil)
acct.Invalidate()
})
sidebar := acct.UiConfig().SidebarWidth
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 sidebar
}},
{Strategy: ui.SIZE_WEIGHT, Size: ui.Const(1)},
})
if sidebar > 0 {
acct.grid.AddChild(ui.NewBordered(acct.dirlist, ui.BORDER_RIGHT, acct.UiConfig()))
}
acct.grid.AddChild(acct.msglist).At(0, 1)
acct.setTitle()
// handle splits
if acct.split != nil {
acct.split.Close()
}
splitDirection := acct.splitDir
acct.splitDir = config.SPLIT_NONE
switch splitDirection {
case config.SPLIT_HORIZONTAL:
acct.Split(acct.SplitSize())
case config.SPLIT_VERTICAL:
acct.Vsplit(acct.SplitSize())
}
}
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() {
UpdateStatus()
}
}
func (acct *AccountView) Select() {
for i, widget := range aerc.tabs.TabContent.Children() {
if widget == acct {
aerc.SelectTabIndex(i)
}
}
}
func (acct *AccountView) PushStatus(status string, expiry time.Duration) {
PushStatus(fmt.Sprintf("%s: %s", acct.acct.Name, status), expiry)
}
func (acct *AccountView) PushError(err error) {
PushError(fmt.Sprintf("%s: %v", acct.acct.Name, err))
}
func (acct *AccountView) PushWarning(warning string) {
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 vaxis.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) SetDirectories(d DirectoryLister) {
if acct.grid != nil {
acct.grid.ReplaceChild(acct.dirlist, d)
}
acct.dirlist = d
}
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() ([]models.UID, 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) Terminal() *Terminal {
if acct.split == nil {
return nil
}
return acct.split.Terminal()
}
func (acct *AccountView) isSelected() bool {
return acct == SelectedAccount()
}
func (acct *AccountView) newStore(name string) *lib.MessageStore {
uiConf := acct.dirlist.UiConfig(name)
dir := acct.dirlist.Directory(name)
role := ""
if dir != nil {
role = string(dir.Role)
}
backend := acct.AccountConfig().Backend
store := lib.NewMessageStore(acct.worker, name,
func() *config.UIConfig {
return config.Ui.
ForAccount(acct.Name()).
ForFolder(name)
},
func(msg *models.MessageInfo) {
err := hooks.RunHook(&hooks.MailReceived{
Account: acct.Name(),
Backend: backend,
Folder: name,
Role: role,
MsgInfo: msg,
})
if err != nil {
msg := fmt.Sprintf("mail-received hook: %s", err)
PushError(msg)
}
}, func() {
if uiConf.NewMessageBell {
aerc.Beep()
}
}, func() {
err := hooks.RunHook(&hooks.MailDeleted{
Account: acct.Name(),
Backend: backend,
Folder: name,
Role: role,
})
if err != nil {
msg := fmt.Sprintf("mail-deleted hook: %s", err)
PushError(msg)
}
}, func(dest string) {
err := hooks.RunHook(&hooks.MailAdded{
Account: acct.Name(),
Backend: backend,
Folder: dest,
Role: role,
})
if err != nil {
msg := fmt.Sprintf("mail-added hook: %s", err)
PushError(msg)
}
}, func(add []string, remove []string) {
err := hooks.RunHook(&hooks.TagModified{
Account: acct.Name(),
Backend: backend,
Add: add,
Remove: remove,
})
if err != nil {
msg := fmt.Sprintf("tag-modified hook: %s", err)
PushError(msg)
}
}, func(flagname string) {
err := hooks.RunHook(&hooks.FlagChanged{
Account: acct.Name(),
Backend: backend,
Folder: acct.SelectedDirectory(),
Role: role,
FlagName: flagname,
})
if err != nil {
msg := fmt.Sprintf("flag-changed hook: %s", err)
PushError(msg)
}
},
func(msg *models.MessageInfo) {
acct.updateSplitView(msg)
auto := false
if c := acct.AccountConfig(); c != nil {
r, ok := c.Params["pama-auto-switch"]
if ok {
if strings.ToLower(r) == "true" {
auto = true
}
}
}
if !auto {
return
}
var name string
if msg != nil && msg.Envelope != nil {
name = pama.FromSubject(msg.Envelope.Subject)
}
pama.DebouncedSwitchProject(name)
},
)
store.Configure(acct.SortCriteria(uiConf))
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 []models.UID) {
// 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 = config.SPLIT_NONE
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.UiConfig()))
acct.grid.AddChild(acct.msglist).At(0, 1)
ui.Invalidate()
}
func (acct *AccountView) updateSplitView(msg *models.MessageInfo) {
uiConf := acct.UiConfig()
if !acct.splitLoaded {
switch uiConf.MessageListSplit.Direction {
case config.SPLIT_HORIZONTAL:
acct.Split(uiConf.MessageListSplit.Size)
case config.SPLIT_VERTICAL:
acct.Vsplit(uiConf.MessageListSplit.Size)
}
acct.splitLoaded = true
}
if acct.splitSize == 0 || !acct.splitLoaded {
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(), CryptoProvider(), DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
PushError(err.Error())
return
}
viewer, err := NewMessageViewer(acct, view)
if err != nil {
PushError(err.Error())
return
}
acct.split = viewer
switch acct.splitDir {
case config.SPLIT_HORIZONTAL:
acct.grid.AddChild(acct.split).At(1, 1)
case config.SPLIT_VERTICAL:
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) {
acct.SetSplitSize(n)
if acct.splitDir == config.SPLIT_HORIZONTAL || n == 0 {
return
}
acct.splitDir = config.SPLIT_HORIZONTAL
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.UiConfig())).Span(2, 1)
acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_BOTTOM, acct.UiConfig())).At(0, 1)
acct.split, _ = NewMessageViewer(acct, nil)
acct.grid.AddChild(acct.split).At(1, 1)
msg, err := acct.SelectedMessage()
if err != nil {
log.Debugf("split: load message error: %v", err)
}
acct.updateSplitView(msg)
}
// 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) {
acct.SetSplitSize(n)
if acct.splitDir == config.SPLIT_VERTICAL || n == 0 {
return
}
acct.splitDir = config.SPLIT_VERTICAL
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.UiConfig())).At(0, 0)
acct.grid.AddChild(ui.NewBordered(acct.msglist, ui.BORDER_RIGHT, acct.UiConfig())).At(0, 1)
acct.split, _ = NewMessageViewer(acct, nil)
acct.grid.AddChild(acct.split).At(0, 2)
msg, err := acct.SelectedMessage()
if err != nil {
log.Debugf("split: load message error: %v", err)
}
acct.updateSplitView(msg)
}
// 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)
data.SetState(&acct.state)
var buf bytes.Buffer
err := templates.Render(acct.UiConfig().TabTitleAccount, &buf, data.Data())
if err != nil {
acct.PushError(err)
return
}
acct.tab.SetTitle(buf.String())
}
+962
View File
@@ -0,0 +1,962 @@
package app
import (
"context"
"errors"
"fmt"
"io"
"net/url"
"os/exec"
"sort"
"strings"
"time"
"unicode"
"git.sr.ht/~rjarry/go-opt/v2"
"git.sr.ht/~rockorager/vaxis"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/emersion/go-message/mail"
"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/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
"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(ctx context.Context, cmd string) ([]opt.Completion, 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
beep func()
dialog ui.DrawableInteractive
Crypto crypto.Provider
}
type Choice struct {
Key string
Text string
Command string
}
func (aerc *Aerc) Init(
crypto crypto.Provider,
cmd func(string, *config.AccountConfig, *models.MessageInfo) error,
complete func(ctx context.Context, cmd string) ([]opt.Completion, string), cmdHistory lib.History,
deferLoop chan struct{},
) {
tabs := ui.NewTabs(func(d ui.Drawable) *config.UIConfig {
acct := aerc.account(d)
if acct != nil {
return config.Ui.ForAccount(acct.Name())
}
return 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.accounts = make(map[string]*AccountView)
aerc.cmd = cmd
aerc.cmdHistory = cmdHistory
aerc.complete = complete
aerc.grid = grid
aerc.statusbar = statusbar
aerc.statusline = statusline
aerc.prompts = ui.NewStack(config.Ui)
aerc.tabs = tabs
aerc.Crypto = crypto
for _, acct := range config.Accounts {
view, err := NewAccountView(acct, deferLoop)
if err != nil {
tabs.Add(errorScreen(err.Error()), acct.Name, false)
} else {
aerc.accounts[acct.Name] = view
view.tab = tabs.Add(view, acct.Name, false)
}
}
if len(config.Accounts) == 0 {
wizard := NewAccountWizard()
wizard.Focus(true)
aerc.NewTab(wizard, "New account", false)
}
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()
}
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()) {
aerc.beep = f
}
func (aerc *Aerc) Beep() {
if aerc.beep == nil {
log.Warnf("should beep, but no beeper")
return
}
aerc.beep()
}
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 {
w, h := ctx.Width(), ctx.Height()
if d, ok := aerc.dialog.(Dialog); ok {
xstart, width := d.ContextWidth()
ystart, height := d.ContextHeight()
aerc.dialog.Draw(
ctx.Subcontext(xstart(w), ystart(h),
width(w), height(h)))
} else if w > 8 && h > 4 {
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, "%", "%%")
}
annotate := func(b *config.Binding) string {
if b.Annotation == "" {
return ""
}
return "[" + b.Annotation + "]"
}
fmtStr := "%10s %s %s"
for _, bind := range binds.Bindings {
result = append(result, fmt.Sprintf(fmtStr,
format(config.FormatKeyStrokes(bind.Input)),
format(config.FormatKeyStrokes(bind.Output)),
annotate(bind),
))
}
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)),
annotate(bind),
))
}
}
result = append(result, fmt.Sprintf(fmtStr,
"$ex",
fmt.Sprintf("'%c'", binds.ExKey.Key), "",
))
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:
var binds *config.KeyBindings
switch view.Bindings() {
case "compose::editor":
binds = config.Binds.ComposeEditor.ForAccount(
selectedAccountName)
case "compose::review":
binds = config.Binds.ComposeReview.ForAccount(
selectedAccountName)
default:
binds = config.Binds.Compose.ForAccount(
selectedAccountName)
}
return binds.ForFolder(view.SelectedDirectory())
case *MessageViewer:
var binds *config.KeyBindings
switch view.Bindings() {
case "view::passthrough":
binds = config.Binds.MessageViewPassthrough.ForAccount(
selectedAccountName)
default:
binds = config.Binds.MessageView.ForAccount(
selectedAccountName)
}
return binds.ForFolder(view.SelectedAccount().SelectedDirectory())
case *Terminal:
return config.Binds.Terminal
default:
return config.Binds.Global
}
}
func (aerc *Aerc) simulate(strokes []config.KeyStroke) {
aerc.pendingKeys = []config.KeyStroke{}
bindings := aerc.getBindings()
complete := aerc.SelectedAccountUiConfig().CompletionMinChars != config.MANUAL_COMPLETE
aerc.simulating += 1
for _, stroke := range strokes {
simulated := vaxis.Key{
Keycode: stroke.Key,
Modifiers: stroke.Modifiers,
}
if unicode.IsUpper(stroke.Key) {
simulated.Keycode = unicode.ToLower(stroke.Key)
simulated.Modifiers |= vaxis.ModShift
}
// If none of these mods are present, set the text field to
// enable matching keys like ":"
if stroke.Modifiers&vaxis.ModCtrl == 0 &&
stroke.Modifiers&vaxis.ModAlt == 0 &&
stroke.Modifiers&vaxis.ModSuper == 0 &&
stroke.Modifiers&vaxis.ModHyper == 0 {
simulated.Text = string(stroke.Key)
}
aerc.Event(simulated)
complete = stroke == bindings.CompleteKey
}
aerc.simulating -= 1
if exline, ok := aerc.focused.(*ExLine); ok {
// we are still focused on the exline, turn on tab complete
exline.TabComplete(func(ctx context.Context, cmd string) ([]opt.Completion, string) {
return aerc.complete(ctx, cmd)
})
if complete {
// force completion now
exline.Event(vaxis.Key{Keycode: vaxis.KeyTab})
}
}
}
func (aerc *Aerc) Event(event vaxis.Event) bool {
if config.General.QuakeMode {
if e, ok := event.(vaxis.Key); ok && e.MatchString("F1") {
ToggleQuake()
return true
}
}
if aerc.dialog != nil {
return aerc.dialog.Event(event)
}
if aerc.focused != nil {
return aerc.focused.Event(event)
}
switch event := event.(type) {
// TODO: more vaxis events handling
case vaxis.Key:
// 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()
stroke := config.KeyStroke{
Modifiers: event.Modifiers,
}
switch {
case event.ShiftedCode != 0:
stroke.Key = event.ShiftedCode
stroke.Modifiers &^= vaxis.ModShift
default:
stroke.Key = event.Keycode
}
aerc.pendingKeys = append(aerc.pendingKeys, stroke)
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 vaxis.Mouse:
aerc.grid.MouseEvent(event.Col, event.Row, event)
return true
case vaxis.PasteStartEvent:
aerc.pasting = true
interactive, ok := aerc.SelectedTabContent().(ui.Interactive)
if ok {
return interactive.Event(event)
}
return false
case vaxis.PasteEndEvent:
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, background bool) *ui.Tab {
tab := aerc.tabs.Add(clickable, name, background)
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) SelectTabAtOffset(offset int) {
aerc.tabs.SelectOffset(offset)
}
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(context.Context, string) ([]opt.Completion, string)
if aerc.simulating != 0 {
// Don't try to draw completions for simulated events
tabComplete = nil
} else {
tabComplete = aerc.complete
}
exline := NewExLine(cmd, func(cmd string) {
err := aerc.cmd(cmd, 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 += " " + opt.QuoteArg(text)
}
err := aerc.cmd(cmd, nil, nil)
if err != nil {
aerc.PushError(err.Error())
}
}, func(ctx context.Context, cmd string) ([]opt.Completion, 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(ctx context.Context, cmd string) ([]opt.Completion, string) {
return nil, "" // TODO: completions
})
aerc.prompts.Push(p)
}
func (aerc *Aerc) Command(args []string) error {
switch {
case len(args) == 0:
return nil // noop success, i.e. ping
case strings.HasPrefix(args[0], "mailto:"):
mailto, err := url.Parse(args[0])
if err != nil {
return err
}
return aerc.mailto(mailto)
case strings.HasPrefix(args[0], "mbox:"):
return aerc.mbox(args[0])
case strings.HasPrefix(args[0], ":"):
cmdline := args[0]
if len(args) > 1 {
cmdline = opt.QuoteArgs(args...).String()
}
defer ui.Invalidate()
return aerc.cmd(cmdline, nil, nil)
default:
return errors.New("command not understood")
}
}
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(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, false)
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 = []string{"Sent"}
defer ui.Invalidate()
mboxView, err := NewAccountView(&acctConf, nil)
if err != nil {
aerc.NewTab(errorScreen(err.Error()), acctConf.Name, false)
} else {
aerc.accounts[acctConf.Name] = mboxView
aerc.NewTab(mboxView, acctConf.Name, false)
}
return 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) 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(' ', vaxis.Style{})).At(0, 0)
grid.AddChild(text).At(1, 0)
grid.AddChild(ui.NewFill(' ', vaxis.Style{})).At(2, 0)
return grid
}
func (aerc *Aerc) isExKey(key vaxis.Key, exKey config.KeyStroke) bool {
return key.Matches(exKey.Key, exKey.Modifiers)
}
// CmdFallbackSearch checks cmds for the first executable available in PATH. An error is
// returned if none are found
func CmdFallbackSearch(cmds []string, silent bool) (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)
if !silent {
warn := fmt.Sprintf("cmd '%s' not found in PATH, using fallback", cmd)
PushWarning(warn)
}
continue
}
return cmd, nil
}
return "", fmt.Errorf("no command found in PATH: %s", tried)
}
+92
View File
@@ -0,0 +1,92 @@
package app
import (
"context"
"time"
"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/ipc"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rjarry/go-opt/v2"
"github.com/ProtonMail/go-crypto/openpgp"
)
var aerc Aerc
func Init(
crypto crypto.Provider,
cmd func(string, *config.AccountConfig, *models.MessageInfo) error,
complete func(ctx context.Context, cmd string) ([]opt.Completion, string), history lib.History,
deferLoop chan struct{},
) {
aerc.Init(crypto, cmd, complete, history, deferLoop)
}
func Drawable() ui.DrawableInteractive { return &aerc }
func IPCHandler() ipc.Handler { return &aerc }
func Command(args []string) error { return aerc.Command(args) }
func HandleMessage(msg types.WorkerMessage) { aerc.HandleMessage(msg) }
func CloseBackends() error { return aerc.CloseBackends() }
func AddDialog(d ui.DrawableInteractive) { aerc.AddDialog(d) }
func CloseDialog() { aerc.CloseDialog() }
func HumanReadableBindings() []string {
return aerc.HumanReadableBindings()
}
func Account(name string) (*AccountView, error) { return aerc.Account(name) }
func AccountNames() []string { return aerc.AccountNames() }
func NextAccount() (*AccountView, error) { return aerc.NextAccount() }
func PrevAccount() (*AccountView, error) { return aerc.PrevAccount() }
func SelectedAccount() *AccountView { return aerc.SelectedAccount() }
func SelectedAccountUiConfig() *config.UIConfig { return aerc.SelectedAccountUiConfig() }
func NextTab() { aerc.NextTab() }
func PrevTab() { aerc.PrevTab() }
func PinTab() { aerc.PinTab() }
func UnpinTab() { aerc.UnpinTab() }
func MoveTab(i int, relative bool) { aerc.MoveTab(i, relative) }
func TabNames() []string { return aerc.TabNames() }
func GetTab(i int) *ui.Tab { return aerc.tabs.Get(i) }
func SelectTab(name string) bool { return aerc.SelectTab(name) }
func SelectPreviousTab() bool { return aerc.SelectPreviousTab() }
func SelectedTab() *ui.Tab { return aerc.SelectedTab() }
func SelectedTabContent() ui.Drawable { return aerc.SelectedTabContent() }
func SelectTabIndex(index int) bool { return aerc.SelectTabIndex(index) }
func SelectTabAtOffset(offset int) { aerc.SelectTabAtOffset(offset) }
func RemoveTab(tab ui.Drawable, closeContent bool) { aerc.RemoveTab(tab, closeContent) }
func NewTab(clickable ui.Drawable, name string) *ui.Tab {
return aerc.NewTab(clickable, name, false)
}
func NewBackgroundTab(clickable ui.Drawable, name string) *ui.Tab {
return aerc.NewTab(clickable, name, true)
}
func ReplaceTab(tabSrc ui.Drawable, tabTarget ui.Drawable, name string, closeSrc bool) {
aerc.ReplaceTab(tabSrc, tabTarget, name, closeSrc)
}
func UpdateStatus() { aerc.UpdateStatus() }
func PushPrompt(prompt *ExLine) { aerc.PushPrompt(prompt) }
func SetError(text string) { aerc.SetError(text) }
func PushError(text string) *StatusMessage { return aerc.PushError(text) }
func PushWarning(text string) *StatusMessage { return aerc.PushWarning(text) }
func PushSuccess(text string) *StatusMessage { return aerc.PushSuccess(text) }
func PushStatus(text string, expiry time.Duration) *StatusMessage {
return aerc.PushStatus(text, expiry)
}
func RegisterChoices(choices []Choice) { aerc.RegisterChoices(choices) }
func RegisterPrompt(prompt string, cmd string) { aerc.RegisterPrompt(prompt, cmd) }
func CryptoProvider() crypto.Provider { return aerc.Crypto }
func DecryptKeys(keys []openpgp.Key, symmetric bool) (b []byte, err error) {
return aerc.DecryptKeys(keys, symmetric)
}
+86
View File
@@ -0,0 +1,86 @@
package app
import (
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/auth"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
"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, "%s", text)
case a.authdetails.Err != nil:
style := a.uiConfig.GetStyle(config.STYLE_ERROR)
text = a.authdetails.Err.Error()
ctx.Printf(0, 0, style, "%s", text)
default:
checkBounds := func(x int) bool {
return x < ctx.Width()
}
setResult := func(result auth.Result) (string, vaxis.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, "%s", 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, " (%s)", text)
}
}
}
}
}
func (a *AuthInfo) Invalidate() {
ui.Invalidate()
}
+1995
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
package app
import (
"git.sr.ht/~rjarry/aerc/lib/ui"
)
type Dialog interface {
ui.DrawableInteractive
ContextWidth() (func(int) int, func(int) int)
ContextHeight() (func(int) int, func(int) int)
}
type dialog struct {
ui.DrawableInteractive
x func(int) int
y func(int) int
w func(int) int
h func(int) int
}
func (d *dialog) ContextWidth() (func(int) int, func(int) int) {
return d.x, d.w
}
func (d *dialog) ContextHeight() (func(int) int, func(int) int) {
return d.y, d.h
}
func NewDialog(
d ui.DrawableInteractive,
x func(int) int, y func(int) int,
w func(int) int, h func(int) int,
) *dialog {
return &dialog{DrawableInteractive: d, x: x, y: y, w: w, h: h}
}
// DefaultDialog creates a dialog window spanning half of the screen
func DefaultDialog(d ui.DrawableInteractive) Dialog {
position := SelectedAccountUiConfig().DialogPosition
width := SelectedAccountUiConfig().DialogWidth
height := SelectedAccountUiConfig().DialogHeight
return NewDialog(d,
// horizontal starting position in columns from the left
func(w int) int {
return (w * (100 - width)) / 200
},
// vertical starting position in lines from the top
func(h int) int {
switch position {
case "center":
return (h * (100 - height)) / 200
case "bottom":
return h - (h * height / 100)
default:
return 1
}
},
// dialog width from the starting column
func(w int) int {
return w * width / 100
},
// dialog height from the starting line
func(h int) int {
if position == "bottom" {
return h*height/100 - 1
}
return h * height / 100
},
)
}
+559
View File
@@ -0,0 +1,559 @@
package app
import (
"bytes"
"context"
"math"
"regexp"
"sort"
"time"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"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/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rockorager/vaxis"
)
type DirectoryLister interface {
ui.Drawable
Selected() string
Previous() string
Select(string)
Open(string, string, time.Duration, func(types.WorkerMessage), bool)
Update(types.WorkerMessage)
List() []string
ClearList()
OnVirtualNode(func())
NextPrev(int)
CollapseFolder(string)
ExpandFolder(string)
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
previous string
spinner *Spinner
worker *types.Worker
ctx context.Context
cancel context.CancelFunc
}
func NewDirectoryList(acctConf *config.AccountConfig,
worker *types.Worker,
) DirectoryLister {
dirlist := &DirectoryList{
acctConf: acctConf,
store: lib.NewDirStore(),
worker: worker,
}
dirlist.NewContext()
uiConf := dirlist.UiConfig("")
dirlist.spinner = NewSpinner(uiConf)
dirlist.spinner.Start()
if uiConf.DirListTree {
return NewDirectoryTree(dirlist)
}
return dirlist
}
func (dirlist *DirectoryList) NewContext() {
if dirlist.cancel != nil {
dirlist.cancel()
}
dirlist.ctx, dirlist.cancel = context.WithCancel(context.Background())
}
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.previous = dirlist.selected
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(string) {
// no effect for the DirectoryList
}
func (dirlist *DirectoryList) ExpandFolder(string) {
// no effect for the DirectoryList
}
func (dirlist *DirectoryList) Select(name string) {
dirlist.Open(name, "", dirlist.UiConfig(name).DirListDelay, nil, false)
}
func (dirlist *DirectoryList) Open(name string, query string, delay time.Duration,
cb func(types.WorkerMessage), force bool,
) {
dirlist.selecting = name
dirlist.NewContext()
go func(ctx context.Context) {
defer log.PanicHandler()
select {
case <-time.After(delay):
dirlist.worker.PostAction(&types.OpenDirectory{
Context: ctx,
Directory: name,
Query: query,
Force: force,
},
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")
}
if cb != nil {
cb(msg)
}
})
case <-ctx.Done():
log.Tracef("dirlist: skip %s", name)
return
}
}(dirlist.ctx)
}
func (dirlist *DirectoryList) Selected() string {
return dirlist.selected
}
func (dirlist *DirectoryList) Previous() string {
return dirlist.previous
}
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, "%s", 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, vaxis.Style) {
var left, right string
var buf bytes.Buffer
var styles []config.StyleObject
var style vaxis.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 := ui.StyledString(left)
ui.ApplyAttrs(lbuf, style)
lwidth := lbuf.Len()
rbuf := ui.StyledString(right)
ui.ApplyAttrs(rbuf, style)
rwidth := rbuf.Len()
if lwidth+rwidth+1 > width {
if rwidth > 3*width/4 {
rwidth = 3 * width / 4
}
lwidth = width - rwidth - 1
ui.TruncateHead(rbuf, rwidth)
right = rbuf.Encode()
ui.Truncate(lbuf, lwidth)
left = lbuf.Encode()
} else {
for i := 0; i < (width - lwidth - rwidth - 1); i += 1 {
lbuf.Cells = append(lbuf.Cells, vaxis.Cell{
Character: vaxis.Character{
Grapheme: " ",
Width: 1,
},
})
}
left = lbuf.Encode()
right = rbuf.Encode()
}
return left, right, style
}
func (dirlist *DirectoryList) drawScrollbar(ctx *ui.Context) {
gutterStyle := vaxis.Style{}
pillStyle := vaxis.Style{Attribute: vaxis.AttrReverse}
// 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 vaxis.Event) {
if event, ok := event.(vaxis.Mouse); ok {
switch event.Button {
case vaxis.MouseLeftButton:
clickedDir, ok := dirlist.Clicked(localX, localY)
if ok {
dirlist.Select(clickedDir)
}
case vaxis.MouseWheelDown:
dirlist.Next()
case vaxis.MouseWheelUp:
dirlist.Prev()
}
}
}
func (dirlist *DirectoryList) Clicked(x int, y int) (string, bool) {
if 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
}
+543
View File
@@ -0,0 +1,543 @@
package app
import (
"fmt"
"sort"
"strings"
"time"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rockorager/vaxis"
)
type DirectoryTree struct {
*DirectoryList
listIdx int
list []*types.Thread
virtual bool
virtualCb func()
}
func NewDirectoryTree(dirlist *DirectoryList) DirectoryLister {
dt := &DirectoryTree{
DirectoryList: dirlist,
listIdx: -1,
virtualCb: func() {},
}
return dt
}
func (dt *DirectoryTree) OnVirtualNode(cb func()) {
dt.virtualCb = cb
}
func (dt *DirectoryTree) Selected() string {
if dt.listIdx < 0 || dt.listIdx >= len(dt.list) {
return dt.DirectoryList.Selected()
}
node := dt.list[dt.listIdx]
elems := dt.nodeElems(node)
n := countLevels(node)
if n < 0 || n >= len(elems) {
return ""
}
return strings.Join(elems[:(n+1)], dt.DirectoryList.worker.PathSeparator())
}
func (dt *DirectoryTree) SelectedDirectory() *models.Directory {
if dt.virtual {
return &models.Directory{
Name: dt.Selected(),
Role: models.VirtualRole,
}
}
return dt.DirectoryList.SelectedDirectory()
}
func (dt *DirectoryTree) ClearList() {
dt.list = make([]*types.Thread, 0)
}
func (dt *DirectoryTree) Update(msg types.WorkerMessage) {
selected := dt.Selected()
switch msg := msg.(type) {
case *types.Done:
switch msg.InResponseTo().(type) {
case *types.RemoveDirectory, *types.ListDirectories, *types.CreateDirectory:
dt.DirectoryList.Update(msg)
dt.buildTree()
if selected != "" {
dt.reindex(selected)
}
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, "%s", 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 vaxis.Event) {
if event, ok := event.(vaxis.Mouse); ok {
switch event.Button {
case vaxis.MouseLeftButton:
clickedDir, ok := dt.Clicked(localX, localY)
if ok {
dt.Select(clickedDir)
}
case vaxis.MouseWheelDown:
dt.NextPrev(1)
case vaxis.MouseWheelUp:
dt.NextPrev(-1)
}
}
}
func (dt *DirectoryTree) Clicked(x int, y int) (string, bool) {
if 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
}
if node.Hidden == 0 {
node.Hidden = 1
} else {
node.Hidden = 0
}
dt.Invalidate()
return "", false
}
}
return "", false
}
func (dt *DirectoryTree) SelectedMsgStore() (*lib.MessageStore, bool) {
if dt.virtual {
return nil, false
}
selected := models.UID(dt.selected)
if _, node := dt.getTreeNode(selected); node == nil {
dt.buildTree()
selIdx, node := dt.getTreeNode(selected)
if node != nil {
makeVisible(node)
dt.listIdx = selIdx
}
}
return dt.DirectoryList.SelectedMsgStore()
}
func (dt *DirectoryTree) reindex(name string) {
selIdx, node := dt.getTreeNode(models.UID(name))
if node != nil {
makeVisible(node)
dt.listIdx = selIdx
}
}
func (dt *DirectoryTree) Select(name string) {
if name == "" {
return
}
dt.Open(name, "", dt.UiConfig(name).DirListDelay, nil, false)
}
func (dt *DirectoryTree) Open(name string, query string, delay time.Duration, cb func(types.WorkerMessage), force bool) {
if name == "" {
return
}
again := false
uid := models.UID(name)
if _, node := dt.getTreeNode(uid); node == nil {
again = true
} else {
dt.reindex(name)
}
dt.DirectoryList.Open(name, query, delay, func(msg types.WorkerMessage) {
if cb != nil {
cb(msg)
}
if _, ok := msg.(*types.Done); ok && again {
if findString(dt.dirs, name) < 0 {
dt.dirs = append(dt.dirs, name)
}
dt.buildTree()
dt.reindex(name)
}
}, force)
}
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.selectIndex(newIdx)
}
func (dt *DirectoryTree) selectIndex(i int) {
dt.listIdx = i
node := dt.list[dt.listIdx]
if node.Dummy {
dt.virtual = true
dt.NewContext()
dt.virtualCb()
} else {
dt.virtual = false
dt.Select(dt.getDirectory(node))
}
}
func (dt *DirectoryTree) CollapseFolder(name string) {
name = strings.TrimRight(name, dt.worker.PathSeparator())
index, node := dt.getTreeNode(models.UID(name))
if node == nil {
return
}
if node.Parent != nil && (node.Hidden != 0 || node.FirstChild == nil) {
node.Parent.Hidden = 1
// highlight parent node and select it
for i, t := range dt.list {
if t == node.Parent && index == dt.listIdx {
dt.selectIndex(i)
break
}
}
} else {
node.Hidden = 1
}
dt.Invalidate()
}
func (dt *DirectoryTree) ExpandFolder(name string) {
name = strings.TrimRight(name, dt.worker.PathSeparator())
_, node := dt.getTreeNode(models.UID(name))
if node == nil {
return
}
node.Hidden = 0
dt.Invalidate()
}
func (dt *DirectoryTree) countVisible(list []*types.Thread) (n int) {
for _, node := range list {
if isVisible(node) {
n++
}
}
return
}
func (dt *DirectoryTree) nodeElems(node *types.Thread) []string {
dir := string(node.Uid)
sep := dt.DirectoryList.worker.PathSeparator()
return strings.Split(dir, sep)
}
func (dt *DirectoryTree) nodeName(node *types.Thread) string {
if elems := dt.nodeElems(node); len(elems) > 0 {
return elems[len(elems)-1]
}
return ""
}
func (dt *DirectoryTree) displayText(node *types.Thread) string {
return fmt.Sprintf("%s%s%s",
threadPrefix(node, false, false),
getFlag(node), dt.nodeName(node))
}
func (dt *DirectoryTree) getDirectory(node *types.Thread) string {
return string(node.Uid)
}
func (dt *DirectoryTree) getTreeNode(uid models.UID) (int, *types.Thread) {
for i, node := range dt.list {
if node.Uid == uid {
return i, node
}
}
return -1, nil
}
func (dt *DirectoryTree) hiddenDirectories() map[string]bool {
hidden := make(map[string]bool, 0)
for _, node := range dt.list {
if node.Hidden != 0 && node.FirstChild != nil {
elems := dt.nodeElems(node)
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) {
log.Tracef("setHiddenDirectories: %#v", hiddenDirs)
for _, node := range dt.list {
elems := dt.nodeElems(node)
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 = 1
log.Tracef("setHiddenDirectories: %q -> %#v", strDir, node)
}
}
}
}
func (dt *DirectoryTree) buildTree() {
if len(dt.list) != 0 {
hiddenDirs := dt.hiddenDirectories()
defer dt.setHiddenDirectories(hiddenDirs)
}
dirs := make([]string, len(dt.dirs))
copy(dirs, dt.dirs)
root := &types.Thread{}
dt.buildTreeNode(root, dirs, 1)
var threads []*types.Thread
for iter := root.FirstChild; iter != nil; iter = iter.NextSibling {
iter.Parent = nil
threads = append(threads, iter)
}
// folders-sort
if dt.DirectoryList.acctConf.EnableFoldersSort {
sort.Slice(threads, func(i, j int) bool {
foldersSort := dt.DirectoryList.acctConf.FoldersSort
iInFoldersSort := findString(foldersSort, dt.getDirectory(threads[i]))
jInFoldersSort := findString(foldersSort, dt.getDirectory(threads[j]))
if iInFoldersSort >= 0 && jInFoldersSort >= 0 {
return iInFoldersSort < jInFoldersSort
}
if iInFoldersSort >= 0 {
return true
}
if jInFoldersSort >= 0 {
return false
}
return dt.getDirectory(threads[i]) < dt.getDirectory(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, dirs []string, depth int) {
dirmap := make(map[string][]string)
for _, dir := range dirs {
base, dir, cut := strings.Cut(
dir, dt.DirectoryList.worker.PathSeparator())
if _, found := dirmap[base]; found {
if cut {
dirmap[base] = append(dirmap[base], dir)
}
} else if cut {
dirmap[base] = append(dirmap[base], dir)
} else {
dirmap[base] = []string{}
}
}
bases := make([]string, 0, len(dirmap))
for base, dirs := range dirmap {
bases = append(bases, base)
sort.Strings(dirs)
}
sort.Strings(bases)
basePath := dt.getDirectory(node)
collapse := dt.UiConfig(basePath).DirListCollapse
if collapse != 0 && depth > collapse {
node.Hidden = 1
} else {
node.Hidden = 0
}
for _, base := range bases {
path := dt.childPath(basePath, base)
nextNode := &types.Thread{Uid: models.UID(path)}
nextNode.Dummy = findString(dt.dirs, path) == -1
node.AddChild(nextNode)
dt.buildTreeNode(nextNode, dirmap[base], depth+1)
}
}
func (dt *DirectoryTree) childPath(base, relpath string) string {
if base == "" {
return relpath
}
return base + dt.DirectoryList.worker.PathSeparator() + relpath
}
func makeVisible(node *types.Thread) {
if node == nil {
return
}
for iter := node.Parent; iter != nil; iter = iter.Parent {
iter.Hidden = 0
}
}
func isVisible(node *types.Thread) bool {
for iter := node.Parent; iter != nil; iter = iter.Parent {
if iter.Hidden != 0 {
return false
}
}
return true
}
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 != 0 {
return "+"
}
return ""
}
+125
View File
@@ -0,0 +1,125 @@
package app
import (
"context"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/go-opt/v2"
"git.sr.ht/~rockorager/vaxis"
)
type ExLine struct {
commit func(cmd string)
finish func()
tabcomplete func(ctx context.Context, cmd string) ([]opt.Completion, string)
cmdHistory lib.History
input *ui.TextInput
}
func NewExLine(cmd string, commit func(cmd string), finish func(),
tabcomplete func(ctx context.Context, cmd string) ([]opt.Completion, 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,
&config.Binds.Global.CompleteKey,
)
}
exline := &ExLine{
commit: commit,
finish: finish,
tabcomplete: tabcomplete,
cmdHistory: cmdHistory,
input: input,
}
return exline
}
func (x *ExLine) TabComplete(tabComplete func(context.Context, string) ([]opt.Completion, string)) {
x.input.TabComplete(
tabComplete,
config.Ui.CompletionDelay,
config.Ui.CompletionMinChars,
&config.Binds.Global.CompleteKey,
)
}
func NewPrompt(prompt string, commit func(text string),
tabcomplete func(ctx context.Context, cmd string) ([]opt.Completion, string),
) *ExLine {
input := ui.NewTextInput("", config.Ui).Prompt(prompt)
if config.Ui.CompletionPopovers {
input.TabComplete(
tabcomplete,
config.Ui.CompletionDelay,
config.Ui.CompletionMinChars,
&config.Binds.Global.CompleteKey,
)
}
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 vaxis.Event) bool {
if key, ok := event.(vaxis.Key); ok {
switch {
case key.Matches(vaxis.KeyEnter), key.Matches('j', vaxis.ModCtrl):
cmd := ex.input.String()
ex.input.Focus(false)
ex.commit(cmd)
ex.finish()
case key.Matches(vaxis.KeyUp):
ex.input.Set(ex.cmdHistory.Prev())
ex.Invalidate()
case key.Matches(vaxis.KeyDown):
ex.input.Set(ex.cmdHistory.Next())
ex.Invalidate()
case key.Matches(vaxis.KeyEsc), key.Matches('c', vaxis.ModCtrl):
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() {}
+67
View File
@@ -0,0 +1,67 @@
package app
import (
"fmt"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
)
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, "%s", gp.prompt)
gp.input.Draw(ctx.Subcontext(1, 3, ctx.Width()-2, 1))
}
func (gp *GetPasswd) Invalidate() {
ui.Invalidate()
}
func (gp *GetPasswd) Event(event vaxis.Event) bool {
switch event := event.(type) {
case vaxis.Key:
switch {
case event.Matches(vaxis.KeyEnter):
gp.input.Focus(false)
gp.callback(gp.input.String(), nil)
case event.Matches(vaxis.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
}
+44
View File
@@ -0,0 +1,44 @@
package app
import (
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
)
type HeaderLayout [][]string
type HeaderLayoutFilter struct {
layout HeaderLayout
keep func(msg *models.MessageInfo, header string) bool // filter criteria
}
// forMessage returns a filtered header layout, removing rows whose headers
// do not appear in the provided message.
func (filter HeaderLayoutFilter) forMessage(msg *models.MessageInfo) HeaderLayout {
result := make(HeaderLayout, 0, len(filter.layout))
for _, row := range filter.layout {
// To preserve layout alignment, only hide rows if all columns are empty
for _, col := range row {
if filter.keep(msg, col) {
result = append(result, row)
break
}
}
}
return result
}
// grid builds a ui grid, populating each cell by calling a callback function
// with the current header string.
func (layout HeaderLayout) grid(cb func(string) ui.Drawable) (grid *ui.Grid, height int) {
rowCount := len(layout)
grid = ui.MakeGrid(rowCount, 1, ui.SIZE_EXACT, ui.SIZE_WEIGHT)
for i, cols := range layout {
r := ui.MakeGrid(1, len(cols), ui.SIZE_EXACT, ui.SIZE_WEIGHT)
for j, col := range cols {
r.AddChild(cb(col)).At(0, j)
}
grid.AddChild(r).At(i, 0)
}
return grid, rowCount
}
+325
View File
@@ -0,0 +1,325 @@
package app
import (
"math"
"strings"
"sync"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
"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
textFilter func([]string, string) []string
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,
textFilter: nil,
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) SetTextFilter(fn func([]string, string) []string) *ListBox {
lb.textFilter = fn
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 (%d/%d): ",
len(lb.filtered()), len(lb.lines))
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 {
term := lb.filter.String()
if lb.textFilter != nil {
return lb.textFilter(lb.lines, term)
}
list := make([]string, 0, len(lb.lines))
for _, line := range lb.lines {
if strings.Contains(line, term) {
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, "%s", line)
y += 1
}
if needScrollbar {
scrollBarCtx := ctx.Subcontext(w, 0, 1, ctx.Height())
lb.drawScrollbar(scrollBarCtx)
}
}
func (lb *ListBox) drawScrollbar(ctx *ui.Context) {
gutterStyle := vaxis.Style{}
pillStyle := vaxis.Style{Attribute: vaxis.AttrReverse}
// 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 vaxis.Event) bool {
showFilter := lb.showFilterField()
if key, ok := event.(vaxis.Key); ok {
switch {
case key.Matches(vaxis.KeyLeft):
if showFilter {
break
}
lb.moveHorizontal(-1)
lb.Invalidate()
return true
case key.Matches(vaxis.KeyRight):
if showFilter {
break
}
lb.moveHorizontal(+1)
lb.Invalidate()
return true
case key.Matches('b', vaxis.ModCtrl):
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 key.Matches('w', vaxis.ModCtrl):
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 key.Matches('a', vaxis.ModCtrl), key.Matches(vaxis.KeyHome):
if showFilter {
break
}
lb.horizPos = 0
lb.Invalidate()
return true
case key.Matches('e', vaxis.ModCtrl), key.Matches(vaxis.KeyEnd):
if showFilter {
break
}
lb.horizPos = len(lb.selected)
lb.Invalidate()
return true
case key.Matches('p', vaxis.ModCtrl), key.Matches(vaxis.KeyUp):
lb.moveCursor(-1)
lb.Invalidate()
return true
case key.Matches('n', vaxis.ModCtrl), key.Matches(vaxis.KeyDown):
lb.moveCursor(+1)
lb.Invalidate()
return true
case key.Matches(vaxis.KeyPgUp):
if lb.jump >= 0 {
lb.moveCursor(-lb.jump)
lb.Invalidate()
}
return true
case key.Matches(vaxis.KeyPgDown):
if lb.jump >= 0 {
lb.moveCursor(+lb.jump)
lb.Invalidate()
}
return true
case key.Matches(vaxis.KeyEnter):
return lb.quit(lb.selected)
case key.Matches(vaxis.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)
}
+602
View File
@@ -0,0 +1,602 @@
package app
import (
"bytes"
"math"
"strings"
sortthread "github.com/emersion/go-imap-sortthread"
"github.com/emersion/go-message/mail"
"github.com/mattn/go-runewidth"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/aerc/worker/types"
"git.sr.ht/~rockorager/vaxis"
)
type MessageList struct {
Scrollable
height int
width int
nmsgs int
spinner *Spinner
store *lib.MessageStore
isInitalizing bool
}
func NewMessageList(account *AccountView) *MessageList {
ml := &MessageList{
spinner: NewSpinner(account.UiConfig()),
isInitalizing: true,
}
// TODO: stop spinner, probably
ml.spinner.Start()
return ml
}
func (ml *MessageList) Invalidate() {
ui.Invalidate()
}
type messageRowParams struct {
uid models.UID
needsHeaders bool
err error
uiConfig *config.UIConfig
styles []config.StyleObject
headers *mail.Header
}
// AlignMessage aligns the selected message to position pos.
func (ml *MessageList) AlignMessage(pos AlignPosition) {
store := ml.Store()
if store == nil {
return
}
idx := 0
iter := store.UidsIterator()
for i := 0; iter.Next(); i++ {
if store.SelectedUid() == iter.Value().(models.UID) {
idx = i
break
}
}
ml.Align(idx, pos)
}
func (ml *MessageList) Draw(ctx *ui.Context) {
ml.height = ctx.Height()
ml.width = ctx.Width()
uiConfig := SelectedAccountUiConfig()
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
uiConfig.GetStyle(config.STYLE_MSGLIST_DEFAULT))
acct := 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.SetOffset(uiConfig.MsglistScrollOffset)
ml.UpdateScroller(ml.height, len(store.Uids()))
iter := store.UidsIterator()
for i := 0; iter.Next(); i++ {
if store.SelectedUid() == iter.Value().(models.UID) {
ml.EnsureScroll(i)
break
}
}
store.UpdateScroll(ml.Scroll(), ml.height)
textWidth := ctx.Width()
if ml.NeedScrollbar() {
textWidth -= 1
}
if textWidth <= 0 {
return
}
var needsHeaders []models.UID
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.err != nil {
var style vaxis.Style
if params.uid == store.SelectedUid() {
style = uiConfig.GetStyle(config.STYLE_ERROR)
} else {
style = uiConfig.GetStyleSelected(config.STYLE_ERROR)
}
ctx.Printf(0, r, style, "error: %s", params.err)
return true
}
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) vaxis.Style {
var style vaxis.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().(models.UID)
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 models.UID,
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 && msg.Error == nil) {
params.needsHeaders = true
return table.AddRow(cells, params)
} else if msg.Error != nil {
params.err = msg.Error
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.ForwardedFlag) {
params.styles = append(params.styles, config.STYLE_MSGLIST_FORWARDED)
}
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)
}
if templateData.ThreadOrphan() {
params.styles = append(params.styles, config.STYLE_MSGLIST_THREAD_ORPHAN)
}
}
// 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 := 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 vaxis.Event) {
if event, ok := event.(vaxis.Mouse); ok {
switch event.Button {
case vaxis.MouseLeftButton:
selectedMsg, ok := ml.Clicked(localX, localY)
if ok {
ml.Select(selectedMsg)
acct := 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, CryptoProvider(), DecryptKeys,
func(view lib.MessageView, err error) {
if err != nil {
PushError(err.Error())
return
}
viewer, err := NewMessageViewer(acct, view)
if err != nil {
PushError(err.Error())
return
}
NewTab(viewer, msg.Envelope.Subject)
})
}
case vaxis.MouseWheelDown:
if ml.store != nil {
ml.store.Next()
}
ml.Invalidate()
case vaxis.MouseWheelUp:
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 models.UID
if index < 0 {
uid = uids[iter.EndIndex()]
} else {
uid = uids[iter.StartIndex()]
for i := 0; iter.Next(); i++ {
if i >= index {
uid = iter.Value().(models.UID)
break
}
}
}
store.Select(uid)
ml.Invalidate()
}
func (ml *MessageList) drawEmptyMessage(ctx *ui.Context) {
uiConfig := 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 unreadInThread(thread *types.Thread, store *lib.MessageStore) (ctr int) {
if thread == nil {
return
}
_ = thread.Walk(func(t *types.Thread, _ int, _ error) error {
msg := store.Messages[t.Uid]
if msg != nil && !msg.Flags.Has(models.SeenFlag) {
ctr++
}
return nil
})
return
}
func threadPrefix(t *types.Thread, reverse bool, msglist bool) string {
uiConfig := SelectedAccountUiConfig()
var tip, prefix, firstChild, lastSibling, orphan, dummy string
if msglist {
tip = uiConfig.ThreadPrefixTip
} else {
threadPrefixSibling := "├─"
threadPrefixReverse := "┌─"
threadPrefixEnd := "└─"
threadStem := "│"
threadIndent := strings.Repeat(" ", runewidth.StringWidth(threadPrefixSibling)-1)
switch {
case t.Parent != nil && t.NextSibling != nil:
prefix += threadPrefixSibling
case t.Parent != nil && reverse:
prefix += threadPrefixReverse
case t.Parent != nil:
prefix += threadPrefixEnd
}
for n := t.Parent; n != nil && n.Parent != nil; n = n.Parent {
if n.NextSibling != nil {
prefix = threadStem + threadIndent + prefix
} else {
prefix = " " + threadIndent + prefix
}
}
return prefix
}
if reverse {
firstChild = uiConfig.ThreadPrefixFirstChildReverse
lastSibling = uiConfig.ThreadPrefixLastSiblingReverse
orphan = uiConfig.ThreadPrefixOrphanReverse
dummy = uiConfig.ThreadPrefixDummyReverse
} else {
firstChild = uiConfig.ThreadPrefixFirstChild
lastSibling = uiConfig.ThreadPrefixLastSibling
orphan = uiConfig.ThreadPrefixOrphan
dummy = uiConfig.ThreadPrefixDummy
}
var hiddenOffspring bool = t.FirstChild != nil && t.FirstChild.Hidden > 0
var parentAndSiblings bool = t.Parent != nil && t.NextSibling != nil
var hasSiblings string = uiConfig.ThreadPrefixHasSiblings
if t.Parent != nil && t.Parent.Hidden > 0 && t.Hidden == 0 {
hasSiblings = dummy
}
switch {
case parentAndSiblings && hiddenOffspring:
prefix = hasSiblings +
uiConfig.ThreadPrefixFolded
case parentAndSiblings && t.FirstChild != nil:
prefix = hasSiblings +
firstChild + tip
case parentAndSiblings:
prefix = hasSiblings +
uiConfig.ThreadPrefixLimb +
uiConfig.ThreadPrefixUnfolded + tip
case t.Parent != nil && hiddenOffspring:
prefix = lastSibling + uiConfig.ThreadPrefixFolded
case t.Parent != nil && t.FirstChild != nil:
prefix = lastSibling + firstChild + tip
case t.Parent != nil && t.FirstChild == nil:
prefix = lastSibling + uiConfig.ThreadPrefixLimb + tip
case t.Parent != nil:
prefix = lastSibling + uiConfig.ThreadPrefixUnfolded +
uiConfig.ThreadPrefixTip
case t.Parent == nil && hiddenOffspring:
prefix = uiConfig.ThreadPrefixFolded
case t.Parent == nil && t.Dummy:
prefix = dummy + tip
case t.Parent == nil && t.FirstChild != nil:
prefix = orphan
case t.Parent == nil && t.FirstChild == nil:
prefix = uiConfig.ThreadPrefixLone
}
for n := t.Parent; n != nil && n.Parent != nil; n = n.Parent {
if n.NextSibling != nil {
prefix = uiConfig.ThreadPrefixStem +
uiConfig.ThreadPrefixIndent + prefix
} else {
prefix = " " + uiConfig.ThreadPrefixIndent + prefix
}
}
return prefix
}
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 models.UID) {
thread, err := t.store.Thread(uid)
info := state.ThreadInfo{}
if thread != nil && err == nil {
info.Prefix = threadPrefix(thread, t.reverse, true)
subject := threadSubject(t.store, thread)
info.SameSubject = subject == t.prevSubj && sameParent(thread, t.prev) && !isParent(thread)
t.prev = thread
t.prevSubj = subject
info.Count = countThreads(thread)
info.Unread = unreadInThread(thread, t.store)
info.Folded = thread.FirstChild != nil && thread.FirstChild.Hidden != 0
info.Context = thread.Context
info.Orphan = thread.Parent != nil && thread.Parent.Hidden > 0 && thread.Hidden == 0
}
data.SetThreading(info)
}
+914
View File
@@ -0,0 +1,914 @@
package app
import (
"bytes"
"errors"
"fmt"
"image"
"io"
"os"
"os/exec"
"strings"
"sync/atomic"
"github.com/danwakefield/fnmatch"
"github.com/emersion/go-message/textproto"
"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/log"
"git.sr.ht/~rjarry/aerc/lib/parse"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rjarry/go-opt/v2"
"git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rockorager/vaxis/widgets/align"
// Image support
_ "image/jpeg"
_ "image/png"
_ "golang.org/x/image/bmp"
_ "golang.org/x/image/tiff"
_ "golang.org/x/image/webp"
)
// All imported image types need to be explicitly stated here. We want to check
// if we _can_ display something before we download it
var supportedImageTypes = []string{
"image/jpeg",
"image/png",
"image/bmp",
"image/tiff",
"image/webp",
}
var _ ProvidesMessages = (*MessageViewer)(nil)
type MessageViewer struct {
acct *AccountView
grid *ui.Grid
switcher *PartSwitcher
msg lib.MessageView
uiConfig *config.UIConfig
}
func NewMessageViewer(
acct *AccountView, msg lib.MessageView,
) (*MessageViewer, error) {
if msg == nil {
return &MessageViewer{acct: acct}, nil
}
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 nil, err
}
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.uiConfig = mv.uiConfig
return mv, nil
}
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 "Sender":
return format.FormatAddresses(msg.Envelope.Sender)
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
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.switcher == nil {
style := mv.acct.UiConfig().GetStyle(config.STYLE_DEFAULT)
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
ctx.Printf(0, 0, style, "%s", "(no message selected)")
return
}
mv.grid.Draw(ctx)
}
func (mv *MessageViewer) MouseEvent(localX int, localY int, event vaxis.Event) {
if mv.switcher == nil {
return
}
mv.grid.MouseEvent(localX, localY, event)
}
func (mv *MessageViewer) Invalidate() {
ui.Invalidate()
}
func (mv *MessageViewer) Terminal() *Terminal {
if mv.switcher == nil {
return nil
}
nparts := len(mv.switcher.parts)
if nparts == 0 || mv.switcher.selected < 0 || mv.switcher.selected >= nparts {
return nil
}
pv := mv.switcher.parts[mv.switcher.selected]
if pv == nil {
return nil
}
return pv.term
}
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() ([]models.UID, error) {
return mv.acct.MarkedMessages()
}
func (mv *MessageViewer) ToggleHeaders() {
if mv.switcher == nil {
return
}
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 {
if mv.switcher == nil {
return nil
}
part := mv.switcher.SelectedPart()
return &PartInfo{
Index: part.index,
Msg: part.msg.MessageInfo(),
Part: part.part,
Links: part.links,
}
}
func (mv *MessageViewer) AttachmentParts(all bool) []*PartInfo {
if mv.switcher == nil {
return nil
}
return mv.switcher.AttachmentParts(all)
}
func (mv *MessageViewer) PreviousPart() {
if mv.switcher == nil {
return
}
mv.switcher.PreviousPart()
mv.Invalidate()
}
func (mv *MessageViewer) NextPart() {
if mv.switcher == nil {
return
}
mv.switcher.NextPart()
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 (mv *MessageViewer) Event(event vaxis.Event) bool {
if mv.switcher != nil {
return mv.switcher.Event(event)
}
return false
}
func (mv *MessageViewer) Focus(focus bool) {
if mv.switcher != nil {
mv.switcher.Focus(focus)
}
}
func (mv *MessageViewer) Show(visible bool) {
if mv.switcher != nil {
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
inlineImg bool
image image.Image
graphic vaxis.Image
width int
height int
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
)
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)
}
case config.FILTER_FILENAME:
if f.Regex.Match([]byte(part.DispositionParams["filename"])) {
filter = exec.Command("sh", "-c", f.Command)
log.Tracef("command %v", f.Command)
}
}
if filter == nil {
continue
}
if !f.NeedsPager {
pager = filter
break
}
pagerCmd, err := CmdFallbackSearch(config.PagerCmds(), false)
if err != nil {
acct.PushError(fmt.Errorf("could not start pager: %w", err))
return nil, err
}
cmd := opt.SplitArgs(pagerCmd)
pager = exec.Command(cmd[0], cmd[1:]...)
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")
}
if pager == filter {
log.Debugf("<%s> part=%v %s: %v",
info.Envelope.MessageId, curindex, mime, filter)
} else {
log.Debugf("<%s> part=%v %s: %v | %v",
info.Envelope.MessageId, curindex, mime, filter, pager)
}
var err error
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(),
}
return pv, nil
}
func (pv *PartViewer) SetSource(reader io.Reader) {
pv.source = reader
switch pv.inlineImg {
case true:
pv.decodeImage()
default:
pv.attemptCopy()
}
}
func (pv *PartViewer) decodeImage() {
atomic.StoreInt32(&pv.copying, copying)
go func() {
defer log.PanicHandler()
defer pv.Invalidate()
defer atomic.StoreInt32(&pv.copying, 0)
img, _, err := image.Decode(pv.source)
if err != nil {
log.Errorf("error decoding image: %v", err)
return
}
pv.image = img
}()
}
func (pv *PartViewer) attemptCopy() {
if pv.source == nil ||
pv.filter == nil ||
atomic.SwapInt32(&pv.copying, copying) == copying {
return
}
pv.writeMailHeaders()
if strings.EqualFold(pv.part.MIMEType, "text") {
pv.source = parse.StripAnsi(pv.hyperlinks(pv.source))
}
if pv.filter != pv.pager {
// Filter is a separate process that needs to output to the pager.
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)
var err error
if pv.filter == pv.pager {
// Filter already implements its own paging.
_, err = io.Copy(pv.pagerin, pv.source)
if err != nil {
log.Errorf("io.Copy: %s", err)
}
} else {
err = pv.filter.Wait()
if err != nil {
log.Errorf("filter.Wait: %v", err)
}
}
err = pv.pagerin.Close()
if err != nil {
log.Errorf("error closing pager pipe: %v", err)
}
}()
}
func (pv *PartViewer) writeMailHeaders() {
info := pv.msg.MessageInfo()
if !config.Viewer.ShowHeaders || info.RFC822Headers == nil {
return
}
if pv.filter == pv.pager {
// Filter already implements its own paging.
// Piping another filter into it will cause mayhem.
return
}
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)
switch {
case pv.filter == nil && canInline(pv.part.FullMIMEType()) && pv.err == nil:
pv.inlineImg = true
case pv.filter == nil:
// No filter, can't inline, and/or we attempted to inline an image
// and resulted in an error (maybe because of a bad encoding or
// the terminal doesn't support any graphics protocol).
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
pv.noFilter.Draw(ctx)
return
case !pv.fetched:
w, h := ctx.Window().Size()
pv.filter.Env = append(pv.filter.Env, fmt.Sprintf("COLUMNS=%d", w))
pv.filter.Env = append(pv.filter.Env, fmt.Sprintf("LINES=%d", h))
}
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)
}
if pv.image != nil && (pv.resized(ctx) || pv.graphic == nil) {
// This path should only occur on resizes or the first pass
// after the image is downloaded and could be slow due to
// encoding the image to either sixel or uploading via the kitty
// protocol. Generally it's pretty fast since we will only ever
// be downsizing images
vx := ctx.Window().Vx
if pv.graphic == nil {
var err error
pv.graphic, err = vx.NewImage(pv.image)
if err != nil {
log.Errorf("Couldn't create image: %v", err)
return
}
}
pv.graphic.Resize(pv.width, pv.height)
}
if pv.graphic != nil {
w, h := pv.graphic.CellSize()
win := align.Center(ctx.Window(), w, h)
pv.graphic.Draw(win)
}
}
func (pv *PartViewer) Cleanup() {
if pv.term != nil {
pv.term.Close()
}
if pv.graphic != nil {
pv.graphic.Destroy()
}
}
func (pv *PartViewer) resized(ctx *ui.Context) bool {
w := ctx.Width()
h := ctx.Height()
if pv.width != w || pv.height != h {
pv.width = w
pv.height = h
return true
}
return false
}
func (pv *PartViewer) Event(event vaxis.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()
}
func canInline(mime string) bool {
for _, ext := range supportedImageTypes {
if mime == ext {
return true
}
}
return false
}
+207
View File
@@ -0,0 +1,207 @@
package app
import (
"math"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
"github.com/mattn/go-runewidth"
)
type PartSwitcher struct {
Scrollable
parts []*PartViewer
selected int
height int
offset int
uiConfig *config.UIConfig
}
func (ps *PartSwitcher) PreviousPart() {
for {
ps.selected--
if ps.selected < 0 {
ps.selected = len(ps.parts) - 1
}
if ps.parts[ps.selected].part.MIMEType != "multipart" {
break
}
}
}
func (ps *PartSwitcher) NextPart() {
for {
ps.selected++
if ps.selected >= len(ps.parts) {
ps.selected = 0
}
if ps.parts[ps.selected].part.MIMEType != "multipart" {
break
}
}
}
func (ps *PartSwitcher) SelectedPart() *PartViewer {
return ps.parts[ps.selected]
}
func (ps *PartSwitcher) AttachmentParts(all bool) []*PartInfo {
var attachments []*PartInfo
for _, p := range ps.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 (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 vaxis.Event) bool {
return ps.parts[ps.selected].Event(event)
}
func (ps *PartSwitcher) Draw(ctx *ui.Context) {
uiConfig := ps.uiConfig
n := len(ps.parts)
if n == 1 && !config.Viewer.AlwaysShowMime {
ps.parts[ps.selected].Draw(ctx)
return
}
ps.height = config.Viewer.MaxMimeHeight
if ps.height <= 0 || n < ps.height {
ps.height = n
}
if ps.height > ctx.Height()/2 {
ps.height = ctx.Height() / 2
}
ps.UpdateScroller(ps.height, n)
ps.EnsureScroll(ps.selected)
var styleSwitcher, styleFile, styleMime vaxis.Style
scrollbarWidth := 0
if ps.NeedScrollbar() {
scrollbarWidth = 1
}
ps.offset = ctx.Height() - ps.height
y := ps.offset
row := ps.offset
ctx.Fill(0, y, ctx.Width(), ps.height, ' ', uiConfig.GetStyle(config.STYLE_PART_SWITCHER))
for i := ps.Scroll(); i < n; i++ {
part := ps.parts[i]
if ps.selected == i {
styleSwitcher = uiConfig.GetStyleSelected(config.STYLE_PART_SWITCHER)
styleFile = uiConfig.GetStyleSelected(config.STYLE_PART_FILENAME)
styleMime = uiConfig.GetStyleSelected(config.STYLE_PART_MIMETYPE)
} else {
styleSwitcher = uiConfig.GetStyle(config.STYLE_PART_SWITCHER)
styleFile = uiConfig.GetStyle(config.STYLE_PART_FILENAME)
styleMime = uiConfig.GetStyle(config.STYLE_PART_MIMETYPE)
}
ctx.Fill(0, row, 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, row, styleFile, "%s ", name)
}
t := "(" + part.part.FullMIMEType() + ")"
t = runewidth.Truncate(t, ctx.Width()-left-scrollbarWidth, "…")
ctx.Printf(left, row, styleMime, "%s", t)
row++
if (i - ps.Scroll()) >= ps.height {
break
}
}
if ps.NeedScrollbar() {
ps.drawScrollbar(ctx.Subcontext(ctx.Width()-1, y, 1, ps.height))
}
ps.parts[ps.selected].Draw(ctx.Subcontext(
0, 0, ctx.Width(), ctx.Height()-ps.height))
}
func (ps *PartSwitcher) drawScrollbar(ctx *ui.Context) {
uiConfig := ps.uiConfig
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()) * ps.PercentVisible()))
pillOffset := int(math.Floor(float64(ctx.Height()) * ps.PercentScrolled()))
ctx.Fill(0, pillOffset, 1, pillSize, ' ', pillStyle)
}
func (ps *PartSwitcher) MouseEvent(localX int, localY int, event vaxis.Event) {
if localY < ps.offset && ps.parts[ps.selected].term != nil {
ps.parts[ps.selected].term.MouseEvent(localX, localY, event)
return
}
e, ok := event.(vaxis.Mouse)
if !ok {
return
}
if ps.parts[ps.selected].term != nil {
ps.parts[ps.selected].term.Focus(false)
}
switch e.Button {
case vaxis.MouseLeftButton:
i := localY - ps.offset + ps.Scroll()
if i < 0 || i >= len(ps.parts) {
break
}
if ps.parts[i].part.MIMEType == "multipart" {
break
}
ps.selected = i
ps.Invalidate()
case vaxis.MouseWheelDown:
ps.NextPart()
ps.Invalidate()
case vaxis.MouseWheelUp:
ps.PreviousPart()
ps.Invalidate()
}
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()
}
}
+98
View File
@@ -0,0 +1,98 @@
package app
import (
"fmt"
"strings"
"unicode/utf8"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
"git.sr.ht/~rockorager/vaxis"
)
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 vaxis.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, "%s", 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()
}
+30
View File
@@ -0,0 +1,30 @@
package app
import (
"git.sr.ht/~rjarry/aerc/lib"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rjarry/aerc/models"
)
type PartInfo struct {
Index []int
Msg *models.MessageInfo
Part *models.BodyStructure
Links []string
}
type ProvidesMessage interface {
ui.Drawable
Store() *lib.MessageStore
SelectedAccount() *AccountView
SelectedMessage() (*models.MessageInfo, error)
SelectedMessagePart() *PartInfo
}
type ProvidesMessages interface {
ui.Drawable
Store() *lib.MessageStore
SelectedAccount() *AccountView
SelectedMessage() (*models.MessageInfo, error)
MarkedMessages() ([]models.UID, error)
}
+243
View File
@@ -0,0 +1,243 @@
package app
import (
"os/exec"
"sync"
"sync/atomic"
"time"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
"github.com/riywo/loginshell"
)
var qt quakeTerminal
type quakeTerminal struct {
mu sync.Mutex
rolling int32
visible bool
term *Terminal
}
func ToggleQuake() {
handleErr := func(err error) {
log.Errorf("quake-terminal: %v", err)
}
if !qt.HasTerm() {
shell, err := loginshell.Shell()
if err != nil {
handleErr(err)
return
}
args := []string{shell}
cmd := exec.Command(args[0], args[1:]...)
term, err := NewTerminal(cmd)
if err != nil {
handleErr(err)
return
}
term.OnClose = func(err error) {
if err != nil {
aerc.PushError(err.Error())
}
qt.Hide()
qt.SetTerm(nil)
}
qt.SetTerm(term)
}
if qt.Rolling() {
return
}
if qt.Visible() {
qt.Hide()
} else {
qt.Show()
}
}
func (q *quakeTerminal) Rolling() bool {
return atomic.LoadInt32(&q.rolling) > 0
}
func (q *quakeTerminal) SetTerm(t *Terminal) {
q.mu.Lock()
defer q.mu.Unlock()
q.term = t
}
func (q *quakeTerminal) HasTerm() bool {
q.mu.Lock()
defer q.mu.Unlock()
return q.term != nil
}
func (q *quakeTerminal) Visible() bool {
q.mu.Lock()
defer q.mu.Unlock()
return q.visible
}
// inputReturn is helper function to create dialog boxes.
func inputReturn() func(int) int {
return func(x int) int { return x }
}
// fixReturn is helper function to create dialog boxes.
func fixReturn(x int) func(int) int {
return func(_ int) int { return x }
}
func (q *quakeTerminal) Show() {
q.mu.Lock()
defer q.mu.Unlock()
if q.term == nil {
return
}
uiConfig := SelectedAccountUiConfig()
h := uiConfig.QuakeHeight
termBox := NewDialog(
ui.NewBox(q.term, "", "", uiConfig),
fixReturn(0),
fixReturn(0),
inputReturn(),
fixReturn(h),
)
f := Roller{
span: 100 * time.Millisecond,
done: func() {
log.Tracef("restore after show")
atomic.StoreInt32(&q.rolling, 0)
ui.QueueFunc(func() {
CloseDialog()
AddDialog(termBox)
})
},
}
atomic.StoreInt32(&q.rolling, 1)
emptyBox := NewDialog(
ui.NewBox(&EmptyInteractive{}, "", "", uiConfig),
fixReturn(0),
fixReturn(0),
inputReturn(),
f.Roll(1, h),
)
q.visible = true
if q.term != nil {
q.term.Show(q.visible)
q.term.Focus(q.visible)
}
CloseDialog()
AddDialog(emptyBox)
}
func (q *quakeTerminal) Hide() {
uiConfig := SelectedAccountUiConfig()
f := Roller{
span: 100 * time.Millisecond,
done: func() {
atomic.StoreInt32(&q.rolling, 0)
ui.QueueFunc(CloseDialog)
log.Tracef("restore after hide")
},
}
atomic.StoreInt32(&q.rolling, 1)
emptyBox := NewDialog(
ui.NewBox(&EmptyInteractive{}, "", "", uiConfig),
fixReturn(0),
fixReturn(0),
inputReturn(),
f.Roll(uiConfig.QuakeHeight, 2),
)
q.mu.Lock()
q.visible = false
if q.term != nil {
q.term.Focus(q.visible)
q.term.Show(q.visible)
}
q.mu.Unlock()
ui.QueueFunc(func() {
CloseDialog()
AddDialog(emptyBox)
})
}
type EmptyInteractive struct{}
func (e *EmptyInteractive) Draw(ctx *ui.Context) {
w := ctx.Width()
h := ctx.Height()
if w == 0 || h == 0 {
return
}
style := SelectedAccountUiConfig().GetStyle(config.STYLE_DEFAULT)
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
}
func (e *EmptyInteractive) Invalidate() {
}
func (e *EmptyInteractive) MouseEvent(_ int, _ int, _ vaxis.Event) {
}
func (e *EmptyInteractive) Event(_ vaxis.Event) bool {
return true
}
func (e *EmptyInteractive) Focus(_ bool) {
}
type Roller struct {
span time.Duration
done func()
value int64
}
func (f *Roller) Roll(start, end int) func(int) int {
nsteps := end - start
var step int64 = 1
if end < start {
step = -1
nsteps = -nsteps
}
span := f.span.Milliseconds() / int64(nsteps)
refresh := time.Duration(span) * time.Millisecond
atomic.StoreInt64(&f.value, int64(start))
go func() {
defer log.PanicHandler()
for i := 0; i < int(nsteps); i++ {
aerc.Invalidate()
time.Sleep(refresh)
atomic.AddInt64(&f.value, step)
}
if f.done != nil {
ui.QueueFunc(f.done)
}
}()
return func(_ int) int {
log.Tracef("in roller")
return int(atomic.LoadInt64(&f.value))
}
}
+101
View File
@@ -0,0 +1,101 @@
package app
// Scrollable implements vertical scrolling
type Scrollable struct {
scroll int
offset int
height int
elems int
}
func (s *Scrollable) Scroll() int {
return s.scroll
}
func (s *Scrollable) SetOffset(offset int) {
s.offset = offset
}
func (s *Scrollable) ScrollOffset() int {
return s.offset
}
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(idx int) {
if idx < 0 {
return
}
middle := s.height / 2
switch {
case s.offset > middle:
s.scroll = idx - middle
case idx < s.scroll+s.offset:
s.scroll = idx - s.offset
case idx >= s.scroll-s.offset+s.height:
s.scroll = idx + s.offset - s.height + 1
}
s.checkBounds()
}
func (s *Scrollable) checkBounds() {
maxScroll := s.elems - s.height
if maxScroll < 0 {
maxScroll = 0
}
if s.scroll > maxScroll {
s.scroll = maxScroll
}
if s.scroll < 0 {
s.scroll = 0
}
}
type AlignPosition uint
const (
AlignTop AlignPosition = iota
AlignCenter
AlignBottom
)
func (s *Scrollable) Align(idx int, pos AlignPosition) {
switch pos {
case AlignTop:
s.scroll = idx
case AlignCenter:
s.scroll = idx - s.height/2
case AlignBottom:
s.scroll = idx - s.height + 1
}
s.checkBounds()
}
+275
View File
@@ -0,0 +1,275 @@
package app
import (
"fmt"
"strings"
"github.com/mattn/go-runewidth"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
)
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 vaxis.Event) bool {
if key, ok := event.(vaxis.Key); ok {
switch {
case key.Matches('h', vaxis.ModCtrl):
fallthrough
case key.Matches(vaxis.KeyLeft):
if sel.focus > 0 {
sel.focus--
sel.Invalidate()
}
if sel.onSelect != nil {
sel.onSelect(sel.Selected())
}
case key.Matches('l', vaxis.ModCtrl):
fallthrough
case key.Matches(vaxis.KeyRight):
if sel.focus < len(sel.options)-1 {
sel.focus++
sel.Invalidate()
}
if sel.onSelect != nil {
sel.onSelect(sel.Selected())
}
case key.Matches(vaxis.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) ContextWidth() (func(int) int, func(int) int) {
// horizontal starting position in columns from the left
start := func(int) int {
return 4
}
// dialog width from the starting column
width := func(w int) int {
return w - 8
}
return start, width
}
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 vaxis.Event) bool {
switch event := event.(type) {
case vaxis.Key:
switch {
case event.Matches(vaxis.KeyEnter):
gp.selector.Focus(false)
gp.callback(gp.selector.Selected(), nil)
case event.Matches(vaxis.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)
}
+85
View File
@@ -0,0 +1,85 @@
package app
import (
"strings"
"sync/atomic"
"time"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
)
type Spinner struct {
frame int64 // access via atomic
frames []string
interval time.Duration
stop chan struct{}
style vaxis.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()
}
+165
View File
@@ -0,0 +1,165 @@
package app
import (
"bytes"
"sync"
"time"
"github.com/mattn/go-runewidth"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/state"
"git.sr.ht/~rjarry/aerc/lib/templates"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
)
type StatusLine struct {
sync.Mutex
stack []*StatusMessage
acct *AccountView
err string
}
type StatusMessage struct {
style vaxis.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.acct != nil:
data := state.NewDataSetter()
data.SetPendingKeys(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)
data.SetRUE(status.acct.dirlist.List(), status.acct.dirlist.GetRUECount)
if store := status.acct.Store(); store != nil {
data.SetVisual(store.Marker().IsVisualMark())
}
table := ui.NewTable(
ctx.Height(),
config.Statusline.StatusColumns,
config.Statusline.ColumnSeparator,
nil,
func(*ui.Table, int) vaxis.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 SelectedAccountUiConfig()
}
func (msg *StatusMessage) Color(style vaxis.Style) {
msg.style = style
}
+177
View File
@@ -0,0 +1,177 @@
package app
import (
"os/exec"
"sync/atomic"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/ui"
"git.sr.ht/~rockorager/vaxis"
"git.sr.ht/~rockorager/vaxis/widgets/term"
)
type HasTerminal interface {
Terminal() *Terminal
}
type Terminal struct {
closed int32
visible int32 // visible if >0
cmd *exec.Cmd
ctx *ui.Context
focus bool
vterm *term.Model
running bool
OnClose func(err error)
OnEvent func(event vaxis.Event) bool
OnStart func()
OnTitle func(title string)
}
func NewTerminal(cmd *exec.Cmd) (*Terminal, error) {
term := &Terminal{
cmd: cmd,
vterm: term.New(),
visible: 1,
}
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.ctx != nil {
term.ctx.HideCursor()
}
}
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) {
if ctx.Width() == 0 || ctx.Height() == 0 {
return
}
term.ctx = ctx
if !term.running && term.cmd != nil {
term.vterm.Attach(term.HandleEvent)
w, h := ctx.Window().Size()
if err := term.vterm.StartWithSize(term.cmd, w, h); 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(ctx.Window())
}
func (term *Terminal) Show(visible bool) {
if visible {
atomic.StoreInt32(&term.visible, 1)
} else {
atomic.StoreInt32(&term.visible, 0)
}
}
func (term *Terminal) Terminal() *Terminal {
return term
}
func (term *Terminal) MouseEvent(localX int, localY int, event vaxis.Event) {
ev, ok := event.(vaxis.Mouse)
if !ok {
return
}
if term.OnEvent != nil {
term.OnEvent(ev)
}
if term.isClosed() {
return
}
ev.Row = localY
ev.Col = localX
term.vterm.Update(ev)
}
func (term *Terminal) Focus(focus bool) {
if term.isClosed() {
return
}
term.focus = focus
if term.focus {
term.vterm.Focus()
} else {
term.vterm.Blur()
}
}
// HandleEvent is used to watch the underlying terminal events
func (t *Terminal) HandleEvent(ev vaxis.Event) {
if t.isClosed() {
return
}
switch ev := ev.(type) {
case vaxis.Redraw:
if atomic.LoadInt32(&t.visible) > 0 {
ui.Invalidate()
}
case term.EventTitle:
if t.OnTitle != nil {
t.OnTitle(string(ev))
}
case term.EventClosed:
t.Close()
ui.Invalidate()
case term.EventBell:
aerc.Beep()
}
}
func (term *Terminal) Event(event vaxis.Event) bool {
if term.OnEvent != nil {
if term.OnEvent(event) {
return true
}
}
if term.isClosed() {
return false
}
term.vterm.Update(event)
return true
}