0a1a75aed1
This patch should fix encoding problem with non-utf8 text/plain mime. It is now correctly convert to utf8 before sending to pager. It will also solve quoting such mails. Leszek
676 lines
16 KiB
Go
676 lines
16 KiB
Go
package widgets
|
|
|
|
import (
|
|
"bufio"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os/exec"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/danwakefield/fnmatch"
|
|
message "github.com/emersion/go-message"
|
|
_ "github.com/emersion/go-message/charset"
|
|
"github.com/emersion/go-message/mail"
|
|
"github.com/gdamore/tcell"
|
|
"github.com/google/shlex"
|
|
"github.com/mattn/go-runewidth"
|
|
|
|
"git.sr.ht/~sircmpwn/aerc/config"
|
|
"git.sr.ht/~sircmpwn/aerc/lib"
|
|
"git.sr.ht/~sircmpwn/aerc/lib/ui"
|
|
"git.sr.ht/~sircmpwn/aerc/models"
|
|
)
|
|
|
|
var ansi = regexp.MustCompile("^\x1B\\[[0-?]*[ -/]*[@-~]")
|
|
|
|
type MessageViewer struct {
|
|
ui.Invalidatable
|
|
acct *AccountView
|
|
conf *config.AercConfig
|
|
err error
|
|
grid *ui.Grid
|
|
msg *models.MessageInfo
|
|
switcher *PartSwitcher
|
|
store *lib.MessageStore
|
|
}
|
|
|
|
type PartSwitcher struct {
|
|
ui.Invalidatable
|
|
parts []*PartViewer
|
|
selected int
|
|
showHeaders bool
|
|
alwaysShowMime bool
|
|
|
|
height int
|
|
mv *MessageViewer
|
|
}
|
|
|
|
func NewMessageViewer(acct *AccountView, conf *config.AercConfig,
|
|
store *lib.MessageStore, msg *models.MessageInfo) *MessageViewer {
|
|
|
|
layout := HeaderLayout(conf.Viewer.HeaderLayout).forMessage(msg)
|
|
header, headerHeight := layout.grid(
|
|
func(header string) ui.Drawable {
|
|
return &HeaderView{
|
|
Name: header,
|
|
Value: fmtHeader(msg, header),
|
|
}
|
|
},
|
|
)
|
|
|
|
grid := ui.NewGrid().Rows([]ui.GridSpec{
|
|
{ui.SIZE_EXACT, headerHeight},
|
|
{ui.SIZE_WEIGHT, 1},
|
|
}).Columns([]ui.GridSpec{
|
|
{ui.SIZE_WEIGHT, 1},
|
|
})
|
|
|
|
switcher := &PartSwitcher{}
|
|
err := createSwitcher(acct, switcher, conf, store, msg)
|
|
if err != nil {
|
|
return &MessageViewer{
|
|
err: err,
|
|
grid: grid,
|
|
msg: msg,
|
|
}
|
|
}
|
|
|
|
grid.AddChild(header).At(0, 0)
|
|
grid.AddChild(switcher).At(1, 0)
|
|
|
|
mv := &MessageViewer{
|
|
acct: acct,
|
|
conf: conf,
|
|
grid: grid,
|
|
msg: msg,
|
|
store: store,
|
|
switcher: switcher,
|
|
}
|
|
switcher.mv = mv
|
|
|
|
return mv
|
|
}
|
|
|
|
func fmtHeader(msg *models.MessageInfo, header string) string {
|
|
switch header {
|
|
case "From":
|
|
return models.FormatAddresses(msg.Envelope.From)
|
|
case "To":
|
|
return models.FormatAddresses(msg.Envelope.To)
|
|
case "Cc":
|
|
return models.FormatAddresses(msg.Envelope.Cc)
|
|
case "Bcc":
|
|
return models.FormatAddresses(msg.Envelope.Bcc)
|
|
case "Date":
|
|
return msg.Envelope.Date.Format("Mon Jan 2, 2006 at 3:04 PM")
|
|
case "Subject":
|
|
return msg.Envelope.Subject
|
|
default:
|
|
return msg.RFC822Headers.Get(header)
|
|
}
|
|
}
|
|
|
|
func enumerateParts(acct *AccountView, conf *config.AercConfig, store *lib.MessageStore,
|
|
msg *models.MessageInfo, body *models.BodyStructure,
|
|
index []int) ([]*PartViewer, error) {
|
|
|
|
var parts []*PartViewer
|
|
for i, part := range body.Parts {
|
|
curindex := append(index, i+1)
|
|
if part.MIMEType == "multipart" {
|
|
// Multipart meta-parts are faked
|
|
pv := &PartViewer{part: part}
|
|
parts = append(parts, pv)
|
|
subParts, err := enumerateParts(
|
|
acct, conf, store, msg, part, curindex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts = append(parts, subParts...)
|
|
continue
|
|
}
|
|
pv, err := NewPartViewer(acct, conf, store, msg, part, curindex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts = append(parts, pv)
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func createSwitcher(acct *AccountView, switcher *PartSwitcher, conf *config.AercConfig,
|
|
store *lib.MessageStore, msg *models.MessageInfo) error {
|
|
|
|
var err error
|
|
switcher.selected = -1
|
|
switcher.showHeaders = conf.Viewer.ShowHeaders
|
|
switcher.alwaysShowMime = conf.Viewer.AlwaysShowMime
|
|
|
|
if len(msg.BodyStructure.Parts) == 0 {
|
|
switcher.selected = 0
|
|
pv, err := NewPartViewer(acct, conf, store, msg, msg.BodyStructure, []int{1})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switcher.parts = []*PartViewer{pv}
|
|
pv.OnInvalidate(func(_ ui.Drawable) {
|
|
switcher.Invalidate()
|
|
})
|
|
} else {
|
|
switcher.parts, err = enumerateParts(acct, conf, store,
|
|
msg, msg.BodyStructure, []int{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
selectedPriority := -1
|
|
fmt.Printf("Selecting best message from %v\n", conf.Viewer.Alternatives)
|
|
for i, pv := range switcher.parts {
|
|
pv.OnInvalidate(func(_ ui.Drawable) {
|
|
switcher.Invalidate()
|
|
})
|
|
// Switch to user's preferred mimetype
|
|
if switcher.selected == -1 && pv.part.MIMEType != "multipart" {
|
|
switcher.selected = i
|
|
}
|
|
mime := strings.ToLower(pv.part.MIMEType) +
|
|
"/" + strings.ToLower(pv.part.MIMESubType)
|
|
for idx, m := range conf.Viewer.Alternatives {
|
|
if m != mime {
|
|
continue
|
|
}
|
|
priority := len(conf.Viewer.Alternatives) - idx
|
|
if priority > selectedPriority {
|
|
selectedPriority = priority
|
|
switcher.selected = i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (mv *MessageViewer) Draw(ctx *ui.Context) {
|
|
if mv.err != nil {
|
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
|
|
ctx.Printf(0, 0, tcell.StyleDefault, "%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() {
|
|
mv.grid.Invalidate()
|
|
}
|
|
|
|
func (mv *MessageViewer) OnInvalidate(fn func(d ui.Drawable)) {
|
|
mv.grid.OnInvalidate(func(_ ui.Drawable) {
|
|
fn(mv)
|
|
})
|
|
}
|
|
|
|
func (mv *MessageViewer) Store() *lib.MessageStore {
|
|
return mv.store
|
|
}
|
|
|
|
func (mv *MessageViewer) SelectedAccount() *AccountView {
|
|
return mv.acct
|
|
}
|
|
|
|
func (mv *MessageViewer) SelectedMessage() (*models.MessageInfo, error) {
|
|
if mv.msg == nil {
|
|
return nil, errors.New("no message selected")
|
|
}
|
|
return mv.msg, nil
|
|
}
|
|
|
|
func (mv *MessageViewer) ToggleHeaders() {
|
|
switcher := mv.switcher
|
|
mv.conf.Viewer.ShowHeaders = !mv.conf.Viewer.ShowHeaders
|
|
err := createSwitcher(
|
|
mv.acct, switcher, mv.conf, mv.store, mv.msg)
|
|
if err != nil {
|
|
mv.acct.Logger().Printf(
|
|
"warning: error during create switcher - %v", err)
|
|
}
|
|
switcher.Invalidate()
|
|
}
|
|
|
|
func (mv *MessageViewer) SelectedMessagePart() *PartInfo {
|
|
switcher := mv.switcher
|
|
part := switcher.parts[switcher.selected]
|
|
|
|
return &PartInfo{
|
|
Index: part.index,
|
|
Msg: part.msg,
|
|
Part: part.part,
|
|
Store: part.store,
|
|
}
|
|
}
|
|
|
|
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) Close() {
|
|
mv.switcher.Cleanup()
|
|
}
|
|
|
|
func (ps *PartSwitcher) Invalidate() {
|
|
ps.DoInvalidate(ps)
|
|
}
|
|
|
|
func (ps *PartSwitcher) Focus(focus bool) {
|
|
if ps.parts[ps.selected].term != nil {
|
|
ps.parts[ps.selected].term.Focus(focus)
|
|
}
|
|
}
|
|
|
|
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 && !ps.alwaysShowMime {
|
|
ps.parts[ps.selected].Draw(ctx)
|
|
return
|
|
}
|
|
// 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 {
|
|
style := tcell.StyleDefault.Reverse(ps.selected == i)
|
|
ctx.Fill(0, y+i, ctx.Width(), 1, ' ', style)
|
|
name := fmt.Sprintf("%s/%s",
|
|
strings.ToLower(part.part.MIMEType),
|
|
strings.ToLower(part.part.MIMESubType))
|
|
if filename, ok := part.part.DispositionParams["filename"]; ok {
|
|
name += fmt.Sprintf(" (%s)", filename)
|
|
}
|
|
ctx.Printf(len(part.index)*2, y+i, style, "%s", name)
|
|
}
|
|
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) {
|
|
switch event := event.(type) {
|
|
case *tcell.EventMouse:
|
|
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.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.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)
|
|
}
|
|
|
|
type PartViewer struct {
|
|
ui.Invalidatable
|
|
err error
|
|
fetched bool
|
|
filter *exec.Cmd
|
|
index []int
|
|
msg *models.MessageInfo
|
|
pager *exec.Cmd
|
|
pagerin io.WriteCloser
|
|
part *models.BodyStructure
|
|
showHeaders bool
|
|
sink io.WriteCloser
|
|
source io.Reader
|
|
store *lib.MessageStore
|
|
term *Terminal
|
|
selecter *Selecter
|
|
grid *ui.Grid
|
|
}
|
|
|
|
func NewPartViewer(acct *AccountView, conf *config.AercConfig,
|
|
store *lib.MessageStore, msg *models.MessageInfo,
|
|
part *models.BodyStructure,
|
|
index []int) (*PartViewer, error) {
|
|
|
|
var (
|
|
filter *exec.Cmd
|
|
pager *exec.Cmd
|
|
pipe io.WriteCloser
|
|
pagerin io.WriteCloser
|
|
term *Terminal
|
|
)
|
|
cmd, err := shlex.Split(conf.Viewer.Pager)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pager = exec.Command(cmd[0], cmd[1:]...)
|
|
|
|
for _, f := range conf.Filters {
|
|
mime := strings.ToLower(part.MIMEType) +
|
|
"/" + strings.ToLower(part.MIMESubType)
|
|
switch f.FilterType {
|
|
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 = msg.Envelope.Subject
|
|
case "from":
|
|
header = models.FormatAddresses(msg.Envelope.From)
|
|
case "to":
|
|
header = models.FormatAddresses(msg.Envelope.To)
|
|
case "cc":
|
|
header = models.FormatAddresses(msg.Envelope.Cc)
|
|
}
|
|
if f.Regex.Match([]byte(header)) {
|
|
filter = exec.Command("sh", "-c", f.Command)
|
|
}
|
|
}
|
|
if filter != nil {
|
|
break
|
|
}
|
|
}
|
|
if filter != nil {
|
|
if pipe, err = filter.StdinPipe(); err != nil {
|
|
return nil, err
|
|
}
|
|
if pagerin, _ = pager.StdinPipe(); err != nil {
|
|
return nil, err
|
|
}
|
|
if term, err = NewTerminal(pager); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
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,
|
|
msg: msg,
|
|
pager: pager,
|
|
pagerin: pagerin,
|
|
part: part,
|
|
showHeaders: conf.Viewer.ShowHeaders,
|
|
sink: pipe,
|
|
store: store,
|
|
term: term,
|
|
selecter: selecter,
|
|
grid: grid,
|
|
}
|
|
|
|
if term != nil {
|
|
term.OnStart = func() {
|
|
pv.attemptCopy()
|
|
}
|
|
term.OnInvalidate(func(_ ui.Drawable) {
|
|
pv.Invalidate()
|
|
})
|
|
}
|
|
|
|
return pv, nil
|
|
}
|
|
|
|
func (pv *PartViewer) SetSource(reader io.Reader) {
|
|
pv.source = reader
|
|
pv.attemptCopy()
|
|
}
|
|
|
|
func (pv *PartViewer) attemptCopy() {
|
|
if pv.source != nil && pv.pager != nil && pv.pager.Process != nil {
|
|
header := message.Header{}
|
|
header.SetText("Content-Transfer-Encoding", pv.part.Encoding)
|
|
header.SetContentType(fmt.Sprintf("%s/%s", pv.part.MIMEType, pv.part.MIMESubType), pv.part.Params)
|
|
header.SetText("Content-Description", pv.part.Description)
|
|
if pv.filter != nil {
|
|
stdout, _ := pv.filter.StdoutPipe()
|
|
stderr, _ := pv.filter.StderrPipe()
|
|
pv.filter.Start()
|
|
ch := make(chan interface{})
|
|
go func() {
|
|
_, err := io.Copy(pv.pagerin, stdout)
|
|
if err != nil {
|
|
pv.err = err
|
|
pv.Invalidate()
|
|
}
|
|
stdout.Close()
|
|
ch <- nil
|
|
}()
|
|
go func() {
|
|
_, err := io.Copy(pv.pagerin, stderr)
|
|
if err != nil {
|
|
pv.err = err
|
|
pv.Invalidate()
|
|
}
|
|
stderr.Close()
|
|
ch <- nil
|
|
}()
|
|
go func() {
|
|
<-ch
|
|
<-ch
|
|
pv.filter.Wait()
|
|
pv.pagerin.Close()
|
|
}()
|
|
}
|
|
go func() {
|
|
if pv.showHeaders && pv.msg.RFC822Headers != nil {
|
|
fields := pv.msg.RFC822Headers.Fields()
|
|
for fields.Next() {
|
|
field := fmt.Sprintf(
|
|
"%s: %s\n", fields.Key(), fields.Value())
|
|
pv.sink.Write([]byte(field))
|
|
}
|
|
pv.sink.Write([]byte{'\n'})
|
|
}
|
|
|
|
entity, err := message.New(header, pv.source)
|
|
if err != nil {
|
|
pv.err = err
|
|
pv.Invalidate()
|
|
return
|
|
}
|
|
reader := mail.NewReader(entity)
|
|
part, err := reader.NextPart()
|
|
if err != nil {
|
|
pv.err = err
|
|
pv.Invalidate()
|
|
return
|
|
}
|
|
if pv.part.MIMEType == "text" {
|
|
scanner := bufio.NewScanner(part.Body)
|
|
for scanner.Scan() {
|
|
text := scanner.Text()
|
|
text = ansi.ReplaceAllString(text, "")
|
|
io.WriteString(pv.sink, text+"\n")
|
|
}
|
|
} else {
|
|
io.Copy(pv.sink, part.Body)
|
|
}
|
|
pv.sink.Close()
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (pv *PartViewer) Invalidate() {
|
|
pv.DoInvalidate(pv)
|
|
}
|
|
|
|
func (pv *PartViewer) Draw(ctx *ui.Context) {
|
|
if pv.filter == nil {
|
|
// TODO: Let them download it directly or something
|
|
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 {
|
|
pv.store.FetchBodyPart(pv.msg.Uid, pv.index, pv.SetSource)
|
|
pv.fetched = true
|
|
}
|
|
if pv.err != nil {
|
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', tcell.StyleDefault)
|
|
ctx.Printf(0, 0, tcell.StyleDefault, "%s", pv.err.Error())
|
|
return
|
|
}
|
|
pv.term.Draw(ctx)
|
|
}
|
|
|
|
func (pv *PartViewer) Cleanup() {
|
|
if pv.pager != nil && pv.pager.Process != nil {
|
|
pv.pager.Process.Kill()
|
|
pv.pager = nil
|
|
}
|
|
}
|
|
|
|
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
|
|
Value string
|
|
}
|
|
|
|
func (hv *HeaderView) Draw(ctx *ui.Context) {
|
|
name := hv.Name
|
|
size := runewidth.StringWidth(name)
|
|
lim := ctx.Width() - size - 1
|
|
value := runewidth.Truncate(" "+hv.Value, lim, "…")
|
|
var (
|
|
hstyle tcell.Style
|
|
vstyle tcell.Style
|
|
)
|
|
// TODO: Make this more robust and less dumb
|
|
if hv.Name == "PGP" {
|
|
vstyle = tcell.StyleDefault.Foreground(tcell.ColorGreen)
|
|
hstyle = tcell.StyleDefault.Bold(true)
|
|
} else {
|
|
vstyle = tcell.StyleDefault
|
|
hstyle = tcell.StyleDefault.Bold(true)
|
|
}
|
|
ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', vstyle)
|
|
ctx.Printf(0, 0, hstyle, name)
|
|
ctx.Printf(size, 0, vstyle, value)
|
|
}
|
|
|
|
func (hv *HeaderView) Invalidate() {
|
|
hv.DoInvalidate(hv)
|
|
}
|