Mouse Router & Drag-and-Drop
Tako's Mouse Router brings the same declarative, zone-based philosophy from the Key Router to mouse and pointer events. Register hitboxes and handlers — Tako does the rest.
Core Concepts
Hitbox Registry
Because Tako is UI-agnostic, the framework does not know where your UI elements are rendered on screen. The solution is a Hitbox Registry: before each mouse event is dispatched, you tell the router the current position and dimensions of each zone:
// Call this every render cycle, inside your View() function
app.Mouse().UpdateHitbox("sidebar", x, y, width, height)When a mouse event arrives, the router performs a hit-test across all registered hitboxes at the current stack level. The first zone whose bounds contain the event coordinates receives the event.
Stack-level Isolation
The Mouse Router mirrors the Key Router's strict isolation: only the topmost stack level's hitboxes are eligible when dispatching a mouse event. If a modal is open (level 1), clicking behind it at coordinates that land in a level-0 zone has no effect.
Stack: ["base", "modal"] → only level 1 hitboxes receive events
Stack: ["base"] → level 0 hitboxes receive eventsMemory Management
When a layer is popped via app.Stack().Pop() or app.Overlay().Close(), all hitboxes and handlers for that stack level are automatically removed from the registry — no manual cleanup required.
API Reference
app.Mouse() / ctx.Mouse()
Returns a MouseBuilder (from the host app) or ContextMouseBuilder (from a plugin). Both expose the same fluent interface.
Registering Hitboxes
// Base level (level 0)
app.Mouse().UpdateHitbox("sidebar", x, y, width, height)
// Explicit stack level (e.g. an overlay at level 1)
app.Mouse().UpdateHitboxAt(1, "modal-btn", x, y, width, height)Important: Call
UpdateHitboxinside yourView()function every render cycle. If the terminal is resized or the layout shifts, the hitbox must be refreshed.
Registering Handlers
All handler methods return the ZoneMouseBuilder so you can chain them:
app.Mouse().Zone("sidebar").
OnClick(func(x, y int) {
// left-click at terminal coordinates (x, y)
}).
OnRightClick(func(x, y int) {
// right-click
}).
OnMiddleClick(func(x, y int) {
// middle button click
}).
OnScrollUp(func(x, y int) {
// mouse wheel up
}).
OnScrollDown(func(x, y int) {
// mouse wheel down
}).
OnDragStart(func(x, y int) {
// drag operation began in this zone
}).
OnDrop(func(fromZone string, x, y int) {
// item dropped here; fromZone is where the drag started
})Explicit Stack Level
// For a zone inside a modal overlay at stack level 1
app.Mouse().Zone("close-btn", 1).OnClick(func(x, y int) {
app.Overlay().Close()
})Drag & Drop
Drag-and-drop is a two-zone interaction:
- The source zone registers
OnDragStart. - The target zone registers
OnDrop.
The router tracks the active drag internally. When the mouse button is released over a target zone, OnDrop(fromZone, x, y) is called with the name of the zone where the drag started.
app.Mouse().Zone("file-list").
OnDragStart(func(x, y int) {
// remember which item is being dragged
})
app.Mouse().Zone("trash-bin").
OnDrop(func(fromZone string, x, y int) {
if fromZone == "file-list" {
deleteSelected()
}
})You can check drag state at any time:
router := app.Router()
if router.Mouse().DragActive() {
fmt.Println("dragging from:", router.Mouse().DragSource())
}Full Example
The demo/layouts/mouse_demo.go file contains a working two-panel demo:
- Left panel: click to select, right-click to cycle, scroll to navigate, drag to move items.
- Right panel: accepts drops from the left panel.
// In main.go (or a plugin's OnInit)
app.Mouse().Zone("left-panel").
OnClick(func(x, y int) {
itemRow := y - headerOffset
if itemRow >= 0 && itemRow < len(items) {
selected = itemRow
}
}).
OnScrollUp(func(x, y int) {
if selected > 0 { selected-- }
}).
OnScrollDown(func(x, y int) {
if selected < len(items)-1 { selected++ }
}).
OnDragStart(func(x, y int) {
// drag tracking is automatic
})
app.Mouse().Zone("right-panel").
OnDrop(func(fromZone string, x, y int) {
if fromZone == "left-panel" {
move(selected, &leftItems, &rightItems)
}
})
// In your View() function — update hitboxes every render
func (l *MyLayout) View(ctx *tako.Context, r *router.Router) tea.View {
// ... render panels ...
ctx.Mouse().UpdateHitbox("left-panel", leftX, topY, panelW, panelH)
ctx.Mouse().UpdateHitbox("right-panel", rightX, topY, panelW, panelH)
// ...
}In a Plugin (ctx.Mouse())
Plugins access the same API via the Context:
func (p *MyPlugin) OnInit(ctx *tako.Context) {
ctx.Mouse().Zone("my-zone").OnClick(func(x, y int) {
ctx.Emit("my-zone:clicked", map[string]int{"x": x, "y": y})
})
}
// In the plugin's render hook, update the hitbox:
func (p *MyPlugin) render(ctx *tako.Context) string {
ctx.Mouse().UpdateHitbox("my-zone", p.x, p.y, p.w, p.h)
return p.content
}As a MouseComponent
For self-contained components (registered via app.Overlay().ShowComponent(c)), implement the optional contracts.MouseComponent interface:
type FilePanel struct{ x, y, w, h int }
func (p *FilePanel) ID() string { return "files" }
func (p *FilePanel) Render() any { /* ... */ }
func (p *FilePanel) RegisterKeys(km contracts.KeyManager) { /* ... */ }
// Optional: declare mouse handlers
func (p *FilePanel) RegisterMouse(mm contracts.MouseManager) {
mm.Zone(p.ID()).OnClick(func(x, y int) {
// select item
})
mm.Zone(p.ID()).OnDrop(func(fromZone string, x, y int) {
// accept drop
})
}The overlay manager calls RegisterMouse automatically if the component implements MouseComponent.
Key Behaviours Summary
| Situation | Behaviour |
|---|---|
| Click outside all hitboxes | Dropped (no handler called) |
| Click on zone with no handler | Dropped |
OnDrop called without prior drag | Not called (DragActive must be true) |
| Overlay closed | All hitboxes for that level are freed |
| Multiple hitboxes overlap | First registered zone wins |
ctrl+c while mouse is active | Handled by key router (unchanged) |
Up next: 04 — Layer Management
