From 1f765c8695c3f4b35dae57f469d3aee0b1adbde7 Mon Sep 17 00:00:00 2001 From: rivo <480930+rivo@users.noreply.github.com> Date: Sun, 29 Mar 2020 21:43:46 +0200 Subject: [PATCH] Merge pull request #422 from rivo/mouse Add mouse support --- application.go | 321 ++++++++++++++++---------------- box.go | 60 +++--- button.go | 14 +- checkbox.go | 18 +- demos/button/main.go | 2 +- demos/checkbox/main.go | 6 +- demos/dropdown/main.go | 2 +- demos/flex/main.go | 2 +- demos/form/main.go | 2 +- demos/frame/main.go | 2 +- demos/grid/main.go | 2 +- demos/inputfield/simple/main.go | 2 +- demos/list/main.go | 3 +- demos/modal/main.go | 2 +- demos/pages/main.go | 2 +- demos/presentation/cover.go | 4 +- demos/presentation/main.go | 56 ++---- demos/table/main.go | 2 +- demos/textview/main.go | 2 +- demos/treeview/main.go | 2 +- doc.go | 4 - doc_test.go | 20 +- dropdown.go | 113 ++++++----- events.go | 47 ----- flex.go | 25 ++- form.go | 48 +++-- frame.go | 18 +- grid.go | 25 ++- inputfield.go | 41 +++- list.go | 60 +++--- modal.go | 19 +- pages.go | 34 ++-- primitive.go | 8 +- table.go | 89 ++++++++- textview.go | 306 ++++++++++++++++++++++-------- treeview.go | 38 ++++ 36 files changed, 858 insertions(+), 543 deletions(-) delete mode 100644 events.go diff --git a/application.go b/application.go index 32b3446..e3e8c1d 100644 --- a/application.go +++ b/application.go @@ -8,11 +8,52 @@ import ( "github.com/gdamore/tcell" ) -// The size of the event/update/redraw channels. -const queueSize = 100 +const ( + // The size of the event/update/redraw channels. + queueSize = 100 -// The minimum duration between resize event callbacks. -const resizeEventThrottle = 200 * time.Millisecond + // The minimum time between two consecutive redraws. + redrawPause = 50 * time.Millisecond + + // The minimum duration between resize event callbacks. + resizeEventThrottle = 200 * time.Millisecond +) + +// DoubleClickInterval specifies the maximum time between clicks to register a +// double click rather than click. +var DoubleClickInterval = 500 * time.Millisecond + +// MouseAction indicates one of the actions the mouse is logically doing. +type MouseAction int16 + +// Available mouse actions. +const ( + MouseMove MouseAction = iota + MouseLeftDown + MouseLeftUp + MouseLeftClick + MouseLeftDoubleClick + MouseMiddleDown + MouseMiddleUp + MouseMiddleClick + MouseMiddleDoubleClick + MouseRightDown + MouseRightUp + MouseRightClick + MouseRightDoubleClick + MouseScrollUp + MouseScrollDown + MouseScrollLeft + MouseScrollRight +) + +// queuedUpdate represented the execution of f queued by +// Application.QueueUpdate(). The "done" channel receives exactly one element +// after f has executed. +type queuedUpdate struct { + f func() + done chan struct{} +} // Application represents the top node of an application. // @@ -84,14 +125,13 @@ type Application struct { // An optional capture function which receives a mouse event and returns the // event to be forwarded to the default mouse handler (nil if nothing should // be forwarded). - mouseCapture func(event *EventMouse) *EventMouse + mouseCapture func(event *tcell.EventMouse, action MouseAction) (*tcell.EventMouse, MouseAction) - // A temporary capture function overriding the above. - tempMouseCapture func(event *EventMouse) *EventMouse - - lastMouseX, lastMouseY int - lastMouseBtn tcell.ButtonMask - lastMouseTarget Primitive // nil if none + mouseCapturingPrimitive Primitive // A Primitive returned by a MouseHandler which will capture future mouse events. + lastMouseX, lastMouseY int // The last position of the mouse. + mouseDownX, mouseDownY int // The position of the mouse when its button was last pressed. + lastMouseClick time.Time // The time when a mouse button was last clicked. + lastMouseButtons tcell.ButtonMask // The last mouse button state. sync.RWMutex } @@ -131,45 +171,22 @@ func (a *Application) GetInputCapture() func(event *tcell.EventKey) *tcell.Event return a.inputCapture } -// SetMouseCapture sets a function which captures mouse events before they are +// SetMouseCapture sets a function which captures mouse events (consisting of +// the original tcell mouse event and the semantic mouse action) before they are // forwarded to the appropriate mouse event handler. This function can then // choose to forward that event (or a different one) by returning it or stop -// the event processing by returning nil. -func (a *Application) SetMouseCapture(capture func(event *EventMouse) *EventMouse) *Application { - a.Lock() +// the event processing by returning a nil mouse event. +func (a *Application) SetMouseCapture(capture func(event *tcell.EventMouse, action MouseAction) (*tcell.EventMouse, MouseAction)) *Application { a.mouseCapture = capture - a.Unlock() return a } // GetMouseCapture returns the function installed with SetMouseCapture() or nil // if no such function has been installed. -func (a *Application) GetMouseCapture() func(event *EventMouse) *EventMouse { - a.RLock() - defer a.RUnlock() - +func (a *Application) GetMouseCapture() func(event *tcell.EventMouse, action MouseAction) (*tcell.EventMouse, MouseAction) { return a.mouseCapture } -// SetTemporaryMouseCapture temporarily overrides the normal capture function. -// Calling this function from anywhere other than a widget may result in -// unexpected behavior. -func (a *Application) SetTemporaryMouseCapture(capture func(event *EventMouse) *EventMouse) *Application { - a.Lock() - a.tempMouseCapture = capture - a.Unlock() - return a -} - -// GetTemporaryMouseCapture returns the function installed with -// SetTemporaryMouseCapture() or nil if no such function has been installed. -func (a *Application) GetTemporaryMouseCapture() func(event *EventMouse) *EventMouse { - a.RLock() - defer a.RUnlock() - - return a.tempMouseCapture -} - // SetScreen allows you to provide your own tcell.Screen object. For most // applications, this is not needed and you should be familiar with // tcell.Screen when using this function. @@ -199,10 +216,17 @@ func (a *Application) SetScreen(screen tcell.Screen) *Application { } // EnableMouse enables mouse events. -func (a *Application) EnableMouse() *Application { +func (a *Application) EnableMouse(enable bool) *Application { a.Lock() - a.enableMouse = true - a.Unlock() + defer a.Unlock() + if enable != a.enableMouse && a.screen != nil { + if enable { + a.screen.EnableMouse() + } else { + a.screen.DisableMouse() + } + } + a.enableMouse = enable return a } @@ -301,10 +325,7 @@ EventLoop: a.RLock() p := a.focus inputCapture := a.inputCapture - mouseCapture := a.mouseCapture - tempMouseCapture := a.tempMouseCapture screen := a.screen - root := a.root a.RUnlock() switch event := event.(type) { @@ -368,85 +389,14 @@ EventLoop: a.draw() case *tcell.EventMouse: - atX, atY := event.Position() - btn := event.Buttons() - - pstack := a.appendStackAtPoint(nil, atX, atY) - var punderMouse Primitive - if len(pstack) > 0 { - punderMouse = pstack[len(pstack)-1] - } - var ptarget Primitive - if a.lastMouseBtn != 0 { - // While a button is down, the same primitive gets events. - ptarget = a.lastMouseTarget - } - if ptarget == nil { - ptarget = punderMouse - if ptarget == nil { - ptarget = root // Fallback to root. - } - } - a.lastMouseTarget = ptarget - - // Calculate mouse actions. - var act MouseAction - if atX != a.lastMouseX || atY != a.lastMouseY { - act |= MouseMove - a.lastMouseX = atX - a.lastMouseY = atY - } - btnDiff := btn ^ a.lastMouseBtn - if btnDiff != 0 { - if btn&btnDiff != 0 { - act |= MouseDown - } - if a.lastMouseBtn&btnDiff != 0 { - act |= MouseUp - } - if a.lastMouseBtn == tcell.Button1 && btn == 0 { - if ptarget == punderMouse { - // Only if Button1 and mouse up over same p. - act |= MouseClick - } - } - a.lastMouseBtn = btn - } - - event2 := NewEventMouse(event, ptarget, a, act) - - // Intercept event. - if tempMouseCapture != nil { - event2 = tempMouseCapture(event2) - if event2 == nil { - a.draw() - continue // Don't forward event. - } - } - if mouseCapture != nil { - event2 = mouseCapture(event2) - if event2 == nil { - a.draw() - continue // Don't forward event. - } - } - - if ptarget == punderMouse { - // Observe mouse events inward ("capture") - for _, pp := range pstack { - // If the primitive has this ObserveMouseEvent func. - if pp, ok := pp.(interface { - ObserveMouseEvent(*EventMouse) - }); ok { - pp.ObserveMouseEvent(event2) - } - } - } - - if handler := ptarget.MouseHandler(); handler != nil { - handler(event2) + consumed, isMouseDownAction := a.fireMouseActions(event) + if consumed { a.draw() } + a.lastMouseButtons = event.Buttons() + if isMouseDownAction { + a.mouseDownX, a.mouseDownY = event.Position() + } } // If we have updates, now is the time to execute them. @@ -462,48 +412,103 @@ EventLoop: return nil } -func findAtPoint(atX, atY int, p Primitive, capture func(p Primitive)) Primitive { - x, y, w, h := p.GetRect() - if atX < x || atY < y { - return nil +// fireMouseActions analyzes the provided mouse event, derives mouse actions +// from it and then forwards them to the corresponding primitives. +func (a *Application) fireMouseActions(event *tcell.EventMouse) (consumed, isMouseDownAction bool) { + // We want to relay follow-up events to the same target primitive. + var targetPrimitive Primitive + + // Helper function to fire a mouse action. + fire := func(action MouseAction) { + switch action { + case MouseLeftDown, MouseMiddleDown, MouseRightDown: + isMouseDownAction = true + } + + // Intercept event. + if a.mouseCapture != nil { + event, action = a.mouseCapture(event, action) + if event == nil { + consumed = true + return // Don't forward event. + } + } + + // Determine the target primitive. + var primitive, capturingPrimitive Primitive + if a.mouseCapturingPrimitive != nil { + primitive = a.mouseCapturingPrimitive + targetPrimitive = a.mouseCapturingPrimitive + } else if targetPrimitive != nil { + primitive = targetPrimitive + } else { + primitive = a.root + } + if primitive != nil { + if handler := primitive.MouseHandler(); handler != nil { + var wasConsumed bool + wasConsumed, capturingPrimitive = handler(action, event, func(p Primitive) { + a.SetFocus(p) + }) + if wasConsumed { + consumed = true + } + } + } + a.mouseCapturingPrimitive = capturingPrimitive } - if atX >= x+w || atY >= y+h { - return nil + + x, y := event.Position() + buttons := event.Buttons() + clickMoved := x != a.mouseDownX || y != a.mouseDownY + buttonChanges := buttons ^ a.lastMouseButtons + + if x != a.lastMouseX || y != a.lastMouseY { + fire(MouseMove) + a.lastMouseX = x + a.lastMouseY = y } - if capture != nil { - capture(p) - } - bestp := p - for _, pchild := range p.GetChildren() { - x := findAtPoint(atX, atY, pchild, capture) - if x != nil { - // Always overwrite if we find another one, - // this is because if any overlap, the last one is "on top". - bestp = x + + for _, buttonEvent := range []struct { + button tcell.ButtonMask + down, up, click, dclick MouseAction + }{ + {tcell.Button1, MouseLeftDown, MouseLeftUp, MouseLeftClick, MouseLeftDoubleClick}, + {tcell.Button2, MouseMiddleDown, MouseMiddleUp, MouseMiddleClick, MouseMiddleDoubleClick}, + {tcell.Button3, MouseRightDown, MouseRightUp, MouseRightClick, MouseRightDoubleClick}, + } { + if buttonChanges&buttonEvent.button != 0 { + if buttons&buttonEvent.button != 0 { + fire(buttonEvent.down) + } else { + fire(buttonEvent.up) + if !clickMoved { + if a.lastMouseClick.Add(DoubleClickInterval).Before(time.Now()) { + fire(buttonEvent.click) + a.lastMouseClick = time.Now() + } else { + fire(buttonEvent.dclick) + a.lastMouseClick = time.Time{} // reset + } + } + } } } - return bestp -} -// GetPrimitiveAtPoint returns the Primitive at the specified point, or nil. -// Note that this only works with a valid hierarchy of primitives (children) -func (a *Application) GetPrimitiveAtPoint(atX, atY int) Primitive { - a.RLock() - defer a.RUnlock() + for _, wheelEvent := range []struct { + button tcell.ButtonMask + action MouseAction + }{ + {tcell.WheelUp, MouseScrollUp}, + {tcell.WheelDown, MouseScrollDown}, + {tcell.WheelLeft, MouseScrollLeft}, + {tcell.WheelRight, MouseScrollRight}} { + if buttons&wheelEvent.button != 0 { + fire(wheelEvent.action) + } + } - return findAtPoint(atX, atY, a.root, nil) -} - -// The last element appended to buf is the primitive clicked, -// the preceeding are its parents. -func (a *Application) appendStackAtPoint(buf []Primitive, atX, atY int) []Primitive { - a.RLock() - defer a.RUnlock() - - findAtPoint(atX, atY, a.root, func(p Primitive) { - buf = append(buf, p) - }) - return buf + return consumed, isMouseDownAction } // Stop stops the application, causing Run() to return. diff --git a/box.go b/box.go index 2563a6b..a67a545 100644 --- a/box.go +++ b/box.go @@ -59,9 +59,9 @@ type Box struct { draw func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) // An optional capture function which receives a mouse event and returns the - // event to be forwarded to the primitive's default mouse event handler (nil if - // nothing should be forwarded). - mouseCapture func(event *EventMouse) *EventMouse + // event to be forwarded to the primitive's default mouse event handler (at + // least one nil if nothing should be forwarded). + mouseCapture func(action MouseAction, event *tcell.EventMouse) (MouseAction, *tcell.EventMouse) l sync.RWMutex } @@ -229,50 +229,56 @@ func (b *Box) GetInputCapture() func(event *tcell.EventKey) *tcell.EventKey { } // WrapMouseHandler wraps a mouse event handler (see MouseHandler()) with the -// functionality to capture input (see SetMouseCapture()) before passing it -// on to the provided (default) event handler. +// functionality to capture mouse events (see SetMouseCapture()) before passing +// them on to the provided (default) event handler. // // This is only meant to be used by subclassing primitives. -func (b *Box) WrapMouseHandler(mouseHandler func(*EventMouse)) func(*EventMouse) { - return func(event *EventMouse) { +func (b *Box) WrapMouseHandler(mouseHandler func(MouseAction, *tcell.EventMouse, func(p Primitive)) (bool, Primitive)) func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { if b.mouseCapture != nil { - event = b.mouseCapture(event) + action, event = b.mouseCapture(action, event) } if event != nil && mouseHandler != nil { - mouseHandler(event) + consumed, capture = mouseHandler(action, event, setFocus) } + return } } // MouseHandler returns nil. -func (b *Box) MouseHandler() func(event *EventMouse) { - b.l.RLock() - defer b.l.RUnlock() - - return b.WrapMouseHandler(nil) +func (b *Box) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return b.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + if action == MouseLeftClick && b.InRect(event.Position()) { + setFocus(b) + consumed = true + } + return + }) } -// SetMouseCapture installs a function which captures events before they are -// forwarded to the primitive's default event handler. This function can -// then choose to forward that event (or a different one) to the default -// handler by returning it. If nil is returned, the default handler will not -// be called. +// SetMouseCapture sets a function which captures mouse events (consisting of +// the original tcell mouse event and the semantic mouse action) before they are +// forwarded to the primitive's default mouse event handler. This function can +// then choose to forward that event (or a different one) by returning it or +// returning a nil mouse event, in which case the default handler will not be +// called. // // Providing a nil handler will remove a previously existing handler. -func (b *Box) SetMouseCapture(capture func(*EventMouse) *EventMouse) *Box { - b.l.Lock() - defer b.l.Unlock() - +func (b *Box) SetMouseCapture(capture func(action MouseAction, event *tcell.EventMouse) (MouseAction, *tcell.EventMouse)) *Box { b.mouseCapture = capture return b } +// InRect returns true if the given coordinate is within the bounds of the box's +// rectangle. +func (b *Box) InRect(x, y int) bool { + rectX, rectY, width, height := b.GetRect() + return x >= rectX && x < rectX+width && y >= rectY && y < rectY+height +} + // GetMouseCapture returns the function installed with SetMouseCapture() or nil // if no such function has been installed. -func (b *Box) GetMouseCapture() func(*EventMouse) *EventMouse { - b.l.RLock() - defer b.l.RUnlock() - +func (b *Box) GetMouseCapture() func(action MouseAction, event *tcell.EventMouse) (MouseAction, *tcell.EventMouse) { return b.mouseCapture } diff --git a/button.go b/button.go index 31a90eb..81eee6f 100644 --- a/button.go +++ b/button.go @@ -167,13 +167,21 @@ func (b *Button) InputHandler() func(event *tcell.EventKey, setFocus func(p Prim } // MouseHandler returns the mouse handler for this primitive. -func (b *Button) MouseHandler() func(event *EventMouse) { - return b.WrapMouseHandler(func(event *EventMouse) { +func (b *Button) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return b.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + if !b.InRect(event.Position()) { + return false, nil + } + // Process mouse event. - if event.Action()&MouseClick != 0 { + if action == MouseLeftClick { + setFocus(b) if b.selected != nil { b.selected() } + consumed = true } + + return }) } diff --git a/checkbox.go b/checkbox.go index 67c0e2c..753d4c5 100644 --- a/checkbox.go +++ b/checkbox.go @@ -279,16 +279,24 @@ func (c *Checkbox) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr } // MouseHandler returns the mouse handler for this primitive. -func (c *Checkbox) MouseHandler() func(event *EventMouse) { - return c.WrapMouseHandler(func(event *EventMouse) { +func (c *Checkbox) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return c.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + x, y := event.Position() + _, rectY, _, _ := c.GetInnerRect() + if !c.InRect(x, y) { + return false, nil + } + // Process mouse event. - if event.Action()&MouseClick != 0 { - c.Lock() + if action == MouseLeftClick && y == rectY { + setFocus(c) c.checked = !c.checked - c.Unlock() if c.changed != nil { c.changed(c.checked) } + consumed = true } + + return }) } diff --git a/demos/button/main.go b/demos/button/main.go index 729b269..039adf6 100644 --- a/demos/button/main.go +++ b/demos/button/main.go @@ -9,7 +9,7 @@ func main() { app.Stop() }) button.SetBorder(true).SetRect(0, 0, 22, 3) - if err := app.SetRoot(button, false).Run(); err != nil { + if err := app.SetRoot(button, false).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/checkbox/main.go b/demos/checkbox/main.go index d9e04e5..ef5f654 100644 --- a/demos/checkbox/main.go +++ b/demos/checkbox/main.go @@ -1,12 +1,14 @@ // Demo code for the Checkbox primitive. package main -import "gitlab.com/tslocum/cview" +import ( + "gitlab.com/tslocum/cview" +) func main() { app := cview.NewApplication() checkbox := cview.NewCheckbox().SetLabel("Hit Enter to check box: ") - if err := app.SetRoot(checkbox, true).Run(); err != nil { + if err := app.SetRoot(checkbox, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/dropdown/main.go b/demos/dropdown/main.go index 650ffa8..d54d655 100644 --- a/demos/dropdown/main.go +++ b/demos/dropdown/main.go @@ -8,7 +8,7 @@ func main() { dropdown := cview.NewDropDown(). SetLabel("Select an option (hit Enter): "). SetOptions([]string{"First", "Second", "Third", "Fourth", "Fifth"}, nil) - if err := app.SetRoot(dropdown, true).Run(); err != nil { + if err := app.SetRoot(dropdown, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/flex/main.go b/demos/flex/main.go index ae56296..a8de152 100644 --- a/demos/flex/main.go +++ b/demos/flex/main.go @@ -14,7 +14,7 @@ func main() { AddItem(cview.NewBox().SetBorder(true).SetTitle("Middle (3 x height of Top)"), 0, 3, false). AddItem(cview.NewBox().SetBorder(true).SetTitle("Bottom (5 rows)"), 5, 1, false), 0, 2, false). AddItem(cview.NewBox().SetBorder(true).SetTitle("Right (20 cols)"), 20, 1, false) - if err := app.SetRoot(flex, true).Run(); err != nil { + if err := app.SetRoot(flex, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/form/main.go b/demos/form/main.go index 0318025..8cccc13 100644 --- a/demos/form/main.go +++ b/demos/form/main.go @@ -18,7 +18,7 @@ func main() { app.Stop() }) form.SetBorder(true).SetTitle("Enter some data").SetTitleAlign(cview.AlignLeft) - if err := app.SetRoot(form, true).Run(); err != nil { + if err := app.SetRoot(form, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/frame/main.go b/demos/frame/main.go index 794b99f..362bc65 100644 --- a/demos/frame/main.go +++ b/demos/frame/main.go @@ -16,7 +16,7 @@ func main() { AddText("Header second middle", true, cview.AlignCenter, tcell.ColorRed). AddText("Footer middle", false, cview.AlignCenter, tcell.ColorGreen). AddText("Footer second middle", false, cview.AlignCenter, tcell.ColorGreen) - if err := app.SetRoot(frame, true).Run(); err != nil { + if err := app.SetRoot(frame, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/grid/main.go b/demos/grid/main.go index f8f8add..867027d 100644 --- a/demos/grid/main.go +++ b/demos/grid/main.go @@ -32,7 +32,7 @@ func main() { AddItem(main, 1, 1, 1, 1, 0, 100, false). AddItem(sideBar, 1, 2, 1, 1, 0, 100, false) - if err := cview.NewApplication().SetRoot(grid, true).Run(); err != nil { + if err := cview.NewApplication().SetRoot(grid, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/inputfield/simple/main.go b/demos/inputfield/simple/main.go index a10ca2e..e45f477 100644 --- a/demos/inputfield/simple/main.go +++ b/demos/inputfield/simple/main.go @@ -16,7 +16,7 @@ func main() { SetDoneFunc(func(key tcell.Key) { app.Stop() }) - if err := app.SetRoot(inputField, true).Run(); err != nil { + if err := app.SetRoot(inputField, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/list/main.go b/demos/list/main.go index dd1cc31..1460c7d 100644 --- a/demos/list/main.go +++ b/demos/list/main.go @@ -15,8 +15,7 @@ func main() { AddItem("Quit", "Press to exit", 'q', func() { app.Stop() }) - app.EnableMouse() - if err := app.SetRoot(list, true).Run(); err != nil { + if err := app.SetRoot(list, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/modal/main.go b/demos/modal/main.go index 04dc401..76c773f 100644 --- a/demos/modal/main.go +++ b/demos/modal/main.go @@ -15,7 +15,7 @@ func main() { app.Stop() } }) - if err := app.SetRoot(modal, false).Run(); err != nil { + if err := app.SetRoot(modal, false).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/pages/main.go b/demos/pages/main.go index 279aeea..e7c2d5e 100644 --- a/demos/pages/main.go +++ b/demos/pages/main.go @@ -29,7 +29,7 @@ func main() { page == 0) }(page) } - if err := app.SetRoot(pages, true).Run(); err != nil { + if err := app.SetRoot(pages, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/presentation/cover.go b/demos/presentation/cover.go index 7f8438c..504f7e4 100644 --- a/demos/presentation/cover.go +++ b/demos/presentation/cover.go @@ -19,6 +19,7 @@ const logo = ` const ( subtitle = `Terminal-based user interface toolkit` navigation = `Ctrl-N: Next slide Ctrl-P: Previous slide Ctrl-C: Exit` + mouse = `(or use your mouse)` ) // Cover returns the cover page. @@ -44,7 +45,8 @@ func Cover(nextSlide func()) (title string, content cview.Primitive) { SetBorders(0, 0, 0, 0, 0, 0). AddText(subtitle, true, cview.AlignCenter, tcell.ColorWhite). AddText("", true, cview.AlignCenter, tcell.ColorWhite). - AddText(navigation, true, cview.AlignCenter, tcell.ColorDarkMagenta) + AddText(navigation, true, cview.AlignCenter, tcell.ColorDarkMagenta). + AddText(mouse, true, cview.AlignCenter, tcell.ColorDarkMagenta) // Create a Flex layout that centers the logo and subtitle. flex := cview.NewFlex(). diff --git a/demos/presentation/main.go b/demos/presentation/main.go index 23b4c4b..cc01072 100644 --- a/demos/presentation/main.go +++ b/demos/presentation/main.go @@ -62,27 +62,29 @@ func main() { End, } + pages := cview.NewPages() + // The bottom row has some info on where we are. info := cview.NewTextView(). SetDynamicColors(true). SetRegions(true). - SetWrap(false) + SetWrap(false). + SetHighlightedFunc(func(added, removed, remaining []string) { + pages.SwitchToPage(added[0]) + }) // Create the pages for all slides. - currentSlide := 0 - info.Highlight(strconv.Itoa(currentSlide)) - pages := cview.NewPages() previousSlide := func() { - currentSlide = (currentSlide - 1 + len(slides)) % len(slides) - info.Highlight(strconv.Itoa(currentSlide)). + slide, _ := strconv.Atoi(info.GetHighlights()[0]) + slide = (slide - 1 + len(slides)) % len(slides) + info.Highlight(strconv.Itoa(slide)). ScrollToHighlight() - pages.SwitchToPage(strconv.Itoa(currentSlide)) } nextSlide := func() { - currentSlide = (currentSlide + 1) % len(slides) - info.Highlight(strconv.Itoa(currentSlide)). + slide, _ := strconv.Atoi(info.GetHighlights()[0]) + slide = (slide + 1) % len(slides) + info.Highlight(strconv.Itoa(slide)). ScrollToHighlight() - pages.SwitchToPage(strconv.Itoa(currentSlide)) } cursor := 0 @@ -91,11 +93,12 @@ func main() { slideRegions = append(slideRegions, cursor) title, primitive := slide(nextSlide) - pages.AddPage(strconv.Itoa(index), primitive, true, index == currentSlide) + pages.AddPage(strconv.Itoa(index), primitive, true, index == 0) fmt.Fprintf(info, `%d ["%d"][darkcyan]%s[white][""] `, index+1, index, title) cursor += len(title) + 4 } + info.Highlight("0") // Create the main layout. layout := cview.NewFlex(). @@ -113,37 +116,8 @@ func main() { return event }) - app.EnableMouse() - - var screenHeight int - - app.SetAfterResizeFunc(func(_ int, height int) { - screenHeight = height - }) - - app.SetMouseCapture(func(event *cview.EventMouse) *cview.EventMouse { - atX, atY := event.Position() - if event.Action()&cview.MouseDown != 0 && atY == screenHeight-1 { - slideClicked := -1 - for i, region := range slideRegions { - if atX >= region { - slideClicked = i - } - } - if slideClicked >= 0 { - currentSlide = slideClicked - info.Highlight(strconv.Itoa(currentSlide)). - ScrollToHighlight() - pages.SwitchToPage(strconv.Itoa(currentSlide)) - } - return nil - } - - return event - }) - // Start the application. - if err := app.SetRoot(layout, true).Run(); err != nil { + if err := app.SetRoot(layout, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/table/main.go b/demos/table/main.go index bb7e3c3..4eb415e 100644 --- a/demos/table/main.go +++ b/demos/table/main.go @@ -39,7 +39,7 @@ func main() { table.GetCell(row, column).SetTextColor(tcell.ColorRed) table.SetSelectable(false, false) }) - if err := app.SetRoot(table, true).Run(); err != nil { + if err := app.SetRoot(table, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/textview/main.go b/demos/textview/main.go index 1f7adfc..4da4a7a 100644 --- a/demos/textview/main.go +++ b/demos/textview/main.go @@ -63,7 +63,7 @@ func main() { } }) textView.SetBorder(true) - if err := app.SetRoot(textView, true).Run(); err != nil { + if err := app.SetRoot(textView, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/treeview/main.go b/demos/treeview/main.go index c86a181..c8abeb8 100644 --- a/demos/treeview/main.go +++ b/demos/treeview/main.go @@ -56,7 +56,7 @@ func main() { } }) - if err := cview.NewApplication().SetRoot(tree, true).Run(); err != nil { + if err := cview.NewApplication().SetRoot(tree, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/doc.go b/doc.go index 0762d4d..5e918e5 100644 --- a/doc.go +++ b/doc.go @@ -59,10 +59,6 @@ Application.EnableMouse documentation. Mouse events are passed to: -- The handler set with SetTemporaryMouseCapture, which is reserved for use by -widgets to temporarily intercept mouse events, such as to close a Dropdown when -the user clicks outside of the list. - - The handler set with SetMouseCapture, which is reserved for use by application developers to permanently intercept mouse events. diff --git a/doc_test.go b/doc_test.go index 30cde9b..5adbd3e 100644 --- a/doc_test.go +++ b/doc_test.go @@ -61,27 +61,33 @@ func ExampleNewApplication() { // Example of an application with mouse support. func ExampleApplication_EnableMouse() { // Initialize application and enable mouse support. - app := NewApplication().EnableMouse() + app := NewApplication() // Create a textview. tv := NewTextView().SetText("Click somewhere!") // Set a mouse capture function which prints where the mouse was clicked. - app.SetMouseCapture(func(event *EventMouse) *EventMouse { - if event.Action()&MouseDown != 0 && event.Buttons()&tcell.Button1 != 0 { + app.SetMouseCapture(func(event *tcell.EventMouse, action MouseAction) (*tcell.EventMouse, MouseAction) { + if action == MouseLeftClick || action == MouseLeftDoubleClick { + actionLabel := "click" + if action == MouseLeftDoubleClick { + actionLabel = "double-click" + } + x, y := event.Position() - fmt.Fprintf(tv, "\nYou clicked at %d,%d! Amazing!", x, y) + + fmt.Fprintf(tv, "\nYou %sed at %d,%d! Amazing!", actionLabel, x, y) // Return nil to stop propagating the event to any remaining handlers. - return nil + return nil, 0 } // Return the event to continue propagating it. - return event + return event, action }) // Run the application. - if err := app.SetRoot(tv, true).Run(); err != nil { + if err := app.EnableMouse(true).SetRoot(tv, true).Run(); err != nil { panic(err) } } diff --git a/dropdown.go b/dropdown.go index 4b44a91..d6d9e2c 100644 --- a/dropdown.go +++ b/dropdown.go @@ -81,6 +81,9 @@ type DropDown struct { // selection. selected func(text string, index int) + // Set to true when mouse dragging is in progress. + dragging bool + sync.RWMutex } @@ -482,7 +485,7 @@ func (d *DropDown) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr d.evalPrefix() } - d.openList(setFocus, nil) + d.openList(setFocus) case tcell.KeyEscape, tcell.KeyTab, tcell.KeyBacktab: if d.done != nil { d.done(key) @@ -494,8 +497,7 @@ func (d *DropDown) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr }) } -// A helper function which selects an item in the drop-down list based on -// the current prefix. +// evalPrefix selects an item in the drop-down list based on the current prefix. func (d *DropDown) evalPrefix() { if len(d.prefix) > 0 { for index, option := range d.options { @@ -504,31 +506,33 @@ func (d *DropDown) evalPrefix() { return } } + // Prefix does not match any item. Remove last rune. r := []rune(d.prefix) d.prefix = string(r[:len(r)-1]) } } -// Hand control over to the list. -func (d *DropDown) openList(setFocus func(Primitive), app *Application) { +// openList hands control over to the embedded List primitive. +func (d *DropDown) openList(setFocus func(Primitive)) { d.open = true optionBefore := d.currentOption + d.list.SetSelectedFunc(func(index int, mainText, secondaryText string, shortcut rune) { + if d.dragging { + return // If we're dragging the mouse, we don't want to trigger any events. + } + // An option was selected. Close the list again. d.currentOption = index - d.closeList(setFocus, app) + d.closeList(setFocus) // Trigger "selected" event. if d.selected != nil { - d.Unlock() d.selected(d.options[d.currentOption].Text, d.currentOption) - d.Lock() } if d.options[d.currentOption].Selected != nil { - d.Unlock() d.options[d.currentOption].Selected() - d.Lock() } }).SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyRune { @@ -542,50 +546,20 @@ func (d *DropDown) openList(setFocus func(Primitive), app *Application) { d.evalPrefix() } else if event.Key() == tcell.KeyEscape { d.currentOption = optionBefore - d.closeList(setFocus, app) + d.closeList(setFocus) } else { d.prefix = "" } + return event }) - if app != nil { - app.SetTemporaryMouseCapture(func(event *EventMouse) *EventMouse { - if d.open { - // Forward the mouse event to the list. - atX, atY := event.Position() - x, y, w, h := d.list.GetInnerRect() - if atX >= x && atY >= y && atX < x+w && atY < y+h { - // Mouse is within the list. - if handler := d.list.MouseHandler(); handler != nil { - if event.Action()&MouseUp != 0 { - // Treat mouse up as click here. - // This allows you to expand and select in one go. - event = NewEventMouse(event.EventMouse, - event.Target(), event.Application(), - event.Action()|MouseClick) - } - handler(event) - return nil // handled - } - } else { - // Mouse not within the list. - if event.Action()&MouseDown != 0 { - // If a mouse button was pressed, cancel this capture. - d.closeList(event.SetFocus, app) - } - } - } - return event - }) - } + setFocus(d.list) } -func (d *DropDown) closeList(setFocus func(Primitive), app *Application) { - if app != nil { - app.SetTemporaryMouseCapture(nil) - } - +// closeList closes the embedded List element by hiding it and removing focus +// from it. +func (d *DropDown) closeList(setFocus func(Primitive)) { d.open = false if d.list.HasFocus() { setFocus(d) @@ -612,20 +586,43 @@ func (d *DropDown) HasFocus() bool { } // MouseHandler returns the mouse handler for this primitive. -func (d *DropDown) MouseHandler() func(event *EventMouse) { - return d.WrapMouseHandler(func(event *EventMouse) { - // Process mouse event. - if event.Action()&MouseDown != 0 && event.Buttons()&tcell.Button1 != 0 { - d.Lock() - defer d.Unlock() +func (d *DropDown) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return d.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + // Was the mouse event in the drop-down box itself (or on its label)? + x, y := event.Position() + _, rectY, _, _ := d.GetInnerRect() + inRect := y == rectY + if !d.open && !inRect { + return d.InRect(x, y), nil // No, and it's not expanded either. Ignore. + } - //d.open = !d.open - //event.SetFocus(d) - if d.open { - d.closeList(event.SetFocus, event.Application()) - } else { - d.openList(event.SetFocus, event.Application()) + // Handle dragging. Clicks are implicitly handled by this logic. + switch action { + case MouseLeftDown: + consumed = d.open || inRect + capture = d + if !d.open { + d.openList(setFocus) + d.dragging = true + } else if consumed, _ := d.list.MouseHandler()(MouseLeftClick, event, setFocus); !consumed { + d.closeList(setFocus) // Close drop-down if clicked outside of it. + } + case MouseMove: + if d.dragging { + // We pretend it's a left click so we can see the selection during + // dragging. Because we don't act upon it, it's not a problem. + d.list.MouseHandler()(MouseLeftClick, event, setFocus) + consumed = true + capture = d + } + case MouseLeftUp: + if d.dragging { + d.dragging = false + d.list.MouseHandler()(MouseLeftClick, event, setFocus) + consumed = true } } + + return }) } diff --git a/events.go b/events.go deleted file mode 100644 index 64010c6..0000000 --- a/events.go +++ /dev/null @@ -1,47 +0,0 @@ -package cview - -import "github.com/gdamore/tcell" - -// MouseAction are bit flags indicating what the mouse is logically doing. -type MouseAction int - -// All MouseActions -const ( - MouseDown MouseAction = 1 << iota - MouseUp - MouseClick // Button1 only. - MouseMove // The mouse position changed. -) - -// EventMouse is the mouse event info. -type EventMouse struct { - *tcell.EventMouse - target Primitive - app *Application - action MouseAction -} - -// Target gets the target Primitive of the mouse event. -func (e *EventMouse) Target() Primitive { - return e.target -} - -// Application gets the event originating *Application. -func (e *EventMouse) Application() *Application { - return e.app -} - -// Action gets the mouse action of this event. -func (e *EventMouse) Action() MouseAction { - return e.action -} - -// SetFocus will set focus to the primitive. -func (e *EventMouse) SetFocus(p Primitive) { - e.app.SetFocus(p) -} - -// NewEventMouse creates a new mouse event. -func NewEventMouse(base *tcell.EventMouse, target Primitive, app *Application, action MouseAction) *EventMouse { - return &EventMouse{base, target, app, action} -} diff --git a/flex.go b/flex.go index 75d3506..a8448bd 100644 --- a/flex.go +++ b/flex.go @@ -226,14 +226,21 @@ func (f *Flex) HasFocus() bool { return false } -// GetChildren returns all primitives that have been added. -func (f *Flex) GetChildren() []Primitive { - f.Lock() - defer f.Unlock() +// MouseHandler returns the mouse handler for this primitive. +func (f *Flex) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return f.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + if !f.InRect(event.Position()) { + return false, nil + } - children := make([]Primitive, len(f.items)) - for i, item := range f.items { - children[i] = item.Item - } - return children + // Pass mouse events along to the first child item that takes it. + for _, item := range f.items { + consumed, capture = item.Item.MouseHandler()(action, event, setFocus) + if consumed { + return + } + } + + return + }) } diff --git a/form.go b/form.go index 3c7aca5..0de8007 100644 --- a/form.go +++ b/form.go @@ -754,20 +754,38 @@ func (f *Form) focusIndex() int { return -1 } -// GetChildren returns all primitives that have been added. -func (f *Form) GetChildren() []Primitive { - f.Lock() - defer f.Unlock() +// MouseHandler returns the mouse handler for this primitive. +func (f *Form) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return f.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + if !f.InRect(event.Position()) { + return false, nil + } - children := make([]Primitive, len(f.items)+len(f.buttons)) - i := 0 - for _, item := range f.items { - children[i] = item - i++ - } - for _, button := range f.buttons { - children[i] = button - i++ - } - return children + // Determine items to pass mouse events to. + for _, item := range f.items { + consumed, capture = item.MouseHandler()(action, event, setFocus) + if consumed { + return + } + } + for _, button := range f.buttons { + consumed, capture = button.MouseHandler()(action, event, setFocus) + if consumed { + return + } + } + + // A mouse click anywhere else will return the focus to the last selected + // element. + if action == MouseLeftClick { + if f.focusedElement < len(f.items) { + setFocus(f.items[f.focusedElement]) + } else if f.focusedElement < len(f.items)+len(f.buttons) { + setFocus(f.buttons[f.focusedElement-len(f.items)]) + } + consumed = true + } + + return + }) } diff --git a/frame.go b/frame.go index 259a9e6..09ed82b 100644 --- a/frame.go +++ b/frame.go @@ -14,8 +14,8 @@ type frameText struct { Color tcell.Color // The text color. } -// Frame is a wrapper which adds a border around another primitive. The top area -// (header) and the bottom area (footer) may also contain text. +// Frame is a wrapper which adds space around another primitive. In addition, +// the top area (header) and the bottom area (footer) may also contain text. // // See https://gitlab.com/tslocum/cview/wiki/Frame for an example. type Frame struct { @@ -179,10 +179,14 @@ func (f *Frame) HasFocus() bool { return false } -// GetChildren returns all primitives that have been added. -func (f *Frame) GetChildren() []Primitive { - f.Lock() - defer f.Unlock() +// MouseHandler returns the mouse handler for this primitive. +func (f *Frame) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return f.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + if !f.InRect(event.Position()) { + return false, nil + } - return []Primitive{f.primitive} + // Pass mouse events on to contained primitive. + return f.primitive.MouseHandler()(action, event, setFocus) + }) } diff --git a/grid.go b/grid.go index a8d0dd9..339d3c9 100644 --- a/grid.go +++ b/grid.go @@ -720,14 +720,21 @@ func (g *Grid) Draw(screen tcell.Screen) { } } -// GetChildren returns all primitives that have been added. -func (g *Grid) GetChildren() []Primitive { - g.Lock() - defer g.Unlock() +// MouseHandler returns the mouse handler for this primitive. +func (g *Grid) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return g.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + if !g.InRect(event.Position()) { + return false, nil + } - children := make([]Primitive, len(g.items)) - for i, item := range g.items { - children[i] = item.Item - } - return children + // Pass mouse events along to the first child item that takes it. + for _, item := range g.items { + consumed, capture = item.Item.MouseHandler()(action, event, setFocus) + if consumed { + return + } + } + + return + }) } diff --git a/inputfield.go b/inputfield.go index dc73f94..351eac4 100644 --- a/inputfield.go +++ b/inputfield.go @@ -69,9 +69,6 @@ type InputField struct { // The cursor position as a byte index into the text string. cursorPos int - // The number of bytes of the text string skipped ahead while drawing. - offset int - // An optional autocomplete function which receives the current text of the // input field and returns a slice of strings to be displayed in a drop-down // selection. @@ -96,6 +93,12 @@ type InputField struct { // this form item. finished func(tcell.Key) + // The x-coordinate of the input field as determined during the last call to Draw(). + fieldX int + + // The number of bytes of the text string skipped ahead while drawing. + offset int + sync.RWMutex } @@ -396,6 +399,7 @@ func (i *InputField) Draw(screen tcell.Screen) { } // Draw input area. + i.fieldX = x fieldWidth := i.fieldWidth if fieldWidth == 0 { fieldWidth = math.MaxInt32 @@ -681,11 +685,32 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p } // MouseHandler returns the mouse handler for this primitive. -func (i *InputField) MouseHandler() func(event *EventMouse) { - return i.WrapMouseHandler(func(event *EventMouse) { - // Process mouse event. - if event.Action()&MouseDown != 0 { - event.SetFocus(i) +func (i *InputField) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return i.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + x, y := event.Position() + _, rectY, _, _ := i.GetInnerRect() + if !i.InRect(x, y) { + return false, nil } + + // Process mouse event. + if action == MouseLeftClick && y == rectY { + // Determine where to place the cursor. + if x >= i.fieldX { + if !iterateString(i.text, func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth int) bool { + if x-i.fieldX < screenPos+screenWidth { + i.cursorPos = textPos + return true + } + return false + }) { + i.cursorPos = len(i.text) + } + } + setFocus(i) + consumed = true + } + + return }) } diff --git a/list.go b/list.go index 6517f09..9a447f2 100644 --- a/list.go +++ b/list.go @@ -696,54 +696,68 @@ func (l *List) InputHandler() func(event *tcell.EventKey, setFocus func(p Primit }) } -// returns -1 if not found. -func (l *List) indexAtPoint(atX, atY int) int { - _, y, _, h := l.GetInnerRect() - if atY < y || atY >= y+h { +// indexAtPoint returns the index of the list item found at the given position +// or a negative value if there is no such list item. +func (l *List) indexAtPoint(x, y int) int { + rectX, rectY, width, height := l.GetInnerRect() + if rectX < 0 || rectX >= rectX+width || y < rectY || y >= rectY+height { return -1 } - n := atY - y + index := y - rectY if l.showSecondaryText { - n /= 2 + index /= 2 } + index += l.offset - if n >= len(l.items) { + if index >= len(l.items) { return -1 } - return n + return index } // MouseHandler returns the mouse handler for this primitive. -func (l *List) MouseHandler() func(event *EventMouse) { - return l.WrapMouseHandler(func(event *EventMouse) { - // Process mouse event. - if event.Action()&MouseClick != 0 { - l.Lock() +func (l *List) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return l.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + if !l.InRect(event.Position()) { + return false, nil + } - atX, atY := event.Position() - index := l.indexAtPoint(atX, atY) + // Process mouse event. + switch action { + case MouseLeftClick: + setFocus(l) + index := l.indexAtPoint(event.Position()) if index != -1 { item := l.items[index] if item.Selected != nil { - l.Unlock() item.Selected() - l.Lock() } if l.selected != nil { - l.Unlock() l.selected(index, item.MainText, item.SecondaryText, item.Shortcut) - l.Lock() } if index != l.currentItem && l.changed != nil { - l.Unlock() l.changed(index, item.MainText, item.SecondaryText, item.Shortcut) - l.Lock() } l.currentItem = index } - - l.Unlock() + consumed = true + case MouseScrollUp: + if l.offset > 0 { + l.offset-- + } + consumed = true + case MouseScrollDown: + lines := len(l.items) - l.offset + if l.showSecondaryText { + lines *= 2 + } + if _, _, _, height := l.GetInnerRect(); lines > height { + l.offset++ + } + consumed = true } + + return }) } diff --git a/modal.go b/modal.go index b2b60a6..6fab7c2 100644 --- a/modal.go +++ b/modal.go @@ -14,7 +14,7 @@ import ( type Modal struct { *Box - // The framed embedded in the modal. + // The frame embedded in the modal. frame *Frame // The form embedded in the modal's frame. @@ -213,10 +213,15 @@ func (m *Modal) Draw(screen tcell.Screen) { m.frame.Draw(screen) } -// GetChildren returns all primitives that have been added. -func (m *Modal) GetChildren() []Primitive { - m.Lock() - defer m.Unlock() - - return []Primitive{m.frame} +// MouseHandler returns the mouse handler for this primitive. +func (m *Modal) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return m.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + // Pass mouse events on to the form. + consumed, capture = m.form.MouseHandler()(action, event, setFocus) + if !consumed && action == MouseLeftClick && m.InRect(event.Position()) { + setFocus(m) + consumed = true + } + return + }) } diff --git a/pages.go b/pages.go index 88bcf8f..a243f9e 100644 --- a/pages.go +++ b/pages.go @@ -22,7 +22,7 @@ type page struct { type Pages struct { *Box - // The contained pages. + // The contained pages. (Visible) pages are drawn from back to front. pages []*page // We keep a reference to the function which allows us to set the focus to @@ -368,18 +368,24 @@ func (p *Pages) Draw(screen tcell.Screen) { } } -// GetChildren returns all primitives that have been added. -func (p *Pages) GetChildren() []Primitive { - p.Lock() - defer p.Unlock() - - var children []Primitive - for _, page := range p.pages { - // Considering invisible pages as not children. - // Even though we track all the pages, not all are "children" currently. - if page.Visible { - children = append(children, page.Item) +// MouseHandler returns the mouse handler for this primitive. +func (p *Pages) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return p.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + if !p.InRect(event.Position()) { + return false, nil } - } - return children + + // Pass mouse events along to the last visible page item that takes it. + for index := len(p.pages) - 1; index >= 0; index-- { + page := p.pages[index] + if page.Visible { + consumed, capture = page.Item.MouseHandler()(action, event, setFocus) + if consumed { + return + } + } + } + + return + }) } diff --git a/primitive.go b/primitive.go index e70504d..9714471 100644 --- a/primitive.go +++ b/primitive.go @@ -44,16 +44,14 @@ type Primitive interface { // GetFocusable returns the item's Focusable. GetFocusable() Focusable - // GetChildren returns all child primitives that have been added. - GetChildren() []Primitive - // MouseHandler returns a handler which receives mouse events. // It is called by the Application class. // - // A value of nil may also be returned to stop propagation. + // A value of nil may also be returned to stop the downward propagation of + // mouse events. // // The Box class provides functionality to intercept mouse events. If you // subclass from Box, it is recommended that you wrap your handler using // Box.WrapMouseHandler() so you inherit that functionality. - MouseHandler() func(event *EventMouse) + MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) } diff --git a/table.go b/table.go index 5efbeca..38b0c97 100644 --- a/table.go +++ b/table.go @@ -290,6 +290,13 @@ type Table struct { // The number of visible rows the last time the table was drawn. visibleRows int + // The indices of the visible columns as of the last time the table was drawn. + visibleColumnIndices []int + + // The net widths of the visible columns as of the last time the table was + // drawn. + visibleColumnWidths []int + // Visibility of the scroll bar. scrollBarVisibility ScrollBarVisibility @@ -454,8 +461,8 @@ func (t *Table) GetSelection() (row, column int) { // Select sets the selected cell. Depending on the selection settings // specified via SetSelectable(), this may be an entire row or column, or even // ignored completely. The "selection changed" event is fired if such a callback -// is available (even if the selection ends up being the same as before, even if -// cells are not selectable). +// is available (even if the selection ends up being the same as before and even +// if cells are not selectable). func (t *Table) Select(row, column int) *Table { t.Lock() defer t.Unlock() @@ -677,6 +684,49 @@ func (t *Table) GetColumnCount() int { return t.lastColumn + 1 } +// cellAt returns the row and column located at the given screen coordinates. +// Each returned value may be negative if there is no row and/or cell. This +// function will also process coordinates outside the table's inner rectangle so +// callers will need to check for bounds themselves. +func (t *Table) cellAt(x, y int) (row, column int) { + rectX, rectY, _, _ := t.GetInnerRect() + + // Determine row as seen on screen. + if t.borders { + row = (y - rectY - 1) / 2 + } else { + row = y - rectY + } + + // Respect fixed rows and row offset. + if row >= 0 { + if row >= t.fixedRows { + row += t.rowOffset + } + if row >= len(t.cells) { + row = -1 + } + } + + // Saerch for the clicked column. + column = -1 + if x >= rectX { + columnX := rectX + if t.borders { + columnX++ + } + for index, width := range t.visibleColumnWidths { + columnX += width + 1 + if x < columnX { + column = t.visibleColumnIndices[index] + break + } + } + } + + return +} + // ScrollToBeginning scrolls the table to the beginning to that the top left // corner of the table is shown. Note that this position may be corrected if // there is a selection. @@ -978,8 +1028,8 @@ ColumnLoop: cell.x, cell.y, cell.width = x+columnX+1, y+rowY, finalWidth _, printed := printWithStyle(screen, cell.Text, x+columnX+1, y+rowY, finalWidth, cell.Align, tcell.StyleDefault.Foreground(cell.Color)|tcell.Style(cell.Attributes)) if TaggedStringWidth(cell.Text)-printed > 0 && printed > 0 { - _, _, style, _ := screen.GetContent(x+columnX+1+finalWidth-1, y+rowY) - printWithStyle(screen, string(SemigraphicsHorizontalEllipsis), x+columnX+1+finalWidth-1, y+rowY, 1, AlignLeft, style) + _, _, style, _ := screen.GetContent(x+columnX+finalWidth, y+rowY) + printWithStyle(screen, string(SemigraphicsHorizontalEllipsis), x+columnX+finalWidth, y+rowY, 1, AlignLeft, style) } } @@ -1150,6 +1200,9 @@ ColumnLoop: } } } + + // Remember column infos. + t.visibleColumnIndices, t.visibleColumnWidths = columns, widths } // InputHandler returns the handler for this primitive. @@ -1378,3 +1431,31 @@ func (t *Table) InputHandler() func(event *tcell.EventKey, setFocus func(p Primi } }) } + +// MouseHandler returns the mouse handler for this primitive. +func (t *Table) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return t.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + x, y := event.Position() + if !t.InRect(x, y) { + return false, nil + } + + switch action { + case MouseLeftClick: + if t.rowsSelectable || t.columnsSelectable { + t.Select(t.cellAt(x, y)) + } + consumed = true + setFocus(t) + case MouseScrollUp: + t.trackEnd = false + t.rowOffset-- + consumed = true + case MouseScrollDown: + t.rowOffset++ + consumed = true + } + + return + }) +} diff --git a/textview.go b/textview.go index ae8e1cb..ea57d70 100644 --- a/textview.go +++ b/textview.go @@ -35,6 +35,16 @@ type textViewIndex struct { Region string // The starting region ID. } +// textViewRegion contains information about a region. +type textViewRegion struct { + // The region ID. + ID string + + // The starting and end screen position of the region as determined the last + // time Draw() was called. A negative value indicates out-of-rect positions. + FromX, FromY, ToX, ToY int +} + // TextView is a box which displays text. It implements the io.Writer interface // so you can stream text to it. This does not trigger a redraw automatically // but if a handler is installed via SetChangedFunc(), you can cause it to be @@ -104,6 +114,9 @@ type TextView struct { // The text alignment, one of AlignLeft, AlignCenter, or AlignRight. align int + // Information about visible regions as of the last call to Draw(). + regionInfos []*textViewRegion + // Indices into the "index" slice which correspond to the first line of the // first highlight and the last line of the last highlight. This is calculated // during re-indexing. Set to -1 if there is no current highlight. @@ -161,6 +174,10 @@ type TextView struct { // highlight(s) into the visible screen. scrollToHighlights bool + // If true, setting new highlights will be a XOR instead of an overwrite + // operation. + toggleHighlights bool + // An optional function which is called when the content of the text view has // changed. changed func() @@ -169,6 +186,10 @@ type TextView struct { // following keys: Escape, Enter, Tab, Backtab. done func(tcell.Key) + // An optional function which is called when one or more regions were + // highlighted. + highlighted func(added, removed, remaining []string) + sync.RWMutex } @@ -358,6 +379,18 @@ func (t *TextView) SetDoneFunc(handler func(key tcell.Key)) *TextView { return t } +// SetHighlightedFunc sets a handler which is called when the list of currently +// highlighted regions change. It receives a list of region IDs which were newly +// highlighted, those that are not highlighted anymore, and those that remain +// highlighted. +// +// Note that because regions are only determined during drawing, this function +// can only fire for regions that have existed during the last call to Draw(). +func (t *TextView) SetHighlightedFunc(handler func(added, removed, remaining []string)) *TextView { + t.highlighted = handler + return t +} + // ScrollTo scrolls to the specified row and column (both starting with 0). func (t *TextView) ScrollTo(row, column int) *TextView { t.Lock() @@ -426,18 +459,59 @@ func (t *TextView) clear() *TextView { return t } -// Highlight specifies which regions should be highlighted. See class -// description for details on regions. Empty region strings are ignored. +// Highlight specifies which regions should be highlighted. If highlight +// toggling is set to true (see SetToggleHighlights()), the highlight of the +// provided regions is toggled (highlighted regions are un-highlighted and vice +// versa). If toggling is set to false, the provided regions are highlighted and +// all other regions will not be highlighted (you may also provide nil to turn +// off all highlights). +// +// For more information on regions, see class description. Empty region strings +// are ignored. // // Text in highlighted regions will be drawn inverted, i.e. with their // background and foreground colors swapped. -// -// Calling this function will remove any previous highlights. To remove all -// highlights, call this function without any arguments. func (t *TextView) Highlight(regionIDs ...string) *TextView { t.Lock() defer t.Unlock() + // Toggle highlights. + if t.toggleHighlights { + var newIDs []string + HighlightLoop: + for regionID := range t.highlights { + for _, id := range regionIDs { + if regionID == id { + continue HighlightLoop + } + } + newIDs = append(newIDs, regionID) + } + for _, regionID := range regionIDs { + if _, ok := t.highlights[regionID]; !ok { + newIDs = append(newIDs, regionID) + } + } + regionIDs = newIDs + } // Now we have a list of region IDs that end up being highlighted. + + // Determine added and removed regions. + var added, removed, remaining []string + if t.highlighted != nil { + for _, regionID := range regionIDs { + if _, ok := t.highlights[regionID]; ok { + remaining = append(remaining, regionID) + delete(t.highlights, regionID) + } else { + added = append(added, regionID) + } + } + for regionID := range t.highlights { + removed = append(removed, regionID) + } + } + + // Make new selection. t.highlights = make(map[string]struct{}) for _, id := range regionIDs { if id == "" { @@ -446,6 +520,12 @@ func (t *TextView) Highlight(regionIDs ...string) *TextView { t.highlights[id] = struct{}{} } t.index = nil + + // Notify. + if t.highlighted != nil && len(added) > 0 || len(removed) > 0 { + t.highlighted(added, removed, remaining) + } + return t } @@ -460,6 +540,15 @@ func (t *TextView) GetHighlights() (regionIDs []string) { return } +// SetToggleHighlights sets a flag to determine how regions are highlighted. +// When set to true, the Highlight() function (or a mouse click) will toggle the +// provided/selected regions. When set to false, Highlight() (or a mouse click) +// will simply highlight the provided regions. +func (t *TextView) SetToggleHighlights(toggle bool) *TextView { + t.toggleHighlights = toggle + return t +} + // ScrollToHighlight will cause the visible area to be scrolled so that the // highlighted regions appear in the visible area of the text view. This // repositioning happens the next time the text view is drawn. It happens only @@ -833,6 +922,9 @@ func (t *TextView) Draw(screen tcell.Screen) { // Re-index. t.reindexBuffer(width) + if t.regions { + t.regionInfos = nil + } // If we don't have an index, there's nothing to draw. if t.index == nil { @@ -917,6 +1009,15 @@ func (t *TextView) Draw(screen tcell.Screen) { backgroundColor := index.BackgroundColor attributes := index.Attributes regionID := index.Region + if t.regions && regionID != "" && (len(t.regionInfos) == 0 || t.regionInfos[len(t.regionInfos)-1].ID != regionID) { + t.regionInfos = append(t.regionInfos, &textViewRegion{ + ID: regionID, + FromX: x, + FromY: y + line - t.lineOffset, + ToX: -1, + ToY: -1, + }) + } // Process tags. colorTagIndices, colorTags, regionIndices, regions, escapeIndices, strippedText, _ := decomposeString(text, t.dynamicColors, t.regions) @@ -936,82 +1037,99 @@ func (t *TextView) Draw(screen tcell.Screen) { } // Print the line. - var colorPos, regionPos, escapePos, tagOffset, skipped int - iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { - // Process tags. - for { - if colorPos < len(colorTags) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] { - // Get the color. - foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos]) - tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0] - colorPos++ - } else if regionPos < len(regionIndices) && textPos+tagOffset >= regionIndices[regionPos][0] && textPos+tagOffset < regionIndices[regionPos][1] { - // Get the region. - regionID = regions[regionPos][1] - tagOffset += regionIndices[regionPos][1] - regionIndices[regionPos][0] - regionPos++ - } else { - break - } - } - - // Skip the second-to-last character of an escape tag. - if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 { - tagOffset++ - escapePos++ - } - - // Mix the existing style with the new style. - _, _, existingStyle, _ := screen.GetContent(x+posX, y+line-t.lineOffset) - _, background, _ := existingStyle.Decompose() - style := overlayStyle(background, defaultStyle, foregroundColor, backgroundColor, attributes) - - // Do we highlight this character? - var highlighted bool - if len(regionID) > 0 { - if _, ok := t.highlights[regionID]; ok { - highlighted = true - } - } - if highlighted { - fg, bg, _ := style.Decompose() - if bg == tcell.ColorDefault { - r, g, b := fg.RGB() - c := colorful.Color{R: float64(r) / 255, G: float64(g) / 255, B: float64(b) / 255} - _, _, li := c.Hcl() - if li < .5 { - bg = tcell.ColorWhite + if y+line-t.lineOffset >= 0 { + var colorPos, regionPos, escapePos, tagOffset, skipped int + iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { + // Process tags. + for { + if colorPos < len(colorTags) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] { + // Get the color. + foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos]) + tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0] + colorPos++ + } else if regionPos < len(regionIndices) && textPos+tagOffset >= regionIndices[regionPos][0] && textPos+tagOffset < regionIndices[regionPos][1] { + // Get the region. + if regionID != "" && len(t.regionInfos) > 0 && t.regionInfos[len(t.regionInfos)-1].ID == regionID { + // End last region. + t.regionInfos[len(t.regionInfos)-1].ToX = x + posX + t.regionInfos[len(t.regionInfos)-1].ToY = y + line - t.lineOffset + } + regionID = regions[regionPos][1] + if regionID != "" { + // Start new region. + t.regionInfos = append(t.regionInfos, &textViewRegion{ + ID: regionID, + FromX: x + posX, + FromY: y + line - t.lineOffset, + ToX: -1, + ToY: -1, + }) + } + tagOffset += regionIndices[regionPos][1] - regionIndices[regionPos][0] + regionPos++ } else { - bg = tcell.ColorBlack + break } } - style = style.Background(fg).Foreground(bg) - } - // Skip to the right. - if !t.wrap && skipped < skip { - skipped += screenWidth - return false - } - - // Stop at the right border. - if posX+screenWidth > width { - return true - } - - // Draw the character. - for offset := screenWidth - 1; offset >= 0; offset-- { - if offset == 0 { - screen.SetContent(x+posX+offset, y+line-t.lineOffset, main, comb, style) - } else { - screen.SetContent(x+posX+offset, y+line-t.lineOffset, ' ', nil, style) + // Skip the second-to-last character of an escape tag. + if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 { + tagOffset++ + escapePos++ } - } - // Advance. - posX += screenWidth - return false - }) + // Mix the existing style with the new style. + _, _, existingStyle, _ := screen.GetContent(x+posX, y+line-t.lineOffset) + _, background, _ := existingStyle.Decompose() + style := overlayStyle(background, defaultStyle, foregroundColor, backgroundColor, attributes) + + // Do we highlight this character? + var highlighted bool + if regionID != "" { + if _, ok := t.highlights[regionID]; ok { + highlighted = true + } + } + if highlighted { + fg, bg, _ := style.Decompose() + if bg == tcell.ColorDefault { + r, g, b := fg.RGB() + c := colorful.Color{R: float64(r) / 255, G: float64(g) / 255, B: float64(b) / 255} + _, _, li := c.Hcl() + if li < .5 { + bg = tcell.ColorWhite + } else { + bg = tcell.ColorBlack + } + } + style = style.Background(fg).Foreground(bg) + } + + // Skip to the right. + if !t.wrap && skipped < skip { + skipped += screenWidth + return false + } + + // Stop at the right border. + if posX+screenWidth > width { + return true + } + + // Draw the character. + for offset := screenWidth - 1; offset >= 0; offset-- { + if offset == 0 { + screen.SetContent(x+posX+offset, y+line-t.lineOffset, main, comb, style) + } else { + screen.SetContent(x+posX+offset, y+line-t.lineOffset, ' ', nil, style) + } + } + + // Advance. + posX += screenWidth + return false + }) + } } // If this view is not scrollable, we'll purge the buffer of lines that have @@ -1090,3 +1208,41 @@ func (t *TextView) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr } }) } + +// MouseHandler returns the mouse handler for this primitive. +func (t *TextView) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return t.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + x, y := event.Position() + if !t.InRect(x, y) { + return false, nil + } + + switch action { + case MouseLeftClick: + if t.regions { + // Find a region to highlight. + for _, region := range t.regionInfos { + if y == region.FromY && x < region.FromX || + y == region.ToY && x >= region.ToX || + region.FromY >= 0 && y < region.FromY || + region.ToY >= 0 && y > region.ToY { + continue + } + t.Highlight(region.ID) + break + } + } + consumed = true + setFocus(t) + case MouseScrollUp: + t.trackEnd = false + t.lineOffset-- + consumed = true + case MouseScrollDown: + t.lineOffset++ + consumed = true + } + + return + }) +} diff --git a/treeview.go b/treeview.go index 6208c5b..9c5aadf 100644 --- a/treeview.go +++ b/treeview.go @@ -911,3 +911,41 @@ func (t *TreeView) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr t.process() }) } + +// MouseHandler returns the mouse handler for this primitive. +func (t *TreeView) MouseHandler() func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + return t.WrapMouseHandler(func(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) { + x, y := event.Position() + if !t.InRect(x, y) { + return false, nil + } + + switch action { + case MouseLeftClick: + _, rectY, _, _ := t.GetInnerRect() + y -= rectY + if y >= 0 && y < len(t.nodes) { + node := t.nodes[y] + if node.selectable { + if t.currentNode != node && t.changed != nil { + t.changed(node) + } + if t.selected != nil { + t.selected(node) + } + t.currentNode = node + } + } + consumed = true + setFocus(t) + case MouseScrollUp: + t.movement = treeUp + consumed = true + case MouseScrollDown: + t.movement = treeDown + consumed = true + } + + return + }) +}