~sircmpwn/aerc

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
2

[PATCH 1/3] Refactor keystore

Luke Adams
Details
Message ID
<20200908234732.93844-1-luke@lukeclydeadams.com>
DKIM signature
fail
Download raw message
Patch: +12 -2 DKIM signature: fail
Now we can import keys into the keyring that have been generated in
aerc, rather than just reading them off the disk.

TODO: We need some unified way to make changes in the keyring be
reflected on the disk. Maybe the file should be overwritten with the
current contents of the keyring when the file is freed?
---
I am sending a set of three WIP that will add key creation and
management support to aerc. However, before I get too far, I would like
to open a conversation about how these tasks should work in aerc. So
far, I have implemented two commands, :setup-pgp and :manage-keys (names
subject to change). I would appreciate feedback on these, and any other
thought about how encryption should work in aerc.

 lib/keystore.go | 14 ++++++++++++--
 1 file changed, 12 insertions(+), 2 deletions(-)

diff --git a/lib/keystore.go b/lib/keystore.go
index dcdbd74..c6e1c20 100644
--- a/lib/keystore.go
+++ b/lib/keystore.go
@@ -52,15 +52,25 @@ func UnlockKeyring() {
	os.Remove(lockpath)
}

func ImportKeys(r io.Reader) error {
func ImportKeysFromReader(r io.Reader) error {
	keys, err := openpgp.ReadKeyRing(r)
	if err != nil {
		return err
	}
	err = ImportKeys(keys...)
	return err
}

func ImportKeys(keys ...*openpgp.Entity) error {
	Keyring = append(Keyring, keys...)
	err := writeKeysToDisk(keys...)
	return err
}

func writeKeysToDisk(keys ...*openpgp.Entity) error {
	if locked {
		keypath := path.Join(xdg.DataHome(), "aerc", "keyring.asc")
		keyfile, err := os.OpenFile(keypath, os.O_CREATE|os.O_APPEND, 0600)
		keyfile, err := os.OpenFile(keypath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0600)
		if err != nil {
			return err
		}
-- 
2.28.0

[PATCH 2/3] Add the manage-keys command

Luke Adams
Details
Message ID
<20200908234732.93844-2-luke@lukeclydeadams.com>
In-Reply-To
<20200908234732.93844-1-luke@lukeclydeadams.com> (view parent)
DKIM signature
fail
Download raw message
Patch: +173 -0 DKIM signature: fail
In its current state, this is a basic mockup of a UI to manage keys.
The only functionality currently is to view the keyring.

TODO: This UI should be fleshed out to include, at a minimum, the
ability to import and delete keys.
---
 commands/manage-keys.go |  25 +++++++
 widgets/manage-keys.go  | 148 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 173 insertions(+)
 create mode 100644 commands/manage-keys.go
 create mode 100644 widgets/manage-keys.go

diff --git a/commands/manage-keys.go b/commands/manage-keys.go
new file mode 100644
index 0000000..6fc9cd3
--- /dev/null
+++ b/commands/manage-keys.go
@@ -0,0 +1,25 @@
package commands

import (
	"git.sr.ht/~sircmpwn/aerc/widgets"
)

type ManageKeys struct{}

func init() {
	register(ManageKeys{})
}

func (ManageKeys) Aliases() []string {
	return []string{"manage-keys"}
}

func (ManageKeys) Complete(aerc *widgets.Aerc, args []string) []string {
	return nil
}

func (ManageKeys) Execute(aerc *widgets.Aerc, args []string) error {
	manageKeys := widgets.NewManageKeys(aerc.Config(), aerc)
	aerc.NewTab(manageKeys, "Manage Keys")
	return nil
}
diff --git a/widgets/manage-keys.go b/widgets/manage-keys.go
new file mode 100644
index 0000000..8f122d1
--- /dev/null
+++ b/widgets/manage-keys.go
@@ -0,0 +1,148 @@
package widgets

import (
	"strconv"

	"golang.org/x/crypto/openpgp"
	"golang.org/x/crypto/openpgp/packet"

	"github.com/gdamore/tcell"

	"git.sr.ht/~sircmpwn/aerc/config"
	"git.sr.ht/~sircmpwn/aerc/lib"
	"git.sr.ht/~sircmpwn/aerc/lib/ui"
)

type keyView struct {
	key          *openpgp.Entity
	primaryKeyId string
	identities   []*openpgp.Identity
	subkeyIds    []string
}

type ManageKeys struct {
	ui.Invalidatable
	aerc     *Aerc
	conf     *config.AercConfig
	item     int
	focus    bool
	keyViews []*keyView
}

func NewManageKeys(conf *config.AercConfig, aerc *Aerc) *ManageKeys {
	manageKeys := &ManageKeys{
		aerc: aerc,
		conf: conf,
	}

	for _, key := range lib.Keyring {
		keyView := &keyView{
			key:          key,
			primaryKeyId: keyIdString(key.PrimaryKey),
		}

		var identities []*openpgp.Identity
		for _, id := range key.Identities {
			identities = append(identities, id)
		}
		keyView.identities = identities

		var subkeyIds []string
		for _, subkey := range key.Subkeys {
			subkeyIds = append(subkeyIds, keyIdString(subkey.PublicKey))
		}
		keyView.subkeyIds = subkeyIds

		manageKeys.keyViews = append(manageKeys.keyViews, keyView)
	}

	return manageKeys
}

func keyIdString(key *packet.PublicKey) string {
	return decodePublicKeyAlgo(key) + "/" + key.KeyIdString()
}

func decodePublicKeyAlgo(key *packet.PublicKey) string {
	switch key.PubKeyAlgo {
	case packet.PubKeyAlgoRSA:
		size, err := key.BitLength()
		if err != nil {
			return "unknown"
		}
		return "rsa" + strconv.Itoa(int(size))
	}

	return "unknown"
}

func gpgStyleString(keyView *keyView) (string, int) {
	numLines := 1
	str := "pub   " + keyView.primaryKeyId + "\n"

	for _, id := range keyView.identities {
		numLines += 1
		str += "uid   " + id.Name + "\n"
	}

	for _, subkey := range keyView.subkeyIds {
		numLines += 1
		str += "sub   " + subkey + "\n"
	}

	return str, numLines
}

func (manageKeys *ManageKeys) Invalidate() {
	manageKeys.DoInvalidate(manageKeys)
}

func (manageKeys *ManageKeys) Draw(ctx *ui.Context) {
	ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ',
		manageKeys.conf.Ui.GetStyle(config.STYLE_MSGLIST_DEFAULT))
	selectStyle := manageKeys.conf.Ui.GetStyleSelected(config.STYLE_DEFAULT)
	defaultStyle := manageKeys.conf.Ui.GetStyle(config.STYLE_DEFAULT)
	row := 1
	for i, keyView := range manageKeys.keyViews {
		keyStr, numLines := gpgStyleString(keyView)
		style := defaultStyle
		if i == manageKeys.item {
			style = selectStyle
		}
		ctx.Fill(0, row, ctx.Width(), numLines, ' ', style)
		ctx.Printf(0, row, style, "%s", keyStr)
		row += numLines + 1
	}
}

func (manageKeys *ManageKeys) Focus(focus bool) {
	manageKeys.focus = focus
}

func (manageKeys *ManageKeys) Event(event tcell.Event) bool {
	switch event := event.(type) {
	case *tcell.EventKey:
		switch event.Rune() {
		case 'q':
			manageKeys.aerc.RemoveTab(manageKeys)
		case 'j':
			manageKeys.Focus(false)
			manageKeys.item++
			if manageKeys.item >= len(lib.Keyring) {
				manageKeys.item = 0
			}
			manageKeys.Focus(true)
			manageKeys.Invalidate()
		case 'k':
			manageKeys.Focus(false)
			manageKeys.item--
			if manageKeys.item < 0 {
				manageKeys.item = len(lib.Keyring) - 1
			}
			manageKeys.Focus(true)
			manageKeys.Invalidate()
		}
		return true
	}
	return false
}
-- 
2.28.0

[PATCH 3/3] Add an interactive setup-pgp command

Luke Adams
Details
Message ID
<20200908234732.93844-3-luke@lukeclydeadams.com>
In-Reply-To
<20200908234732.93844-1-luke@lukeclydeadams.com> (view parent)
DKIM signature
fail
Download raw message
Patch: +445 -0 DKIM signature: fail
TODO: Support importing keys from GPG
TODO: Figure out how to encrypt newly created keys
---
 commands/setup-pgp.go |  25 +++
 widgets/pgp-wizard.go | 420 ++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 445 insertions(+)
 create mode 100644 commands/setup-pgp.go
 create mode 100644 widgets/pgp-wizard.go

diff --git a/commands/setup-pgp.go b/commands/setup-pgp.go
new file mode 100644
index 0000000..648a98e
--- /dev/null
+++ b/commands/setup-pgp.go
@@ -0,0 +1,25 @@
package commands

import (
	"git.sr.ht/~sircmpwn/aerc/widgets"
)

type SetupPGP struct{}

func init() {
	register(SetupPGP{})
}

func (SetupPGP) Aliases() []string {
	return []string{"setup-pgp"}
}

func (SetupPGP) Complete(aerc *widgets.Aerc, args []string) []string {
	return nil
}

func (SetupPGP) Execute(aerc *widgets.Aerc, args []string) error {
	wizard := widgets.NewPGPSetupWizard(aerc.Config(), aerc)
	aerc.NewTab(wizard, "Setup PGP")
	return nil
}
diff --git a/widgets/pgp-wizard.go b/widgets/pgp-wizard.go
new file mode 100644
index 0000000..db3f251
--- /dev/null
+++ b/widgets/pgp-wizard.go
@@ -0,0 +1,420 @@
package widgets

import (
	"crypto"
	"errors"
	"strconv"

	"golang.org/x/crypto/openpgp"
	"golang.org/x/crypto/openpgp/packet"

	"github.com/gdamore/tcell"

	"git.sr.ht/~sircmpwn/aerc/config"
	"git.sr.ht/~sircmpwn/aerc/lib"
	"git.sr.ht/~sircmpwn/aerc/lib/ui"
)

const (
	PGP_SETUP_INTRO          = iota
	PGP_SETUP_IMPORT         = iota
	PGP_SETUP_CREATE         = iota
	PGP_SETUP_CREATE_DETAILS = iota
	PGP_SETUP_COMPLETE       = iota
)

type PGPSetupWizard struct {
	ui.Invalidatable
	aerc  *Aerc
	conf  *config.AercConfig
	step  int
	steps []*ui.Grid
	focus int

	intro     []ui.Interactive
	gpgimport []ui.Interactive
	// PGP_SETUP_CREATE
	create  []ui.Interactive
	name    *ui.TextInput
	email   *ui.TextInput
	comment *ui.TextInput
	// PGP_SETUP_CREATE_DETAILS
	createDetails  []ui.Interactive
	rsaBits        *ui.TextInput
	cipherFunction packet.CipherFunction
	hash           crypto.Hash

	complete []ui.Interactive
}

func NewPGPSetupWizard(conf *config.AercConfig, aerc *Aerc) *PGPSetupWizard {
	wizard := &PGPSetupWizard{
		aerc:    aerc,
		conf:    conf,
		step:    PGP_SETUP_CREATE,
		rsaBits: ui.NewTextInput("4096", conf.Ui).Prompt("> "),

		name:    ui.NewTextInput("", conf.Ui).Prompt("> "),
		email:   ui.NewTextInput("", conf.Ui).Prompt("> "),
		comment: ui.NewTextInput("", conf.Ui).Prompt("> "),
	}

	intro := ui.NewGrid().Rows([]ui.GridSpec{
		{ui.SIZE_EXACT, ui.Const(9)},
		{ui.SIZE_WEIGHT, ui.Const(1)},
	}).Columns([]ui.GridSpec{
		{ui.SIZE_WEIGHT, ui.Const(1)},
	})
	intro.AddChild(
		ui.NewText("\nWelcome to the PGP setup wizard.\n\n"+
			"aerc manages its own keyring to make PGP support plug-and-play.\n"+
			"Currently, the keyring is stored at ~/.local/share/aerc/keyring.asc,\n"+
			"and private keys must be stored on disk. Support for hardware tokens\n"+
			"will be added later.\n\n"+
			"You can either import your key from GPG, or create a new PGP key.",
			conf.Ui.GetStyle(config.STYLE_DEFAULT)))
	selector := NewSelector([]string{"Import from GPG", "New PGP key"}, 0, conf.Ui).
		OnChoose(func(option string) {
			switch option {
			case "Import from GPG":
				wizard.update_step(PGP_SETUP_IMPORT)
			case "New PGP key":
				wizard.update_step(PGP_SETUP_CREATE)
			}
		})
	intro.AddChild(selector).At(1, 0)
	wizard.intro = []ui.Interactive{selector}
	intro.OnInvalidate(func(_ ui.Drawable) {
		wizard.Invalidate()
	})

	gpgimport := ui.NewGrid().Rows([]ui.GridSpec{
		{ui.SIZE_EXACT, ui.Const(1)},
		{ui.SIZE_WEIGHT, ui.Const(1)},
	}).Columns([]ui.GridSpec{
		{ui.SIZE_WEIGHT, ui.Const(1)},
	})
	gpgimport.AddChild(
		ui.NewText("TODO", conf.Ui.GetStyle(config.STYLE_DEFAULT)))
	selector = NewSelector([]string{"Previous"}, 0, conf.Ui).
		OnChoose(func(option string) {
			wizard.update_step(PGP_SETUP_INTRO)
		})
	gpgimport.AddChild(selector).At(1, 0)
	wizard.gpgimport = []ui.Interactive{selector}
	gpgimport.OnInvalidate(func(_ ui.Drawable) {
		wizard.Invalidate()
	})

	create := ui.NewGrid().Rows([]ui.GridSpec{
		{ui.SIZE_EXACT, ui.Const(6)},  // Introduction
		{ui.SIZE_EXACT, ui.Const(1)},  // Real name (label)
		{ui.SIZE_EXACT, ui.Const(1)},  // (input)
		{ui.SIZE_EXACT, ui.Const(1)},  // Padding
		{ui.SIZE_EXACT, ui.Const(1)},  // Email address (label)
		{ui.SIZE_EXACT, ui.Const(1)},  // (input)
		{ui.SIZE_EXACT, ui.Const(1)},  // Padding
		{ui.SIZE_EXACT, ui.Const(1)},  // Comment (label)
		{ui.SIZE_EXACT, ui.Const(1)},  // (input)
		{ui.SIZE_EXACT, ui.Const(1)},  // Padding
		{ui.SIZE_EXACT, ui.Const(2)},  // Back / Next
		{ui.SIZE_EXACT, ui.Const(1)},  // Padding
		{ui.SIZE_EXACT, ui.Const(1)},  // Padding
		{ui.SIZE_EXACT, ui.Const(1)},  // Edit details (label)
		{ui.SIZE_WEIGHT, ui.Const(1)}, // Edit
	}).Columns([]ui.GridSpec{
		{ui.SIZE_WEIGHT, ui.Const(1)},
	})
	create.AddChild(
		ui.NewText("\nPGP key details.\n\n"+
			"Helpful text here\n"+
			"Continued here\n",
			conf.Ui.GetStyle(config.STYLE_DEFAULT)))
	create.AddChild(
		ui.NewText("Real name",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(1, 0)
	create.AddChild(wizard.name).
		At(2, 0)
	create.AddChild(ui.NewFill(' ')).
		At(3, 0)
	create.AddChild(
		ui.NewText("Email address",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(4, 0)
	create.AddChild(wizard.email).
		At(5, 0)
	create.AddChild(ui.NewFill(' ')).
		At(6, 0)
	create.AddChild(
		ui.NewText("Comment (optional)",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(7, 0)
	create.AddChild(wizard.comment).
		At(8, 0)
	create.AddChild(ui.NewFill(' ')).
		At(9, 0)
	selector = NewSelector([]string{"Back", "Create key"}, 1, conf.Ui).
		OnChoose(func(option string) {
			switch option {
			case "Back":
				wizard.update_step(PGP_SETUP_INTRO)
			case "Create key":
				wizard.createKey()
			}
		})
	create.AddChild(selector).At(10, 0)
	create.AddChild(ui.NewFill(' ')).
		At(11, 0)
	create.AddChild(ui.NewFill('-')).
		At(12, 0)
	create.AddChild(
		ui.NewText("Details follow",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(13, 0)
	detailSelector := NewSelector([]string{"Edit"}, 0, conf.Ui).
		OnChoose(func(option string) {
			wizard.update_step(PGP_SETUP_CREATE_DETAILS)
		})
	create.AddChild(detailSelector).At(14, 0)
	wizard.create = []ui.Interactive{
		wizard.name, wizard.email, wizard.comment, selector, detailSelector,
	}
	create.OnInvalidate(func(_ ui.Drawable) {
		wizard.Invalidate()
	})

	createDetails := ui.NewGrid().Rows([]ui.GridSpec{
		{ui.SIZE_EXACT, ui.Const(6)}, // Introduction
		{ui.SIZE_EXACT, ui.Const(1)}, // RSA key size (label)
		{ui.SIZE_EXACT, ui.Const(1)}, // (input)
		{ui.SIZE_EXACT, ui.Const(1)}, // Padding
		{ui.SIZE_EXACT, ui.Const(1)}, // Encryption algorithm (label)
		{ui.SIZE_EXACT, ui.Const(2)}, // (input)
		{ui.SIZE_EXACT, ui.Const(1)}, // Padding
		{ui.SIZE_EXACT, ui.Const(1)}, // Hash algorithm (label)
		{ui.SIZE_EXACT, ui.Const(2)}, // (input)
		{ui.SIZE_EXACT, ui.Const(1)}, // Padding
		{ui.SIZE_WEIGHT, ui.Const(1)},
	}).Columns([]ui.GridSpec{
		{ui.SIZE_WEIGHT, ui.Const(1)},
	})
	createDetails.AddChild(
		ui.NewText("\nCreating a PGP key\n\n"+
			"Helpful text here\n"+
			"Continued here\n",
			conf.Ui.GetStyle(config.STYLE_DEFAULT)))
	createDetails.AddChild(
		ui.NewText("RSA key size",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(1, 0)
	createDetails.AddChild(wizard.rsaBits).
		At(2, 0)
	createDetails.AddChild(ui.NewFill(' ')).
		At(3, 0)
	createDetails.AddChild(
		ui.NewText("Encryption algorithm",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(4, 0)
	cypherSelector := NewSelector([]string{
		"3DES",
		"CAST5",
		"AES128",
		"AES192",
		"AES256",
	}, 4, conf.Ui).Chooser(true).OnSelect(func(option string) {
		switch option {
		case "3DES":
			wizard.cipherFunction = packet.Cipher3DES
		case "AST5":
			wizard.cipherFunction = packet.CipherCAST5
		case "ES128":
			wizard.cipherFunction = packet.CipherAES128
		case "ES192":
			wizard.cipherFunction = packet.CipherAES192
		case "ES256":
			wizard.cipherFunction = packet.CipherAES256
		}
	})
	createDetails.AddChild(cypherSelector).At(5, 0)
	createDetails.AddChild(ui.NewFill(' ')).
		At(6, 0)
	createDetails.AddChild(
		ui.NewText("Hash Algorithm",
			conf.Ui.GetStyle(config.STYLE_HEADER))).
		At(7, 0)
	hashSelector := NewSelector([]string{
		"SHA1",
		"SHA256",
		"SHA512",
	}, 2, conf.Ui).Chooser(true).OnSelect(func(option string) {
		switch option {
		case "SHA1":
			wizard.hash = crypto.SHA1
		case "SHA256":
			wizard.hash = crypto.SHA256
		case "SHA512":
			wizard.hash = crypto.SHA512
		}
	})
	createDetails.AddChild(hashSelector).At(8, 0)
	createDetails.AddChild(ui.NewFill(' ')).
		At(9, 0)
	selector = NewSelector([]string{"Done"}, 0, conf.Ui).
		OnChoose(func(option string) {
			wizard.update_step(PGP_SETUP_CREATE)
		})
	createDetails.AddChild(selector).At(10, 0)
	wizard.createDetails = []ui.Interactive{
		wizard.rsaBits, cypherSelector, hashSelector, selector,
	}
	createDetails.OnInvalidate(func(_ ui.Drawable) {
		wizard.Invalidate()
	})

	complete := ui.NewGrid().Rows([]ui.GridSpec{
		{ui.SIZE_EXACT, ui.Const(7)},
		{ui.SIZE_WEIGHT, ui.Const(1)},
	}).Columns([]ui.GridSpec{
		{ui.SIZE_WEIGHT, ui.Const(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'.",
		conf.Ui.GetStyle(config.STYLE_DEFAULT)))
	selector = NewSelector([]string{"Previous"}, 0, conf.Ui).
		OnChoose(func(option string) {
			wizard.update_step(PGP_SETUP_INTRO)
		})
	complete.AddChild(selector).At(1, 0)
	wizard.complete = []ui.Interactive{selector}
	complete.OnInvalidate(func(_ ui.Drawable) {
		wizard.Invalidate()
	})

	wizard.steps = []*ui.Grid{intro, gpgimport, create, createDetails, complete}
	return wizard
}

func (wizard *PGPSetupWizard) createKey() {
	rsaBitsNumeric, err := strconv.Atoi(wizard.rsaBits.String())
	if err != nil {
		rsaBitsNumeric = 4096
	}
	config := &packet.Config{
		RSABits:       rsaBitsNumeric,
		DefaultCipher: wizard.cipherFunction,
		DefaultHash:   wizard.hash,
	}

	key, err := openpgp.NewEntity(
		wizard.name.String(),
		wizard.email.String(),
		wizard.comment.String(),
		config,
	)
	if err != nil {
		panic(err)
	}

	for _, id := range key.Identities {
		err := id.SelfSignature.SignUserId(id.UserId.Id, key.PrimaryKey,
			key.PrivateKey, config)
		if err != nil {
			panic(err)
		}
	}

	err = lib.ImportKeys(key)
	if err != nil {
		panic(err)
	}
	wizard.aerc.RemoveTab(wizard)
}

func (wizard *PGPSetupWizard) Invalidate() {
	wizard.DoInvalidate(wizard)
}

func (wizard *PGPSetupWizard) Draw(ctx *ui.Context) {
	wizard.steps[wizard.step].Draw(ctx)
}

func (wizard *PGPSetupWizard) getInteractive() []ui.Interactive {
	switch wizard.step {
	case PGP_SETUP_INTRO:
		return wizard.intro
	case PGP_SETUP_IMPORT:
		return wizard.gpgimport
	case PGP_SETUP_CREATE:
		return wizard.create
	case PGP_SETUP_CREATE_DETAILS:
		return wizard.createDetails
	case PGP_SETUP_COMPLETE:
		return wizard.complete
	}
	return nil
}

func (wizard *PGPSetupWizard) update_step(step int) error {
	wizard.Focus(false)
	if step < 0 || step >= len(wizard.steps) {
		return errors.New("step out of bounds")
	}
	wizard.step = step
	wizard.focus = 0
	wizard.Focus(true)
	wizard.Invalidate()
	return nil
}

func (wizard *PGPSetupWizard) Focus(focus bool) {
	if interactive := wizard.getInteractive(); interactive != nil {
		interactive[wizard.focus].Focus(focus)
	}
}

func (wizard *PGPSetupWizard) Event(event tcell.Event) bool {
	interactive := wizard.getInteractive()
	switch event := event.(type) {
	case *tcell.EventKey:
		switch event.Key() {
		case tcell.KeyUp:
			fallthrough
		case tcell.KeyBacktab:
			fallthrough
		case tcell.KeyCtrlK:
			if interactive != nil {
				interactive[wizard.focus].Focus(false)
				wizard.focus--
				if wizard.focus < 0 {
					wizard.focus = len(interactive) - 1
				}
				interactive[wizard.focus].Focus(true)
			}
			wizard.Invalidate()
			return true
		case tcell.KeyDown:
			fallthrough
		case tcell.KeyTab:
			fallthrough
		case tcell.KeyCtrlJ:
			if interactive != nil {
				interactive[wizard.focus].Focus(false)
				wizard.focus++
				if wizard.focus >= len(interactive) {
					wizard.focus = 0
				}
				interactive[wizard.focus].Focus(true)
			}
			wizard.Invalidate()
			return true
		}
	}
	if interactive != nil {
		return interactive[wizard.focus].Event(event)
	}
	return false
}
-- 
2.28.0
Review patch Export thread (mbox)