diff --git a/README.md b/README.md index ddceec7..3a1b1cd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Rich Interactive Widgets for Terminal UIs -[![Godoc Reference](https://img.shields.io/badge/godoc-reference-blue.svg)](https://godoc.org/github.com/rivo/tview) +[![Godoc Reference](https://img.shields.io/badge/godoc-reference-blue.svg)](https://pkg.go.dev/github.com/rivo/tview) [![Go Report](https://img.shields.io/badge/go%20report-A%2B-brightgreen.svg)](https://goreportcard.com/report/github.com/rivo/tview) This Go package provides commonly needed components for terminal based user interfaces. @@ -60,51 +60,3 @@ This package is based on [github.com/gdamore/tcell](https://github.com/gdamore/t ## Your Feedback Add your issue here on GitHub. Feel free to get in touch if you have any questions. - -## Version History - -(There are no corresponding tags in the project. I only keep such a history in this README.) - -- v0.20 (2019-07-08) - - Added autocomplete functionality to `InputField`. -- v0.19 (2018-10-28) - - Added `QueueUpdate()` and `QueueEvent()` to `Application` to help with modifications to primitives from goroutines. -- v0.18 (2018-10-18) - - `InputField` elements can now be navigated freely. -- v0.17 (2018-06-20) - - Added `TreeView`. -- v0.15 (2018-05-02) - - `Flex` and `Grid` don't clear their background per default, thus allowing for custom modals. See the [Wiki](https://github.com/rivo/tview/wiki/Modal) for an example. -- v0.14 (2018-04-13) - - Added an `Escape()` function which keep strings like color or region tags from being recognized as such. - - Added `ANSIWriter()` and `TranslateANSI()` which convert ANSI escape sequences to `tview` color tags. -- v0.13 (2018-04-01) - - Added background colors and text attributes to color tags. -- v0.12 (2018-03-13) - - Added "suspended mode" to `Application`. -- v0.11 (2018-03-02) - - Added a `RemoveItem()` function to `Grid` and `Flex`. -- v0.10 (2018-02-22) - - Direct access to the `screen` object through callback in `Box` (i.e. for all primitives). -- v0.9 (2018-02-20) - - Introduced `Grid` layout. - - Direct access to the `screen` object through callbacks in `Application`. -- v0.8 (2018-01-17) - - Color tags can now be used almost everywhere. -- v0.7 (2018-01-16) - - Forms can now also have a horizontal layout. -- v0.6 (2018-01-14) - - All primitives can now intercept all key events when they have focus. - - Key events can also be intercepted globally (changed to a more general, consistent handling) -- v0.5 (2018-01-13) - - `TextView` now has word wrapping and text alignment -- v0.4 (2018-01-12) - - `TextView` now accepts color tags with any W3C color (including RGB hex values). - - Support for wide unicode characters. -- v0.3 (2018-01-11) - - Added masking to `InputField` and password entry to `Form`. -- v0.2 (2018-01-10) - - Added `Styles` variable with default colors for primitives. - - Completed some missing InputField functions. -- v0.1 (2018-01-06) - - First Release. diff --git a/application.go b/application.go index 16f9541..67ef6dd 100644 --- a/application.go +++ b/application.go @@ -15,6 +15,34 @@ const ( redrawPause = 50 * 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. @@ -52,6 +80,9 @@ type Application struct { // Whether or not the application resizes the root primitive. rootFullscreen bool + // Set to true if mouse events are enabled. + enableMouse bool + // An optional capture function which receives a key event and returns the // event to be forwarded to the default input handler (nil if nothing should // be forwarded). @@ -76,6 +107,17 @@ type Application struct { // (screen.Init() and draw() will be called implicitly). A value of nil will // stop the application. screenReplacement chan tcell.Screen + + // 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 *tcell.EventMouse, action MouseAction) (*tcell.EventMouse, MouseAction) + + 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. } // NewApplication creates and returns a new application. @@ -107,6 +149,22 @@ func (a *Application) GetInputCapture() func(event *tcell.EventKey) *tcell.Event return a.inputCapture } +// 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 a nil mouse event. +func (a *Application) SetMouseCapture(capture func(event *tcell.EventMouse, action MouseAction) (*tcell.EventMouse, MouseAction)) *Application { + a.mouseCapture = capture + return a +} + +// GetMouseCapture returns the function installed with SetMouseCapture() or nil +// if no such function has been installed. +func (a *Application) GetMouseCapture() func(event *tcell.EventMouse, action MouseAction) (*tcell.EventMouse, MouseAction) { + return a.mouseCapture +} + // 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. @@ -135,6 +193,21 @@ func (a *Application) SetScreen(screen tcell.Screen) *Application { return a } +// EnableMouse enables mouse events. +func (a *Application) EnableMouse(enable bool) *Application { + a.Lock() + defer a.Unlock() + if enable != a.enableMouse && a.screen != nil { + if enable { + a.screen.EnableMouse() + } else { + a.screen.DisableMouse() + } + } + a.enableMouse = enable + return a +} + // Run starts the application and thus the event loop. This function returns // when Stop() was called. func (a *Application) Run() error { @@ -157,6 +230,9 @@ func (a *Application) Run() error { a.Unlock() return err } + if a.enableMouse { + a.screen.EnableMouse() + } } // We catch panics to clean up because they mess up the terminal. @@ -279,6 +355,15 @@ EventLoop: lastRedraw = time.Now() screen.Clear() a.draw() + case *tcell.EventMouse: + 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. @@ -295,6 +380,105 @@ EventLoop: 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 + } + + 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 + } + + 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 + } + } + } + } + } + + 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 consumed, isMouseDownAction +} + // Stop stops the application, causing Run() to return. func (a *Application) Stop() { a.Lock() diff --git a/box.go b/box.go index 53a8991..1870597 100644 --- a/box.go +++ b/box.go @@ -59,6 +59,11 @@ type Box struct { // An optional function which is called before the box is drawn. 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 (at + // least one nil if nothing should be forwarded). + mouseCapture func(action MouseAction, event *tcell.EventMouse) (MouseAction, *tcell.EventMouse) } // NewBox returns a Box without a border. @@ -193,6 +198,60 @@ func (b *Box) GetInputCapture() func(event *tcell.EventKey) *tcell.EventKey { return b.inputCapture } +// WrapMouseHandler wraps a mouse event handler (see MouseHandler()) with the +// 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(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 { + action, event = b.mouseCapture(action, event) + } + if event != nil && mouseHandler != nil { + consumed, capture = mouseHandler(action, event, setFocus) + } + return + } +} + +// MouseHandler returns 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 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(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(action MouseAction, event *tcell.EventMouse) (MouseAction, *tcell.EventMouse) { + return b.mouseCapture +} + // SetBackgroundColor sets the box's background color. func (b *Box) SetBackgroundColor(color tcell.Color) *Box { b.backgroundColor = color diff --git a/button.go b/button.go index d3a6aed..fd7c234 100644 --- a/button.go +++ b/button.go @@ -135,3 +135,23 @@ 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(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 action == MouseLeftClick { + setFocus(b) + if b.selected != nil { + b.selected() + } + consumed = true + } + + return + }) +} diff --git a/checkbox.go b/checkbox.go index 8f099d8..7c4b505 100644 --- a/checkbox.go +++ b/checkbox.go @@ -201,3 +201,26 @@ 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(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 action == MouseLeftClick && y == rectY { + setFocus(c) + c.checked = !c.checked + if c.changed != nil { + c.changed(c.checked) + } + consumed = true + } + + return + }) +} diff --git a/demos/button/main.go b/demos/button/main.go index 429b5be..10e52f9 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 ef9ef8e..0a6bc70 100644 --- a/demos/checkbox/main.go +++ b/demos/checkbox/main.go @@ -6,7 +6,7 @@ import "github.com/rivo/tview" func main() { app := tview.NewApplication() checkbox := tview.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 be9db66..3d9510b 100644 --- a/demos/dropdown/main.go +++ b/demos/dropdown/main.go @@ -8,7 +8,7 @@ func main() { dropdown := tview.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 1c22172..2df2466 100644 --- a/demos/flex/main.go +++ b/demos/flex/main.go @@ -14,7 +14,7 @@ func main() { AddItem(tview.NewBox().SetBorder(true).SetTitle("Middle (3 x height of Top)"), 0, 3, false). AddItem(tview.NewBox().SetBorder(true).SetTitle("Bottom (5 rows)"), 5, 1, false), 0, 2, false). AddItem(tview.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 e64614f..1b50c13 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(tview.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 9b03e64..db6e184 100644 --- a/demos/frame/main.go +++ b/demos/frame/main.go @@ -16,7 +16,7 @@ func main() { AddText("Header second middle", true, tview.AlignCenter, tcell.ColorRed). AddText("Footer middle", false, tview.AlignCenter, tcell.ColorGreen). AddText("Footer second middle", false, tview.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 cbdb1df..57efe1e 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 := tview.NewApplication().SetRoot(grid, true).Run(); err != nil { + if err := tview.NewApplication().SetRoot(grid, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/demos/inputfield/main.go b/demos/inputfield/main.go index b5ccd77..923bf2d 100644 --- a/demos/inputfield/main.go +++ b/demos/inputfield/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 50cae17..402703a 100644 --- a/demos/list/main.go +++ b/demos/list/main.go @@ -15,7 +15,7 @@ func main() { AddItem("Quit", "Press to exit", 'q', func() { app.Stop() }) - 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 893230d..e7ef1f8 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 8eb67ab..67c06e0 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 2c06e5c..6027561 100644 --- a/demos/presentation/cover.go +++ b/demos/presentation/cover.go @@ -20,6 +20,7 @@ const logo = ` const ( subtitle = `tview - Rich Widgets for Terminal UIs` navigation = `Ctrl-N: Next slide Ctrl-P: Previous slide Ctrl-C: Exit` + mouse = `(or use your mouse)` ) // Cover returns the cover page. @@ -45,7 +46,8 @@ func Cover(nextSlide func()) (title string, content tview.Primitive) { SetBorders(0, 0, 0, 0, 0, 0). AddText(subtitle, true, tview.AlignCenter, tcell.ColorWhite). AddText("", true, tview.AlignCenter, tcell.ColorWhite). - AddText(navigation, true, tview.AlignCenter, tcell.ColorDarkMagenta) + AddText(navigation, true, tview.AlignCenter, tcell.ColorDarkMagenta). + AddText(mouse, true, tview.AlignCenter, tcell.ColorDarkMagenta) // Create a Flex layout that centers the logo and subtitle. flex := tview.NewFlex(). diff --git a/demos/presentation/main.go b/demos/presentation/main.go index c75fdc0..065d798 100644 --- a/demos/presentation/main.go +++ b/demos/presentation/main.go @@ -47,33 +47,36 @@ func main() { End, } + pages := tview.NewPages() + // The bottom row has some info on where we are. info := tview.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 := tview.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)) } for index, slide := range slides { 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) } + info.Highlight("0") // Create the main layout. layout := tview.NewFlex(). @@ -92,7 +95,7 @@ func main() { }) // 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 38cb0c1..906ed56 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 66cc45e..701a9c1 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 bce3cba..5a6805d 100644 --- a/demos/treeview/main.go +++ b/demos/treeview/main.go @@ -56,7 +56,7 @@ func main() { } }) - if err := tview.NewApplication().SetRoot(tree, true).Run(); err != nil { + if err := tview.NewApplication().SetRoot(tree, true).EnableMouse(true).Run(); err != nil { panic(err) } } diff --git a/dropdown.go b/dropdown.go index 58eee24..89d50c1 100644 --- a/dropdown.go +++ b/dropdown.go @@ -79,6 +79,8 @@ type DropDown struct { // A callback function which is called when the user changes the drop-down's // selection. selected func(text string, index int) + + dragging bool // Set to true when mouse dragging is in progress. } // NewDropDown returns a new drop-down. @@ -394,22 +396,6 @@ func (d *DropDown) Draw(screen tcell.Screen) { // InputHandler returns the handler for this primitive. func (d *DropDown) InputHandler() func(event *tcell.EventKey, setFocus func(p Primitive)) { return d.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p Primitive)) { - // A helper function which selects an item in the drop-down list based on - // the current prefix. - evalPrefix := func() { - if len(d.prefix) > 0 { - for index, option := range d.options { - if strings.HasPrefix(strings.ToLower(option.Text), d.prefix) { - d.list.SetCurrentItem(index) - return - } - } - // Prefix does not match any item. Remove last rune. - r := []rune(d.prefix) - d.prefix = string(r[:len(r)-1]) - } - } - // Process key event. switch key := event.Key(); key { case tcell.KeyEnter, tcell.KeyRune, tcell.KeyDown: @@ -418,45 +404,10 @@ func (d *DropDown) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr // If the first key was a letter already, it becomes part of the prefix. if r := event.Rune(); key == tcell.KeyRune && r != ' ' { d.prefix += string(r) - evalPrefix() + d.evalPrefix() } - // Hand control over to the list. - d.open = true - optionBefore := d.currentOption - d.list.SetSelectedFunc(func(index int, mainText, secondaryText string, shortcut rune) { - // An option was selected. Close the list again. - d.open = false - setFocus(d) - d.currentOption = index - - // Trigger "selected" event. - if d.selected != nil { - d.selected(d.options[d.currentOption].Text, d.currentOption) - } - if d.options[d.currentOption].Selected != nil { - d.options[d.currentOption].Selected() - } - }).SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyRune { - d.prefix += string(event.Rune()) - evalPrefix() - } else if event.Key() == tcell.KeyBackspace || event.Key() == tcell.KeyBackspace2 { - if len(d.prefix) > 0 { - r := []rune(d.prefix) - d.prefix = string(r[:len(r)-1]) - } - evalPrefix() - } else if event.Key() == tcell.KeyEscape { - d.open = false - d.currentOption = optionBefore - setFocus(d) - } else { - d.prefix = "" - } - return event - }) - setFocus(d.list) + d.openList(setFocus) case tcell.KeyEscape, tcell.KeyTab, tcell.KeyBacktab: if d.done != nil { d.done(key) @@ -468,6 +419,75 @@ func (d *DropDown) InputHandler() func(event *tcell.EventKey, setFocus func(p Pr }) } +// 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 { + if strings.HasPrefix(strings.ToLower(option.Text), d.prefix) { + d.list.SetCurrentItem(index) + return + } + } + + // Prefix does not match any item. Remove last rune. + r := []rune(d.prefix) + d.prefix = string(r[:len(r)-1]) + } +} + +// 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) + + // Trigger "selected" event. + if d.selected != nil { + d.selected(d.options[d.currentOption].Text, d.currentOption) + } + if d.options[d.currentOption].Selected != nil { + d.options[d.currentOption].Selected() + } + }).SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyRune { + d.prefix += string(event.Rune()) + d.evalPrefix() + } else if event.Key() == tcell.KeyBackspace || event.Key() == tcell.KeyBackspace2 { + if len(d.prefix) > 0 { + r := []rune(d.prefix) + d.prefix = string(r[:len(r)-1]) + } + d.evalPrefix() + } else if event.Key() == tcell.KeyEscape { + d.currentOption = optionBefore + d.closeList(setFocus) + } else { + d.prefix = "" + } + + return event + }) + + setFocus(d.list) +} + +// 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) + } +} + // Focus is called by the application when the primitive receives focus. func (d *DropDown) Focus(delegate func(p Primitive)) { d.Box.Focus(delegate) @@ -483,3 +503,45 @@ func (d *DropDown) HasFocus() bool { } return d.hasFocus } + +// MouseHandler returns the mouse handler for this primitive. +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. + } + + // 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/flex.go b/flex.go index d012392..108b5b0 100644 --- a/flex.go +++ b/flex.go @@ -201,3 +201,22 @@ func (f *Flex) HasFocus() bool { } return false } + +// 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 + } + + // 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 d5d9b48..ca123bf 100644 --- a/form.go +++ b/form.go @@ -619,3 +619,39 @@ func (f *Form) focusIndex() int { } return -1 } + +// 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 + } + + // 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 77c5316..1ea9d6e 100644 --- a/frame.go +++ b/frame.go @@ -12,8 +12,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://github.com/rivo/tview/wiki/Frame for an example. type Frame struct { @@ -155,3 +155,15 @@ func (f *Frame) HasFocus() bool { } return false } + +// 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 + } + + // Pass mouse events on to contained primitive. + return f.primitive.MouseHandler()(action, event, setFocus) + }) +} diff --git a/grid.go b/grid.go index 2494b13..b814d35 100644 --- a/grid.go +++ b/grid.go @@ -660,3 +660,22 @@ func (g *Grid) Draw(screen tcell.Screen) { } } } + +// 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 + } + + // 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 3cd255e..784a06b 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,9 @@ type InputField struct { // A callback function set by the Form class and called when the user leaves // this form item. finished func(tcell.Key) + + fieldX int // The x-coordinate of the input field as determined during the last call to Draw(). + offset int // The number of bytes of the text string skipped ahead while drawing. } // NewInputField returns a new input field. @@ -326,6 +326,7 @@ func (i *InputField) Draw(screen tcell.Screen) { } // Draw input area. + i.fieldX = x fieldWidth := i.fieldWidth if fieldWidth == 0 { fieldWidth = math.MaxInt32 @@ -591,3 +592,34 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p } }) } + +// MouseHandler returns the mouse handler for this primitive. +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 a8cab97..e28b415 100644 --- a/list.go +++ b/list.go @@ -558,3 +558,69 @@ func (l *List) InputHandler() func(event *tcell.EventKey, setFocus func(p Primit } }) } + +// 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 + } + + index := y - rectY + if l.showSecondaryText { + index /= 2 + } + index += l.offset + + if index >= len(l.items) { + return -1 + } + return index +} + +// MouseHandler returns the mouse handler for this primitive. +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 + } + + // 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 { + item.Selected() + } + if l.selected != nil { + l.selected(index, item.MainText, item.SecondaryText, item.Shortcut) + } + if index != l.currentItem && l.changed != nil { + l.changed(index, item.MainText, item.SecondaryText, item.Shortcut) + } + l.currentItem = index + } + 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 2b10d94..f534113 100644 --- a/modal.go +++ b/modal.go @@ -12,7 +12,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. @@ -175,3 +175,16 @@ func (m *Modal) Draw(screen tcell.Screen) { m.frame.SetRect(x, y, width, height) m.frame.Draw(screen) } + +// 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 155da73..0955045 100644 --- a/pages.go +++ b/pages.go @@ -20,7 +20,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 @@ -278,3 +278,25 @@ func (p *Pages) Draw(screen tcell.Screen) { page.Item.Draw(screen) } } + +// 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 + } + + // 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 88a9d46..416d708 100644 --- a/primitive.go +++ b/primitive.go @@ -43,4 +43,15 @@ type Primitive interface { // GetFocusable returns the item's Focusable. GetFocusable() Focusable + + // 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 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(action MouseAction, event *tcell.EventMouse, setFocus func(p Primitive)) (consumed bool, capture Primitive) } diff --git a/table.go b/table.go index 04fe448..a6f1b3e 100644 --- a/table.go +++ b/table.go @@ -251,6 +251,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 + // The style of the selected rows. If this value is 0, selected rows are // simply inverted. selectedStyle tcell.Style @@ -360,8 +367,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.selectedRow, t.selectedColumn = row, column if t.selectionChanged != nil { @@ -537,6 +544,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. @@ -824,8 +874,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) } } @@ -964,6 +1014,9 @@ ColumnLoop: } } } + + // Remember column infos. + t.visibleColumnIndices, t.visibleColumnWidths = columns, widths } // InputHandler returns the handler for this primitive. @@ -1173,3 +1226,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 38876b9..5b2944a 100644 --- a/textview.go +++ b/textview.go @@ -36,6 +36,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 @@ -106,6 +116,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. @@ -163,6 +176,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() @@ -170,6 +187,10 @@ type TextView struct { // An optional function which is called when the user presses one of the // 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) } // NewTextView returns a new text view. @@ -325,6 +346,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 { if !t.scrollable { @@ -374,15 +407,56 @@ 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 { + // 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 == "" { @@ -391,6 +465,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 } @@ -402,6 +482,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 @@ -766,6 +855,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 { @@ -850,6 +942,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) @@ -881,7 +982,22 @@ func (t *TextView) Draw(screen tcell.Screen) { 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 { @@ -902,7 +1018,7 @@ func (t *TextView) Draw(screen tcell.Screen) { // Do we highlight this character? var highlighted bool - if len(regionID) > 0 { + if regionID != "" { if _, ok := t.highlights[regionID]; ok { highlighted = true } @@ -1022,3 +1138,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 2a8c16c..ddefa62 100644 --- a/treeview.go +++ b/treeview.go @@ -730,3 +730,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 + }) +}