init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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() {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user