summary refs log tree commit diff stats
path: root/widgets
diff options
context:
space:
mode:
authorDrew DeVault <sir@cmpwn.com>2019-05-21 16:31:04 -0400
committerDrew DeVault <sir@cmpwn.com>2019-05-21 16:53:50 -0400
commit6811143925384ba1cfda8b3e1b338b0cfb9ac6e3 (patch)
tree584ce88b40ab87828d6adbfdb6bef7a5d3046600 /widgets
parent176245208d40a9ca2ec324be7863a22819de29bc (diff)
downloadaerc-6811143925384ba1cfda8b3e1b338b0cfb9ac6e3.tar.gz
New account wizard, part one
Diffstat (limited to 'widgets')
-rw-r--r--widgets/account-wizard.go625
-rw-r--r--widgets/aerc.go2
2 files changed, 627 insertions, 0 deletions
diff --git a/widgets/account-wizard.go b/widgets/account-wizard.go
new file mode 100644
index 0000000..3ce207c
--- /dev/null
+++ b/widgets/account-wizard.go
@@ -0,0 +1,625 @@
+package widgets
+
+import (
+	"net/url"
+	"strings"
+
+	"github.com/gdamore/tcell"
+
+	"git.sr.ht/~sircmpwn/aerc/lib/ui"
+)
+
+const (
+	CONFIGURE_BASICS   = iota
+	CONFIGURE_INCOMING = iota
+	CONFIGURE_OUTGOING = iota
+	CONFIGURE_COMPLETE = iota
+)
+
+const (
+	IMAP_OVER_TLS = iota
+	IMAP_STARTTLS = iota
+	IMAP_INSECURE = iota
+)
+
+const (
+	SMTP_OVER_TLS = iota
+	SMTP_STARTTLS = iota
+	SMTP_INSECURE = iota
+)
+
+type AccountWizard struct {
+	ui.Invalidatable
+	step    int
+	steps   []*ui.Grid
+	focus   int
+	testing bool
+	// CONFIGURE_BASICS
+	accountName *ui.TextInput
+	email       *ui.TextInput
+	fullName    *ui.TextInput
+	basics      []ui.Interactive
+	// CONFIGURE_INCOMING
+	imapUsername *ui.TextInput
+	imapPassword *ui.TextInput
+	imapServer   *ui.TextInput
+	imapMode     int
+	imapStr      *ui.Text
+	imapUrl      url.URL
+	incoming     []ui.Interactive
+	// CONFIGURE_OUTGOING
+	smtpUsername *ui.TextInput
+	smtpPassword *ui.TextInput
+	smtpServer   *ui.TextInput
+	smtpMode     int
+	smtpStr      *ui.Text
+	smtpUrl      url.URL
+	copySent     bool
+	outgoing     []ui.Interactive
+	// CONFIGURE_COMPLETE
+	complete []ui.Interactive
+}
+
+func NewAccountWizard() *AccountWizard {
+	wizard := &AccountWizard{
+		accountName:  ui.NewTextInput("").Prompt("> "),
+		email:        ui.NewTextInput("").Prompt("> "),
+		fullName:     ui.NewTextInput("").Prompt("> "),
+		imapUsername: ui.NewTextInput("").Prompt("> "),
+		imapPassword: ui.NewTextInput("").Prompt("] ").Password(true),
+		imapServer:   ui.NewTextInput("").Prompt("> "),
+		imapStr:      ui.NewText("imaps://"),
+		smtpUsername: ui.NewTextInput("").Prompt("> "),
+		smtpPassword: ui.NewTextInput("").Prompt("] ").Password(true),
+		smtpServer:   ui.NewTextInput("").Prompt("> "),
+		smtpStr:      ui.NewText("smtps://"),
+		copySent:     true,
+	}
+
+	// Autofill some stuff for the user
+	wizard.email.OnChange(func(_ *ui.TextInput) {
+		value := wizard.email.String()
+		wizard.imapUsername.Set(value)
+		wizard.smtpUsername.Set(value)
+		if strings.ContainsRune(value, '@') {
+			server := value[strings.IndexRune(value, '@')+1:]
+			wizard.imapServer.Set(server)
+			wizard.smtpServer.Set(server)
+		}
+		wizard.imapUri()
+		wizard.smtpUri()
+	})
+	wizard.imapServer.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpServer.Set(wizard.imapServer.String())
+		wizard.imapUri()
+		wizard.smtpUri()
+	})
+	wizard.imapUsername.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpUsername.Set(wizard.imapUsername.String())
+		wizard.imapUri()
+		wizard.smtpUri()
+	})
+	wizard.imapPassword.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpPassword.Set(wizard.imapPassword.String())
+		wizard.imapUri()
+		wizard.smtpUri()
+	})
+	wizard.smtpServer.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpUri()
+	})
+	wizard.smtpUsername.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpUri()
+	})
+	wizard.smtpPassword.OnChange(func(_ *ui.TextInput) {
+		wizard.smtpUri()
+	})
+
+	basics := ui.NewGrid().Rows([]ui.GridSpec{
+		{ui.SIZE_EXACT, 8}, // Introduction
+		{ui.SIZE_EXACT, 1}, // Account name (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Full name (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Email address (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_WEIGHT, 1},
+	}).Columns([]ui.GridSpec{
+		{ui.SIZE_WEIGHT, 1},
+	})
+	basics.AddChild(
+		ui.NewText("\nWelcome to aerc! Let's configure your account.\n\n" +
+			"This wizard supports basic IMAP & SMTP configuration.\n" +
+			"For other configurations, use <Ctrl+q> to exit and read the " +
+			"aerc-config(5) man page.\n" +
+			"Press <Tab> to cycle between each field in this form, or <Ctrl+k> and <Ctrl+j>."))
+	basics.AddChild(
+		ui.NewText("Name for this account? (e.g. 'Personal' or 'Work')").
+			Bold(true)).
+		At(1, 0)
+	basics.AddChild(wizard.accountName).
+		At(2, 0)
+	basics.AddChild(ui.NewFill(' ')).
+		At(3, 0)
+	basics.AddChild(
+		ui.NewText("Full name for outgoing emails? (e.g. 'John Doe')").
+			Bold(true)).
+		At(4, 0)
+	basics.AddChild(wizard.fullName).
+		At(5, 0)
+	basics.AddChild(ui.NewFill(' ')).
+		At(6, 0)
+	basics.AddChild(
+		ui.NewText("Your email address? (e.g. 'john@example.org')").Bold(true)).
+		At(7, 0)
+	basics.AddChild(wizard.email).
+		At(8, 0)
+	selecter := newSelecter([]string{"Next"}, 0).
+		OnChoose(wizard.advance)
+	basics.AddChild(selecter).At(9, 0)
+	wizard.basics = []ui.Interactive{
+		wizard.accountName, wizard.fullName, wizard.email, selecter,
+	}
+	basics.OnInvalidate(func(_ ui.Drawable) {
+		wizard.Invalidate()
+	})
+
+	incoming := ui.NewGrid().Rows([]ui.GridSpec{
+		{ui.SIZE_EXACT, 3}, // Introduction
+		{ui.SIZE_EXACT, 1}, // Username (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Password (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Server (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Connection mode (label)
+		{ui.SIZE_EXACT, 2}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 2}, // Connection string
+		{ui.SIZE_WEIGHT, 1},
+	}).Columns([]ui.GridSpec{
+		{ui.SIZE_WEIGHT, 1},
+	})
+	incoming.AddChild(ui.NewText("\nConfigure incoming mail (IMAP)"))
+	incoming.AddChild(
+		ui.NewText("Username").Bold(true)).
+		At(1, 0)
+	incoming.AddChild(wizard.imapUsername).
+		At(2, 0)
+	incoming.AddChild(ui.NewFill(' ')).
+		At(3, 0)
+	incoming.AddChild(
+		ui.NewText("Password").Bold(true)).
+		At(4, 0)
+	incoming.AddChild(wizard.imapPassword).
+		At(5, 0)
+	incoming.AddChild(ui.NewFill(' ')).
+		At(6, 0)
+	incoming.AddChild(
+		ui.NewText("Server address "+
+			"(e.g. 'mail.example.org' or 'mail.example.org:1313')").Bold(true)).
+		At(7, 0)
+	incoming.AddChild(wizard.imapServer).
+		At(8, 0)
+	incoming.AddChild(ui.NewFill(' ')).
+		At(9, 0)
+	incoming.AddChild(
+		ui.NewText("Connection mode").Bold(true)).
+		At(10, 0)
+	imapMode := newSelecter([]string{
+		"IMAP over SSL/TLS",
+		"IMAP with STARTTLS",
+		"Insecure IMAP",
+	}, 0).Chooser(true).OnSelect(func(option string) {
+		switch option {
+		case "IMAP over SSL/TLS":
+			wizard.imapMode = IMAP_OVER_TLS
+		case "IMAP with STARTTLS":
+			wizard.imapMode = IMAP_STARTTLS
+		case "Insecure IMAP":
+			wizard.imapMode = IMAP_INSECURE
+		}
+		wizard.imapUri()
+	})
+	incoming.AddChild(imapMode).At(11, 0)
+	selecter = newSelecter([]string{"Previous", "Next"}, 1).
+		OnChoose(wizard.advance)
+	incoming.AddChild(ui.NewFill(' ')).At(12, 0)
+	incoming.AddChild(wizard.imapStr).At(13, 0)
+	incoming.AddChild(selecter).At(14, 0)
+	wizard.incoming = []ui.Interactive{
+		wizard.imapUsername, wizard.imapPassword, wizard.imapServer,
+		imapMode, selecter,
+	}
+	incoming.OnInvalidate(func(_ ui.Drawable) {
+		wizard.Invalidate()
+	})
+
+	outgoing := ui.NewGrid().Rows([]ui.GridSpec{
+		{ui.SIZE_EXACT, 3}, // Introduction
+		{ui.SIZE_EXACT, 1}, // Username (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Password (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Server (label)
+		{ui.SIZE_EXACT, 1}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Connection mode (label)
+		{ui.SIZE_EXACT, 2}, // (input)
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Connection string
+		{ui.SIZE_EXACT, 1}, // Padding
+		{ui.SIZE_EXACT, 1}, // Copy to sent (label)
+		{ui.SIZE_EXACT, 2}, // (input)
+		{ui.SIZE_WEIGHT, 1},
+	}).Columns([]ui.GridSpec{
+		{ui.SIZE_WEIGHT, 1},
+	})
+	outgoing.AddChild(ui.NewText("\nConfigure outgoing mail (SMTP)"))
+	outgoing.AddChild(
+		ui.NewText("Username").Bold(true)).
+		At(1, 0)
+	outgoing.AddChild(wizard.smtpUsername).
+		At(2, 0)
+	outgoing.AddChild(ui.NewFill(' ')).
+		At(3, 0)
+	outgoing.AddChild(
+		ui.NewText("Password").Bold(true)).
+		At(4, 0)
+	outgoing.AddChild(wizard.smtpPassword).
+		At(5, 0)
+	outgoing.AddChild(ui.NewFill(' ')).
+		At(6, 0)
+	outgoing.AddChild(
+		ui.NewText("Server address "+
+			"(e.g. 'mail.example.org' or 'mail.example.org:1313')").Bold(true)).
+		At(7, 0)
+	outgoing.AddChild(wizard.smtpServer).
+		At(8, 0)
+	outgoing.AddChild(ui.NewFill(' ')).
+		At(9, 0)
+	outgoing.AddChild(
+		ui.NewText("Connection mode").Bold(true)).
+		At(10, 0)
+	smtpMode := newSelecter([]string{
+		"SMTP over SSL/TLS",
+		"SMTP with STARTTLS",
+		"Insecure SMTP",
+	}, 0).Chooser(true).OnSelect(func(option string) {
+		switch option {
+		case "SMTP over SSL/TLS":
+			wizard.smtpMode = SMTP_OVER_TLS
+		case "SMTP with STARTTLS":
+			wizard.smtpMode = SMTP_STARTTLS
+		case "Insecure SMTP":
+			wizard.smtpMode = SMTP_INSECURE
+		}
+		wizard.smtpUri()
+	})
+	outgoing.AddChild(smtpMode).At(11, 0)
+	selecter = newSelecter([]string{"Previous", "Next"}, 1).
+		OnChoose(wizard.advance)
+	outgoing.AddChild(ui.NewFill(' ')).At(12, 0)
+	outgoing.AddChild(wizard.smtpStr).At(13, 0)
+	outgoing.AddChild(ui.NewFill(' ')).At(14, 0)
+	outgoing.AddChild(
+		ui.NewText("Copy sent messages to 'Sent' folder?").Bold(true)).
+		At(15, 0)
+	copySent := newSelecter([]string{"Yes", "No"}, 0).
+		Chooser(true).OnChoose(func(option string) {
+		switch option {
+		case "Yes":
+			wizard.copySent = true
+		case "No":
+			wizard.copySent = false
+		}
+	})
+	outgoing.AddChild(copySent).At(16, 0)
+	outgoing.AddChild(selecter).At(17, 0)
+	wizard.outgoing = []ui.Interactive{
+		wizard.smtpUsername, wizard.smtpPassword, wizard.smtpServer,
+		smtpMode, copySent, selecter,
+	}
+	outgoing.OnInvalidate(func(_ ui.Drawable) {
+		wizard.Invalidate()
+	})
+
+	complete := ui.NewGrid().Rows([]ui.GridSpec{
+		{ui.SIZE_EXACT, 7},  // Introduction
+		{ui.SIZE_WEIGHT, 1}, // Previous / Finish / Finish & open tutorial
+	}).Columns([]ui.GridSpec{
+		{ui.SIZE_WEIGHT, 1},
+	})
+	complete.AddChild(ui.NewText(
+		"\nConfiguration complete!\n\n" +
+			"You can go back and double check your settings, or choose 'Finish' to\n" +
+			"save your settings to accounts.conf.\n\n" +
+			"To add another account in the future, run ':new-account'."))
+	selecter = newSelecter([]string{
+		"Previous",
+		"Finish",
+		"Finish & open tutorial",
+	}, 1).OnChoose(func(option string) {
+		switch option {
+		case "Previous":
+			wizard.advance("Previous")
+		case "Finish & open tutorial":
+			// TODO
+			fallthrough
+		case "Finish":
+			// TODO
+		}
+	})
+	complete.AddChild(selecter).At(1, 0)
+	wizard.complete = []ui.Interactive{selecter}
+	complete.OnInvalidate(func(_ ui.Drawable) {
+		wizard.Invalidate()
+	})
+
+	wizard.steps = []*ui.Grid{basics, incoming, outgoing, complete}
+	return wizard
+}
+
+func (wizard *AccountWizard) imapUri() url.URL {
+	host := wizard.imapServer.String()
+	user := wizard.imapUsername.String()
+	pass := wizard.imapPassword.String()
+	var scheme string
+	switch wizard.imapMode {
+	case IMAP_OVER_TLS:
+		scheme = "imaps"
+	case IMAP_STARTTLS:
+		scheme = "imap"
+	case IMAP_INSECURE:
+		scheme = "imap+insecure"
+	}
+	var (
+		userpass   *url.Userinfo
+		userwopass *url.Userinfo
+	)
+	if pass == "" {
+		userpass = url.User(user)
+		userwopass = userpass
+	} else {
+		userpass = url.UserPassword(user, pass)
+		userwopass = url.UserPassword(user, strings.Repeat("*", len(pass)))
+	}
+	uri := url.URL{
+		Scheme: scheme,
+		Host:   host,
+		User:   userpass,
+	}
+	clean := url.URL{
+		Scheme: scheme,
+		Host:   host,
+		User:   userwopass,
+	}
+	wizard.imapStr.Text("Connection URL: " +
+		strings.ReplaceAll(clean.String(), "%2A", "*"))
+	wizard.imapUrl = uri
+	return uri
+}
+
+func (wizard *AccountWizard) smtpUri() url.URL {
+	host := wizard.smtpServer.String()
+	user := wizard.smtpUsername.String()
+	pass := wizard.smtpPassword.String()
+	var scheme string
+	switch wizard.smtpMode {
+	case SMTP_OVER_TLS:
+		scheme = "smtps+plain"
+	case SMTP_STARTTLS:
+		scheme = "smtp+plain"
+	case SMTP_INSECURE:
+		scheme = "smtp+plain"
+	}
+	var (
+		userpass   *url.Userinfo
+		userwopass *url.Userinfo
+	)
+	if pass == "" {
+		userpass = url.User(user)
+		userwopass = userpass
+	} else {
+		userpass = url.UserPassword(user, pass)
+		userwopass = url.UserPassword(user, strings.Repeat("*", len(pass)))
+	}
+	uri := url.URL{
+		Scheme: scheme,
+		Host:   host,
+		User:   userpass,
+	}
+	clean := url.URL{
+		Scheme: scheme,
+		Host:   host,
+		User:   userwopass,
+	}
+	wizard.smtpStr.Text("Connection URL: " +
+		strings.ReplaceAll(clean.String(), "%2A", "*"))
+	wizard.smtpUrl = uri
+	return uri
+}
+
+func (wizard *AccountWizard) Invalidate() {
+	wizard.DoInvalidate(wizard)
+}
+
+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_INCOMING:
+		return wizard.incoming
+	case CONFIGURE_OUTGOING:
+		return wizard.outgoing
+	case CONFIGURE_COMPLETE:
+		return wizard.complete
+	}
+	return nil
+}
+
+func (wizard *AccountWizard) advance(direction string) {
+	wizard.Focus(false)
+	if direction == "Next" && wizard.step < len(wizard.steps)-1 {
+		wizard.step++
+	}
+	if direction == "Previous" && wizard.step > 0 {
+		wizard.step--
+	}
+	wizard.focus = 0
+	wizard.Focus(true)
+	wizard.Invalidate()
+}
+
+func (wizard *AccountWizard) Focus(focus bool) {
+	if interactive := wizard.getInteractive(); interactive != nil {
+		interactive[wizard.focus].Focus(focus)
+	}
+}
+
+func (wizard *AccountWizard) Event(event tcell.Event) bool {
+	interactive := wizard.getInteractive()
+	switch event := event.(type) {
+	case *tcell.EventKey:
+		switch event.Key() {
+		case tcell.KeyUp:
+			fallthrough
+		case tcell.KeyCtrlK:
+			if interactive != nil {
+				interactive[wizard.focus].Focus(false)
+				wizard.focus--
+				if wizard.focus < 0 {
+					wizard.focus = len(interactive) - 1
+				}
+				interactive[wizard.focus].Focus(true)
+			}
+			wizard.Invalidate()
+			return true
+		case tcell.KeyDown:
+			fallthrough
+		case tcell.KeyTab:
+			fallthrough
+		case tcell.KeyCtrlJ:
+			if interactive != nil {
+				interactive[wizard.focus].Focus(false)
+				wizard.focus++
+				if wizard.focus >= len(interactive) {
+					wizard.focus = 0
+				}
+				interactive[wizard.focus].Focus(true)
+			}
+			wizard.Invalidate()
+			return true
+		}
+	}
+	if interactive != nil {
+		return interactive[wizard.focus].Event(event)
+	}
+	return false
+}
+
+type selecter struct {
+	ui.Invalidatable
+	chooser bool
+	focused bool
+	focus   int
+	options []string
+
+	onChoose func(option string)
+	onSelect func(option string)
+}
+
+func newSelecter(options []string, focus int) *selecter {
+	return &selecter{
+		focus:   focus,
+		options: options,
+	}
+}
+
+func (sel *selecter) Chooser(chooser bool) *selecter {
+	sel.chooser = chooser
+	return sel
+}
+
+func (sel *selecter) Invalidate() {
+	sel.DoInvalidate(sel)
+}
+
+func (sel *selecter) Draw(ctx *ui.Context) {
+	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
+	x := 2
+	for i, option := range sel.options {
+		style := tcell.StyleDefault
+		if sel.focus == i {
+			if sel.focused {
+				style = style.Reverse(true)
+			} else if sel.chooser {
+				style = style.Bold(true)
+			}
+		}
+		x += ctx.Printf(x, 1, style, "[%s]", option)
+		x += 5
+	}
+}
+
+func (sel *selecter) OnChoose(fn func(option string)) *selecter {
+	sel.onChoose = fn
+	return sel
+}
+
+func (sel *selecter) OnSelect(fn func(option string)) *selecter {
+	sel.onSelect = fn
+	return sel
+}
+
+func (sel *selecter) Selected() string {
+	return sel.options[sel.focus]
+}
+
+func (sel *selecter) Focus(focus bool) {
+	sel.focused = focus
+	sel.Invalidate()
+}
+
+func (sel *selecter) Event(event tcell.Event) bool {
+	switch event := event.(type) {
+	case *tcell.EventKey:
+		switch event.Key() {
+		case tcell.KeyCtrlH:
+			fallthrough
+		case tcell.KeyLeft:
+			if sel.focus > 0 {
+				sel.focus--
+				sel.Invalidate()
+			}
+			if sel.onSelect != nil {
+				sel.onSelect(sel.Selected())
+			}
+		case tcell.KeyCtrlL:
+			fallthrough
+		case tcell.KeyRight:
+			if sel.focus < len(sel.options)-1 {
+				sel.focus++
+				sel.Invalidate()
+			}
+			if sel.onSelect != nil {
+				sel.onSelect(sel.Selected())
+			}
+		case tcell.KeyEnter:
+			if sel.onChoose != nil {
+				sel.onChoose(sel.Selected())
+			}
+		}
+	}
+	return false
+}
diff --git a/widgets/aerc.go b/widgets/aerc.go
index 187eddb..eba76a2 100644
--- a/widgets/aerc.go
+++ b/widgets/aerc.go
@@ -99,6 +99,8 @@ func (aerc *Aerc) getBindings() *config.KeyBindings {
 	switch view := aerc.SelectedTab().(type) {
 	case *AccountView:
 		return aerc.conf.Bindings.MessageList
+	case *AccountWizard:
+		return aerc.conf.Bindings.AccountWizard
 	case *Composer:
 		switch view.Bindings() {
 		case "compose::editor":