Mouse events
Attach click, wheel, hover, and drag handlers to the nodes they're about with the on package, change state directly or send a message, and use hover styles, drags, and modal layers.
Bubble Tea reports mouse events in screen coordinates. bloom knows where it drew every node, so you attach handlers to the nodes themselves, and bloom runs the right one with positions relative to that node.
Turn on mouse reporting
Set a mouse mode on the view you return:
v := m.ui.View(m.width, m.height, layout)
v.MouseMode = tea.MouseModeCellMotion // clicks, the wheel, and drags
return vUse tea.MouseModeAllMotion to also get motion while no button is pressed, for hover.
Attach handlers
Every node has an On method, which takes handlers made by the
on package:
import "github.com/lrstanley/bloom/on"for i, s := range services {
rows[i] = bloom.Text(s).On(
on.Click(func() tea.Cmd { m.selected = i; return nil }),
on.DoubleClick(func() tea.Cmd { return m.open(i) }),
on.RightClick(on.Msg(menuMsg{i})),
)
}No IDs are needed: the handler is already on the node it's about. Text, slots, boxes, rows, columns, grids, wraps,
and layers all have On.
| Handler | Runs on |
|---|---|
on.Click(fn) | a left click: the button goes down and comes up over the node |
on.RightClick(fn), on.MiddleClick(fn) | a click with those buttons |
on.DoubleClick(fn) | the second of two clicks within 400ms; both clicks still run Click |
on.Press(fn) | a left click, or enter or space while the node has focus |
on.ClickAt(fn) | a left click, with where it happened |
on.Scroll(fn) | the wheel, as dx, dy steps |
on.Hover(fn) | the pointer moving onto the node (true) or off it (false) |
on.Drag(fn) | the button going down on the node, the pointer moving while it's held, and it coming up |
on.ClickOutside(fn) | a button going down anywhere but on the node |
on.Mouse(fn) | every mouse event over the node, for anything else |
Most take a func() tea.Cmd, so methods such as m.save can be passed as they are. Clicks run when the button
comes up over the same node it went down on, like a real button: pressing a button and sliding off it before
letting go doesn't click it.
Change state, or send a message
Handlers run on the Bubble Tea event loop, just before Update gets the same event, so a handler can change the
model directly, the same way Update does: change state, then return a command. The command runs in the
background, so a tea.Tick or I/O doesn't freeze the screen. Handlers themselves must not block.
A handler can only change the real model if it reaches it. Handlers are closures made in View, so they capture
whatever View had. With a pointer receiver, func (m *model) View(), that's the model itself. With a value
receiver, it's a copy, and changes made to it are lost without warning.
When a handler can't reach the state, have it send a message instead. on.Msg(msg) makes a handler function that
sends msg to Update:
bloom.Text(name).On(on.Click(on.Msg(openMsg(i)))) case openMsg:
m.page = int(msg)Components kept by value
This matters most for components. A Bubbles-style component is often kept by value in its parent
(m.list, cmd = m.list.Update(msg)), so a handler built in its own View that sets l.selected changes a copy,
even when the parent is a pointer. Have the component send a message with on.Msg and handle it in its own
Update, give it pointer receivers, or build the handler in the parent over &m.list.
Messages from separate events can arrive in any order, since Bubble Tea sends each in its own goroutine. Changes made directly in handlers happen in order.
Which node gets the event
For each kind of event, the innermost node under the pointer with a handler for it takes it, and the nodes it's
inside don't. A row's Click and its pane's Scroll work side by side, with no need to stop anything from
spreading:
pane := bloom.Column(rows...).On(on.Scroll(func(_, dy int) tea.Cmd { m.offset += dy * 3; return nil }))A click on a row runs the row's Click; the wheel over the same row scrolls the pane. A click on the pane where
there's no row runs the pane's Click, if it has one. Gaps between the children of a row or column belong to the
row or column, so a click anywhere along a list row's line can select it.
Hover is the exception: every node under the pointer is hovered, as in CSS.
Use the position inside a node
on.ClickAt gets an on.Event, with X and Y
relative to the node the handler is on, and W and H, its size. A seek bar needs nothing else:
bloom.Fixed(1, bar).On(on.ClickAt(func(e on.Event) tea.Cmd {
m.position = m.duration * time.Duration(e.X) / time.Duration(e.W)
return nil
}))The rectangle of a box includes its border and padding, so X and Y count from its outer corner.
Scroll with the wheel
on.Scroll gets dy as 1 for each step down and -1 for each step up, and dx the same for left and right.
Each pane scrolls on its own:
bloom.Fill(m.reader).On(on.Scroll(func(_, dy int) tea.Cmd { m.offset += dy * 3; return nil }))Clamp the offset in Update, which gets the wheel event after the handler ran.
Hover
HoverStyle on text and boxes swaps in a style while the pointer is over the node, with no state in the model:
bloom.Text("save").Style(button).HoverStyle(buttonHover).On(on.Press(m.save))Give it a style that takes the same space as the node's own, changing only its colors and attributes.
For anything more, such as showing a tooltip, on.Hover runs when the pointer moves onto the node and off it:
bloom.Text("deploy").On(on.Hover(func(over bool) tea.Cmd { m.tip = over; return nil }))Both need tea.MouseModeAllMotion to follow the pointer while no button is down. A node stops being hovered when
something covers it, such as a dialog opening over it, even without the pointer moving. bloom notices with the next
message m.ui.Update gets after the new frame is drawn, such as an animation frame, a key, or the pointer moving. Terminals don't report the
pointer leaving the window, so the hover also ends when the window loses focus, which needs v.ReportFocus = true.
Drag
on.Drag runs when the left button goes down on the node, each time the pointer moves while it's held, and once
more with Done set when it comes up. The node keeps getting the drag after the pointer leaves it, which a
one-cell splitter needs:
divider := bloom.Fixed(1, bloom.Text("│")).On(on.Drag(func(e on.Event) tea.Cmd {
m.split = e.Screen.X
return nil
}))e.Screen is where the pointer is on screen, e.Start is where the drag started, and e.X and e.Y are relative
to the node, outside it when the pointer is.
A drag also ends, with Done set, when the node leaves the frame or a modal layer opens over it, and when the
release gets lost, such as outside the window. bloom follows the dragged node from frame to frame by where it is in
the tree, or by its ID if it has one, so give a node that moves around in the tree while it's dragged an ID.
Layers and the mouse
Layers drawn on top get events first. A layer that's modal takes every event from
what's beneath it. on.ClickOutside on a modal layer runs for a click on its backdrop, which is how a dialog closes
when you click away from it:
bloom.Layer(dialog).Modal().On(on.ClickOutside(m.closeDialog))On a layer that isn't modal, such as a dropdown, on.ClickOutside runs for a click anywhere
else, and the click still reaches what it's on.
A layer whose presence is hidden doesn't take events. One that's still animating out does, so a modal dialog keeps blocking clicks until it's gone.
Handle raw events yourself
The original tea.MouseClickMsg and the other mouse messages still reach Update. If you handle them as well as
using handlers, don't act on the same event twice. To find what's under a point, m.ui.Frame().Hit(x, y) returns
the IDs of the nodes there, outermost first:
case tea.MouseClickMsg:
if path := m.ui.Frame().Hit(msg.X, msg.Y); slices.Contains(path, any(paneLog)) {
m.follow = false
}For things other than events, such as drawing an effect from a button's position, m.ui.Frame().Rect(id) returns
where a node was drawn in the last frame, in screen coordinates:
if r, ok := m.ui.Frame().Rect(buttonDeploy); ok {
center := image.Pt((r.Min.X+r.Max.X)/2, (r.Min.Y+r.Max.Y)/2)
// ...
}Fewer frames for the mouse
Terminals can send wheel and motion events faster than they're worth drawing. bloom.ThrottleMouse makes a
Bubble Tea filter that drops those that come too soon after the last:
p := tea.NewProgram(m, tea.WithFilter(bloom.ThrottleMouse(15*time.Millisecond)))A dropped event isn't made up for: the wheel scrolls a little less, and if the pointer stops right after a dropped motion event, hover catches up when it moves again.
How it works
m.ui.View installs a tea.View.OnMouse handler. Bubble Tea calls it on its event loop for each mouse event,
before Update and the next View, which is what lets handlers change the model safely and have the change drawn
right away. bloom runs the handlers there, and batches the commands they return, which Bubble Tea then runs in the
background like any other.
If the view doesn't come from m.ui.View, or something replaces its OnMouse, m.ui.Update runs the handlers
instead when it gets the event, so pass it every message. It skips events that OnMouse already handled.
Test handlers
m.ui.Click(id) clicks the middle of the node with an ID, and m.ui.ClickAt(x, y) clicks a point, running the
handlers of the last frame drawn and returning their commands. m.ui.Update runs handlers for any other mouse
message, so a test can drive hover, the wheel, and drags without a terminal:
m.View()
m.ui.Update(tea.MouseMotionMsg{X: 3, Y: 1})
if _, ok := m.ui.Click(buttonSave); !ok || m.saves != 1 {
t.Fatal("save was not pressed")
}Related
- Focus and keyboard navigation, which can focus the node you click
- Scrolling and sizing in Update
onAPI reference- The
mouseexample, with hover styles, double clicks, the wheel, and a draggable divider
Last updated on
Toast notifications
Show a notification in a corner of the screen that animates in and hides itself after a while, and stack several of them.
Focus and keyboard navigation
Track keyboard focus with bloom.Focus, move it with tab and clicks, press buttons with enter, confine it to dialogs and panels with scopes, and show the terminal cursor.