Browse Source

Implement mouseClip and mouseMode API (#1101)

* Implement mouseClip and mouseMode API
[DX] Add mouse API integration
Use relative mouse mode for CameraController

* Add setCursorPos emitEvent optional flag.

* Add `restorePos` flag to relative mouse modes
Add `onMouseModeChange`

* Add MouseApi sample
Fix restorePos and relative event propagation.

* Implement Mouse API for hlsdl

* Implement JS mouse lock API

* [JS] Improve pointer lock logic

* Fix MouseApi sample

* Remove AbsoluteWrap mouse mode

* Update mouse api docs

* Finish JS mouse api implementation
Fix capture issues with Firefox
Add note regarding chromium undocumented non-spec 1s re-capture timeout.
Pavel Alexandrov 3 years ago
parent
commit
7608fa715a
6 changed files with 447 additions and 47 deletions
  1. 10 1
      h3d/scene/CameraController.hx
  2. 133 10
      hxd/Window.hl.hx
  3. 46 2
      hxd/Window.hx
  4. 110 34
      hxd/Window.js.hx
  5. 43 0
      hxd/impl/MouseMode.hx
  6. 105 0
      samples/MouseApi.hx

+ 10 - 1
h3d/scene/CameraController.hx

@@ -156,14 +156,23 @@ class CameraController extends h3d.scene.Object {
 			else
 				zoom(e.wheelDelta);
 		case EPush:
-			@:privateAccess scene.events.startCapture(onEvent, function() pushing = -1, e.touchId);
+			@:privateAccess scene.events.startCapture(onEvent, function() {
+				pushing = -1;
+				var wnd = hxd.Window.getInstance();
+				wnd.mouseMode = Absolute;
+				wnd.setCursorPos(Std.int(pushStartX), Std.int(pushStartY));
+			}, e.touchId);
 			pushing = e.button;
 			pushTime = haxe.Timer.stamp();
 			pushStartX = pushX = e.relX;
 			pushStartY = pushY = e.relY;
+			hxd.Window.getInstance().mouseMode = AbsoluteUnbound(true);
 		case ERelease, EReleaseOutside:
 			if( pushing == e.button ) {
 				pushing = -1;
+				var wnd = hxd.Window.getInstance();
+				wnd.mouseMode = Absolute;
+				wnd.setCursorPos(Std.int(pushStartX), Std.int(pushStartY));
 				@:privateAccess scene.events.stopCapture();
 				if( e.kind == ERelease && haxe.Timer.stamp() - pushTime < 0.2 && hxd.Math.distance(e.relX - pushStartX,e.relY - pushStartY) < 5 )
 					onClick(e);

+ 133 - 10
hxd/Window.hl.hx

@@ -1,5 +1,6 @@
 package hxd;
 import hxd.Key in K;
+import hxd.impl.MouseMode;
 
 #if (hlsdl && hldx)
 #error "You shouldn't use both -lib hlsdl and -lib hldx"
@@ -39,7 +40,18 @@ class Window {
 	public var height(get, never) : Int;
 	public var mouseX(get, never) : Int;
 	public var mouseY(get, never) : Int;
+	@:deprecated("Use mouseMode = AbsoluteUnbound(true)")
 	public var mouseLock(get, set) : Bool;
+	/**
+		If set, will restrain the mouse cursor within the window boundaries.
+	**/
+	public var mouseClip(get, set) : Bool;
+	/**
+		Set the mouse movement input handling mode.
+
+		@see `hxd.impl.MouseMode` for more details on each mode.
+	**/
+	public var mouseMode(default, set): MouseMode = Absolute;
 	public var monitor : Null<Int> = null;
 	public var framerate : Null<Int> = null;
 	public var vsync(get, set) : Bool;
@@ -55,11 +67,14 @@ class Window {
 	var window : sdl.Window;
 	#elseif hldx
 	var window : dx.Window;
+	var _mouseClip : Bool;
 	#end
 	var windowWidth = 800;
 	var windowHeight = 600;
 	var curMouseX = 0;
 	var curMouseY = 0;
+	var startMouseX = 0;
+	var startMouseY = 0;
 	var savedSize : { x : Int, y : Int, width : Int, height : Int };
 
 	static var CODEMAP = [for( i in 0...2048 ) i];
@@ -95,6 +110,10 @@ class Window {
 		return true;
 	}
 
+	public dynamic function onMouseModeChange( from : MouseMode, to : MouseMode ) : Null<MouseMode> {
+		return null;
+	}
+
 	public function event( e : hxd.Event ) : Void {
 		for( et in eventTargets )
 			et(e);
@@ -149,6 +168,19 @@ class Window {
 		for( f in resizeEvents ) f();
 	}
 
+	public function setCursorPos( x : Int, y : Int, emitEvent : Bool = false ) : Void {
+		#if hldx
+		if (mouseMode == Absolute) window.setCursorPosition(x, y);
+		#elseif hlsdl
+		if (mouseMode == Absolute) window.warpMouse(x, y);
+		#else
+		throw "Not implemented";
+		#end
+		curMouseX = x;
+		curMouseY = y;
+		if (emitEvent) event(new hxd.Event(EMove, x, y));
+	}
+
 	@:deprecated("Use the displayMode property instead")
 	public function setFullScreen( v : Bool ) : Void {
 		#if (hldx || hlsdl)
@@ -173,12 +205,73 @@ class Window {
 	}
 
 	function get_mouseLock() : Bool {
-		return false;
+		return switch (mouseMode) { case AbsoluteUnbound(_): true; default: false; };
 	}
 
 	function set_mouseLock(v:Bool) : Bool {
+		return set_mouseMode(v ? AbsoluteUnbound(true) : Absolute).equals(AbsoluteUnbound(true));
+	}
+
+	function get_mouseClip() : Bool {
+		#if hldx
+		return _mouseClip;
+		#elseif hlsdl
+		return window.grab;
+		#else
+		return false;
+		#end
+	}
+
+	function set_mouseClip( v : Bool ) : Bool {
+		#if hldx
+		window.clipCursor(v);
+		return _mouseClip = v;
+		#elseif hlsdl
+		return window.grab = v;
+		#else
 		if( v ) throw "Not implemented";
 		return false;
+		#end
+	}
+
+	function set_mouseMode( v : MouseMode ) : MouseMode {
+		if ( v.equals(mouseMode) ) return v;
+
+		var forced = onMouseModeChange(mouseMode, v);
+		if (forced != null) v = forced;
+
+		#if hldx
+		window.setRelativeMouseMode(v != Absolute);
+		return mouseMode = v;
+		#elseif hlsdl
+		sdl.Sdl.setRelativeMouseMode(v != Absolute);
+		#else
+		if ( v != Absolute ) throw "Not implemented";
+		#end
+
+		if ( v == Absolute ) {
+			switch ( mouseMode ) {
+				case Relative(_, restorePos) | AbsoluteUnbound(restorePos):
+					if ( restorePos ) {
+						curMouseX = startMouseX;
+						curMouseY = startMouseY;
+					} else {
+						curMouseX = hxd.Math.iclamp(curMouseX, 0, width);
+						curMouseY = hxd.Math.iclamp(curMouseY, 0, height);
+					}
+					#if hldx
+					window.setCursorPosition(curMouseX, curMouseY);
+					#elseif hlsdl
+					window.warpMouse(curMouseX, curMouseY);
+					#end
+				default:
+			}
+		}
+
+		startMouseX = curMouseX;
+		startMouseY = curMouseY;
+		
+		return mouseMode = v;
 	}
 
 	#if (hldx||hlsdl)
@@ -238,9 +331,11 @@ class Window {
 			default:
 			}
 		case MouseDown if (!hxd.System.getValue(IsTouch)):
-			curMouseX = e.mouseX;
-			curMouseY = e.mouseY;
-			eh = new Event(EPush, e.mouseX, e.mouseY);
+			if (mouseMode == Absolute) {
+				curMouseX = e.mouseX;
+				curMouseY = e.mouseY;
+			}
+			eh = new Event(EPush, curMouseX, curMouseY);
 			// middle button -> 2 / right button -> 1
 			eh.button = switch( e.button - 1 ) {
 			case 0: 0;
@@ -249,9 +344,11 @@ class Window {
 			case x: x;
 			}
 		case MouseUp if (!hxd.System.getValue(IsTouch)):
-			curMouseX = e.mouseX;
-			curMouseY = e.mouseY;
-			eh = new Event(ERelease, e.mouseX, e.mouseY);
+			if (mouseMode == Absolute) {
+				curMouseX = e.mouseX;
+				curMouseY = e.mouseY;
+			}
+			eh = new Event(ERelease, curMouseX, curMouseY);
 			eh.button = switch( e.button - 1 ) {
 			case 0: 0;
 			case 1: 2;
@@ -259,9 +356,35 @@ class Window {
 			case x: x;
 			};
 		case MouseMove if (!hxd.System.getValue(IsTouch)):
-			curMouseX = e.mouseX;
-			curMouseY = e.mouseY;
-			eh = new Event(EMove, e.mouseX, e.mouseY);
+			switch (mouseMode) {
+				case Absolute:
+					curMouseX = e.mouseX;
+					curMouseY = e.mouseY;
+					eh = new Event(EMove, e.mouseX, e.mouseY);
+				case Relative(callback, _):
+					#if (hldx || hlsdl)
+					var ev = new Event(EMove, e.mouseXRel, e.mouseYRel);
+					#else
+					var ev = new Event(EMove, e.mouseX - curMouseX, e.mouseY - curMouseY);
+					#end
+					callback(ev);
+					if (!ev.cancel && ev.propagate) {
+						ev.cancel = false;
+						ev.propagate = false;
+						ev.relX = curMouseX;
+						ev.relY = curMouseY;
+						eh = ev;
+					}
+				case AbsoluteUnbound(_):
+					#if (hldx || hlsdl)
+					curMouseX += e.mouseXRel;
+					curMouseY += e.mouseYRel;
+					#else
+					curMouseX += e.mouseX - curMouseX;
+					curMouseY += e.mouseY - curMouseY;
+					#end
+					eh = new Event(EMove, curMouseX, curMouseY);
+			}
 		case MouseWheel:
 			eh = new Event(EWheel, mouseX, mouseY);
 			eh.wheelDelta = -e.wheelDelta;

+ 46 - 2
hxd/Window.hx

@@ -1,5 +1,7 @@
 package hxd;
 
+import hxd.impl.MouseMode;
+
 enum DisplayMode {
 	Windowed;
 	Borderless;
@@ -15,7 +17,18 @@ class Window {
 	public var height(get, never) : Int;
 	public var mouseX(get, never) : Int;
 	public var mouseY(get, never) : Int;
+	@:deprecated("Use mouseMode = AbsoluteUnbound(true)")
 	public var mouseLock(get, set) : Bool;
+	/**
+		If set, will restrain the mouse cursor within the window boundaries.
+	**/
+	public var mouseClip(get, set) : Bool;
+	/**
+		Set the mouse movement input handling mode.
+
+		@see `hxd.impl.MouseMode` for more details on each mode.
+	**/
+	public var mouseMode(default, set) : MouseMode = Absolute;
 	public var vsync(get, set) : Bool;
 	public var isFocused(get, never) : Bool;
 
@@ -31,6 +44,17 @@ class Window {
 		return true;
 	}
 
+	/**
+		An event called when `mouseMode` is changed.
+
+		Note that changing from `Relative(callbackA)` to `Relative(callbackB)` would also cause this event as any other parameter changes.
+
+		@returns Force-override of the mouse mode that will be used as an active mode or null.
+	**/
+	public dynamic function onMouseModeChange( from : MouseMode, to : MouseMode ) : Null<MouseMode> {
+		return null;
+	}
+
 	public function event( e : hxd.Event ) : Void {
 		for( et in eventTargets )
 			et(e);
@@ -72,6 +96,13 @@ class Window {
 	public function setFullScreen( v : Bool ) : Void {
 	}
 
+	/**
+		Set the hardware mouse cursor position relative to window boundaries.
+	**/
+	public function setCursorPos( x : Int, y : Int, emitEvent : Bool = false ) : Void {
+		throw "Not implemented";
+	}
+
 	static var inst : Window = null;
 	public static function getInstance() : Window {
 		if( inst == null ) inst = new Window();
@@ -95,14 +126,27 @@ class Window {
 	}
 
 	function get_mouseLock() : Bool {
+		return switch (mouseMode) { case AbsoluteUnbound(_): true; default: false; };
+	}
+
+	function set_mouseLock(v:Bool) : Bool {
+		return set_mouseMode(v ? AbsoluteUnbound(true) : Absolute).equals(AbsoluteUnbound(true));
+	}
+
+	function get_mouseClip() : Bool {
 		return false;
 	}
 
-	function set_mouseLock( v : Bool ) : Bool {
-		if( v ) throw "Not implemented";
+	function set_mouseClip( v : Bool ) : Bool {
+		if ( v ) throw "Not implemented";
 		return false;
 	}
 
+	function set_mouseMode( v : MouseMode ) : MouseMode {
+		if ( v != Absolute ) throw "Not implemented";
+		return Absolute;
+	}
+
 	function get_vsync() : Bool return true;
 
 	function set_vsync( b : Bool ) : Bool {

+ 110 - 34
hxd/Window.js.hx

@@ -1,5 +1,8 @@
 package hxd;
 
+import js.Browser;
+import hxd.impl.MouseMode;
+
 enum DisplayMode {
 	Windowed;
 	Borderless;
@@ -16,7 +19,18 @@ class Window {
 	public var height(get, never) : Int;
 	public var mouseX(get, never) : Int;
 	public var mouseY(get, never) : Int;
+	@:deprecated("Use mouseMode = AbsoluteUnbound(true)")
 	public var mouseLock(get, set) : Bool;
+	/**
+		If set, will restrain the mouse cursor within the window boundaries.
+	**/
+	public var mouseClip(get, set) : Bool;
+	/**
+		Set the mouse movement input handling mode.
+
+		@see `hxd.impl.MouseMode` for more details on each mode.
+	**/
+	public var mouseMode(default, set) : MouseMode = Absolute;
 	public var vsync(get, set) : Bool;
 	public var isFocused(get, never) : Bool;
 	public var propagateKeyEvents : Bool;
@@ -26,7 +40,7 @@ class Window {
 
 	var curMouseX : Float = 0.;
 	var curMouseY : Float = 0.;
-	var _mouseLock : Bool = false;
+	var pointerLockTarget : js.html.Element;
 
 	var canvas : js.html.CanvasElement;
 	var element : js.html.EventTarget;
@@ -43,6 +57,13 @@ class Window {
 		(default : true)
 	**/
 	public var useScreenPixels : Bool = js.Browser.supported;
+	/**
+		When enabled, the user click event on the canvas that would trigger mouse capture to be enabled would be discarded.
+		(default : true)
+	**/
+	public var discardMouseCaptureEvent : Bool = true;
+	var discardMouseUp : Int = -1;
+	var canLockMouse : Bool = true;
 
 	public function new( ?canvas : js.html.CanvasElement, ?globalEvents ) : Void {
 		var customCanvas = canvas != null;
@@ -106,6 +127,8 @@ class Window {
 			js.Browser.window.addEventListener("resize", checkResize);
 		}
 
+		js.Browser.document.addEventListener("pointerlockchange", onPointerLockChange);
+
 		canvas.addEventListener("contextmenu", function(e){
 			e.stopPropagation();
 			if (e.button == 2) {
@@ -157,6 +180,10 @@ class Window {
 		return true;
 	}
 
+	public dynamic function onMouseModeChange( from : MouseMode, to : MouseMode ) : Null<MouseMode> {
+		return null;
+	}
+
 	public function event( e : hxd.Event ) : Void {
 		for( et in eventTargets )
 			et(e);
@@ -205,6 +232,14 @@ class Window {
 		else
 			doc.exitFullscreen();
 	}
+	
+
+	public function setCursorPos( x : Int, y : Int, emitEvent : Bool = false ) : Void {
+		if ( mouseMode == Absolute ) throw "setCursorPos only allowed in relative mouse modes on this platform.";
+		curMouseX = x + canvasPos.left;
+		curMouseY = y + canvasPos.top;
+		if (emitEvent) event(new hxd.Event(EMove, x, y));
+	}
 
 	public function setCurrent() {
 		inst = this;
@@ -237,21 +272,35 @@ class Window {
 	}
 
 	function get_mouseLock() : Bool {
-		return _mouseLock;
+		return switch (mouseMode) { case AbsoluteUnbound(_): true; default: false; };
 	}
 
-	function set_mouseLock( v : Bool ) : Bool {
-		var customCanvas = canvas != null;
-		if (v) {
-				if (customCanvas) canvas.requestPointerLock();
-				else js.Browser.window.document.documentElement.requestPointerLock();
-			}
+	function set_mouseLock(v:Bool) : Bool {
+		return set_mouseMode(v ? AbsoluteUnbound(true) : Absolute).equals(AbsoluteUnbound(true));
+	}
 
-		else {
-			if (customCanvas) canvas.ownerDocument.exitPointerLock();
-			else js.Browser.window.document.exitPointerLock();
+	function get_mouseClip() : Bool {
+		return false;
+	}
+
+	function set_mouseClip( v : Bool ) : Bool {
+		if ( v ) throw "Can't clip cursor on this platform.";
+		return false;
+	}
+
+	function set_mouseMode( v : MouseMode ) : MouseMode {
+		if ( v.equals(mouseMode) ) return v;
+
+		var forced = onMouseModeChange(mouseMode, v);
+		if (forced != null) v = forced;
+		var target = this.pointerLockTarget = canvas != null ? canvas : Browser.window.document.documentElement;
+
+		if ( v == Absolute ) {
+			if ( target.ownerDocument.pointerLockElement == target ) target.ownerDocument.exitPointerLock();
+		} else if ( canLockMouse ) {
+			if ( target.ownerDocument.pointerLockElement != target ) target.requestPointerLock();
 		}
-		return _mouseLock = v;
+		return mouseMode = v;
 	}
 
 	function get_vsync() : Bool return true;
@@ -261,14 +310,30 @@ class Window {
 		return true;
 	}
 
-	function onMouseDown(e:js.html.MouseEvent) {
-		if (mouseLock) {
-			if (e.movementX != 0 || e.movementY != 0)
-				onMouseMove(e);
+	function onPointerLockChange( e : js.html.Event ) {
+		if ( mouseMode != Absolute && pointerLockTarget.ownerDocument.pointerLockElement != pointerLockTarget ) {
+			// Firefox: Do not instantly re-lock the mouse if user altered mouseMode via `onMouseMouseChange` back into relative.
+			canLockMouse = false;
+			// User cancelled out of the pointer lock by pressing escape or by other means: Switch to Absolute mode
+			mouseMode = Absolute;
+			canLockMouse = true;
 		}
-		else {
-			if(e.clientX != curMouseX || e.clientY != curMouseY)
-				onMouseMove(e);
+	}
+
+	function onMouseDown(e:js.html.MouseEvent) {
+		if ( mouseMode == Absolute ) {
+			if ( e.clientX != curMouseX || e.clientY != curMouseY ) onMouseMove(e);
+		} else {
+			// If we attempted to enter locked mode when browser didn't let us - try to enter locked mode on user click.
+			if ( pointerLockTarget.ownerDocument.pointerLockElement != pointerLockTarget ) {
+				pointerLockTarget.requestPointerLock();
+				if (discardMouseCaptureEvent) {
+					// Avoid stray ERelease due to discarded EPush event.
+					discardMouseUp = e.button;
+					return;
+				}
+			}
+			if ( e.movementX != 0 || e.movementY != 0 ) onMouseMove(e);
 		}
 		var ev = new Event(EPush, mouseX, mouseY);
 		ev.button = switch( e.button ) {
@@ -280,13 +345,12 @@ class Window {
 	}
 
 	function onMouseUp(e:js.html.MouseEvent) {
-		if (mouseLock) {
-			if (e.movementX != 0 || e.movementY != 0)
-				onMouseMove(e);
+		if ( discardMouseUp == e.button ) {
+			discardMouseUp = -1;
+			return;
 		}
-		else {
-			if(e.clientX != curMouseX || e.clientY != curMouseY)
-				onMouseMove(e);
+		if ( mouseMode == Absolute ? (e.clientX != curMouseX || e.clientY != curMouseY) : (e.movementX != 0 || e.movementY != 0) ) {
+			onMouseMove(e);
 		}
 		var ev = new Event(ERelease, mouseX, mouseY);
 		ev.button = switch( e.button ) {
@@ -308,16 +372,28 @@ class Window {
 	}
 
 	function onMouseMove(e:js.html.MouseEvent) {
-		if (mouseLock) {
-			curMouseX += e.movementX;
-			curMouseY += e.movementY;
-		}
-		else {
-			curMouseX = e.clientX;
-			curMouseY = e.clientY;
+		switch (mouseMode) {
+			case Absolute:
+				curMouseX = e.clientX;
+				curMouseY = e.clientY;
+				event(new Event(EMove, curMouseX, curMouseY));
+			case Relative(callback, _):
+				if (pointerLockTarget.ownerDocument.pointerLockElement != pointerLockTarget) return;
+				var ev = new Event(EMove, e.movementX, e.movementY);
+				callback(ev);
+				if (!ev.cancel && ev.propagate) {
+					ev.cancel = false;
+					ev.propagate = false;
+					ev.relX = curMouseX;
+					ev.relY = curMouseY;
+					event(ev);
+				}
+			case AbsoluteUnbound(_):
+				if (pointerLockTarget.ownerDocument.pointerLockElement != pointerLockTarget) return;
+				curMouseX += e.movementX;
+				curMouseY += e.movementY;
+				event(new Event(EMove, curMouseX, curMouseY));
 		}
-
-		event(new Event(EMove, mouseX, mouseY));
 	}
 
 	function onMouseWheel(e:js.html.WheelEvent) {

+ 43 - 0
hxd/impl/MouseMode.hx

@@ -0,0 +1,43 @@
+package hxd.impl;
+
+/**
+	The mouse movement input handling mode.
+
+	**JS/HTML5 note**:
+	> Due to browser limitations, `restorePos` is ignored and always treated as `true`.
+	>
+	> Additionally, mouse mode will be forcefully changed to `Absolute` when user performs a browser action that exits the mouse capture mode,
+	e.g. pressing Escape or switching tabs/windows.
+	> Override `Window.onMouseModeChange` event in order to catch such cases.
+	>
+	> If mouse is not currently captured, but `mouseMode` is set to either `Relative` or `AbsoluteUnbound`,
+	mouse movement events are ignored and first click on the canvas is used to capture the mouse and hence discarded.
+	
+	@see `hxd.Window.mouseMode`
+**/
+enum MouseMode {
+	/**
+		Default mouse movement mode. Causes `EMove` events in window coordinates.
+	**/
+	Absolute;
+	/**
+		Relative mouse movement mode. In this mode the mouse cursor is hidden and instead of `EMove` event the `callback` is invoked with relative mouse movement.
+
+		During Relative mouse mode the window mouse position is not updated.
+
+		@param callback The callback to which the relative mouse movements are reported.
+		Unless event is cancelled, set `propagate` flag in order to emit an `EMove` event with current window cursor position.
+		Use `Window.setMousePos` to manually update the window cursor position.
+
+		@param restorePos If set, when changing mouse mode to `Absolute`, cursor position would be restored to the position it was on when this mode was enabled.
+		Otherwise mouse position is clipped to window boundaries. Ignored and treated as `true` on JS.
+	**/
+	Relative(callback : hxd.Event -> Void, restorePos : Bool);
+	/**
+		Alternate relative mouse movement mode. In this mode the mouse cursor is hidden, and `EMove` can report mouse positions outside of window boundaries.
+
+		@param restorePos If set, when changing mouse mode to `Absolute`, cursor position would be restored to the position it was on when this mode was enabled.
+		Otherwise mouse position is clipped to window boundaries. Ignored and treated as `true` on JS.
+	**/
+	AbsoluteUnbound(restorePos : Bool);
+}

+ 105 - 0
samples/MouseApi.hx

@@ -0,0 +1,105 @@
+import hxd.impl.MouseMode;
+import h2d.Graphics;
+import h2d.Text;
+import hxd.Key;
+
+using hxd.Math;
+
+class MouseApi extends SampleApp {
+	
+	var state: Text;
+	var restore: Bool;
+	var propagate: Bool;
+	var virtualCursor: Graphics;
+	var relativePath: Graphics;
+	var relX: Float;
+	var relY: Float;
+	var expectedMode: MouseMode;
+
+	override function init() {
+		super.init();
+
+		var window = hxd.Window.getInstance();
+
+		addText("Press Escape or right click to restore to Absolute mouse mode");
+		state = addText("\n");
+
+		#if !js // Not supported on JS
+		// Lock the mouse within the window boundaries.
+		addCheck("MouseClip", () -> window.mouseClip, (v) -> window.mouseClip = v);
+		#end
+		
+		// Use setCursorPos in order to warp the mouse
+		// JS: Due to browser limitations only useful in relative modes to move the virtual cursor.
+		addButton("Set position to center", () -> window.setCursorPos(s2d.width>>1, s2d.height>>1));
+
+		addText("MouseMode:");
+		// The default mouse mode behaves like a regular mouse.
+		addButton("Absolute", () -> window.mouseMode = expectedMode = Absolute);
+		// Callback will get relative mouse movement.
+		addButton("Relative", () -> {
+			window.mouseMode = expectedMode = Relative(onRelativeMove, restore);
+			relX = 0;
+			relY = 0;
+			relativePath.clear();
+			relativePath.setPosition(s2d.width>>1, s2d.height>>1);
+		});
+		// Mouse position would be able to leave the window boundaries, but clipped on exit.
+		addButton("AbsoluteUnbound", () -> window.mouseMode = expectedMode = AbsoluteUnbound(restore));
+		// When restoring to Absolute: Either clip mouse position or restore to where it entered relative mode.
+		addCheck("RestorePos", () -> restore, (v) -> restore = v);
+		// Would set propagate flag in Relative mode, allowing mouse movement events to pass trough.
+		addCheck("Propagate", () -> propagate, (v) -> propagate = v);
+
+		relativePath = new Graphics();
+		s2d.addChildAt(relativePath, 0);
+
+		virtualCursor = new Graphics(s2d);
+		virtualCursor.lineStyle(1);
+		virtualCursor.beginFill(0xffffff);
+		virtualCursor.moveTo(0, 0);
+		virtualCursor.lineTo(10, 10);
+		virtualCursor.lineTo(0, 16);
+		virtualCursor.endFill();
+
+		#if js
+		// On JS, browser can force-exit the relative mode. In which case switch back to one we want.
+		window.onMouseModeChange = (from, to) -> to != expectedMode ? from : to;
+		#end
+	}
+
+	override function update(dt:Float) {
+
+		var window = hxd.Window.getInstance();
+
+		if (Key.isReleased(Key.ESCAPE) || Key.isReleased(Key.MOUSE_RIGHT)) window.mouseMode = expectedMode = Absolute;
+		state.text = "Current mode: " + window.mouseMode.getName() + "\nMouse position: " + window.mouseX + ", " + window.mouseY;
+		virtualCursor.visible = window.mouseMode != Absolute;
+		virtualCursor.setPosition(window.mouseX, window.mouseY);
+
+	}
+
+	function onRelativeMove(e: hxd.Event) {
+		// Draw the movement segment and keep Graphics centered.
+		relativePath.x -= e.relX;
+		relativePath.y -= e.relY;
+		relativePath.lineStyle(1, Std.int(((Math.cos(relX * .05) + 1)) / 2 * 0xff) << 16 | Std.int(((Math.sin(relY * .05) + 1)) / 2 * 0xff) << 8 | 0xff, 1);
+		relativePath.moveTo(relX, relY);
+		relX += e.relX;
+		relY += e.relY;
+		relativePath.lineTo(relX, relY);
+		relativePath.lineStyle();
+
+		// Since in relative mode mouse position is not updated - update it manually.
+		var window = hxd.Window.getInstance();
+		if (propagate) {
+			window.setCursorPos((window.mouseX + Std.int(e.relX)).iclamp(0, window.width), (window.mouseY + Std.int(e.relY)).iclamp(0, window.height));
+			e.propagate = true;
+		}
+	}
+
+	static function main() {
+		new MouseApi();
+	}
+
+}