diff --git a/.gitignore b/.gitignore index c9ee05f2d3..5a527c3178 100644 --- a/.gitignore +++ b/.gitignore @@ -50,5 +50,9 @@ joysticks-*.txt /jme3-screenshot-tests/jme3-screenshot-tests-desktop/build/ /jme3-screenshot-tests/jme3-screenshot-tests-android/build/ /jme3-screenshot-tests/jme3-screenshot-tests-shared/build/ - /jme3-screenshot-tests/jme3-screenshot-tests-proto-report/build/ +/jme3-screenshot-tests/jme3-screenshot-tests-desktop/bin/ +/jme3-screenshot-tests/jme3-screenshot-tests-android/bin/ +/jme3-screenshot-tests/jme3-screenshot-tests-shared/bin/ +/jme3-screenshot-tests/jme3-screenshot-tests-proto-report/bin/ +hs_err_pid*.log \ No newline at end of file diff --git a/jme3-awt-dialogs/src/main/java/com/jme3/awt/AWTSettingsDialog.java b/jme3-awt-dialogs/src/main/java/com/jme3/awt/AWTSettingsDialog.java index c8c90fc054..f67fffb4e4 100644 --- a/jme3-awt-dialogs/src/main/java/com/jme3/awt/AWTSettingsDialog.java +++ b/jme3-awt-dialogs/src/main/java/com/jme3/awt/AWTSettingsDialog.java @@ -34,6 +34,7 @@ import com.jme3.asset.AssetNotFoundException; import com.jme3.system.AppSettings; import com.jme3.system.JmeSystem; +import com.jme3.system.Platform; import java.awt.*; import java.awt.event.*; @@ -42,7 +43,6 @@ import java.net.MalformedURLException; import java.net.URL; import java.text.MessageFormat; -import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashSet; @@ -125,11 +125,13 @@ public interface SelectionListener { private JCheckBox vsyncBox = null; private JCheckBox gammaBox = null; private JCheckBox fullscreenBox = null; + private JCheckBox x11PlatformPreferredBox = null; private JComboBox displayResCombo = null; private JComboBox colorDepthCombo = null; private JComboBox displayFreqCombo = null; private JComboBox antialiasCombo = null; private JComboBox rendererCombo = null; + private JComboBox displayScaleModeCombo = null; private JLabel icon = null; private int selection = 0; private SelectionListener selectionListener = null; @@ -466,6 +468,8 @@ public void keyPressed(KeyEvent e) { antialiasCombo.addKeyListener(aListener); rendererCombo = setUpRendererChooser(); rendererCombo.addKeyListener(aListener); + displayScaleModeCombo = setUpDisplayScaleModeChooser(); + displayScaleModeCombo.addKeyListener(aListener); fullscreenBox = new JCheckBox(resourceBundle.getString("checkbox.fullscreen")); fullscreenBox.setSelected(source.isFullscreen()); fullscreenBox.addActionListener(new ActionListener() { @@ -481,6 +485,10 @@ public void actionPerformed(ActionEvent e) { gammaBox = new JCheckBox(resourceBundle.getString("checkbox.gamma")); gammaBox.setSelected(source.isGammaCorrection()); + x11PlatformPreferredBox = new JCheckBox(resourceBundle.getString("checkbox.x11PlatformPreferred")); + x11PlatformPreferredBox.setSelected(source.isX11PlatformPreferred()); + boolean linux = JmeSystem.getPlatform().getOs() == Platform.Os.Linux; + GridBagConstraints gbc = new GridBagConstraints(); gbc.weightx = 0.5; gbc.gridx = 0; @@ -562,10 +570,34 @@ public void actionPerformed(ActionEvent e) { gbc = new GridBagConstraints(); gbc.gridx = 1; gbc.gridy = 4; - gbc.gridwidth = 3; + gbc.gridwidth = linux ? 1 : 3; gbc.anchor = GridBagConstraints.WEST; mainPanel.add(rendererCombo, gbc); + if (linux) { + gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 16, 4, 4); + gbc.gridx = 2; + gbc.gridy = 4; + gbc.gridwidth = 2; + gbc.anchor = GridBagConstraints.WEST; + mainPanel.add(x11PlatformPreferredBox, gbc); + } + + gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 4, 4, 4); + gbc.gridx = 0; + gbc.gridy = 5; + gbc.anchor = GridBagConstraints.EAST; + gbc.weightx = 0.5; + mainPanel.add(new JLabel(resourceBundle.getString("label.displayScaleMode")), gbc); + gbc = new GridBagConstraints(); + gbc.gridx = 1; + gbc.gridy = 5; + gbc.gridwidth = 3; + gbc.anchor = GridBagConstraints.WEST; + mainPanel.add(displayScaleModeCombo, gbc); + // Set the button action listeners. Cancel disposes without saving, OK // saves. ok.addActionListener(new ActionListener() { @@ -600,14 +632,14 @@ public void actionPerformed(ActionEvent e) { gbc = new GridBagConstraints(); gbc.gridx = 0; gbc.gridwidth = 2; - gbc.gridy = 5; + gbc.gridy = 6; gbc.anchor = GridBagConstraints.EAST; mainPanel.add(ok, gbc); gbc = new GridBagConstraints(); gbc.insets = new Insets(4, 16, 4, 4); gbc.gridx = 2; gbc.gridwidth = 2; - gbc.gridy = 5; + gbc.gridy = 6; gbc.anchor = GridBagConstraints.WEST; mainPanel.add(cancel, gbc); @@ -739,6 +771,12 @@ private boolean verifyAndSaveCurrentSelection() { source.setFullscreen(fullscreen); source.setVSync(vsync); source.setGammaCorrection(gamma); + source.setX11PlatformPreferred(x11PlatformPreferredBox.isSelected()); + DisplayScaleModeOption displayScaleMode + = (DisplayScaleModeOption) displayScaleModeCombo.getSelectedItem(); + if (displayScaleMode != null) { + source.setDisplayScaleMode(displayScaleMode.value); + } source.setRenderer(renderer); source.setSamples(multisample); @@ -804,6 +842,55 @@ private JComboBox setUpRendererChooser() { return rendererBox; } + private JComboBox setUpDisplayScaleModeChooser() { + JComboBox scaleModeBox = new JComboBox<>(); + float currentMode = source.getDisplayScaleMode(); + float[] standardModes = { + AppSettings.DISPLAY_SCALE_DISABLED, + AppSettings.DISPLAY_SCALE_NATIVE_PIXELS, + AppSettings.DISPLAY_SCALE_DPI_AWARE, + 2f, + 3f, + 4f, + 5f, + 6f, + 7f, + 8f + }; + + boolean currentModeAdded = false; + for (float mode : standardModes) { + DisplayScaleModeOption option = createDisplayScaleModeOption(mode); + scaleModeBox.addItem(option); + if (Float.compare(mode, currentMode) == 0) { + scaleModeBox.setSelectedItem(option); + currentModeAdded = true; + } + } + + if (!currentModeAdded) { + DisplayScaleModeOption option = createDisplayScaleModeOption(currentMode); + scaleModeBox.addItem(option); + scaleModeBox.setSelectedItem(option); + } + + return scaleModeBox; + } + + private DisplayScaleModeOption createDisplayScaleModeOption(float mode) { + String label; + if (mode == AppSettings.DISPLAY_SCALE_DISABLED) { + label = resourceBundle.getString("displayScaleMode.disabled"); + } else if (mode == AppSettings.DISPLAY_SCALE_NATIVE_PIXELS) { + label = resourceBundle.getString("displayScaleMode.nativePixels"); + } else if (mode == AppSettings.DISPLAY_SCALE_DPI_AWARE) { + label = resourceBundle.getString("displayScaleMode.dpiAware"); + } else { + label = MessageFormat.format(resourceBundle.getString("displayScaleMode.supersampling"), mode); + } + return new DisplayScaleModeOption(label, mode); + } + /** * updateDisplayChoices updates the available color depth and * display frequency options to match the currently selected resolution. @@ -1069,4 +1156,19 @@ public int compare(DisplayMode a, DisplayMode b) { return 0; } } + + private static final class DisplayScaleModeOption { + private final String label; + private final float value; + + private DisplayScaleModeOption(String label, float value) { + this.label = label; + this.value = value; + } + + @Override + public String toString() { + return label; + } + } } diff --git a/jme3-core/src/main/java/com/jme3/system/Platform.java b/jme3-core/src/main/java/com/jme3/system/Platform.java index c22dd67e37..a4d5b7684d 100644 --- a/jme3-core/src/main/java/com/jme3/system/Platform.java +++ b/jme3-core/src/main/java/com/jme3/system/Platform.java @@ -1,39 +1,39 @@ -/* - * Copyright (c) 2009-2022 jMonkeyEngine - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * * Neither the name of 'jMonkeyEngine' nor the names of its contributors - * may be used to endorse or promote products derived from this software - * without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR - * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR - * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, - * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.jme3.system; - -/** - * Enumerate known operating system/architecture pairs. - */ +/* + * Copyright (c) 2009-2022 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.jme3.system; + +/** + * Enumerate known operating system/architecture pairs. + */ public enum Platform { /** @@ -100,8 +100,8 @@ public enum Platform { * Apple Mac OS X 64-bit Intel */ MacOSX64(Os.MacOS, true), - - /** + + /** * Apple Mac OS X 64-bit ARM */ MacOSX_ARM64(Os.MacOS, true), @@ -187,60 +187,61 @@ public enum Platform { */ Web(Os.Web, true) // assume always 64-bit, it shouldn't matter for web ; - - - /** - * Enumerate generic names of operating systems - */ - public enum Os { - /** - * Linux operating systems - */ - Linux, - /** - * Microsoft Windows operating systems - */ - Windows, - /** - * iOS operating systems - */ - iOS, - /** - * macOS operating systems - */ - MacOS, - /** - * Android operating systems - */ - Android, - /** - * Generic web platform - */ - Web - } - - private final boolean is64bit; - private final Os os; + + + /** + * Enumerate generic names of operating systems + */ + public enum Os { + /** + * Linux operating systems + */ + Linux, + /** + * Microsoft Windows operating systems + */ + Windows, + /** + * iOS operating systems + */ + iOS, + /** + * macOS operating systems + */ + MacOS, + /** + * Android operating systems + */ + Android, + /** + * Generic web platform + */ + Web + } + + private final boolean is64bit; + private final Os os; private static final boolean NATIVE_IMAGE_RUNTIME = detectNativeImageRuntime(); - - /** - * Test for a 64-bit address space. - * - * @return true if 64 bits, otherwise false - */ - public boolean is64Bit() { - return is64bit; - } - - /** - * Returns the operating system of this platform. - * - * @return the generic name of the operating system of this platform - */ - public Os getOs() { - return os; - } - + private static final boolean WINE_PROTON_RUNTIME = detectWineProtonRuntime(); + + /** + * Test for a 64-bit address space. + * + * @return true if 64 bits, otherwise false + */ + public boolean is64Bit() { + return is64bit; + } + + /** + * Returns the operating system of this platform. + * + * @return the generic name of the operating system of this platform + */ + public Os getOs() { + return os; + } + /** * Test whether this process is running as a GraalVM native-image executable. * @@ -250,16 +251,32 @@ public boolean isGraalVMNativeImage() { return NATIVE_IMAGE_RUNTIME; } + /** + * Test whether this process is running through Wine or Proton. + * + * @return true if running through Wine or Proton, otherwise false + */ + public boolean isWineProton() { + return os == Os.Windows && WINE_PROTON_RUNTIME; + } + private static boolean detectNativeImageRuntime() { return System.getProperty("org.graalvm.nativeimage.imagecode") != null; } - private Platform(Os os, boolean is64bit) { - this.os = os; - this.is64bit = is64bit; - } - - private Platform(Os os) { - this(os, false); - } -} + private static boolean detectWineProtonRuntime() { + return System.getenv("WINEPREFIX") != null + || System.getenv("WINELOADERNOEXEC") != null + || System.getenv("STEAM_COMPAT_DATA_PATH") != null + || System.getenv("PROTON_VERB") != null; + } + + private Platform(Os os, boolean is64bit) { + this.os = os; + this.is64bit = is64bit; + } + + private Platform(Os os) { + this(os, false); + } +} diff --git a/jme3-desktop/src/main/resources/com/jme3/app/SettingsDialog.properties b/jme3-desktop/src/main/resources/com/jme3/app/SettingsDialog.properties index ce0434f275..99e93a4eae 100644 --- a/jme3-desktop/src/main/resources/com/jme3/app/SettingsDialog.properties +++ b/jme3-desktop/src/main/resources/com/jme3/app/SettingsDialog.properties @@ -6,12 +6,19 @@ button.cancel=Cancel checkbox.fullscreen=Fullscreen? checkbox.vsync=Vsync? checkbox.gamma=Gamma correction +checkbox.x11PlatformPreferred=Prefer X11 -label.resolutions=Screen Resolution +label.resolutions=Resolution label.colordepth=Color Depth label.refresh=Refresh Rate label.antialias=Anti-Aliasing label.renderer=Renderer +label.displayScaleMode=Display Scale + +displayScaleMode.disabled=Disabled +displayScaleMode.nativePixels=Native pixels +displayScaleMode.dpiAware=DPI aware +displayScaleMode.supersampling=Supersampling {0}x antialias.disabled=Disabled refresh.na=n/a diff --git a/jme3-examples/src/main/java/jme3test/input/TestCursorVisibility.java b/jme3-examples/src/main/java/jme3test/input/TestCursorVisibility.java new file mode 100644 index 0000000000..48e68a2ea6 --- /dev/null +++ b/jme3-examples/src/main/java/jme3test/input/TestCursorVisibility.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2009-2026 jMonkeyEngine + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * * Neither the name of 'jMonkeyEngine' nor the names of its contributors + * may be used to endorse or promote products derived from this software + * without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED + * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package jme3test.input; + +import com.jme3.app.SimpleApplication; +import com.jme3.font.BitmapText; +import com.jme3.input.KeyInput; +import com.jme3.input.controls.ActionListener; +import com.jme3.input.controls.KeyTrigger; +import com.jme3.math.ColorRGBA; +import com.jme3.math.Vector2f; +import com.jme3.system.AppSettings; + +/** + * Demonstrates toggling cursor visibility while tracking its position. + */ +public class TestCursorVisibility extends SimpleApplication implements ActionListener { + + private static final String TOGGLE_CURSOR = "Toggle cursor mode"; + + private BitmapText cursorStatus; + + public static void main(String[] args) { + TestCursorVisibility app = new TestCursorVisibility(); + AppSettings settings = new AppSettings(true); + settings.setX11PlatformPreferred(true); + app.setSettings(settings); + app.setShowSettings(false); + app.start(); + } + + @Override + public void simpleInitApp() { + flyCam.setEnabled(false); + viewPort.setBackgroundColor(ColorRGBA.DarkGray); + inputManager.setCursorVisible(true); + + inputManager.addMapping(TOGGLE_CURSOR, new KeyTrigger(KeyInput.KEY_H)); + inputManager.addListener(this, TOGGLE_CURSOR); + + BitmapText instructions = new BitmapText(guiFont); + instructions.setText("press H to toggle cursor mode"); + instructions.setLocalTranslation(10f, cam.getHeight() - 10f, 0f); + guiNode.attachChild(instructions); + + cursorStatus = new BitmapText(guiFont); + cursorStatus.setLocalTranslation(10f, + instructions.getLocalTranslation().y - instructions.getLineHeight() * 2f, 0f); + guiNode.attachChild(cursorStatus); + updateCursorStatus(); + } + + @Override + public void simpleUpdate(float tpf) { + updateCursorStatus(); + } + + @Override + public void onAction(String name, boolean isPressed, float tpf) { + if (TOGGLE_CURSOR.equals(name) && isPressed) { + inputManager.setCursorVisible(!inputManager.isCursorVisible()); + updateCursorStatus(); + } + } + + private void updateCursorStatus() { + Vector2f position = inputManager.getCursorPosition(); + String mode = inputManager.isCursorVisible() ? "visible" : "hidden (relative)"; + cursorStatus.setText("Cursor position: (" + position.x + ", " + position.y + ")\n" + + "Cursor mode: " + mode); + } +} diff --git a/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlMouseInput.java b/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlMouseInput.java index e78c5e6eb5..35df15049a 100644 --- a/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlMouseInput.java +++ b/jme3-lwjgl3/src/main/java/com/jme3/input/lwjgl/SdlMouseInput.java @@ -39,6 +39,7 @@ import com.jme3.input.event.MouseMotionEvent; import com.jme3.math.Vector2f; import com.jme3.system.AppSettings; +import com.jme3.system.JmeSystem; import com.jme3.system.lwjgl.LwjglWindow; import com.jme3.util.BufferUtils; import java.nio.ByteBuffer; @@ -58,9 +59,7 @@ import static org.lwjgl.sdl.SDLPixels.*; import static org.lwjgl.sdl.SDLSurface.*; import static org.lwjgl.sdl.SDLEvents.*; -import static org.lwjgl.sdl.SDLHints.*; import static org.lwjgl.sdl.SDLTimer.*; -import static org.lwjgl.sdl.SDLVideo.*; /** * SDL implementation of {@link MouseInput}. @@ -90,11 +89,13 @@ public class SdlMouseInput implements MouseInput { private int currentHeight; private float windowCoordWidth = 1f; private float windowCoordHeight = 1f; + private float visibleCursorX; + private float visibleCursorY; private boolean cursorVisible = true; private boolean windowFocused = true; - private boolean x11WarpGrabMode; - private boolean ignoreNextX11WarpEvent; + private boolean visibleCursorPositionValid; + private boolean protonCursorRestorePending; private boolean initialized; public SdlMouseInput(final LwjglWindow context) { @@ -122,6 +123,7 @@ public void resetContext() { if (!context.isRenderable()) { return; } + visibleCursorPositionValid = false; refreshWindowMetrics(); initCurrentMousePosition(); setCursorVisible(cursorVisible); @@ -133,6 +135,7 @@ public void onSDLEvent(SDL_Event event) { if (type == SDL_EVENT_WINDOW_FOCUS_GAINED) { if (event.window().windowID() == context.getWindowId()) { windowFocused = true; + visibleCursorPositionValid = false; refreshWindowMetrics(); initCurrentMousePosition(); setCursorVisible(cursorVisible); @@ -143,15 +146,11 @@ public void onSDLEvent(SDL_Event event) { if (type == SDL_EVENT_WINDOW_FOCUS_LOST) { if (event.window().windowID() == context.getWindowId()) { windowFocused = false; - x11WarpGrabMode = false; - ignoreNextX11WarpEvent = false; mouseMotionEvents.clear(); - SDL_CaptureMouse(false); - SDL_SetWindowMouseGrab(context.getWindowHandle(), false); - SDL_SetWindowRelativeMouseMode(context.getWindowHandle(), false); if (!cursorVisible) { - SDL_ShowCursor(); + restoreVisibleCursorPosition(); } + SDL_SetWindowRelativeMouseMode(context.getWindowHandle(), false); } return; } @@ -170,29 +169,11 @@ public void onSDLEvent(SDL_Event event) { final int xDelta; final int yDelta; - if (x11WarpGrabMode) { - if (ignoreNextX11WarpEvent && isNearWindowCenter(event.motion().x(), event.motion().y())) { - ignoreNextX11WarpEvent = false; - return; - } - ignoreNextX11WarpEvent = false; - int centerX = currentWidth / 2; - int centerY = currentHeight / 2; + if (relativeMode) { xDelta = Math.round(event.motion().xrel() * inputScale.x); yDelta = -Math.round(event.motion().yrel() * inputScale.y); - mouseX = centerX; - mouseY = centerY; - x = centerX; - y = centerY; - if (xDelta != 0 || yDelta != 0) { - warpMouseToWindowCenter(); - ignoreNextX11WarpEvent = true; - } - } else if (relativeMode) { - xDelta = Math.round(event.motion().xrel() * inputScale.x); - yDelta = -Math.round(event.motion().yrel() * inputScale.y); - mouseX = clamp(mouseX + xDelta, 0, currentWidth); - mouseY = clamp(mouseY + yDelta, 0, currentHeight); + mouseX += xDelta; + mouseY += yDelta; x = mouseX; y = mouseY; } else { @@ -236,9 +217,11 @@ public void onSDLEvent(SDL_Event event) { if (event.button().windowID() != context.getWindowId()) { return; } - refreshWindowMetrics(); - mouseX = toInputX(event.button().x()); - mouseY = toInputY(event.button().y()); + if (!SDL_GetWindowRelativeMouseMode(context.getWindowHandle())) { + refreshWindowMetrics(); + mouseX = toInputX(event.button().x()); + mouseY = toInputY(event.button().y()); + } if (onPointerButton(0, event.button().down(), mouseX, mouseY, event.button().timestamp())) { return; } @@ -297,10 +280,6 @@ private int toInputY(float y) { return Math.round(currentHeight - (y * inputScale.y)); } - private static int clamp(int value, int min, int max) { - return Math.max(min, Math.min(value, max)); - } - private void sendFirstMouseEvent() { MouseMotionEvent evt = new MouseMotionEvent(mouseX, mouseY, 0, 0, mouseWheel, 0); evt.setTime(getInputTimeNanos()); @@ -319,6 +298,12 @@ public int getButtonCount() { @Override public void update() { + if (protonCursorRestorePending && cursorVisible && windowFocused + && !SDL_GetWindowRelativeMouseMode(context.getWindowHandle())) { + protonCursorRestorePending = false; + restoreVisibleCursorPosition(); + } + if (currentCursor != null && currentCursor.length > 1) { long now = SDL_GetTicksNS(); long frameTimeMs = (now - currentCursorFrameStartTimeNs) / 1_000_000L; @@ -376,60 +361,50 @@ public void setCursorVisible(boolean visible) { } if (cursorVisible) { - x11WarpGrabMode = false; - ignoreNextX11WarpEvent = false; - SDL_CaptureMouse(false); - SDL_SetWindowMouseGrab(context.getWindowHandle(), false); - SDL_SetWindowRelativeMouseMode(context.getWindowHandle(), false); if (!wasVisible) { - centerVisibleCursor(); + if (restoreVisibleCursorPosition()) { + queueMousePositionSyncEvent(); + protonCursorRestorePending = JmeSystem.getPlatform().isWineProton(); + } } - SDL_ShowCursor(); + SDL_SetWindowRelativeMouseMode(context.getWindowHandle(), false); } else { - SDL_SetWindowMouseGrab(context.getWindowHandle(), true); - SDL_CaptureMouse(true); - if (isX11Backend()) { - x11WarpGrabMode = true; - ignoreNextX11WarpEvent = true; - SDL_SetWindowRelativeMouseMode(context.getWindowHandle(), false); - warpMouseToWindowCenter(); - syncMouseToWindowCenter(); - } else { - x11WarpGrabMode = false; - SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_MODE_CENTER, "1"); - SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_CURSOR_VISIBLE, "0"); - SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_WARP_MOTION, "0"); - SDL_SetWindowRelativeMouseMode(context.getWindowHandle(), true); - warpMouseToWindowCenter(); + protonCursorRestorePending = false; + if (wasVisible || !visibleCursorPositionValid) { + saveVisibleCursorPosition(); } - SDL_HideCursor(); + SDL_SetWindowRelativeMouseMode(context.getWindowHandle(), true); } } - private boolean isX11Backend() { - return "x11".equalsIgnoreCase(SDL_GetCurrentVideoDriver()); - } - - private void warpMouseToWindowCenter() { - refreshWindowMetrics(); - SDL_WarpMouseInWindow(context.getWindowHandle(), windowCoordWidth * 0.5f, windowCoordHeight * 0.5f); - } - - private void centerVisibleCursor() { - warpMouseToWindowCenter(); - syncMouseToWindowCenter(); + private void saveVisibleCursorPosition() { + try (MemoryStack stack = MemoryStack.stackPush()) { + FloatBuffer x = stack.callocFloat(1); + FloatBuffer y = stack.callocFloat(1); + SDL_GetMouseState(x, y); + visibleCursorX = x.get(0); + visibleCursorY = y.get(0); + visibleCursorPositionValid = true; + } } - private void syncMouseToWindowCenter() { + private boolean restoreVisibleCursorPosition() { + if (!visibleCursorPositionValid) { + return false; + } refreshWindowMetrics(); - mouseX = currentWidth / 2; - mouseY = currentHeight / 2; + float x = Math.max(0f, Math.min(visibleCursorX, windowCoordWidth)); + float y = Math.max(0f, Math.min(visibleCursorY, windowCoordHeight)); + SDL_WarpMouseInWindow(context.getWindowHandle(), x, y); + mouseX = toInputX(x); + mouseY = toInputY(y); + return true; } - private boolean isNearWindowCenter(float x, float y) { - refreshWindowMetrics(); - return Math.abs(x - (windowCoordWidth * 0.5f)) <= 1.5f - && Math.abs(y - (windowCoordHeight * 0.5f)) <= 1.5f; + private void queueMousePositionSyncEvent() { + MouseMotionEvent event = new MouseMotionEvent(mouseX, mouseY, 0, 0, mouseWheel, 0); + event.setTime(getInputTimeNanos()); + mouseMotionEvents.add(event); } @Override diff --git a/jme3-lwjgl3/src/test/java/com/jme3/input/lwjgl/SdlMouseInputTest.java b/jme3-lwjgl3/src/test/java/com/jme3/input/lwjgl/SdlMouseInputTest.java index 5a77ab4cf4..c97c2de598 100644 --- a/jme3-lwjgl3/src/test/java/com/jme3/input/lwjgl/SdlMouseInputTest.java +++ b/jme3-lwjgl3/src/test/java/com/jme3/input/lwjgl/SdlMouseInputTest.java @@ -1,14 +1,27 @@ package com.jme3.input.lwjgl; +import com.jme3.input.event.MouseButtonEvent; +import com.jme3.input.event.MouseMotionEvent; +import com.jme3.system.AppSettings; import com.jme3.system.lwjgl.LwjglWindow; import java.lang.reflect.Field; +import java.nio.FloatBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Queue; import org.junit.jupiter.api.Test; import org.lwjgl.sdl.SDL_Event; +import org.lwjgl.sdl.SDLMouse; +import org.mockito.MockedStatic; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.lwjgl.sdl.SDLEvents.SDL_EVENT_MOUSE_BUTTON_DOWN; import static org.lwjgl.sdl.SDLEvents.SDL_EVENT_MOUSE_MOTION; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; public class SdlMouseInputTest { @@ -35,6 +48,125 @@ public void shouldIgnoreMouseMotionWhenWindowIsUnfocused() throws Exception { assertTrue(mouseMotionEvents.isEmpty()); } + @Test + public void shouldRestoreCursorPositionBeforeDisablingRelativeMode() throws Exception { + LwjglWindow context = mockRenderableContext(800, 600); + List calls = new ArrayList<>(); + + try (MockedStatic sdlMouse = mockStatic(SDLMouse.class)) { + sdlMouse.when(() -> SDLMouse.SDL_GetMouseState(any(FloatBuffer.class), any(FloatBuffer.class))) + .thenAnswer(invocation -> { + invocation.getArgument(0).put(0, 123f); + invocation.getArgument(1).put(0, 456f); + return 0; + }); + sdlMouse.when(() -> SDLMouse.SDL_SetWindowRelativeMouseMode(42L, true)) + .thenReturn(true); + sdlMouse.when(() -> SDLMouse.SDL_WarpMouseInWindow(42L, 123f, 456f)) + .thenAnswer(invocation -> { + calls.add("warp"); + return null; + }); + sdlMouse.when(() -> SDLMouse.SDL_SetWindowRelativeMouseMode(42L, false)) + .thenAnswer(invocation -> { + calls.add("disable relative mode"); + return true; + }); + + SdlMouseInput mouseInput = new SdlMouseInput(context); + mouseInput.setCursorVisible(false); + setField(mouseInput, "mouseX", 999); + setField(mouseInput, "mouseY", -100); + calls.clear(); + + mouseInput.setCursorVisible(true); + + assertEquals(Arrays.asList("warp", "disable relative mode"), calls); + assertEquals(123, (int) getField(mouseInput, "mouseX")); + assertEquals(144, (int) getField(mouseInput, "mouseY")); + Queue mouseMotionEvents = getField(mouseInput, "mouseMotionEvents"); + MouseMotionEvent syncEvent = mouseMotionEvents.remove(); + assertEquals(123, syncEvent.getX()); + assertEquals(144, syncEvent.getY()); + assertEquals(0, syncEvent.getDX()); + assertEquals(0, syncEvent.getDY()); + assertTrue(mouseMotionEvents.isEmpty()); + } + } + + @Test + public void shouldNotClampVirtualCursorPositionInRelativeMode() throws Exception { + LwjglWindow context = mockRenderableContext(800, 600); + when(context.getWindowId()).thenReturn(7); + + try (MockedStatic sdlMouse = mockStatic(SDLMouse.class)) { + sdlMouse.when(() -> SDLMouse.SDL_GetWindowRelativeMouseMode(42L)).thenReturn(true); + + SdlMouseInput mouseInput = new SdlMouseInput(context); + setField(mouseInput, "mouseX", 799); + setField(mouseInput, "mouseY", 1); + + SDL_Event event = SDL_Event.calloc(); + try { + event.type(SDL_EVENT_MOUSE_MOTION); + event.motion().windowID(7); + event.motion().xrel(5f); + event.motion().yrel(5f); + mouseInput.onSDLEvent(event); + } finally { + event.free(); + } + + assertEquals(804, (int) getField(mouseInput, "mouseX")); + assertEquals(-4, (int) getField(mouseInput, "mouseY")); + } + } + + @Test + public void shouldKeepVirtualCursorPositionForButtonsInRelativeMode() throws Exception { + LwjglWindow context = mockRenderableContext(800, 600); + when(context.getWindowId()).thenReturn(7); + + try (MockedStatic sdlMouse = mockStatic(SDLMouse.class)) { + sdlMouse.when(() -> SDLMouse.SDL_GetWindowRelativeMouseMode(42L)).thenReturn(true); + + SdlMouseInput mouseInput = new SdlMouseInput(context); + setField(mouseInput, "mouseX", 900); + setField(mouseInput, "mouseY", -100); + + SDL_Event event = SDL_Event.calloc(); + try { + event.type(SDL_EVENT_MOUSE_BUTTON_DOWN); + event.button().windowID(7); + event.button().button((byte) SDLMouse.SDL_BUTTON_LEFT); + event.button().down(true); + event.button().x(10f); + event.button().y(20f); + mouseInput.onSDLEvent(event); + } finally { + event.free(); + } + + assertEquals(900, (int) getField(mouseInput, "mouseX")); + assertEquals(-100, (int) getField(mouseInput, "mouseY")); + Queue mouseButtonEvents = getField(mouseInput, "mouseButtonEvents"); + MouseButtonEvent buttonEvent = mouseButtonEvents.remove(); + assertEquals(900, buttonEvent.getX()); + assertEquals(-100, buttonEvent.getY()); + assertTrue(mouseButtonEvents.isEmpty()); + } + } + + private static LwjglWindow mockRenderableContext(int width, int height) { + AppSettings settings = new AppSettings(false); + settings.setResolution(width, height); + LwjglWindow context = mock(LwjglWindow.class); + when(context.isRenderable()).thenReturn(true); + when(context.getWindowHandle()).thenReturn(42L); + when(context.getSettings()).thenReturn(settings); + return context; + } + private static void setField(Object target, String fieldName, Object value) throws Exception { Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true);