diff --git a/lib/ui/borders.go b/lib/ui/borders.go index cffd3ca..7a75759 100644 --- a/lib/ui/borders.go +++ b/lib/ui/borders.go @@ -66,3 +66,10 @@ func (bordered *Bordered) Draw(ctx *Context) { subctx := ctx.Subcontext(x, y, width, height) bordered.content.Draw(subctx) } + +func (bordered *Bordered) MouseEvent(localX int, localY int, event tcell.Event) { + switch content := bordered.content.(type) { + case Mouseable: + content.MouseEvent(localX, localY, event) + } +} diff --git a/lib/ui/grid.go b/lib/ui/grid.go index 7f131bd..b47c6bd 100644 --- a/lib/ui/grid.go +++ b/lib/ui/grid.go @@ -5,6 +5,8 @@ import ( "math" "sync" "sync/atomic" + + "github.com/gdamore/tcell" ) type Grid struct { @@ -141,6 +143,45 @@ func (grid *Grid) Draw(ctx *Context) { } } +func (grid *Grid) MouseEvent(localX int, localY int, event tcell.Event) { + switch event := event.(type) { + case *tcell.EventMouse: + invalid := grid.invalid + + grid.mutex.RLock() + defer grid.mutex.RUnlock() + + for _, cell := range grid.cells { + cellInvalid := cell.invalid.Load().(bool) + if !cellInvalid && !invalid { + continue + } + rows := grid.rowLayout[cell.Row : cell.Row+cell.RowSpan] + cols := grid.columnLayout[cell.Column : cell.Column+cell.ColSpan] + x := cols[0].Offset + y := rows[0].Offset + width := 0 + height := 0 + for _, col := range cols { + width += col.Size + } + for _, row := range rows { + height += row.Size + } + if x <= localX && localX < x+width && y <= localY && localY < y+height { + switch content := cell.Content.(type) { + case MouseableDrawableInteractive: + content.MouseEvent(localX-x, localY-y, event) + case Mouseable: + content.MouseEvent(localX-x, localY-y, event) + case MouseHandler: + content.MouseEvent(localX-x, localY-y, event) + } + } + } + } +} + func (grid *Grid) reflow(ctx *Context) { grid.rowLayout = nil grid.columnLayout = nil diff --git a/lib/ui/interfaces.go b/lib/ui/interfaces.go index 2f63424..9e79571 100644 --- a/lib/ui/interfaces.go +++ b/lib/ui/interfaces.go @@ -50,9 +50,18 @@ type Container interface { Children() []Drawable } -// A drawable that can be clicked -type Clickable interface { - Drawable - - MouseEvent(event tcell.Event) +type MouseHandler interface { + // Handle a mouse event which occurred at the local x and y positions + MouseEvent(localX int, localY int, event tcell.Event) +} + +// A drawable that can be interacted with by the mouse +type Mouseable interface { + Drawable + MouseHandler +} + +type MouseableDrawableInteractive interface { + DrawableInteractive + MouseHandler } diff --git a/lib/ui/stack.go b/lib/ui/stack.go index 75cc780..690a869 100644 --- a/lib/ui/stack.go +++ b/lib/ui/stack.go @@ -37,6 +37,15 @@ func (stack *Stack) Draw(ctx *Context) { } } +func (stack *Stack) MouseEvent(localX int, localY int, event tcell.Event) { + if len(stack.children) > 0 { + switch element := stack.Peek().(type) { + case Mouseable: + element.MouseEvent(localX, localY, event) + } + } +} + func (stack *Stack) Push(d Drawable) { if len(stack.children) != 0 { stack.Peek().OnInvalidate(nil) diff --git a/lib/ui/tab.go b/lib/ui/tab.go index 90c7ce9..1fd2b80 100644 --- a/lib/ui/tab.go +++ b/lib/ui/tab.go @@ -14,6 +14,9 @@ type Tabs struct { onInvalidateStrip func(d Drawable) onInvalidateContent func(d Drawable) + + parent *Tabs + CloseTab func(index int) } type Tab struct { @@ -28,7 +31,9 @@ type TabContent Tabs func NewTabs() *Tabs { tabs := &Tabs{} tabs.TabStrip = (*TabStrip)(tabs) + tabs.TabStrip.parent = tabs tabs.TabContent = (*TabContent)(tabs) + tabs.TabContent.parent = tabs tabs.history = []int{} return tabs } @@ -114,6 +119,22 @@ func (tabs *Tabs) SelectPrevious() bool { return true } +func (tabs *Tabs) NextTab() { + next := tabs.Selected + 1 + if next >= len(tabs.Tabs) { + next = 0 + } + tabs.Select(next) +} + +func (tabs *Tabs) PrevTab() { + next := tabs.Selected - 1 + if next < 0 { + next = len(tabs.Tabs) - 1 + } + tabs.Select(next) +} + func (tabs *Tabs) pushHistory(index int) { tabs.history = append(tabs.history, index) } @@ -146,19 +167,6 @@ func (tabs *Tabs) removeHistory(index int) { tabs.history = newHist } -func (tabs *Tabs) MouseEvent(event tcell.Event) { - switch event := event.(type) { - case *tcell.EventMouse: - if event.Buttons()&tcell.Button1 != 0 { - x, y := event.Position() - selectedTab, ok := tabs.TabStrip.Clicked(x, y) - if ok { - tabs.Select(selectedTab) - } - } - } -} - // TODO: Color repository func (strip *TabStrip) Draw(ctx *Context) { x := 0 @@ -187,21 +195,65 @@ func (strip *TabStrip) Invalidate() { } } +func (strip *TabStrip) MouseEvent(localX int, localY int, event tcell.Event) { + changeFocus := func(focus bool) { + interactive, ok := strip.parent.Tabs[strip.parent.Selected].Content.(Interactive) + if ok { + interactive.Focus(focus) + } + } + unfocus := func() { changeFocus(false) } + refocus := func() { changeFocus(true) } + switch event := event.(type) { + case *tcell.EventMouse: + switch event.Buttons() { + case tcell.Button1: + selectedTab, ok := strip.Clicked(localX, localY) + if !ok || selectedTab == strip.parent.Selected { + return + } + unfocus() + strip.parent.Select(selectedTab) + refocus() + case tcell.WheelDown: + unfocus() + strip.parent.NextTab() + refocus() + case tcell.WheelUp: + unfocus() + strip.parent.PrevTab() + refocus() + case tcell.Button3: + selectedTab, ok := strip.Clicked(localX, localY) + if !ok { + return + } + unfocus() + if selectedTab == strip.parent.Selected { + strip.parent.CloseTab(selectedTab) + } else { + current := strip.parent.Selected + strip.parent.CloseTab(selectedTab) + strip.parent.Select(current) + } + refocus() + } + } +} + func (strip *TabStrip) OnInvalidate(onInvalidate func(d Drawable)) { strip.onInvalidateStrip = onInvalidate } func (strip *TabStrip) Clicked(mouseX int, mouseY int) (int, bool) { x := 0 - if mouseY == 0 { - for i, tab := range strip.Tabs { - trunc := runewidth.Truncate(tab.Name, 32, "…") - length := len(trunc) + 2 - if x <= mouseX && mouseX < x+length { - return i, true - } - x += length + for i, tab := range strip.Tabs { + trunc := runewidth.Truncate(tab.Name, 32, "…") + length := len(trunc) + 2 + if x <= mouseX && mouseX < x+length { + return i, true } + x += length } return 0, false } @@ -225,6 +277,14 @@ func (content *TabContent) Draw(ctx *Context) { tab.Content.Draw(ctx) } +func (content *TabContent) MouseEvent(localX int, localY int, event tcell.Event) { + tab := content.Tabs[content.Selected] + switch tabContent := tab.Content.(type) { + case Mouseable: + tabContent.MouseEvent(localX, localY, event) + } +} + func (content *TabContent) Invalidate() { if content.onInvalidateContent != nil { content.onInvalidateContent(content) diff --git a/lib/ui/textinput.go b/lib/ui/textinput.go index 00e91ee..3935173 100644 --- a/lib/ui/textinput.go +++ b/lib/ui/textinput.go @@ -97,6 +97,20 @@ func (ti *TextInput) Draw(ctx *Context) { } } +func (ti *TextInput) MouseEvent(localX int, localY int, event tcell.Event) { + switch event := event.(type) { + case *tcell.EventMouse: + switch event.Buttons() { + case tcell.Button1: + if localX >= len(ti.prompt)+1 && localX <= len(ti.text[ti.scroll:])+len(ti.prompt)+1 { + ti.index = localX - len(ti.prompt) - 1 + ti.ensureScroll() + ti.Invalidate() + } + } + } +} + func (ti *TextInput) Focus(focus bool) { ti.focus = focus if focus && ti.ctx != nil { diff --git a/widgets/account-wizard.go b/widgets/account-wizard.go index 5acd26c..904013f 100644 --- a/widgets/account-wizard.go +++ b/widgets/account-wizard.go @@ -523,7 +523,7 @@ func (wizard *AccountWizard) finish(tutorial bool) { } wizard.conf.Accounts = append(wizard.conf.Accounts, account) - view := NewAccountView(wizard.conf, &account, + view := NewAccountView(wizard.aerc, wizard.conf, &account, wizard.aerc.logger, wizard.aerc) wizard.aerc.accounts[account.Name] = view wizard.aerc.NewTab(view, account.Name) diff --git a/widgets/account.go b/widgets/account.go index 688b660..1220753 100644 --- a/widgets/account.go +++ b/widgets/account.go @@ -17,6 +17,7 @@ import ( type AccountView struct { acct *config.AccountConfig + aerc *Aerc conf *config.AercConfig dirlist *DirectoryList grid *ui.Grid @@ -26,7 +27,7 @@ type AccountView struct { worker *types.Worker } -func NewAccountView(conf *config.AercConfig, acct *config.AccountConfig, +func NewAccountView(aerc *Aerc, conf *config.AercConfig, acct *config.AccountConfig, logger *log.Logger, host TabHost) *AccountView { grid := ui.NewGrid().Rows([]ui.GridSpec{ @@ -42,6 +43,7 @@ func NewAccountView(conf *config.AercConfig, acct *config.AccountConfig, Color(tcell.ColorDefault, tcell.ColorRed) return &AccountView{ acct: acct, + aerc: aerc, grid: grid, host: host, logger: logger, @@ -53,11 +55,12 @@ func NewAccountView(conf *config.AercConfig, acct *config.AccountConfig, grid.AddChild(ui.NewBordered(dirlist, ui.BORDER_RIGHT)) } - msglist := NewMessageList(conf, logger) + msglist := NewMessageList(conf, logger, aerc) grid.AddChild(msglist).At(0, 1) view := &AccountView{ acct: acct, + aerc: aerc, conf: conf, dirlist: dirlist, grid: grid, @@ -124,6 +127,10 @@ func (acct *AccountView) Draw(ctx *ui.Context) { acct.grid.Draw(ctx) } +func (acct *AccountView) MouseEvent(localX int, localY int, event tcell.Event) { + acct.grid.MouseEvent(localX, localY, event) +} + func (acct *AccountView) Focus(focus bool) { // TODO: Unfocus children I guess } diff --git a/widgets/aerc.go b/widgets/aerc.go index 87009cd..fe3c1e2 100644 --- a/widgets/aerc.go +++ b/widgets/aerc.go @@ -74,7 +74,7 @@ func NewAerc(conf *config.AercConfig, logger *log.Logger, conf.Triggers.ExecuteCommand = cmd for i, acct := range conf.Accounts { - view := NewAccountView(conf, &conf.Accounts[i], logger, aerc) + view := NewAccountView(aerc, conf, &conf.Accounts[i], logger, aerc) aerc.accounts[acct.Name] = view tabs.Add(view, acct.Name) } @@ -85,6 +85,22 @@ func NewAerc(conf *config.AercConfig, logger *log.Logger, aerc.NewTab(wizard, "New account") } + tabs.CloseTab = func(index int) { + switch content := aerc.tabs.Tabs[index].Content.(type) { + case *AccountView: + return + case *AccountWizard: + return + case *Composer: + aerc.RemoveTab(content) + content.Close() + case *Terminal: + content.Close(nil) + case *MessageViewer: + aerc.RemoveTab(content) + } + } + return aerc } @@ -235,7 +251,12 @@ func (aerc *Aerc) Event(event tcell.Event) bool { return false } case *tcell.EventMouse: - aerc.tabs.MouseEvent(event) + if event.Buttons() == tcell.ButtonNone { + return false + } + x, y := event.Position() + aerc.grid.MouseEvent(x, y, event) + return true } return false } @@ -260,8 +281,8 @@ func (aerc *Aerc) SelectedTab() ui.Drawable { return aerc.tabs.Tabs[aerc.tabs.Selected].Content } -func (aerc *Aerc) NewTab(drawable ui.Drawable, name string) *ui.Tab { - tab := aerc.tabs.Add(drawable, name) +func (aerc *Aerc) NewTab(clickable ui.Drawable, name string) *ui.Tab { + tab := aerc.tabs.Add(clickable, name) aerc.tabs.Select(len(aerc.tabs.Tabs) - 1) return tab } @@ -275,19 +296,11 @@ func (aerc *Aerc) ReplaceTab(tabSrc ui.Drawable, tabTarget ui.Drawable, name str } func (aerc *Aerc) NextTab() { - next := aerc.tabs.Selected + 1 - if next >= len(aerc.tabs.Tabs) { - next = 0 - } - aerc.tabs.Select(next) + aerc.tabs.NextTab() } func (aerc *Aerc) PrevTab() { - next := aerc.tabs.Selected - 1 - if next < 0 { - next = len(aerc.tabs.Tabs) - 1 - } - aerc.tabs.Select(next) + aerc.tabs.PrevTab() } func (aerc *Aerc) SelectTab(name string) bool { diff --git a/widgets/compose.go b/widgets/compose.go index bd4301a..0e7f09e 100644 --- a/widgets/compose.go +++ b/widgets/compose.go @@ -40,10 +40,12 @@ type Composer struct { worker *types.Worker layout HeaderLayout - focusable []ui.DrawableInteractive + focusable []ui.MouseableDrawableInteractive focused int onClose []func(ti *Composer) + + width int } func NewComposer(conf *config.AercConfig, @@ -87,10 +89,10 @@ func NewComposer(conf *config.AercConfig, func buildComposeHeader(layout HeaderLayout, defaults map[string]string) ( newLayout HeaderLayout, editors map[string]*headerEditor, - focusable []ui.DrawableInteractive, + focusable []ui.MouseableDrawableInteractive, ) { editors = make(map[string]*headerEditor) - focusable = make([]ui.DrawableInteractive, 0) + focusable = make([]ui.MouseableDrawableInteractive, 0) for _, row := range layout { for _, h := range row { @@ -99,7 +101,7 @@ func buildComposeHeader(layout HeaderLayout, defaults map[string]string) ( switch h { case "From": // Prepend From to support backtab - focusable = append([]ui.DrawableInteractive{e}, focusable...) + focusable = append([]ui.MouseableDrawableInteractive{e}, focusable...) default: focusable = append(focusable, e) } @@ -176,6 +178,7 @@ func (c *Composer) OnClose(fn func(composer *Composer)) { } func (c *Composer) Draw(ctx *ui.Context) { + c.width = ctx.Width() c.grid.Draw(ctx) } @@ -617,6 +620,16 @@ func (he *headerEditor) Draw(ctx *ui.Context) { he.input.Draw(ctx.Subcontext(size, 0, ctx.Width()-size, 1)) } +func (he *headerEditor) MouseEvent(localX int, localY int, event tcell.Event) { + switch event := event.(type) { + case *tcell.EventMouse: + width := runewidth.StringWidth(he.name + " ") + if localX >= width { + he.input.MouseEvent(localX-width, localY, event) + } + } +} + func (he *headerEditor) Invalidate() { he.input.Invalidate() } diff --git a/widgets/dirlist.go b/widgets/dirlist.go index 33119dd..ec73082 100644 --- a/widgets/dirlist.go +++ b/widgets/dirlist.go @@ -137,6 +137,35 @@ func (dirlist *DirectoryList) Draw(ctx *ui.Context) { } } +func (dirlist *DirectoryList) MouseEvent(localX int, localY int, event tcell.Event) { + switch event := event.(type) { + case *tcell.EventMouse: + switch event.Buttons() { + case tcell.Button1: + clickedDir, ok := dirlist.Clicked(localX, localY) + if ok { + dirlist.Select(clickedDir) + } + case tcell.WheelDown: + dirlist.Next() + case tcell.WheelUp: + dirlist.Prev() + } + } +} + +func (dirlist *DirectoryList) Clicked(x int, y int) (string, bool) { + if dirlist.dirs == nil || len(dirlist.dirs) == 0 { + return "", false + } + for i, name := range dirlist.dirs { + if i == y { + return name, true + } + } + return "", false +} + func (dirlist *DirectoryList) NextPrev(delta int) { curIdx := sort.SearchStrings(dirlist.dirs, dirlist.selected) if curIdx == len(dirlist.dirs) { diff --git a/widgets/msglist.go b/widgets/msglist.go index 8ed716b..b7c921c 100644 --- a/widgets/msglist.go +++ b/widgets/msglist.go @@ -25,6 +25,7 @@ type MessageList struct { spinner *Spinner store *lib.MessageStore isInitalizing bool + aerc *Aerc } type msgSorter struct { @@ -55,12 +56,13 @@ func (s *msgSorter) Swap(i, j int) { s.uids[j] = tmp } -func NewMessageList(conf *config.AercConfig, logger *log.Logger) *MessageList { +func NewMessageList(conf *config.AercConfig, logger *log.Logger, aerc *Aerc) *MessageList { ml := &MessageList{ conf: conf, logger: logger, spinner: NewSpinner(&conf.Ui), isInitalizing: true, + aerc: aerc, } ml.spinner.OnInvalidate(func(_ ui.Drawable) { ml.Invalidate() @@ -161,6 +163,47 @@ func (ml *MessageList) Draw(ctx *ui.Context) { } } +func (ml *MessageList) MouseEvent(localX int, localY int, event tcell.Event) { + switch event := event.(type) { + case *tcell.EventMouse: + switch event.Buttons() { + case tcell.Button1: + if ml.aerc == nil { + return + } + selectedMsg, ok := ml.Clicked(localX, localY) + if ok { + ml.Select(selectedMsg) + acct := ml.aerc.SelectedAccount() + if acct.Messages().Empty() { + return + } + store := acct.Messages().Store() + msg := acct.Messages().Selected() + if msg == nil { + return + } + viewer := NewMessageViewer(acct, ml.aerc.Config(), store, msg) + ml.aerc.NewTab(viewer, msg.Envelope.Subject) + } + case tcell.WheelDown: + ml.store.Next() + ml.Scroll() + case tcell.WheelUp: + ml.store.Prev() + ml.Scroll() + } + } +} + +func (ml *MessageList) Clicked(x, y int) (int, bool) { + store := ml.Store() + if store == nil || ml.nmsgs == 0 || y >= ml.nmsgs { + return 0, false + } + return y + ml.scroll, true +} + func (ml *MessageList) Height() int { return ml.height } diff --git a/widgets/msgviewer.go b/widgets/msgviewer.go index e210616..c179070 100644 --- a/widgets/msgviewer.go +++ b/widgets/msgviewer.go @@ -42,6 +42,9 @@ type PartSwitcher struct { selected int showHeaders bool alwaysShowMime bool + + height int + mv *MessageViewer } func NewMessageViewer(acct *AccountView, conf *config.AercConfig, @@ -77,7 +80,7 @@ func NewMessageViewer(acct *AccountView, conf *config.AercConfig, grid.AddChild(header).At(0, 0) grid.AddChild(switcher).At(1, 0) - return &MessageViewer{ + mv := &MessageViewer{ acct: acct, conf: conf, grid: grid, @@ -85,6 +88,9 @@ func NewMessageViewer(acct *AccountView, conf *config.AercConfig, store: store, switcher: switcher, } + switcher.mv = mv + + return mv } func fmtHeader(msg *models.MessageInfo, header string) string { @@ -194,6 +200,13 @@ func (mv *MessageViewer) Draw(ctx *ui.Context) { 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() } @@ -295,6 +308,7 @@ func (ps *PartSwitcher) Draw(ctx *ui.Context) { 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) @@ -311,6 +325,62 @@ func (ps *PartSwitcher) Draw(ctx *ui.Context) { 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.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 (mv *MessageViewer) Event(event tcell.Event) bool { return mv.switcher.Event(event) } diff --git a/widgets/terminal.go b/widgets/terminal.go index 008a36f..6ad6904 100644 --- a/widgets/terminal.go +++ b/widgets/terminal.go @@ -311,6 +311,20 @@ func (term *Terminal) Draw(ctx *ui.Context) { } } +func (term *Terminal) MouseEvent(localX int, localY int, event tcell.Event) { + switch event := event.(type) { + case *tcell.EventMouse: + if term.OnEvent != nil { + if term.OnEvent(event) { + return + } + } + if term.closed { + return + } + } +} + func (term *Terminal) Focus(focus bool) { if term.closed { return