diff options
| -rw-r--r-- | commands/new-account.go | 20 | ||||
| -rw-r--r-- | config/config.go | 9 | ||||
| -rw-r--r-- | doc/aerc.1.scd | 7 | ||||
| -rw-r--r-- | lib/ui/text.go | 2 | ||||
| -rw-r--r-- | lib/ui/textinput.go | 31 | ||||
| -rw-r--r-- | widgets/account-wizard.go | 625 | ||||
| -rw-r--r-- | widgets/aerc.go | 2 | 
7 files changed, 683 insertions, 13 deletions
| diff --git a/commands/new-account.go b/commands/new-account.go new file mode 100644 index 0000000..6a64eb2 --- /dev/null +++ b/commands/new-account.go @@ -0,0 +1,20 @@ +package commands + +import ( +	"errors" + +	"git.sr.ht/~sircmpwn/aerc/widgets" +) + +func init() { +	register("new-account", CommandNewAccount) +} + +func CommandNewAccount(aerc *widgets.Aerc, args []string) error { +	if len(args) != 1 { +		return errors.New("Usage: new-account") +	} +	wizard := widgets.NewAccountWizard() +	aerc.NewTab(wizard, "New account") +	return nil +} diff --git a/config/config.go b/config/config.go index d885402..c6136cf 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,7 @@ import (  	"strings"  	"unicode" +	"github.com/gdamore/tcell"  	"github.com/go-ini/ini"  	"github.com/kyoh86/xdg"  ) @@ -45,6 +46,7 @@ type AccountConfig struct {  type BindingConfig struct {  	Global        *KeyBindings +	AccountWizard *KeyBindings  	Compose       *KeyBindings  	ComposeEditor *KeyBindings  	ComposeReview *KeyBindings @@ -208,6 +210,7 @@ func LoadConfig(root *string) (*AercConfig, error) {  	config := &AercConfig{  		Bindings: BindingConfig{  			Global:        NewKeyBindings(), +			AccountWizard: NewKeyBindings(),  			Compose:       NewKeyBindings(),  			ComposeEditor: NewKeyBindings(),  			ComposeReview: NewKeyBindings(), @@ -229,6 +232,12 @@ func LoadConfig(root *string) (*AercConfig, error) {  			EmptyMessage:      "(no messages)",  		},  	} +	// These bindings are not configurable +	config.Bindings.AccountWizard.ExKey = KeyStroke{ +		Key: tcell.KeyCtrlE, +	} +	quit, _ := ParseBinding("<C-q>", ":quit<Enter>") +	config.Bindings.AccountWizard.Add(quit)  	if filters, err := file.GetSection("filters"); err == nil {  		// TODO: Parse the filter more finely, e.g. parse the regex  		for _, match := range filters.KeyStrings() { diff --git a/doc/aerc.1.scd b/doc/aerc.1.scd index e96334d..459ec62 100644 --- a/doc/aerc.1.scd +++ b/doc/aerc.1.scd @@ -8,12 +8,13 @@ aerc - the world's best email client  _aerc_ -Starts the interactive aerc mail client on /dev/tty. +For a guided tutorial, use *:help tutorial*.  # RUNTIME COMMANDS -To execute a command, press : to summon the command interface. Commands may also -be bound to keys, see *aerc-config*(5) for details. +To execute a command, press ':' to bring up the command interface. Commands may +also be bound to keys, see *aerc-config*(5) for details. In some contexts, such +as the terminal emulator, ';' is used to bring up the command interface.  Different commands work in different contexts, depending on the kind of tab you  have selected. diff --git a/lib/ui/text.go b/lib/ui/text.go index 8aea8eb..2b82598 100644 --- a/lib/ui/text.go +++ b/lib/ui/text.go @@ -77,7 +77,7 @@ func (t *Text) Draw(ctx *Context) {  		style = style.Reverse(true)  	}  	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style) -	ctx.Printf(x, 0, style, t.text) +	ctx.Printf(x, 0, style, "%s", t.text)  }  func (t *Text) Invalidate() { diff --git a/lib/ui/textinput.go b/lib/ui/textinput.go index 4a5308e..ce443c3 100644 --- a/lib/ui/textinput.go +++ b/lib/ui/textinput.go @@ -10,14 +10,15 @@ import (  type TextInput struct {  	Invalidatable -	cells  int -	ctx    *Context -	focus  bool -	index  int -	prompt string -	scroll int -	text   []rune -	change []func(ti *TextInput) +	cells    int +	ctx      *Context +	focus    bool +	index    int +	password bool +	prompt   string +	scroll   int +	text     []rune +	change   []func(ti *TextInput)  }  // Creates a new TextInput. TextInputs will render a "textbox" in the entire @@ -31,6 +32,11 @@ func NewTextInput(text string) *TextInput {  	}  } +func (ti *TextInput) Password(password bool) *TextInput { +	ti.password = password +	return ti +} +  func (ti *TextInput) Prompt(prompt string) *TextInput {  	ti.prompt = prompt  	return ti @@ -42,6 +48,7 @@ func (ti *TextInput) String() string {  func (ti *TextInput) Set(value string) {  	ti.text = []rune(value) +	ti.index = len(ti.text)  }  func (ti *TextInput) Invalidate() { @@ -51,7 +58,13 @@ func (ti *TextInput) Invalidate() {  func (ti *TextInput) Draw(ctx *Context) {  	ti.ctx = ctx // gross  	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault) -	ctx.Printf(0, 0, tcell.StyleDefault, "%s%s", ti.prompt, string(ti.text)) +	if ti.password { +		x := ctx.Printf(0, 0, tcell.StyleDefault, "%s", ti.prompt) +		cells := runewidth.StringWidth(string(ti.text)) +		ctx.Fill(x, 0, cells, 1, '*', tcell.StyleDefault) +	} else { +		ctx.Printf(0, 0, tcell.StyleDefault, "%s%s", ti.prompt, string(ti.text)) +	}  	cells := runewidth.StringWidth(string(ti.text[:ti.index]) + ti.prompt)  	if cells != ti.cells && ti.focus {  		ctx.SetCursor(cells, 0) 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": | 
