Fork me on GitHub

TomasMikula/blog

Detecting when the mouse stays still over a node

Everyone has seen tooltips: the mouse enters a node, stays still for a while, and then a tooltip is displayed. JavaFX does have tooltips. They are easy to use, but not very flexible. For example, you cannot control the delay before the tooltip is shown or for how long it is shown. If you want to roll your own tooltip implementation, the first step is to detect when and where the mouse stays still.

For touch devices, JavaFX has the TOUCH_STATIONARY event. There’s no such event for mouse, so we are going to simulate it by means of available mouse events.

Plan

Detect all mouse events on a node. When a MOUSE_MOVED event hasn’t been followed by any mouse event for 1 second, we conclude that the mouse is stationary. When the next mouse event arrives, we conclude that the mouse stopped being stationary. We are going to create a ReactFX event stream that emits the mouse position when the mouse becomes stationary and null when it stops being stationary.

Solution

1
2
3
4
5
6
7
8
9
10
11
12
EventStream<MouseEvent> mouseEvents = eventsOf(node, MouseEvent.ANY);

EventStream<Point2D> stationaryPositions = mouseEvents
        .successionEnds(Duration.ofSeconds(1))
        .filter(e -> e.getEventType() == MouseEvent.MOUSE_MOVED)
        .map(e -> new Point2D(e.getX(), e.getY()));

EventStream<Void> stoppers = mouseEvents.supply((Void) null);

EventStream<Either<Point2D, Void>> stationaryEvents =
        stationaryPositions.or(stoppers)
                .distinct();
  • Line 1 creates a stream of all mouse events on node.
  • Line 4 keeps only events that haven’t been followed by another one for 1 second.
  • Line 5 further filters the stream to contain only mouse moves.
    This means that, for example, if the last event is a click and then the mouse stays still, it is not detected as mouse being stationary. Note that this is consistent with how tooltips work—the tooltip is not displayed after the node is clicked.
  • Line 6 converts mouse events to mouse positions.
  • Line 8 declares that any mouse event is a reason to end the stationary state.
  • Line 11 includes both types of events (started being stationary and stopped being stationary) in one stream.
  • Line 12 filters out repeating stopped being stationary events.

Highlights

  • There are no mutable variables to track state, like lastMousePosition or isStationary. Any use of timers to measure the 1 second delay is completely hidden as well. All the variables above are effectively final and all state is managed by the event streams.
  • There is no CPU overhead when no one is actually subscribed to stationaryEvents. This is due to the lazy nature of event streams. Only when someone subscribes to stationaryEvents does this propagate all the way down and an event handler is registered for mouse events on the node.

Usage

stationaryEvents.subscribe(either -> either.exec(
        pos -> showTooltipAt(pos),
        stop -> hideTooltip()
));

Optionally: Dispatch MouseStationaryEvents on a node

You may want to use JavaFX’s standard addEventHandler method to detect when the mouse starts and stops being stationary.

Let’s define MouseStationaryEvent with the following API:

public class MouseStationaryEvent extends InputEvent {

    public static final EventType<MouseStationaryEvent> ANY;
    public static final EventType<MouseStationaryEvent> MOUSE_STATIONARY_BEGIN;
    public static final EventType<MouseStationaryEvent> MOUSE_STATIONARY_END;

    /** Creates a new event of type MOUSE_STATIONARY_BEGIN. */
    static final MouseStationaryEvent beginAt(Point2D screenPos);

    /** Creates a new event of type MOUSE_STATIONARY_END. */
    static final MouseStationaryEvent end();

    public Point2D getPosition();
    public Point2D getScenePosition();
    public Point2D getScreenPosition();
}
Implementation of this class is not very interesting, but is provided here for completeness.

Now, to start dispatching MouseStationaryEvents for a node, you simply do this:

EventStream<Either<Point2D, Void>> stationaryEvents = // defined above

stationaryEvents.<Event>map(either -> either.unify(
        pos -> MouseStationaryEvent.beginAt(node.localToScreen(pos)),
        stop -> MouseStationaryEvent.end()))
    .subscribe(evt -> Event.fireEvent(node, evt));

The unify operator converts a stream of either Point2D or Void to a stream of a single type MouseStationaryEvent. Point2Ds are converted to a MouseStaionaryEvent of type MOUSE_STATIONARY_BEGIN and Voids are converted to a MouseStationaryEvent of type MOUSE_STATIONARY_END.

Usage

node.addEventHandler(MOUSE_STATIONARY_BEGIN, e -> {
    showTooltipAt(e.getScreenPosition());
});

node.addEventHandler(MOUSE_STATIONARY_END, e -> {
    hideTooltip();
});

Convenient helper class

Finally, here is a helper class combining all of the above that you can use in your projects.

Usage

// detect stationary events on a node after 1 second delay
MouseStationaryHelper helper =
        new MouseStationaryHelper(node, Duration.ofSeconds(1));

helper.events().subscribe(either -> either.exec(
        pos -> showTooltipAt(pos),
        stop -> hideTooltip()
));

// If you would rather use standard JavaFX way,
// start dispatching MouseStationaryEvents on the node.
helper.install();

node.addEventHandler(MOUSE_STATIONARY_BEGIN, e -> {
    showTooltipAt(e.getScreenPosition());
});

node.addEventHandler(MOUSE_STATIONARY_END, e -> {
    hideTooltip();
});

// optionally, stop dispatching MouseStationaryEvents
helper.uninstall();
comments powered by Disqus