~sircmpwn/aerc

4bdef7d8609aa2d382fa74018e28ccb176276615 — Greg Anders 1 year, 2 months ago 809083f
Add UI options to save/pipe messages with unsupported mimetypes

Adds a message indicating the user's ability to :save or :pipe a message
with an unsupported mimetype and also adds a selector widget (similar to
the tutorial).

The selector widget was previously defined in the account wizard module,
so this commit breaks it out into its own module to allow for re-use.

Further, modify the BeginExLine() function to take an argument that
pre-populates the command line, allowing functions to initiate an ex
command without executing it.

Closes #95.
M lib/ui/textinput.go => lib/ui/textinput.go +2 -1
@@ 63,9 63,10 @@ func (ti *TextInput) StringRight() string {
	return string(ti.text[ti.index:])
}

func (ti *TextInput) Set(value string) {
func (ti *TextInput) Set(value string) *TextInput {
	ti.text = []rune(value)
	ti.index = len(ti.text)
	return ti
}

func (ti *TextInput) Invalidate() {

M widgets/account-wizard.go => widgets/account-wizard.go +7 -103
@@ 177,7 177,7 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
		At(7, 0)
	basics.AddChild(wizard.email).
		At(8, 0)
	selecter := newSelecter([]string{"Next"}, 0).
	selecter := NewSelecter([]string{"Next"}, 0).
		OnChoose(func(option string) {
			email := wizard.email.String()
			if strings.ContainsRune(email, '@') {


@@ 254,7 254,7 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
	incoming.AddChild(
		ui.NewText("Connection mode").Bold(true)).
		At(10, 0)
	imapMode := newSelecter([]string{
	imapMode := NewSelecter([]string{
		"IMAP over SSL/TLS",
		"IMAP with STARTTLS",
		"Insecure IMAP",


@@ 270,7 270,7 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
		wizard.imapUri()
	})
	incoming.AddChild(imapMode).At(11, 0)
	selecter = newSelecter([]string{"Previous", "Next"}, 1).
	selecter = NewSelecter([]string{"Previous", "Next"}, 1).
		OnChoose(wizard.advance)
	incoming.AddChild(ui.NewFill(' ')).At(12, 0)
	incoming.AddChild(wizard.imapStr).At(13, 0)


@@ 331,7 331,7 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
	outgoing.AddChild(
		ui.NewText("Connection mode").Bold(true)).
		At(10, 0)
	smtpMode := newSelecter([]string{
	smtpMode := NewSelecter([]string{
		"SMTP over SSL/TLS",
		"SMTP with STARTTLS",
		"Insecure SMTP",


@@ 347,7 347,7 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
		wizard.smtpUri()
	})
	outgoing.AddChild(smtpMode).At(11, 0)
	selecter = newSelecter([]string{"Previous", "Next"}, 1).
	selecter = NewSelecter([]string{"Previous", "Next"}, 1).
		OnChoose(wizard.advance)
	outgoing.AddChild(ui.NewFill(' ')).At(12, 0)
	outgoing.AddChild(wizard.smtpStr).At(13, 0)


@@ 355,7 355,7 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
	outgoing.AddChild(
		ui.NewText("Copy sent messages to 'Sent' folder?").Bold(true)).
		At(15, 0)
	copySent := newSelecter([]string{"Yes", "No"}, 0).
	copySent := NewSelecter([]string{"Yes", "No"}, 0).
		Chooser(true).OnChoose(func(option string) {
		switch option {
		case "Yes":


@@ 385,7 385,7 @@ func NewAccountWizard(conf *config.AercConfig, aerc *Aerc) *AccountWizard {
			"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{
	selecter = NewSelecter([]string{
		"Previous",
		"Finish & open tutorial",
		"Finish",


@@ 716,102 716,6 @@ func (wizard *AccountWizard) Event(event tcell.Event) bool {
	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
}

func getSRV(host string, services []string) (string, string) {
	var hostport, srv string
	for _, srv = range services {

M widgets/aerc.go => widgets/aerc.go +3 -3
@@ 240,7 240,7 @@ func (aerc *Aerc) Event(event tcell.Event) bool {
				exKey = aerc.conf.Bindings.Global.ExKey
			}
			if event.Key() == exKey.Key && event.Rune() == exKey.Rune {
				aerc.BeginExCommand()
				aerc.BeginExCommand("")
				return true
			}
			interactive, ok := aerc.tabs.Tabs[aerc.tabs.Selected].Content.(ui.Interactive)


@@ 370,9 370,9 @@ func (aerc *Aerc) focus(item ui.Interactive) {
	}
}

func (aerc *Aerc) BeginExCommand() {
func (aerc *Aerc) BeginExCommand(cmd string) {
	previous := aerc.focused
	exline := NewExLine(func(cmd string) {
	exline := NewExLine(cmd, func(cmd string) {
		parts, err := shlex.Split(cmd)
		if err != nil {
			aerc.PushStatus(" "+err.Error(), 10*time.Second).

M widgets/exline.go => widgets/exline.go +2 -2
@@ 16,11 16,11 @@ type ExLine struct {
	input       *ui.TextInput
}

func NewExLine(commit func(cmd string), finish func(),
func NewExLine(cmd string, commit func(cmd string), finish func(),
	tabcomplete func(cmd string) []string,
	cmdHistory lib.History) *ExLine {

	input := ui.NewTextInput("").Prompt(":").TabComplete(tabcomplete)
	input := ui.NewTextInput("").Prompt(":").TabComplete(tabcomplete).Set(cmd)
	exline := &ExLine{
		commit:      commit,
		finish:      finish,

M widgets/msgviewer.go => widgets/msgviewer.go +45 -13
@@ 68,7 68,7 @@ func NewMessageViewer(acct *AccountView, conf *config.AercConfig,
	})

	switcher := &PartSwitcher{}
	err := createSwitcher(switcher, conf, store, msg)
	err := createSwitcher(acct, switcher, conf, store, msg)
	if err != nil {
		return &MessageViewer{
			err:  err,


@@ 112,7 112,7 @@ func fmtHeader(msg *models.MessageInfo, header string) string {
	}
}

func enumerateParts(conf *config.AercConfig, store *lib.MessageStore,
func enumerateParts(acct *AccountView, conf *config.AercConfig, store *lib.MessageStore,
	msg *models.MessageInfo, body *models.BodyStructure,
	index []int) ([]*PartViewer, error) {



@@ 124,14 124,14 @@ func enumerateParts(conf *config.AercConfig, store *lib.MessageStore,
			pv := &PartViewer{part: part}
			parts = append(parts, pv)
			subParts, err := enumerateParts(
				conf, store, msg, part, curindex)
				acct, conf, store, msg, part, curindex)
			if err != nil {
				return nil, err
			}
			parts = append(parts, subParts...)
			continue
		}
		pv, err := NewPartViewer(conf, store, msg, part, curindex)
		pv, err := NewPartViewer(acct, conf, store, msg, part, curindex)
		if err != nil {
			return nil, err
		}


@@ 140,7 140,7 @@ func enumerateParts(conf *config.AercConfig, store *lib.MessageStore,
	return parts, nil
}

func createSwitcher(switcher *PartSwitcher, conf *config.AercConfig,
func createSwitcher(acct *AccountView, switcher *PartSwitcher, conf *config.AercConfig,
	store *lib.MessageStore, msg *models.MessageInfo) error {

	var err error


@@ 150,7 150,7 @@ func createSwitcher(switcher *PartSwitcher, conf *config.AercConfig,

	if len(msg.BodyStructure.Parts) == 0 {
		switcher.selected = 0
		pv, err := NewPartViewer(conf, store, msg, msg.BodyStructure, []int{1})
		pv, err := NewPartViewer(acct, conf, store, msg, msg.BodyStructure, []int{1})
		if err != nil {
			return err
		}


@@ 159,7 159,7 @@ func createSwitcher(switcher *PartSwitcher, conf *config.AercConfig,
			switcher.Invalidate()
		})
	} else {
		switcher.parts, err = enumerateParts(conf, store,
		switcher.parts, err = enumerateParts(acct, conf, store,
			msg, msg.BodyStructure, []int{})
		if err != nil {
			return err


@@ 236,7 236,7 @@ func (mv *MessageViewer) ToggleHeaders() {
	switcher := mv.switcher
	mv.conf.Viewer.ShowHeaders = !mv.conf.Viewer.ShowHeaders
	err := createSwitcher(
		switcher, mv.conf, mv.store, mv.msg)
		mv.acct, switcher, mv.conf, mv.store, mv.msg)
	if err != nil {
		mv.acct.Logger().Printf(
			"warning: error during create switcher - %v", err)


@@ 299,10 299,7 @@ func (ps *PartSwitcher) Focus(focus bool) {
}

func (ps *PartSwitcher) Event(event tcell.Event) bool {
	if ps.parts[ps.selected].term != nil {
		return ps.parts[ps.selected].term.Event(event)
	}
	return false
	return ps.parts[ps.selected].Event(event)
}

func (ps *PartSwitcher) Draw(ctx *ui.Context) {


@@ 414,9 411,11 @@ type PartViewer struct {
	source      io.Reader
	store       *lib.MessageStore
	term        *Terminal
	selecter    *Selecter
	grid        *ui.Grid
}

func NewPartViewer(conf *config.AercConfig,
func NewPartViewer(acct *AccountView, conf *config.AercConfig,
	store *lib.MessageStore, msg *models.MessageInfo,
	part *models.BodyStructure,
	index []int) (*PartViewer, error) {


@@ 475,6 474,26 @@ func NewPartViewer(conf *config.AercConfig,
		}
	}

	grid := ui.NewGrid().Rows([]ui.GridSpec{
		{ui.SIZE_EXACT, 3}, // Message
		{ui.SIZE_EXACT, 1}, // Selector
		{ui.SIZE_WEIGHT, 1},
	}).Columns([]ui.GridSpec{
		{ui.SIZE_WEIGHT, 1},
	})

	selecter := NewSelecter([]string{"Save message", "Pipe to command"}, 0).
		OnChoose(func(option string) {
			switch option {
			case "Save message":
				acct.aerc.BeginExCommand("save ")
			case "Pipe to command":
				acct.aerc.BeginExCommand("pipe ")
			}
		})

	grid.AddChild(selecter).At(2, 0)

	pv := &PartViewer{
		filter:      filter,
		index:       index,


@@ 486,6 505,8 @@ func NewPartViewer(conf *config.AercConfig,
		sink:        pipe,
		store:       store,
		term:        term,
		selecter:    selecter,
		grid:        grid,
	}

	if term != nil {


@@ 590,6 611,10 @@ func (pv *PartViewer) Draw(ctx *ui.Context) {
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
		ctx.Printf(0, 0, tcell.StyleDefault.Foreground(tcell.ColorRed),
			"No filter configured for this mimetype")
		ctx.Printf(0, 2, tcell.StyleDefault,
			"You can still :save the message or :pipe it to an external command")
		pv.selecter.Focus(true)
		pv.grid.Draw(ctx)
		return
	}
	if !pv.fetched {


@@ 611,6 636,13 @@ func (pv *PartViewer) Cleanup() {
	}
}

func (pv *PartViewer) Event(event tcell.Event) bool {
	if pv.term != nil {
		return pv.term.Event(event)
	}
	return pv.selecter.Event(event)
}

type HeaderView struct {
	ui.Invalidatable
	Name  string

A widgets/selecter.go => widgets/selecter.go +103 -0
@@ 0,0 1,103 @@
package widgets

import (
	"github.com/gdamore/tcell"

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

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
}

M widgets/tabhost.go => widgets/tabhost.go +1 -1
@@ 5,7 5,7 @@ import (
)

type TabHost interface {
	BeginExCommand()
	BeginExCommand(cmd string)
	SetStatus(status string) *StatusMessage
	PushStatus(text string, expiry time.Duration) *StatusMessage
	Beep()