Add Mouseable

This adds the Mouseable interface. When this is implemented for a
component that item can accept and process mouseevents.

At the top level when a mouse event is received it is passed to the
grid's handler and then it trickles down until it reaches a component
that can actually handle it, such as the tablist, dirlist or msglist.

A mouse event is passed so that components can handle other things such
as scrolling with the mousewheel. The components themselves then perform
the necessary actions.

Clicking emails in the messagelist opens them in a new tab.

Textinputs can be clicked to position the cursor inside them.

Mouseevents are not forwarded to the terminal at the moment.

Elements which do not handle mouse events are not required to implement
the Mouseable interface.
This commit is contained in:
Jeffas 2019-09-05 23:32:36 +01:00 committed by Drew DeVault
parent a441e3b3a5
commit f6216bb621
14 changed files with 378 additions and 49 deletions

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
}

View file

@ -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)

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View file

@ -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
}

View file

@ -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 {

View file

@ -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()
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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