@ink-tools/ink-mouse
v2.0.5
Published
Mouse support for [Ink](https://github.com/vadimdemedes/ink) applications. Provides a React-based API for handling mouse events in terminal user interfaces.
Readme
@ink-tools/ink-mouse
Mouse support for Ink applications. Provides a React-based API for handling mouse events in terminal user interfaces.
Features
- Click detection - Detect mouse clicks on elements
- Press/Release tracking - Track individual button press and release events
- Hover tracking - Track when mouse enters/exits elements
- Mouse move tracking - Track mouse movement over elements
- Drag support - Track mouse drag operations
- Wheel/scroll support - Handle mouse wheel events
- Automatic hit testing - Uses element bounds for accurate event detection
- Performance optimization - Bounds caching reduces CPU usage by up to 60x
- Dynamic layout support - Recalculates element positions on cache expiry
- Configurable cache - Tune cache lifetime for your use case
- Terminal compatibility - Works with xterm-compatible terminals
Installation
pnpm add @ink-tools/ink-mouse
# or
npm install @ink-tools/ink-mouse
# or
yarn add @ink-tools/ink-mousePeer Dependencies
This package requires the following peer dependencies:
ink^6.6.0react^19.2.3
Quick Start
import React from 'react';
import { Box, Text } from 'ink';
import { MouseProvider, useOnClick } from '@ink-tools/ink-mouse';
function Button() {
const ref = React.useRef(null);
useOnClick(ref, () => {
console.log('Button clicked!');
});
return (
<Box ref={ref}>
<Text>Click me!</Text>
</Box>
);
}
function App() {
return (
<MouseProvider>
<Button />
</MouseProvider>
);
}API Reference
<MouseProvider>
Wrapper component that enables mouse tracking for your application.
Props:
autoEnable?: boolean- Automatically enable mouse tracking on mount (default:true)cacheInvalidationMs?: number- Element bounds cache lifetime in milliseconds (default:16)
The cacheInvalidationMs prop controls how long element bounds are cached. Longer values
reduce CPU usage but may cause stale hit detection during rapid layout changes.
Example:
<MouseProvider autoEnable={true} cacheInvalidationMs={16}>
<App />
</MouseProvider>Performance tuning:
16(default): ~60fps, best for most cases50: ~20fps, good for slower terminals0: disable cache, recalculate on every event (highest CPU usage)
useMouse()
Hook for accessing mouse state and control methods.
Returns:
{
isEnabled: boolean; // Is mouse tracking enabled?
isTracking: boolean; // Are mouse events being received?
isSupported: boolean; // Does terminal support mouse?
enable: () => void; // Enable mouse tracking
disable: () => void; // Disable mouse tracking
}Example:
function Status() {
const mouse = useMouse();
return (
<Box>
<Text>Mouse: {mouse.isEnabled ? 'On' : 'Off'}</Text>
<Text>Supported: {mouse.isSupported ? 'Yes' : 'No'}</Text>
</Box>
);
}useOnClick(ref, handler)
Hook for handling click events on an element.
Parameters:
ref: RefObject<DOMElement>- Reference to the elementhandler: (event: InkMouseEvent) => void- Click handler function
Example:
function Clickable() {
const ref = React.useRef(null);
useOnClick(ref, (event) => {
console.log(`Clicked at ${event.x}, ${event.y}`);
console.log(`Button: ${event.button}`);
});
return <Box ref={ref}>Click me</Box>;
}useOnMouseEnter(ref, handler)
Hook for handling mouse enter events only.
Parameters:
ref: RefObject<DOMElement>- Reference to the elementhandler: (event: InkMouseEvent) => void- Mouse enter handler
Example:
function Hoverable() {
const ref = React.useRef(null);
const [message, setMessage] = React.useState('');
useOnMouseEnter(ref, () => setMessage('Mouse entered!'));
return (
<Box ref={ref}>
<Text>{message}</Text>
</Box>
);
}useOnMouseLeave(ref, handler)
Hook for handling mouse leave events only.
Parameters:
ref: RefObject<DOMElement>- Reference to the elementhandler: (event: InkMouseEvent) => void- Mouse leave handler
Example:
function Hoverable() {
const ref = React.useRef(null);
const [message, setMessage] = React.useState('');
useOnMouseLeave(ref, () => setMessage('Mouse left!'));
return (
<Box ref={ref}>
<Text>{message}</Text>
</Box>
);
}useOnWheel(ref, handler)
Hook for handling mouse wheel/scroll events.
Parameters:
ref: RefObject<DOMElement>- Reference to the elementhandler: (event: InkMouseEvent) => void- Wheel handler function
Example:
function Scrollable() {
const ref = React.useRef(null);
const [offset, setOffset] = React.useState(0);
useOnWheel(ref, (event) => {
if (event.button === 'wheel-up') {
setOffset((prev) => Math.max(0, prev - 1));
} else if (event.button === 'wheel-down') {
setOffset((prev) => prev + 1);
}
});
return (
<Box ref={ref} height={10}>
<Box flexDirection="column" translateY={-offset}>
{items.map((item) => (
<Text key={item.id}>{item.name}</Text>
))}
</Box>
</Box>
);
}useOnPress(ref, handler)
Hook for handling mouse button press events on an element.
Press events fire immediately when a mouse button is pressed down, before the click event (which requires press + release).
Parameters:
ref: RefObject<DOMElement>- Reference to the elementhandler: (event: InkMouseEvent) => void- Press event handler
Example:
function Button() {
const ref = useRef<DOMElement>(null);
const [isPressed, setIsPressed] = useState(false);
useOnPress(ref, () => setIsPressed(true));
useOnRelease(ref, () => setIsPressed(false));
return (
<Box ref={ref}>
<Text>{isPressed ? 'Pressed!' : 'Press me'}</Text>
</Box>
);
}useOnRelease(ref, handler)
Hook for handling mouse button release events on an element.
Release events fire when a mouse button is released.
Parameters:
ref: RefObject<DOMElement>- Reference to the elementhandler: (event: InkMouseEvent) => void- Release event handler
Example:
function Button() {
const ref = useRef<DOMElement>(null);
const [isPressed, setIsPressed] = useState(false);
useOnPress(ref, () => setIsPressed(true));
useOnRelease(ref, () => setIsPressed(false));
return (
<Box ref={ref}>
<Text>{isPressed ? 'Pressed!' : 'Press me'}</Text>
</Box>
);
}useOnMouseMove(ref, handler)
Hook for handling mouse move events on an element.
Move events fire when the mouse cursor moves within the element's bounds. Unlike hover events, move events fire continuously as the mouse moves.
Performance Note: Move events fire very frequently. Consider debouncing or throttling handlers for performance-sensitive applications.
Parameters:
ref: RefObject<DOMElement>- Reference to the elementhandler: (event: InkMouseEvent) => void- Mouse move event handler
Example:
function Tracker() {
const ref = useRef<DOMElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
useOnMouseMove(ref, (event) => {
setPosition({ x: event.x, y: event.y });
});
return (
<Box ref={ref}>
<Text>Mouse position: {position.x}, {position.y}</Text>
</Box>
);
}useOnDrag(ref, handler)
Hook for handling mouse drag events on an element.
Drag events fire when the mouse moves while a button is held down. This is useful for implementing drag-and-drop functionality.
Parameters:
ref: RefObject<DOMElement>- Reference to the elementhandler: (event: InkMouseEvent) => void- Drag event handler
Example:
function Draggable() {
const ref = useRef<DOMElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
useOnPress(ref, () => setIsDragging(true));
useOnRelease(ref, () => setIsDragging(false));
useOnDrag(ref, (event) => {
if (isDragging) {
setPosition({ x: event.x, y: event.y });
}
});
return (
<Box ref={ref} flexDirection="column">
<Text>Position: {position.x}, {position.y}</Text>
<Text>{isDragging ? '(dragging)' : '(not dragging)'}</Text>
</Box>
);
}Geometry Utilities
The package also provides utilities for working with element positions and rectangles:
getBoundingClientRect(node)
Get the bounding rectangle of an element.
Parameters:
node: DOMElement | null- The element to measure
Returns: BoundingClientRect | undefined
interface BoundingClientRect {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly top: number;
readonly right: number;
readonly bottom: number;
readonly left: number;
}useBoundingClientRect(ref, deps?)
React hook for tracking element bounds.
Parameters:
ref: RefObject<DOMElement | null>- Reference to the elementdeps?: unknown[]- Dependencies to trigger recalculation (default:[])
Returns: BoundingClientRect
getRectCenter(rect)
Get the center point of a rectangle. Useful for centering elements, calculating anchor points, or positioning tooltips.
Parameters:
rect: BoundingClientRect- The bounding rectangle
Returns: { x: number; y: number } - The center point
Example:
import { getBoundingClientRect, getRectCenter } from '@ink-tools/ink-mouse';
function Tooltip() {
const buttonRect = getBoundingClientRect(buttonRef.current);
if (buttonRect) {
const center = getRectCenter(buttonRect);
console.log(`Button center: ${center.x}, ${center.y}`);
}
return <Box>Tooltip</Box>;
}isRectOverlapping(rect1, rect2)
Check if two rectangles overlap. Useful for collision detection and drag-and-drop.
Parameters:
rect1: BoundingClientRect- The first rectanglerect2: BoundingClientRect- The second rectangle
Returns: boolean - True if rectangles overlap
Example:
import { useBoundingClientRect, isRectOverlapping } from '@ink-tools/ink-mouse';
function DragDropZone() {
const dragRect = useBoundingClientRect(dragRef);
const dropRect = useBoundingClientRect(dropRef);
const canDrop = dragRect && dropRect && isRectOverlapping(dragRect, dropRect);
return <Box>{canDrop ? 'Drop here!' : 'Drag over target'}</Box>;
}isPointInRect(x, y, rect)
Check if a point is inside a rectangle. Useful for hit testing.
Parameters:
x: number- The x coordinate of the pointy: number- The y coordinate of the pointrect: BoundingClientRect- The bounding rectangle
Returns: boolean - True if point is inside rectangle
Example:
import { isPointInRect } from '@ink-tools/ink-mouse';
const rect = { left: 10, top: 10, right: 20, bottom: 20, width: 10, height: 10, x: 10, y: 10 };
isPointInRect(15, 15, rect); // true
isPointInRect(5, 5, rect); // falseEvent Object
All event handlers receive an InkMouseEvent object:
interface InkMouseEvent {
x: number; // X coordinate (terminal column)
y: number; // Y coordinate (terminal row)
button: string; // Button pressed ('left', 'middle', 'right', 'wheel-up', 'wheel-down', etc.)
action: string; // Action type ('press', 'release', 'click', 'wheel', 'move', 'drag')
shift: boolean; // Shift key modifier
alt: boolean; // Alt key modifier
ctrl: boolean; // Ctrl key modifier
raw: number; // Raw event code
data: string; // Raw event data
protocol: string; // Mouse protocol ('SGR' or 'ESC')
}Terminal Requirements
Mouse events require terminal support. Most modern terminals support mouse events:
- macOS: Terminal.app, iTerm2, Warp
- Linux: GNOME Terminal, Konsole, Alacritty, kitty
- Windows: Windows Terminal, PowerShell 7+, ConEmu
To check if your terminal supports mouse events:
import { useMouse } from '@ink-tools/ink-mouse';
function CheckSupport() {
const { isSupported } = useMouse();
return <Text>Mouse support: {isSupported ? 'Yes' : 'No'}</Text>;
}Advanced Usage
Manual Enable/Disable
function App() {
const mouse = useMouse();
React.useEffect(() => {
// Enable mouse tracking only when needed
mouse.enable();
return () => {
mouse.disable();
};
}, [mouse]);
return <Component />;
}Element Hit Testing
The package uses getBoundingClientRect to
calculate element positions. Element bounds are cached for cacheInvalidationMs
milliseconds (default: 16ms) to optimize performance. This ensures accurate
hit detection while minimizing CPU usage during rapid mouse events.
The cache automatically invalidates after the specified time, ensuring accurate hit detection even when elements:
- Change position (e.g., scrolling lists)
- Change size (e.g., dynamic content)
- Are added/removed from the DOM
For applications with very dynamic layouts, you can reduce cacheInvalidationMs
or set it to 0 to disable caching.
Multiple Event Handlers
You can combine multiple hooks on the same element:
function MultiHandler() {
const ref = React.useRef(null);
useOnClick(ref, () => console.log('Clicked!'));
useOnMouseEnter(ref, () => console.log('Entered!'));
useOnMouseLeave(ref, () => console.log('Exited!'));
useOnPress(ref, () => console.log('Pressed!'));
useOnRelease(ref, () => console.log('Released!'));
useOnMouseMove(ref, (e) => console.log(`Moved to ${e.x}, ${e.y}`));
return <Box ref={ref}>Multi-handler element</Box>;
}For a complete draggable component with press/release/drag tracking:
function DraggableButton() {
const ref = React.useRef(null);
const [isDragging, setIsDragging] = React.useState(false);
const [position, setPosition] = React.useState({ x: 0, y: 0 });
useOnPress(ref, () => setIsDragging(true));
useOnRelease(ref, () => setIsDragging(false));
useOnDrag(ref, (event) => {
if (isDragging) {
setPosition({ x: event.x, y: event.y });
}
});
return (
<Box ref={ref}>
<Text>
Position: {position.x}, {position.y} {isDragging ? '(dragging)' : ''}
</Text>
</Box>
);
}Testing
This package has comprehensive test coverage to ensure reliability and prevent regressions.
Running Tests
# Run all tests in the monorepo
pnpm test
# Run tests with coverage report
pnpm run test:coverage
# Generate LCOV coverage reports for CI/CD
pnpm run test:coverage:lcov
# Run tests from the ink-mouse package directory
cd packages/ink-mouse
pnpm testTest Coverage
- 95.26% line coverage
- 97.44% function coverage
- 148 tests across 8 test files
The test suite covers:
- Geometry utilities (point/rectangle calculations)
- Element position and dimension extraction
- React hooks lifecycle and behavior
- Mouse event handler registration and cleanup
- Integration scenarios for complete workflows
Writing Tests
The package provides testing utilities for mocking Ink's internal structures:
Mock Helpers
import {
createMockYogaNode,
createMockDOMElement,
createMockDOMElementChain,
} from '@ink-tools/ink-mouse/test/mocks/ink-element';
// Create a mock Yoga node with computed layout
const yogaNode = createMockYogaNode({
left: 10,
top: 20,
width: 100,
height: 50,
});
// Create a mock DOMElement
const element = createMockDOMElement({
left: 10,
top: 20,
width: 100,
height: 50,
parentNode: null,
});
// Create element with parent chain for position accumulation
const childElement = createMockDOMElementChain([
{ left: 5, top: 10, width: 50, height: 30 }, // child
{ left: 15, top: 20, width: 100, height: 50 }, // parent
{ left: 25, top: 30, width: 150, height: 70 }, // grandparent
]);Example Test
import { render } from 'ink-testing-library';
import { describe, expect, test } from 'vitest';
import React from 'react';
import { Box, Text } from 'ink';
import { MouseProvider, useOnClick } from '@ink-tools/ink-mouse';
describe('useOnClick', () => {
test('registers click handler', () => {
function TestComponent() {
const ref = React.useRef(null);
useOnClick(ref, () => {});
return (
<Box>
<Text>Clickable</Text>
</Box>
);
}
const { lastFrame } = render(
<MouseProvider>
<TestComponent />
</MouseProvider>
);
expect(lastFrame()).toBe('Clickable');
});
});Testing Guidelines
- Use
.tsxextension for test files with JSX - Test files that use JSX must use the.tsxextension - Wrap all text in
<Text>components - Ink requires all text to be wrapped in<Text>components - Create template literals in variables - Don't use template literals directly in JSX expressions
- Test realistic scenarios - Focus on component composition and lifecycle rather than implementation details
- Account for terminal 1-indexing - Terminal coordinates start at (1, 1), not (0, 0)
For detailed testing documentation and best practices, see:
- Testing Guide - Comprehensive testing documentation
- Integration Tests for Ink Mouse Event Workflows
- Testing Ink Components with React Hooks
License
MIT
Related Packages
- xterm-mouse - Low-level xterm mouse protocol handler
