diff options
Diffstat (limited to 'app/msgviewer.go')
-rw-r--r-- | app/msgviewer.go | 927 |
1 files changed, 927 insertions, 0 deletions
diff --git a/app/msgviewer.go b/app/msgviewer.go new file mode 100644 index 00000000..2d261c3f --- /dev/null +++ b/app/msgviewer.go @@ -0,0 +1,927 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync/atomic" + + "github.com/danwakefield/fnmatch" + "github.com/emersion/go-message/textproto" + "github.com/gdamore/tcell/v2" + "github.com/google/shlex" + "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/parse" + "git.sr.ht/~rjarry/aerc/lib/ui" + "git.sr.ht/~rjarry/aerc/log" + "git.sr.ht/~rjarry/aerc/models" +) + +var _ ProvidesMessages = (*MessageViewer)(nil) + +type MessageViewer struct { + acct *AccountView + err error + grid *ui.Grid + switcher *PartSwitcher + msg lib.MessageView + uiConfig *config.UIConfig +} + +type PartSwitcher struct { + parts []*PartViewer + selected int + alwaysShowMime bool + + height int + mv *MessageViewer +} + +func NewMessageViewer( + acct *AccountView, msg lib.MessageView, +) *MessageViewer { + if msg == nil { + return &MessageViewer{ + acct: acct, + err: fmt.Errorf("(no message selected)"), + } + } + 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 &MessageViewer{ + acct: acct, + err: err, + grid: grid, + msg: msg, + uiConfig: acct.UiConfig(), + } + } + + 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.mv = mv + + return mv +} + +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 "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 + switcher.alwaysShowMime = config.Viewer.AlwaysShowMime + + 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.err != nil { + style := mv.acct.UiConfig().GetStyle(config.STYLE_DEFAULT) + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style) + ctx.Printf(0, 0, style, "%s", mv.err.Error()) + return + } + mv.grid.Draw(ctx) +} + +func (mv *MessageViewer) MouseEvent(localX int, localY int, event tcell.Event) { + if mv.err != nil { + return + } + mv.grid.MouseEvent(localX, localY, event) +} + +func (mv *MessageViewer) Invalidate() { + ui.Invalidate() +} + +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() ([]uint32, error) { + return mv.acct.MarkedMessages() +} + +func (mv *MessageViewer) ToggleHeaders() { + 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 { + switcher := mv.switcher + part := switcher.parts[switcher.selected] + + return &PartInfo{ + Index: part.index, + Msg: part.msg.MessageInfo(), + Part: part.part, + Links: part.links, + } +} + +func (mv *MessageViewer) AttachmentParts(all bool) []*PartInfo { + var attachments []*PartInfo + + for _, p := range mv.switcher.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 (mv *MessageViewer) PreviousPart() { + switcher := mv.switcher + for { + switcher.selected-- + if switcher.selected < 0 { + switcher.selected = len(switcher.parts) - 1 + } + if switcher.parts[switcher.selected].part.MIMEType != "multipart" { + break + } + } + mv.Invalidate() +} + +func (mv *MessageViewer) NextPart() { + switcher := mv.switcher + for { + switcher.selected++ + if switcher.selected >= len(switcher.parts) { + switcher.selected = 0 + } + if switcher.parts[switcher.selected].part.MIMEType != "multipart" { + break + } + } + 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 (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 tcell.Event) bool { + return ps.parts[ps.selected].Event(event) +} + +func (ps *PartSwitcher) Draw(ctx *ui.Context) { + height := len(ps.parts) + if height == 1 && !config.Viewer.AlwaysShowMime { + ps.parts[ps.selected].Draw(ctx) + return + } + + var styleSwitcher, styleFile, styleMime tcell.Style + + // TODO: cap height and add scrolling for messages with many parts + ps.height = ctx.Height() + y := ctx.Height() - height + for i, part := range ps.parts { + if ps.selected == i { + styleSwitcher = ps.mv.uiConfig.GetStyleSelected(config.STYLE_PART_SWITCHER) + styleFile = ps.mv.uiConfig.GetStyleSelected(config.STYLE_PART_FILENAME) + styleMime = ps.mv.uiConfig.GetStyleSelected(config.STYLE_PART_MIMETYPE) + } else { + styleSwitcher = ps.mv.uiConfig.GetStyle(config.STYLE_PART_SWITCHER) + styleFile = ps.mv.uiConfig.GetStyle(config.STYLE_PART_FILENAME) + styleMime = ps.mv.uiConfig.GetStyle(config.STYLE_PART_MIMETYPE) + } + ctx.Fill(0, y+i, 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, y+i, styleFile, "%s ", name) + } + t := "(" + part.part.FullMIMEType() + ")" + t = runewidth.Truncate(t, ctx.Width()-left, "…") + ctx.Printf(left, y+i, styleMime, "%s", t) + } + ps.parts[ps.selected].Draw(ctx.Subcontext( + 0, 0, ctx.Width(), ctx.Height()-height)) +} + +func (ps *PartSwitcher) MouseEvent(localX int, localY int, event tcell.Event) { + if event, ok := event.(*tcell.EventMouse); ok { + switch event.Buttons() { + case tcell.Button1: + height := len(ps.parts) + y := ps.height - height + if localY < y && ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.MouseEvent(localX, localY, event) + } + for i := range ps.parts { + if localY != y+i { + continue + } + if ps.parts[i].part.MIMEType == "multipart" { + continue + } + if ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.Focus(false) + } + ps.selected = i + ps.Invalidate() + if ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.Focus(true) + } + } + case tcell.WheelDown: + height := len(ps.parts) + y := ps.height - height + if localY < y && ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.MouseEvent(localX, localY, event) + } + if ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.Focus(false) + } + ps.mv.NextPart() + if ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.Focus(true) + } + case tcell.WheelUp: + height := len(ps.parts) + y := ps.height - height + if localY < y && ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.MouseEvent(localX, localY, event) + } + if ps.parts[ps.selected].term != nil { + ps.parts[ps.selected].term.Focus(false) + } + ps.mv.PreviousPart() + 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() + } +} + +func (mv *MessageViewer) Event(event tcell.Event) bool { + return mv.switcher.Event(event) +} + +func (mv *MessageViewer) Focus(focus bool) { + mv.switcher.Focus(focus) +} + +func (mv *MessageViewer) Show(visible bool) { + 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 + + 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 + ) + cmds := []string{ + config.Viewer.Pager, + os.Getenv("PAGER"), + "less -Rc", + } + pagerCmd, err := acct.aerc.CmdFallbackSearch(cmds) + if err != nil { + acct.PushError(fmt.Errorf("could not start pager: %w", err)) + return nil, err + } + cmd, err := shlex.Split(pagerCmd) + if err != nil { + return nil, err + } + + pager = exec.Command(cmd[0], cmd[1:]...) + + 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) + } + } + if filter != nil { + 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") + } + log.Debugf("<%s> part=%v %s: %v | %v", + info.Envelope.MessageId, curindex, mime, filter, pager) + 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(), + } + + if term != nil { + term.OnStart = func() { + pv.attemptCopy() + } + } + + return pv, nil +} + +func (pv *PartViewer) SetSource(reader io.Reader) { + pv.source = reader + pv.attemptCopy() +} + +func (pv *PartViewer) attemptCopy() { + if pv.source == nil || + pv.filter == nil || + atomic.LoadInt32(&pv.copying) == copying { + return + } + atomic.StoreInt32(&pv.copying, copying) + pv.writeMailHeaders() + if strings.EqualFold(pv.part.MIMEType, "text") { + pv.source = parse.StripAnsi(pv.hyperlinks(pv.source)) + } + 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) + err = pv.filter.Wait() + if err != nil { + log.Errorf("error waiting for filter: %v", err) + return + } + err = pv.pagerin.Close() + if err != nil { + log.Errorf("error closing pager pipe: %v", err) + return + } + }() +} + +func (pv *PartViewer) writeMailHeaders() { + info := pv.msg.MessageInfo() + if config.Viewer.ShowHeaders && info.RFC822Headers != nil { + 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) + if pv.filter == nil { + ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style) + pv.noFilter.Draw(ctx) + return + } + 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) + } +} + +func (pv *PartViewer) Cleanup() { + if pv.term != nil { + pv.term.Close() + } +} + +func (pv *PartViewer) Event(event tcell.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() +} |