diff --git a/ui2/build.gradle.kts b/ui2/build.gradle.kts index febdb95d..9aa49fb9 100644 --- a/ui2/build.gradle.kts +++ b/ui2/build.gradle.kts @@ -35,6 +35,13 @@ dependencies { val configureAppleTargets = rootProject.extra["configureAppleTargets"] as Boolean tasks.withType().configureEach { + if (System.getProperty("os.name").startsWith("Linux")) { + jvmArgs( + "--add-opens=java.desktop/java.awt=ALL-UNNAMED", + "--add-opens=java.desktop/sun.awt=ALL-UNNAMED", + "--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED" + ) + } testLogging { exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarDragHandler.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarDragHandler.kt deleted file mode 100644 index 12962602..00000000 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarDragHandler.kt +++ /dev/null @@ -1,175 +0,0 @@ -/* - * SaltUI - * Copyright (C) 2026 Moriafly - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.moriafly.salt.ui.platform.linux - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState -import com.moriafly.salt.ui.UnstableSaltUiApi -import java.awt.Frame -import java.awt.Point -import java.awt.Window -import java.awt.event.ComponentAdapter -import java.awt.event.ComponentEvent -import java.awt.event.MouseEvent - -/** - * Handles caption gestures before a modal Compose scene can consume them. - * Native X11 movement retains window-manager snapping, with an AWT fallback. - */ -@UnstableSaltUiApi -@Composable -internal fun LinuxCaptionBarDragHandler( - window: Window, - hitTest: LinuxCaptionBarHitTest, - isMoveable: Boolean, - isHitTestInCaptionBar: Boolean, - canDrag: Boolean = true, - canMaximize: Boolean = false, -) { - val currentIsMoveable by rememberUpdatedState(isMoveable) - val currentIsHitTestInCaptionBar by rememberUpdatedState(isHitTestInCaptionBar) - val currentCanDrag by rememberUpdatedState(canDrag) - val currentCanMaximize by rememberUpdatedState(canMaximize) - - DisposableEffect(window, hitTest) { - val fallbackMover = LinuxFallbackWindowMover(window) - var useNativeDrag = true - var restoreWidth = window.width - val handler = object : LinuxWindowMouseInput.Handler { - private var pressed = false - private var suppressClick = false - private var pendingDragEvent: MouseEvent? = null - private var latestDragEvent: MouseEvent? = null - private var restoreDragOffset: Point? = null - - fun onWindowResized() { - if ((window as? Frame)?.extendedState == Frame.MAXIMIZED_BOTH) return - restoreWidth = window.width - val offset = restoreDragOffset ?: return - val event = latestDragEvent ?: return - restoreDragOffset = null - // Wait for the native restore to complete before positioning the restored window. - // Moving it while maximized overwrites the window manager's saved geometry. - window.setLocation(event.xOnScreen - offset.x, event.yOnScreen - offset.y) - startMover(event) - } - - private fun startMover(event: MouseEvent) { - if (useNativeDrag && LinuxX11WindowMover.tryMove(window, event)) return - useNativeDrag = false - fallbackMover.startDrag(event) - } - - override fun onMouseEvent(event: MouseEvent): Boolean { - when (event.id) { - MouseEvent.MOUSE_PRESSED -> { - if (event.button != MouseEvent.BUTTON1) return pressed - cancel() - if (!currentIsMoveable || !currentCanDrag) return false - if (!hitTest.contains( - position = LinuxWindowMouseInput.positionInContent(event, window), - window = window, - isHovered = currentIsHitTestInCaptionBar - ) - ) { - return false - } - pressed = true - pendingDragEvent = event - return true - } - - MouseEvent.MOUSE_DRAGGED -> { - if (!pressed) return false - latestDragEvent = event - if (restoreDragOffset != null) return true - pendingDragEvent?.let { startEvent -> - pendingDragEvent = null - val frame = window as? Frame - if (frame?.extendedState == Frame.MAXIMIZED_BOTH) { - val localX = startEvent.xOnScreen - window.x - val localY = startEvent.yOnScreen - window.y - restoreDragOffset = Point( - (restoreWidth * localX.toFloat() / window.width).toInt(), - localY - ) - frame.extendedState = Frame.NORMAL - return true - } - startMover(startEvent) - } - fallbackMover.updateDrag(event) - return true - } - - MouseEvent.MOUSE_RELEASED -> { - if (!pressed) return false - if (event.button == MouseEvent.BUTTON1) { - pressed = false - pendingDragEvent = null - latestDragEvent = null - restoreDragOffset = null - fallbackMover.endDrag() - suppressClick = true - } - return true - } - - MouseEvent.MOUSE_CLICKED -> { - if (event.button != MouseEvent.BUTTON1 || !suppressClick) return false - suppressClick = false - if (event.clickCount == 2 && currentCanDrag && currentCanMaximize) { - (window as? Frame)?.let { frame -> - frame.extendedState = - if (frame.extendedState == Frame.MAXIMIZED_BOTH) { - Frame.NORMAL - } else { - Frame.MAXIMIZED_BOTH - } - } - } - return true - } - } - return false - } - - override fun cancel() { - pressed = false - suppressClick = false - pendingDragEvent = null - latestDragEvent = null - restoreDragOffset = null - fallbackMover.endDrag() - } - } - val componentListener = object : ComponentAdapter() { - override fun componentResized(e: ComponentEvent) { - handler.onWindowResized() - } - } - window.addComponentListener(componentListener) - val unregister = LinuxWindowMouseInput.register(window, handler) - onDispose { - unregister() - window.removeComponentListener(componentListener) - } - } -} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxFallbackWindowMover.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxFallbackWindowMover.kt deleted file mode 100644 index dc6ae85a..00000000 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxFallbackWindowMover.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SaltUI - * Copyright (C) 2026 Moriafly - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.moriafly.salt.ui.platform.linux - -import com.moriafly.salt.ui.UnstableSaltUiApi -import java.awt.Cursor -import java.awt.Point -import java.awt.Window -import java.awt.event.MouseEvent - -/** - * Fallback window mover using AWT mouse events. - * - * From `androidx.compose.foundation.window.StandardMoveHandler` - */ -@UnstableSaltUiApi -internal class LinuxFallbackWindowMover( - private val window: Window -) { - private var dragStartScreenPos: Point? = null - private var dragStartWindowPos: Point? = null - private var isDragging = false - - fun startDrag(e: MouseEvent) { - dragStartScreenPos = Point(e.xOnScreen, e.yOnScreen) - dragStartWindowPos = window.location - isDragging = true - } - - fun updateDrag(e: MouseEvent) { - if (!isDragging) return - - val startScreen = dragStartScreenPos ?: return - val startWindow = dragStartWindowPos ?: return - - val newX = startWindow.x + (e.xOnScreen - startScreen.x) - val newY = startWindow.y + (e.yOnScreen - startScreen.y) - - window.location = Point(newX, newY) - } - - fun endDrag() { - if (isDragging) { - window.cursor = Cursor.getDefaultCursor() - } - isDragging = false - dragStartScreenPos = null - dragStartWindowPos = null - } - - val dragging: Boolean - get() = isDragging -} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxSaltDialogWindowFrame.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxSaltDialogWindowFrame.kt index 836231f6..b2e08e87 100755 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxSaltDialogWindowFrame.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxSaltDialogWindowFrame.kt @@ -46,6 +46,18 @@ import androidx.compose.ui.window.WindowDecoration import com.moriafly.salt.ui.ChangeSaltThemeIsDark import com.moriafly.salt.ui.SaltTheme import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.platform.linux.decoration.LinuxCaptionButtonClose +import com.moriafly.salt.ui.platform.linux.decoration.LinuxCaptionButtonWidth +import com.moriafly.salt.ui.platform.linux.decoration.LinuxClientShadow +import com.moriafly.salt.ui.platform.linux.decoration.LinuxClientShadowEffect +import com.moriafly.salt.ui.platform.linux.decoration.linuxClientShadow +import com.moriafly.salt.ui.platform.linux.input.LinuxWindowGestures +import com.moriafly.salt.ui.platform.linux.input.LinuxWindowInputEffect +import com.moriafly.salt.ui.platform.linux.input.LocalLinuxWindowGestures +import com.moriafly.salt.ui.platform.linux.window.LinuxCaptionBarHitTest +import com.moriafly.salt.ui.platform.linux.window.LinuxWindowGestureEffects +import com.moriafly.salt.ui.platform.linux.window.LocalLinuxCaptionBarHitTest +import com.moriafly.salt.ui.platform.linux.window.UndecoratedWindowResizer import com.moriafly.salt.ui.thenIf import com.moriafly.salt.ui.window.CaptionButtonsAlign import com.moriafly.salt.ui.window.LocalIsHitTestInCaptionBarState @@ -65,17 +77,11 @@ internal fun DialogWindowScope.LinuxSaltDialogWindowFrame( properties: SaltWindowProperties, content: @Composable DialogWindowScope.() -> Unit ) { - DisposableEffect(window) { - val focus = LinuxWindowFocus.install(window) - onDispose { focus?.close() } - } - val currentProperties by rememberUpdatedState(properties) val isHitTestInCaptionBar = remember { mutableStateOf(false) } val captionBarHitTest = remember { LinuxCaptionBarHitTest() } val undecoratedWindowResizer = remember { UndecoratedWindowResizer( - window = window, onResizeEdgeChange = { edge -> currentProperties.onResizeEdgeChange(window, edge) } @@ -85,6 +91,16 @@ internal fun DialogWindowScope.LinuxSaltDialogWindowFrame( // Dialogs are never maximized, so the shadow margin is constant val shadowMargin = if (clientShadow) LinuxClientShadow.margin else 0.dp + val windowGestures = remember(window) { + LinuxWindowGestures( + window = window, + caption = captionBarHitTest, + resizer = undecoratedWindowResizer, + captionHovered = isHitTestInCaptionBar, + effects = LinuxWindowGestureEffects(window, undecoratedWindowResizer) + ) + } + CompositionLocalProvider( LocalSaltWindowInfo provides SaltWindowInfo( captionBarHeight = properties.captionBarHeight, @@ -96,16 +112,15 @@ internal fun DialogWindowScope.LinuxSaltDialogWindowFrame( cornerRadius = if (clientShadow) LinuxClientShadow.cornerRadius else 0.dp ), LocalLinuxCaptionBarHitTest provides captionBarHitTest, + LocalLinuxWindowGestures provides windowGestures, LocalIsHitTestInCaptionBarState provides isHitTestInCaptionBar ) { - if (currentProperties.moveable) { - LinuxCaptionBarDragHandler( - window = window, - hitTest = captionBarHitTest, - isMoveable = properties.moveable, - isHitTestInCaptionBar = isHitTestInCaptionBar.value - ) - } + LinuxWindowInputEffect( + window = window, + gestures = windowGestures, + moveable = properties.moveable, + resizable = resizable + ) LaunchedEffect(decoration) { undecoratedWindowResizer.resizerThickness = @@ -124,10 +139,6 @@ internal fun DialogWindowScope.LinuxSaltDialogWindowFrame( ) } - LaunchedEffect(resizable) { - undecoratedWindowResizer.enabled = resizable - } - Box( modifier = Modifier .fillMaxSize() diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxSaltWindowFrame.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxSaltWindowFrame.kt index ee11b0a8..c12cc5b3 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxSaltWindowFrame.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxSaltWindowFrame.kt @@ -47,6 +47,20 @@ import androidx.compose.ui.window.WindowPlacement import com.moriafly.salt.ui.ChangeSaltThemeIsDark import com.moriafly.salt.ui.SaltTheme import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.platform.linux.decoration.LinuxCaptionButtonClose +import com.moriafly.salt.ui.platform.linux.decoration.LinuxCaptionButtonMaximize +import com.moriafly.salt.ui.platform.linux.decoration.LinuxCaptionButtonMinimize +import com.moriafly.salt.ui.platform.linux.decoration.LinuxCaptionButtonWidth +import com.moriafly.salt.ui.platform.linux.decoration.LinuxClientShadow +import com.moriafly.salt.ui.platform.linux.decoration.LinuxClientShadowEffect +import com.moriafly.salt.ui.platform.linux.decoration.linuxClientShadow +import com.moriafly.salt.ui.platform.linux.input.LinuxWindowGestures +import com.moriafly.salt.ui.platform.linux.input.LinuxWindowInputEffect +import com.moriafly.salt.ui.platform.linux.input.LocalLinuxWindowGestures +import com.moriafly.salt.ui.platform.linux.window.LinuxCaptionBarHitTest +import com.moriafly.salt.ui.platform.linux.window.LinuxWindowGestureEffects +import com.moriafly.salt.ui.platform.linux.window.LocalLinuxCaptionBarHitTest +import com.moriafly.salt.ui.platform.linux.window.UndecoratedWindowResizer import com.moriafly.salt.ui.thenIf import com.moriafly.salt.ui.window.CaptionButtonsAlign import com.moriafly.salt.ui.window.LocalIsHitTestInCaptionBarState @@ -67,17 +81,11 @@ internal fun FrameWindowScope.LinuxSaltWindowFrame( properties: SaltWindowProperties, content: @Composable FrameWindowScope.() -> Unit ) { - DisposableEffect(window) { - val focus = LinuxWindowFocus.install(window) - onDispose { focus?.close() } - } - val currentProperties by rememberUpdatedState(properties) val isHitTestInCaptionBar = remember { mutableStateOf(false) } val captionBarHitTest = remember { LinuxCaptionBarHitTest() } val undecoratedWindowResizer = remember { UndecoratedWindowResizer( - window = window, onResizeEdgeChange = { edge -> currentProperties.onResizeEdgeChange(window, edge) } @@ -94,6 +102,16 @@ internal fun FrameWindowScope.LinuxSaltWindowFrame( 0.dp } + val windowGestures = remember(window) { + LinuxWindowGestures( + window = window, + caption = captionBarHitTest, + resizer = undecoratedWindowResizer, + captionHovered = isHitTestInCaptionBar, + effects = LinuxWindowGestureEffects(window, undecoratedWindowResizer) + ) + } + CompositionLocalProvider( LocalSaltWindowInfo provides SaltWindowInfo( captionBarHeight = properties.captionBarHeight, @@ -105,18 +123,17 @@ internal fun FrameWindowScope.LinuxSaltWindowFrame( cornerRadius = if (shadowMargin > 0.dp) LinuxClientShadow.cornerRadius else 0.dp ), LocalLinuxCaptionBarHitTest provides captionBarHitTest, + LocalLinuxWindowGestures provides windowGestures, LocalIsHitTestInCaptionBarState provides isHitTestInCaptionBar, ) { - if (currentProperties.moveable) { - LinuxCaptionBarDragHandler( - window = window, - hitTest = captionBarHitTest, - isMoveable = properties.moveable, - isHitTestInCaptionBar = isHitTestInCaptionBar.value, - canDrag = windowState.placement != WindowPlacement.Fullscreen, - canMaximize = resizable && properties.maximizeOrRestoreButtonEnabled, - ) - } + LinuxWindowInputEffect( + window = window, + gestures = windowGestures, + moveable = properties.moveable, + resizable = resizable && !isMaximizedOrFullScreen, + canDrag = windowState.placement != WindowPlacement.Fullscreen, + canMaximize = resizable && properties.maximizeOrRestoreButtonEnabled + ) if (clientShadow) { LinuxClientShadowEffect( @@ -137,10 +154,6 @@ internal fun FrameWindowScope.LinuxSaltWindowFrame( window.undecoratedResizerThickness = 0.dp } - LaunchedEffect(resizable, windowState.placement) { - undecoratedWindowResizer.enabled = resizable && !isMaximizedOrFullScreen - } - Box( modifier = Modifier .fillMaxSize() diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxX11WindowMover.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxX11WindowMover.kt deleted file mode 100644 index c0a03214..00000000 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxX11WindowMover.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * SaltUI - * Copyright (C) 2026 Moriafly - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.moriafly.salt.ui.platform.linux - -import com.moriafly.salt.ui.UnstableSaltUiApi -import com.sun.jna.Native -import com.sun.jna.NativeLong -import com.sun.jna.platform.unix.X11 -import com.sun.jna.ptr.IntByReference -import com.sun.jna.ptr.PointerByReference -import java.awt.Window -import java.awt.event.MouseEvent - -/** - * Native X11 window mover using `_NET_WM_MOVERESIZE` protocol. - * - * Delegates window dragging to the window manager, providing smooth movement - * and native features like window snapping. - */ -@UnstableSaltUiApi -internal object LinuxX11WindowMover { - private const val NET_WM_MOVERESIZE_MOVE = 8L - - /** - * Try to initiate a native X11 window move. - * - * @return `true` if the native move was successfully initiated, `false` otherwise - * (e.g., on Wayland or if X11 calls fail). - */ - fun tryMove(window: Window, startEvent: MouseEvent): Boolean { - if (!window.isDisplayable) return false - - val x11: X11Ext - try { - x11 = X11Ext.INSTANCE - } catch (_: UnsatisfiedLinkError) { - return false - } - - val display = x11.XOpenDisplay(null) ?: return false - - try { - val windowId = Native.getComponentID(window) - if (windowId == 0L) return false - val xWindow = X11.Window(windowId) - - val rootWindow = queryRootWindow(x11, display, xWindow) ?: return false - val netWmMoveresize = x11.XInternAtom(display, "_NET_WM_MOVERESIZE", false) - if (netWmMoveresize == null || netWmMoveresize.toLong() == 0L) return false - - // EWMH requires X11 root pixels, not AWT's logical screen coordinates on HiDPI. - val rootX = IntByReference() - val rootY = IntByReference() - val windowX = IntByReference() - val windowY = IntByReference() - if (!x11.XQueryPointer( - display, - xWindow, - X11.WindowByReference(), - X11.WindowByReference(), - rootX, - rootY, - windowX, - windowY, - IntByReference() - ) - ) { - return false - } - - // Anchor the operation at the initiating event. The pointer may already have moved - // while AWT queued the event or JNA loaded X11, especially on the first drag. - val position = LinuxWindowMouseInput.positionInWindow(startEvent, window) - val event = buildMoveResizeEvent( - display = display, - xWindow = xWindow, - atom = netWmMoveresize, - mouseX = rootX.value - windowX.value + position.x.toInt(), - mouseY = rootY.value - windowY.value + position.y.toInt() - ) - - // Release AWT exclusive mouse lock to allow window manager takeover - x11.XUngrabPointer(display, NativeLong(0)) - - val mask = NativeLong( - (X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong() - ) - x11.XSendEvent(display, rootWindow, 0, mask, event) - x11.XFlush(display) - - return true - } catch (_: Exception) { - return false - } finally { - x11.XCloseDisplay(display) - } - } - - private fun queryRootWindow( - x11: X11Ext, - display: X11.Display, - xWindow: X11.Window - ): X11.Window? { - val rootRet = X11.WindowByReference() - val parentRet = X11.WindowByReference() - val childrenRet = PointerByReference() - val numChildren = IntByReference() - - x11.XQueryTree(display, xWindow, rootRet, parentRet, childrenRet, numChildren) - - if (childrenRet.value != null) { - x11.XFree(childrenRet.value) - } - - return rootRet.value - } - - private fun buildMoveResizeEvent( - display: X11.Display, - xWindow: X11.Window, - atom: X11.Atom, - mouseX: Int, - mouseY: Int - ): X11.XEvent { - val event = X11.XEvent() - event.type = X11.ClientMessage - event.setType(X11.XClientMessageEvent::class.java) - - val clientMsg = event.xclient - clientMsg.type = X11.ClientMessage - clientMsg.serial = NativeLong(0) - clientMsg.send_event = 1 - clientMsg.display = display - clientMsg.window = xWindow - clientMsg.message_type = atom - clientMsg.format = 32 - - clientMsg.data.setType("l") - // x_root - clientMsg.data.l[0] = NativeLong(mouseX.toLong()) - // y_root - clientMsg.data.l[1] = NativeLong(mouseY.toLong()) - // Direction - clientMsg.data.l[2] = NativeLong(NET_WM_MOVERESIZE_MOVE) - // Button (Left click) - clientMsg.data.l[3] = NativeLong(1) - // Source indication (Normal application) - clientMsg.data.l[4] = NativeLong(1) - - return event - } -} - -/** - * JNA's default X11 interface is missing some methods. - * - * This interface extends it to bind the missing XUngrabPointer function. - */ -@UnstableSaltUiApi -@Suppress("FunctionName") -internal interface X11Ext : X11 { - fun XUngrabPointer(display: X11.Display?, time: NativeLong?): Int - - companion object { - val INSTANCE: X11Ext = Native.load("X11", X11Ext::class.java) - } -} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/UndecoratedWindowResizer.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/UndecoratedWindowResizer.kt deleted file mode 100644 index d14881cf..00000000 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/UndecoratedWindowResizer.kt +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.moriafly.salt.ui.platform.linux - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.moriafly.salt.ui.UnstableSaltUiApi -import com.moriafly.salt.ui.window.WindowResizeEdge -import java.awt.Component -import java.awt.Cursor -import java.awt.Dimension -import java.awt.Point -import java.awt.Window -import java.awt.event.MouseEvent - -@OptIn(UnstableSaltUiApi::class) -internal class UndecoratedWindowResizer( - private val window: Window, - private val onResizeEdgeChange: (WindowResizeEdge) -> Unit -) : LinuxWindowMouseInput.Handler { - var enabled: Boolean by mutableStateOf(false) - var resizerThickness: Dp by mutableStateOf(4.dp) - - private val sideBounds = mutableMapOf() - private val sideCursors = mutableMapOf() - private var resizingSide = 0 - private var hoveredSide = 0 - private var suppressClick = false - private var resizeCursor: Cursor? = null - private var cursorComponent: Component? = null - private var previousCursor: Cursor? = null - private var initialPointPos = Point() - private var initialWindowPos = Point() - private var initialWindowSize = Dimension() - - @Composable - fun Content(modifier: Modifier = Modifier) { - DisposableEffect(this, enabled) { - val unregister = LinuxWindowMouseInput.register( - window = window, - handler = this@UndecoratedWindowResizer, - priority = 1 - ) - onDispose { unregister() } - } - if (!enabled) return - - Layout( - { - Side(Cursor.W_RESIZE_CURSOR, Side.Left) - Side(Cursor.E_RESIZE_CURSOR, Side.Right) - Side(Cursor.N_RESIZE_CURSOR, Side.Top) - Side(Cursor.S_RESIZE_CURSOR, Side.Bottom) - Side(Cursor.NW_RESIZE_CURSOR, Side.Left or Side.Top) - Side(Cursor.NE_RESIZE_CURSOR, Side.Right or Side.Top) - Side(Cursor.SW_RESIZE_CURSOR, Side.Left or Side.Bottom) - Side(Cursor.SE_RESIZE_CURSOR, Side.Right or Side.Bottom) - }, - modifier = modifier, - measurePolicy = { measurables, constraints -> - val b = resizerThickness.roundToPx() - - fun Measurable.measureSide(width: Int, height: Int) = measure( - Constraints.fixed(width.coerceAtLeast(0), height.coerceAtLeast(0)) - ) - - val left = measurables[0].measureSide(b, constraints.maxHeight - 2 * b) - val right = measurables[1].measureSide(b, constraints.maxHeight - 2 * b) - val top = measurables[2].measureSide(constraints.maxWidth - 2 * b, b) - val bottom = measurables[3].measureSide(constraints.maxWidth - 2 * b, b) - val leftTop = measurables[4].measureSide(b, b) - val rightTop = measurables[5].measureSide(b, b) - val leftBottom = measurables[6].measureSide(b, b) - val rightBottom = measurables[7].measureSide(b, b) - layout(constraints.maxWidth, constraints.maxHeight) { - left.place(0, b) - right.place(constraints.maxWidth - b, b) - top.place(b, 0) - bottom.place(b, constraints.maxHeight - b) - leftTop.place(0, 0) - rightTop.place(constraints.maxWidth - b, 0) - leftBottom.place(0, constraints.maxHeight - b) - rightBottom.place(constraints.maxWidth - b, constraints.maxHeight - b) - } - } - ) - } - - override fun onMouseEvent(event: MouseEvent): Boolean { - if (!enabled) return false - val position = LinuxWindowMouseInput.positionInContent(event, window) - val side = sideBounds.entries.firstOrNull { it.value.contains(position) }?.key ?: 0 - when (event.id) { - MouseEvent.MOUSE_MOVED, MouseEvent.MOUSE_ENTERED -> setHoveredSide(side) - MouseEvent.MOUSE_EXITED -> if (resizingSide == 0) setHoveredSide(0) - MouseEvent.MOUSE_PRESSED -> { - if (event.button != MouseEvent.BUTTON1) return resizingSide != 0 - suppressClick = false - if (side == 0) return false - resizingSide = side - initialPointPos = event.locationOnScreen - initialWindowPos = window.location - initialWindowSize = window.size - setHoveredSide(side) - resizeCursor = LinuxResizeCursor.get(sideCursors.getValue(side)) - return true - } - - MouseEvent.MOUSE_DRAGGED -> { - if (resizingSide == 0) return false - resize(resizingSide, event.locationOnScreen) - return true - } - - MouseEvent.MOUSE_RELEASED -> { - if (resizingSide == 0) return false - if (event.button == MouseEvent.BUTTON1) { - resizingSide = 0 - suppressClick = true - setHoveredSide(side) - } - return true - } - - MouseEvent.MOUSE_CLICKED -> if (suppressClick && event.button == MouseEvent.BUTTON1) { - suppressClick = false - return true - } - } - return false - } - - override fun afterMouseEvent(event: MouseEvent) { - val cursor = resizeCursor ?: return - val component = event.component - if (cursorComponent !== component) { - restoreCursor() - cursorComponent = component - previousCursor = if (component.isCursorSet) component.cursor else null - } - // Compose may update the cursor while dispatching hover events to a Dialog. - component.cursor = cursor - } - - override fun cancel() { - resizingSide = 0 - suppressClick = false - setHoveredSide(0) - restoreCursor() - } - - private fun setHoveredSide(side: Int) { - if (side == hoveredSide) return - hoveredSide = side - onResizeEdgeChange(side.toWindowResizeEdge()) - resizeCursor = sideCursors[side]?.let(LinuxResizeCursor::get) - if (side == 0) restoreCursor() - } - - private fun restoreCursor() { - cursorComponent?.cursor = previousCursor - cursorComponent = null - previousCursor = null - } - - @Composable - private fun Side(cursorId: Int, sides: Int) { - DisposableEffect(sides) { - sideCursors[sides] = cursorId - onDispose { - sideBounds.remove(sides) - sideCursors.remove(sides) - } - } - Layout( - content = {}, - modifier = Modifier.onGloballyPositioned { - sideBounds[sides] = it.boundsInWindow() - }, - measurePolicy = { _, constraints -> - layout(constraints.maxWidth, constraints.maxHeight) {} - } - ) - } - - private fun Int.toWindowResizeEdge(): WindowResizeEdge { - fun Int.contains(value: Int) = this and value == value - val hasLeft = contains(Side.Left) - val hasRight = contains(Side.Right) - val hasTop = contains(Side.Top) - val hasBottom = contains(Side.Bottom) - return when { - hasLeft && hasTop -> WindowResizeEdge.TopLeft - hasRight && hasTop -> WindowResizeEdge.TopRight - hasLeft && hasBottom -> WindowResizeEdge.BottomLeft - hasRight && hasBottom -> WindowResizeEdge.BottomRight - hasLeft -> WindowResizeEdge.Left - hasRight -> WindowResizeEdge.Right - hasTop -> WindowResizeEdge.Top - hasBottom -> WindowResizeEdge.Bottom - else -> WindowResizeEdge.None - } - } - - private fun resize(sides: Int, pointPos: Point) { - val diffX = pointPos.x - initialPointPos.x - val diffY = pointPos.y - initialPointPos.y - var newXPos = window.x - var newYPos = window.y - var newWidth = window.width - var newHeight = window.height - - fun Int.contains(value: Int) = this and value == value - - if (sides.contains(Side.Left)) { - newWidth = initialWindowSize.width - diffX - newWidth = newWidth.coerceAtLeast(window.minimumSize.width) - newXPos = initialWindowPos.x + initialWindowSize.width - newWidth - } else if (sides.contains(Side.Right)) { - newWidth = (initialWindowSize.width + diffX).coerceAtLeast(window.minimumSize.width) - } - if (sides.contains(Side.Top)) { - newHeight = initialWindowSize.height - diffY - newHeight = newHeight.coerceAtLeast(window.minimumSize.height) - newYPos = initialWindowPos.y + initialWindowSize.height - newHeight - } else if (sides.contains(Side.Bottom)) { - newHeight = (initialWindowSize.height + diffY).coerceAtLeast(window.minimumSize.height) - } - window.setLocation(newXPos, newYPos) - window.setSize(newWidth, newHeight) - } - - @Suppress("ConstPropertyName") - private object Side { - const val Left = 0x0001 - const val Top = 0x0010 - const val Right = 0x0100 - const val Bottom = 0x1000 - } -} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBar.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxCaptionBar.kt similarity index 99% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBar.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxCaptionBar.kt index 73033f85..95736fd3 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBar.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxCaptionBar.kt @@ -17,7 +17,7 @@ @file:Suppress("unused", "ktlint:standard:property-naming") -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.decoration import androidx.compose.foundation.background import androidx.compose.foundation.clickable diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxClientShadow.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt similarity index 91% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxClientShadow.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt index 1d106c79..cf2eb8b2 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxClientShadow.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt @@ -15,7 +15,7 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.decoration import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -37,11 +37,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowDecoration import com.moriafly.salt.ui.UnstableSaltUiApi -import com.sun.jna.Library +import com.moriafly.salt.ui.platform.linux.x11.LinuxX11 +import com.moriafly.salt.ui.platform.linux.x11.LinuxXShape import com.sun.jna.Memory import com.sun.jna.Native import com.sun.jna.NativeLong -import com.sun.jna.Pointer import com.sun.jna.platform.unix.X11 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -129,7 +129,9 @@ internal object LinuxClientShadow { * fully undecorated. */ @OptIn(ExperimentalComposeUiApi::class) - fun shouldUseClientShadow(decoration: WindowDecoration): Boolean = + fun shouldUseClientShadow( + decoration: WindowDecoration + ): Boolean = decoration == WindowDecoration.SystemDefault && isTranslucencySupported /** @@ -139,11 +141,14 @@ internal object LinuxClientShadow { * * Must be called after the window is showing, see [awaitShowing]. */ - fun applyGtkFrameExtents(window: Window, marginPx: Int): Boolean { + fun applyGtkFrameExtents( + window: Window, + marginPx: Int + ): Boolean { if (!window.isDisplayable) return false val x11 = try { - X11Ext.INSTANCE + LinuxX11.INSTANCE } catch (_: UnsatisfiedLinkError) { return false } @@ -193,19 +198,22 @@ internal object LinuxClientShadow { /** * Restricts the window input region to the content rect (window bounds minus [marginPx] on - * each side) via the XFixes shape extension, making the transparent shadow area + * each side) via the X11 Shape extension, making the transparent shadow area * click-through. A [marginPx] of 0 or fewer restores input for the whole window. */ - fun updateInputShape(window: Window, marginPx: Int): Boolean { + fun updateInputShape( + window: Window, + marginPx: Int + ): Boolean { if (!window.isDisplayable) return false val xext = try { - XextExt.INSTANCE + LinuxXShape.INSTANCE } catch (_: UnsatisfiedLinkError) { return false } val x11 = try { - X11Ext.INSTANCE + LinuxX11.INSTANCE } catch (_: UnsatisfiedLinkError) { return false } @@ -237,13 +245,13 @@ internal object LinuxClientShadow { xext.XShapeCombineRectangles( display, X11.Window(windowId), - XextExt.SHAPE_INPUT, + LinuxXShape.SHAPE_INPUT, 0, 0, rectangle, 1, - XextExt.SHAPE_SET, - XextExt.UNSORTED + LinuxXShape.SHAPE_SET, + LinuxXShape.UNSORTED ) x11.XFlush(display) @@ -338,7 +346,9 @@ internal fun LinuxClientShadowEffect( DisposableEffect(window, marginPx) { val listener = object : ComponentAdapter() { - override fun componentResized(e: ComponentEvent?) { + override fun componentResized( + e: ComponentEvent? + ) { // Re-apply both: the input shape depends on the window size, and resizes also // happen on maximize/restore transitions that may drop the frame extents LinuxClientShadow.applyGtkFrameExtents(window, marginPx) @@ -353,29 +363,3 @@ internal fun LinuxClientShadowEffect( } } } - -/** - * JNA bindings for the X11 shape (XFixes) extension in libXext. - */ -@Suppress("FunctionName") -internal interface XextExt : Library { - fun XShapeCombineRectangles( - display: X11.Display, - window: X11.Window, - destKind: Int, - xOffset: Int, - yOffset: Int, - rectangles: Pointer, - rectangleCount: Int, - operation: Int, - ordering: Int - ) - - companion object { - const val SHAPE_INPUT = 2 - const val SHAPE_SET = 0 - const val UNSORTED = 0 - - val INSTANCE: XextExt = Native.load("Xext", XextExt::class.java) - } -} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxCaptionBarInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxCaptionBarInput.kt new file mode 100644 index 00000000..db4efd84 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxCaptionBarInput.kt @@ -0,0 +1,54 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.input + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import com.moriafly.salt.ui.platform.linux.window.LinuxCaptionBarHitTest +import com.moriafly.salt.ui.platform.linux.window.LocalLinuxCaptionBarHitTest + +/** + * Registers caption geometry and reports Compose touch hits to the window gesture owner. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun Modifier.linuxCaptionBarInput(): Modifier { + val hitTest = LocalLinuxCaptionBarHitTest.current ?: return this + val region = remember(hitTest) { LinuxCaptionBarHitTest.Region() } + DisposableEffect(hitTest, region) { + hitTest.regions.add(region) + onDispose { hitTest.regions.remove(region) } + } + + val modifier = onGloballyPositioned { region.bounds = it.boundsInWindow() } + val gestures = LocalLinuxWindowGestures.current ?: return modifier + // Compose hit testing decides whether the caption or a control above it receives the press. + return modifier.onPointerEvent(PointerEventType.Press) { event -> + val native = event.nativeEvent as? LinuxTouchEvent ?: return@onPointerEvent + if (gestures.onCaptionPress(native)) { + event.changes.forEach { it.consume() } + } + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxMouseInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxMouseInput.kt new file mode 100644 index 00000000..063867e8 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxMouseInput.kt @@ -0,0 +1,209 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.input + +import com.moriafly.salt.ui.platform.linux.x11.LinuxXInputSession +import java.awt.Component +import java.awt.Window +import java.awt.event.InputEvent +import java.awt.event.MouseEvent +import kotlin.math.roundToInt + +/** + * Chooses one mouse source before window handlers and Compose receive input. + * LinuxWindowInput validates window eligibility before passing native hover here. + * XI2 supplies unpressed hover with device provenance. + * AWT supplies buttons, wheel and grabbed drag. + * If native hover is unavailable, the complete AWT path remains enabled independently of touch. + */ +internal class LinuxMouseInput( + private val window: Window, + private val canvas: Component +) : AutoCloseable { + private var awaitingNativeCapabilities = true + private var nativeCapabilities = LinuxXInputSession.Capabilities(touch = false, hover = false) + private val pressedButtons = mutableSetOf() + private var suppressTouchMouse = false + private val unregister = LinuxWindowMouseInput.registerSource(window, this) + + fun suppressTouchEmulation() { + suppressTouchMouse = true + } + + fun onNativeMouseRelease() { + pressedButtons.remove(MouseEvent.BUTTON1) + } + + fun awaitNativeCapabilities() { + awaitingNativeCapabilities = true + } + + fun updateNativeCapabilities( + capabilities: LinuxXInputSession.Capabilities + ) { + nativeCapabilities = capabilities + awaitingNativeCapabilities = false + } + + // During attachment/re-attachment, AWT cannot distinguish touch-generated crossings. + // Keep them gated until registration resolves; explicit failure restores full AWT hover. + private val nativeHoverSelected: Boolean + get() = awaitingNativeCapabilities || nativeCapabilities.hover + + fun dispatchAwt( + event: MouseEvent, + deliver: (MouseEvent) -> Unit + ) { + if (event.component !== canvas) { + deliver(event) + return + } + if (event.id == MouseEvent.MOUSE_PRESSED) { + suppressTouchMouse = false + } else if (suppressTouchMouse && + ( + event.id == MouseEvent.MOUSE_DRAGGED || + event.id == MouseEvent.MOUSE_RELEASED || + event.id == MouseEvent.MOUSE_CLICKED + ) + ) { + return + } + when (event.id) { + MouseEvent.MOUSE_PRESSED -> { + if (nativeHoverSelected && pressedButtons.isEmpty()) { + // AWT buttons can overtake XI2 hover. Resolve the real mouse hit target before + // its press, through the same window delivery path without queue re-entry. + deliver( + MouseEvent( + canvas, + MouseEvent.MOUSE_MOVED, + event.`when`, + event.modifiersEx and KEYBOARD_MODIFIERS_MASK, + event.x, + event.y, + event.xOnScreen, + event.yOnScreen, + 0, + false, + MouseEvent.NOBUTTON + ) + ) + } + pressedButtons.add(event.button) + } + + MouseEvent.MOUSE_RELEASED -> { + // A release queued before hide/focus loss must not complete the cancelled press. + if (!pressedButtons.remove(event.button)) return + } + + MouseEvent.MOUSE_ENTERED, + MouseEvent.MOUSE_EXITED, + MouseEvent.MOUSE_MOVED, + MouseEvent.MOUSE_DRAGGED -> if (nativeHoverSelected && pressedButtons.isEmpty()) return + } + deliver(normalizeRelease(event)) + } + + private fun normalizeRelease( + event: MouseEvent + ): MouseEvent { + if (event.id != MouseEvent.MOUSE_RELEASED || + event.button == MouseEvent.NOBUTTON + ) { + return event + } + val releasedMask = InputEvent.getMaskForButton(event.button) + if (event.modifiersEx and releasedMask == 0) return event + // X11 compatibility touch can report the pre-release button mask. Compose derives + // pressed state from modifiersEx, so the released button must be cleared explicitly. + return MouseEvent( + canvas, + event.id, + event.`when`, + event.modifiersEx and releasedMask.inv(), + event.x, + event.y, + event.xOnScreen, + event.yOnScreen, + event.clickCount, + event.isPopupTrigger, + event.button + ) + } + + fun onHover( + event: LinuxXInputSession.Hover + ) { + // All source selection lives here, including grab/ungrab crossings and emulated motion. + // Without a touch grab, touch uses X11's compatibility mouse path and needs its hover too. + val touchOwnsPointer = nativeCapabilities.touch && (event.directTouch || event.emulated) + if (awaitingNativeCapabilities || !nativeCapabilities.hover || touchOwnsPointer || + event.crossingMode != 0 || + pressedButtons.isNotEmpty() + ) { + return + } + val transform = canvas.graphicsConfiguration.defaultTransform + LinuxWindowMouseInput.dispatchToWindow( + window, + MouseEvent( + canvas, + when (event.phase) { + LinuxXInputSession.HoverPhase.Move -> MouseEvent.MOUSE_MOVED + LinuxXInputSession.HoverPhase.Enter -> MouseEvent.MOUSE_ENTERED + LinuxXInputSession.HoverPhase.Exit -> MouseEvent.MOUSE_EXITED + }, + System.currentTimeMillis(), + keyboardModifiers(event.modifiers), + (event.x / transform.scaleX).roundToInt(), + (event.y / transform.scaleY).roundToInt(), + (event.rootX / transform.scaleX).roundToInt(), + (event.rootY / transform.scaleY).roundToInt(), + 0, + false, + MouseEvent.NOBUTTON + ) + ) + } + + fun cancel() { + pressedButtons.clear() + } + + override fun close() = unregister() + + companion object { + private const val KEYBOARD_MODIFIERS_MASK = InputEvent.SHIFT_DOWN_MASK or + InputEvent.CTRL_DOWN_MASK or InputEvent.ALT_DOWN_MASK or + InputEvent.META_DOWN_MASK or InputEvent.ALT_GRAPH_DOWN_MASK + + private fun keyboardModifiers( + state: Int + ): Int { + var modifiers = 0 + if (state and 1 != 0) modifiers = modifiers or InputEvent.SHIFT_DOWN_MASK + if (state and 4 != 0) modifiers = modifiers or InputEvent.CTRL_DOWN_MASK + if (state and 8 != 0) modifiers = modifiers or InputEvent.ALT_DOWN_MASK + if (state and 64 != 0) modifiers = modifiers or InputEvent.META_DOWN_MASK + if (state and 128 != 0) modifiers = modifiers or InputEvent.ALT_GRAPH_DOWN_MASK + return modifiers + } + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchEvent.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchEvent.kt new file mode 100644 index 00000000..3500638d --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchEvent.kt @@ -0,0 +1,35 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.input + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.unit.IntOffset + +/** + * A native contact sample. Screen coordinates are AWT units; content coordinates are pixels. + * Time uses the native input session's monotonic clock. + * Gesture ownership belongs to LinuxWindowGestures, never to the event being dispatched. + */ +internal data class LinuxTouchEvent( + val id: Long, + val type: PointerEventType, + val screenPosition: IntOffset, + val contentPosition: Offset, + val timeMillis: Long +) diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchInput.kt new file mode 100644 index 00000000..f58930db --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchInput.kt @@ -0,0 +1,110 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.input + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers +import androidx.compose.ui.unit.IntOffset +import com.moriafly.salt.ui.platform.input.DesktopTouchScene +import com.moriafly.salt.ui.platform.input.TouchPointerState +import com.moriafly.salt.ui.platform.linux.input.gesture.WindowGestureEffects +import com.moriafly.salt.ui.platform.linux.x11.LinuxXInputSession +import java.awt.Component +import kotlin.math.roundToInt + +/** + * EDT-confined adaptation from native contact samples to Compose and window gestures. + * Window eligibility, native connections and mouse routing belong to LinuxWindowInput. + */ +@OptIn(InternalComposeUiApi::class) +internal class LinuxTouchInput( + private val canvas: Component, + private val target: DesktopTouchScene, + private val gestures: LinuxWindowGestures, + private val focus: WindowGestureEffects.Focus +) { + private val pointers = TouchPointerState() + + fun dispatch( + sample: LinuxXInputSession.Touch, + canMoveNatively: Boolean + ): LinuxWindowGestures.TouchResult? { + if (!sample.directTouch) return null + if (target.isDisposed) { + cancel() + return null + } + val type = when (sample.phase) { + LinuxXInputSession.TouchPhase.Down -> PointerEventType.Press + LinuxXInputSession.TouchPhase.Move -> PointerEventType.Move + LinuxXInputSession.TouchPhase.Up -> PointerEventType.Release + } + val position = Offset(sample.x.toFloat(), sample.y.toFloat()) + val transform = canvas.graphicsConfiguration.defaultTransform + val touchEvent = LinuxTouchEvent( + id = sample.id, + type = type, + screenPosition = IntOffset( + (sample.rootX / transform.scaleX).roundToInt(), + (sample.rootY / transform.scaleY).roundToInt() + ), + contentPosition = position, + timeMillis = sample.timeMillis + ) + if (type == PointerEventType.Press) { + focus.onPress(sample.x11Timestamp) + } + return gestures.dispatchTouch( + event = touchEvent, + dispatchToCompose = { + // XI coordinates include system scale; extraDisplayScale only affects density. + pointers.update( + id = sample.id, + position = target.positionInScene(canvas, position), + eventType = type + )?.let { snapshot -> + target.scene.sendPointerEvent( + eventType = type, + pointers = snapshot, + keyboardModifiers = PointerKeyboardModifiers( + isShiftPressed = sample.modifiers and 1 != 0, + isCtrlPressed = sample.modifiers and 4 != 0, + isAltPressed = sample.modifiers and 8 != 0, + isMetaPressed = sample.modifiers and 64 != 0 + ), + timeMillis = sample.timeMillis, + nativeEvent = touchEvent + ) + } + }, + cancelCompose = ::cancelCompose, + canMoveNatively = canMoveNatively + ) + } + + fun cancel() { + gestures.resetTouchInput() + cancelCompose() + } + + private fun cancelCompose() { + if (pointers.clear() && !target.isDisposed) target.scene.cancelPointerInput() + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowGestures.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowGestures.kt new file mode 100644 index 00000000..8e80ceb0 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowGestures.kt @@ -0,0 +1,315 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.input + +import androidx.compose.runtime.State +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.ViewConfiguration +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxTouchTapRecognizer +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.CaptionHit +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Command +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Input +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Phase +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Pointer +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Position +import com.moriafly.salt.ui.platform.linux.input.gesture.WindowGestureEffects +import com.moriafly.salt.ui.platform.linux.window.LinuxCaptionBarHitTest +import com.moriafly.salt.ui.platform.linux.window.UndecoratedWindowResizer +import com.moriafly.salt.ui.window.WindowResizeEdge +import java.awt.Window +import java.awt.event.MouseEvent + +internal val LocalLinuxWindowGestures = staticCompositionLocalOf { null } + +/** + * Adapts AWT/Compose input to the window's single gesture state machine and executes its commands. + * The platform adapter owns only dispatch-scoped Compose hit queries, never gesture ownership. + */ +@OptIn(UnstableSaltUiApi::class) +internal class LinuxWindowGestures( + private val window: Window, + private val caption: LinuxCaptionBarHitTest, + private val resizer: UndecoratedWindowResizer, + private val captionHovered: State, + private val effects: WindowGestureEffects +) : LinuxWindowMouseInput.Handler { + enum class TouchResult { Handled, PendingNativeMove, PreparedNativeMove } + + private val state = LinuxWindowGestureState() + private var captionQuery: CaptionQuery? = null + + fun configure( + moveable: Boolean, + resizable: Boolean, + canDrag: Boolean, + canMaximize: Boolean, + viewConfiguration: ViewConfiguration + ) { + execute( + state.configure( + LinuxWindowGestureState.Configuration( + moveable = moveable, + resizable = resizable, + canDrag = canDrag, + canMaximize = canMaximize, + taps = LinuxTouchTapRecognizer.Configuration( + touchSlop = viewConfiguration.touchSlop, + longPressTimeoutMillis = viewConfiguration.longPressTimeoutMillis, + doubleTapMinTimeMillis = viewConfiguration.doubleTapMinTimeMillis, + doubleTapTimeoutMillis = viewConfiguration.doubleTapTimeoutMillis + ) + ) + ) + ) + } + + fun install(): () -> Unit { + val uninstallMover = effects.mover.install() + val unregister = LinuxWindowMouseInput.register( + window = window, + handler = this, + priority = 1 + ) + return { + unregister() + uninstallMover() + } + } + + fun onCaptionPress( + event: LinuxTouchEvent + ): Boolean { + val query = captionQuery ?: return false + if (query.dispatch.event !== event) return false + query.hit = true + return true + } + + fun dispatchTouch( + event: LinuxTouchEvent, + dispatchToCompose: () -> Unit, + cancelCompose: () -> Unit, + canMoveNatively: Boolean = false + ): TouchResult { + val phase = when (event.type) { + PointerEventType.Press -> Phase.Press + PointerEventType.Move -> Phase.Move + PointerEventType.Release -> Phase.Release + else -> return TouchResult.Handled + } + val scale = window.graphicsConfiguration.defaultTransform + val input = Input( + pointer = Pointer.Touch(event.id), + phase = phase, + position = Position(event.screenPosition.x, event.screenPosition.y), + timeMillis = event.timeMillis, + scaleX = scale.scaleX, + scaleY = scale.scaleY + ) + val hit = if (phase == Phase.Press) { + hitAt(event.contentPosition) + } else { + LinuxWindowGestureState.Hit() + } + try { + val dispatch = TouchDispatch( + event = event, + deliver = dispatchToCompose, + cancel = cancelCompose, + canMoveNatively = canMoveNatively + ) + val result = execute(state.onInput(input, hit), dispatch) + return if (canMoveNatively && state.isPendingTouchMove(event.id)) { + TouchResult.PendingNativeMove + } else { + result + } + } catch (exception: Throwable) { + cancel() + cancelCompose() + throw exception + } + } + + override fun onMouseEvent( + event: MouseEvent + ): Boolean { + val phase = when (event.id) { + MouseEvent.MOUSE_MOVED, MouseEvent.MOUSE_ENTERED -> Phase.Enter + MouseEvent.MOUSE_EXITED -> Phase.Exit + MouseEvent.MOUSE_PRESSED -> Phase.Press + MouseEvent.MOUSE_DRAGGED -> Phase.Move + MouseEvent.MOUSE_RELEASED -> Phase.Release + MouseEvent.MOUSE_CLICKED -> Phase.Click + else -> return false + } + val position = LinuxWindowMouseInput.positionInContent(event, window) + val hit = if (phase == Phase.Press) { + val target = hitAt(position) + if (target.caption == CaptionHit.ResolveInCompose) { + // Enter and press can arrive before recomposition updates the hover state. + target.copy( + caption = if (captionHovered.value) CaptionHit.Caption else CaptionHit.Content + ) + } else { + target + } + } else { + LinuxWindowGestureState.Hit(edge = resizer.edgeAt(position)) + } + val decision = state.onInput( + Input( + pointer = Pointer.Mouse, + phase = phase, + position = Position(event.xOnScreen, event.yOnScreen), + primary = event.button == MouseEvent.BUTTON1, + clickCount = event.clickCount + ), + hit + ) + execute(decision) + return decision.consumed + } + + override fun afterMouseEvent( + event: MouseEvent + ) = resizer.afterMouseEvent(event) + + override fun cancel() { + captionQuery = null + execute(state.cancel()) + } + + fun endNativeMouseMove(): Boolean { + if (!effects.mover.isNativeMouseMove) return false + cancel() + return true + } + + fun releaseNativeMouseMove(): Boolean { + // The WM still ends the drag on release; only its end notification is lost. + if (!effects.mover.releaseNativeMouseMove()) return false + cancel() + return true + } + + fun resetTouchInput() { + captionQuery = null + execute(state.resetTouchInput()) + } + + fun commitNativeTouchMove( + id: Long + ) { + // XI2 has already rejected the contact; retain quarantine until its physical release. + if (!effects.mover.commitTouchMove(id)) cancel() + } + + private fun hitAt( + position: Offset + ): LinuxWindowGestureState.Hit { + val edge = resizer.edgeAt(position) + if (edge != WindowResizeEdge.None) return LinuxWindowGestureState.Hit(edge = edge) + val target = when (caption.hitTest(position, window)) { + LinuxCaptionBarHitTest.Result.Caption -> CaptionHit.Caption + LinuxCaptionBarHitTest.Result.Excluded -> CaptionHit.Content + LinuxCaptionBarHitTest.Result.RequiresComposeHitTest -> CaptionHit.ResolveInCompose + } + return LinuxWindowGestureState.Hit(caption = target) + } + + private fun execute( + decision: LinuxWindowGestureState.Decision, + touch: TouchDispatch? = null + ): TouchResult { + var result = TouchResult.Handled + for (command in decision.commands) { + when (command) { + Command.DeliverTouch -> checkNotNull(touch).deliver() + Command.CancelCompose -> checkNotNull(touch).cancel() + is Command.ResolveCaption -> { + result = resolveCaption(command.request, checkNotNull(touch)) + } + + is Command.StartMove -> { + result = startMove(command, touch) + } + is Command.Move -> effects.mover.update(command.position) + Command.EndMove -> effects.mover.finish() + Command.ToggleMaximize -> effects.mover.toggleMaximize() + is Command.StartResize -> effects.resizer.start(command.edge, command.position) + is Command.Resize -> effects.resizer.update(command.position) + is Command.EndResize -> effects.resizer.finish(command.applyPending) + is Command.ShowEdge -> effects.resizer.showEdge(command.edge) + is Command.EnableResize -> effects.resizer.setEnabled(command.enabled) + } + } + return result + } + + private fun startMove( + command: Command.StartMove, + touch: TouchDispatch? + ): TouchResult { + if (touch?.canMoveNatively == true && + effects.mover.prepareTouchMove( + id = touch.event.id, + start = command.start, + current = command.current + ) + ) { + return TouchResult.PreparedNativeMove + } + effects.mover.start( + pointer = command.pointer, + start = command.start, + current = command.current + ) + return TouchResult.Handled + } + + private fun resolveCaption( + request: Long, + dispatch: TouchDispatch + ): TouchResult { + val query = CaptionQuery(dispatch) + captionQuery = query + try { + dispatch.deliver() + } finally { + if (captionQuery === query) captionQuery = null + } + return execute(state.resolveCaption(request, query.hit), dispatch) + } + + private class CaptionQuery( + val dispatch: TouchDispatch, + var hit: Boolean = false + ) + + private class TouchDispatch( + val event: LinuxTouchEvent, + val deliver: () -> Unit, + val cancel: () -> Unit, + val canMoveNatively: Boolean + ) +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowInput.kt new file mode 100644 index 00000000..c853b08e --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowInput.kt @@ -0,0 +1,309 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.input + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.platform.LocalViewConfiguration +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.platform.input.DesktopTouchScene +import com.moriafly.salt.ui.platform.linux.window.LinuxWindowFocus +import com.moriafly.salt.ui.platform.linux.window.LinuxWindowModality +import com.moriafly.salt.ui.platform.linux.x11.LinuxXInputSession +import com.moriafly.salt.ui.util.findSkiaLayer +import com.sun.jna.Native +import java.awt.Component +import java.awt.Window +import java.awt.event.HierarchyEvent +import java.awt.event.HierarchyListener +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent + +@Composable +internal fun LinuxWindowInputEffect( + window: Window, + gestures: LinuxWindowGestures, + moveable: Boolean, + resizable: Boolean, + canDrag: Boolean = true, + canMaximize: Boolean = false +) { + val viewConfiguration = LocalViewConfiguration.current + SideEffect { + gestures.configure( + moveable = moveable, + resizable = resizable, + canDrag = canDrag, + canMaximize = canMaximize, + viewConfiguration = viewConfiguration + ) + } + DisposableEffect(window, gestures) { + val uninstallGestures = gestures.install() + val focus = LinuxWindowFocus.install(window) + val input = LinuxWindowInput.create( + window = window, + gestures = gestures, + focus = focus + ) + onDispose { + input?.close() + focus?.close() + uninstallGestures() + } + } +} + +/** + * Attaches the native session to the canvas lifetime and connects its independent input channels. + * Both channels pass AWT's window eligibility before reaching gestures or Compose. + * A stopping session retains its slot until the reader confirms that native resources are closed. + */ +@OptIn(InternalComposeUiApi::class) +internal class LinuxWindowInput private constructor( + private val window: Window, + private val canvas: Component, + private val gestures: LinuxWindowGestures, + private val target: DesktopTouchScene?, + private val touch: LinuxTouchInput?, + private val mouse: LinuxMouseInput +) : AutoCloseable, + LinuxXInputSession.Listener { + private var session: LinuxXInputSession? = null + private var restartAfterClose = false + private var closed = false + private var nativeInputEligible = true + + private val windowListener = object : WindowAdapter() { + override fun windowLostFocus( + event: WindowEvent + ) = cancelInput() + + override fun windowIconified( + event: WindowEvent + ) = cancelInput() + } + + private val hierarchyListener = HierarchyListener { event -> + if (event.changeFlags and ( + HierarchyEvent.SHOWING_CHANGED or HierarchyEvent.DISPLAYABILITY_CHANGED + ).toLong() != 0L + ) { + if (canvas.isShowing) attach() else detach() + } + } + + init { + window.addWindowFocusListener(windowListener) + window.addWindowListener(windowListener) + canvas.addHierarchyListener(hierarchyListener) + attach() + } + + private fun attach() { + if (closed || !canvas.isShowing) return + val existing = session + if (existing != null) { + restartAfterClose = existing.state == LinuxXInputSession.State.Stopping + return + } + try { + val handle = Native.getComponentID(canvas) + if (handle == 0L) { + mouse.updateNativeCapabilities( + LinuxXInputSession.Capabilities( + touch = false, + hover = false + ) + ) + return + } + mouse.awaitNativeCapabilities() + session = LinuxXInputSession( + handle = handle, + windowHandle = Native.getComponentID(window), + requestTouch = touch != null, + listener = this + ).also { it.start() } + } catch (exception: Exception) { + mouse.updateNativeCapabilities( + LinuxXInputSession.Capabilities( + touch = false, + hover = false + ) + ) + logUnavailable(exception) + } catch (error: LinkageError) { + mouse.updateNativeCapabilities( + LinuxXInputSession.Capabilities( + touch = false, + hover = false + ) + ) + logUnavailable(error) + } + } + + private fun detach() { + restartAfterClose = false + cancelInput() + session?.requestStop() + } + + private fun cancelTouch() { + session?.invalidateTouches() + touch?.cancel() + } + + private fun cancelInput() { + gestures.cancel() + cancelTouch() + mouse.cancel() + // Window lifetime cancels every Compose pointer source, including AWT compatibility input. + if (target != null && !target.isDisposed) target.scene.cancelPointerInput() + } + + override fun onReady( + capabilities: LinuxXInputSession.Capabilities + ) { + mouse.updateNativeCapabilities(capabilities) + } + + override fun onTouch( + event: LinuxXInputSession.Touch, + canMoveNatively: Boolean + ): LinuxXInputSession.TouchAction { + if (!acceptNativeInput()) return LinuxXInputSession.TouchAction.Reject + return when (touch?.dispatch(event, canMoveNatively)) { + null -> LinuxXInputSession.TouchAction.Reject + LinuxWindowGestures.TouchResult.Handled -> LinuxXInputSession.TouchAction.Accept + LinuxWindowGestures.TouchResult.PendingNativeMove -> LinuxXInputSession.TouchAction.Pending + LinuxWindowGestures.TouchResult.PreparedNativeMove -> LinuxXInputSession.TouchAction.Move + } + } + + override fun onTouchMove( + id: Long + ) { + // Ownership was rejected even if the WM request fails. Suppress its AWT replay. + mouse.suppressTouchEmulation() + if (acceptNativeInput()) gestures.commitNativeTouchMove(id) + } + + override fun onMouseMoveEnd() { + if (gestures.endNativeMouseMove()) mouse.onNativeMouseRelease() + } + + override fun onHover( + event: LinuxXInputSession.Hover + ) { + if (acceptNativeInput()) mouse.onHover(event) + } + + private fun acceptNativeInput(): Boolean { + // XI2 callbacks run on the EDT but bypass AWT's modal event filter. + val eligible = !closed && canvas.isShowing && window.isEnabled && + !LinuxWindowModality.isBlocked(window) + // Cancel on the eligible -> blocked edge only, not for every hover sample while blocked. + // Native state can only begin from an eligible sample; AWT filters its own input. + if (!eligible && nativeInputEligible) cancelInput() + nativeInputEligible = eligible + return eligible + } + + override fun onDevicesChanged() { + cancelTouch() + } + + override fun onClosed() { + // The display, grabs and registrations are gone before compatibility input is restored. + session = null + mouse.updateNativeCapabilities( + LinuxXInputSession.Capabilities( + touch = false, + hover = false + ) + ) + touch?.cancel() + // A failed reader can no longer report raw button release. Requested stops already + // cancelled input; leave unrelated AWT and Compose input running. + if (gestures.releaseNativeMouseMove()) mouse.onNativeMouseRelease() + if (closed) { + mouse.close() + } else if (restartAfterClose) { + restartAfterClose = false + attach() + } + } + + override fun close() { + if (closed) return + closed = true + restartAfterClose = false + window.removeWindowFocusListener(windowListener) + window.removeWindowListener(windowListener) + canvas.removeHierarchyListener(hierarchyListener) + cancelInput() + val existing = session + if (existing != null) existing.requestStop() else mouse.close() + } + + companion object { + private fun logUnavailable( + exception: Throwable + ) { + System.getLogger(LinuxWindowInput::class.java.name).log( + System.Logger.Level.WARNING, + "Cannot attach native XI2 input; keeping AWT input", + exception + ) + } + + @OptIn(UnstableSaltUiApi::class) + fun create( + window: Window, + gestures: LinuxWindowGestures, + focus: LinuxWindowFocus? + ): LinuxWindowInput? { + val canvas = window.findSkiaLayer()?.canvas ?: return null + val target = DesktopTouchScene.create(window) + val touch = target?.let { + LinuxTouchInput( + canvas = canvas, + target = target, + gestures = gestures, + focus = { timestamp -> + canvas.requestFocusInWindow() + focus?.onPointerPress(canvas, timestamp) + } + ) + } + // Hover remains usable even when Compose's private scene adapter is unavailable. + return LinuxWindowInput( + window = window, + canvas = canvas, + gestures = gestures, + target = target, + touch = touch, + mouse = LinuxMouseInput(window, canvas) + ) + } + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowMouseInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowMouseInput.kt similarity index 65% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowMouseInput.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowMouseInput.kt index 6285a3fb..bb7d36ae 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowMouseInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowMouseInput.kt @@ -15,7 +15,7 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.input import androidx.compose.ui.geometry.Offset import com.moriafly.salt.ui.UnstableSaltUiApi @@ -30,14 +30,18 @@ import java.awt.event.WindowEvent import javax.swing.SwingUtilities /** - * Routes non-client mouse gestures before Compose dispatches them to a modal scene. + * Routes AWT and native mouse input through window handlers before Compose sees it. * Consuming a MouseEvent in an AWT listener alone does not stop other listeners receiving it. */ internal object LinuxWindowMouseInput { interface Handler { - fun onMouseEvent(event: MouseEvent): Boolean + fun onMouseEvent( + event: MouseEvent + ): Boolean - fun afterMouseEvent(event: MouseEvent) {} + fun afterMouseEvent( + event: MouseEvent + ) {} fun cancel() {} } @@ -48,6 +52,7 @@ internal object LinuxWindowMouseInput { ) private val handlers = mutableMapOf>() + private val sources = mutableMapOf() private var queue: WindowEventQueue? = null fun register( @@ -73,12 +78,40 @@ internal object LinuxWindowMouseInput { it.remove(registration) if (it.isEmpty()) handlers.remove(window) } - if (handlers.isEmpty()) queue?.uninstallIfCurrent() + if (handlers.isEmpty() && sources.isEmpty()) queue?.uninstallIfCurrent() } } + fun registerSource( + window: Window, + source: LinuxMouseInput + ): () -> Unit { + check(EventQueue.isDispatchThread()) + check(window in handlers && window !in sources) + sources[window] = source + return { + check(EventQueue.isDispatchThread()) + source.cancel() + sources.remove(window) + if (handlers.isEmpty() && sources.isEmpty()) queue?.uninstallIfCurrent() + } + } + + fun dispatchToWindow( + window: Window, + event: MouseEvent + ) { + check(EventQueue.isDispatchThread()) + // Input has already passed source selection. Deliver directly to this window's handlers + // and AWT component, without queue re-entry or a second source selection pass. + checkNotNull(queue).deliverToWindow(window, event) + } + @OptIn(UnstableSaltUiApi::class) - fun positionInContent(event: MouseEvent, window: Window): Offset { + fun positionInContent( + event: MouseEvent, + window: Window + ): Offset { // Compose coordinates start at its canvas, which can be inset by AWT decoration. val canvas = window.findSkiaLayer()?.canvas ?: window val point = SwingUtilities.convertPoint(event.component, event.point, canvas) @@ -89,16 +122,10 @@ internal object LinuxWindowMouseInput { ) } - fun positionInWindow(event: MouseEvent, window: Window): Offset { - val transform = window.graphicsConfiguration.defaultTransform - return Offset( - ((event.xOnScreen - window.x) * transform.scaleX).toFloat(), - ((event.yOnScreen - window.y) * transform.scaleY).toFloat() - ) - } - private class WindowEventQueue : EventQueue() { - override fun dispatchEvent(event: AWTEvent) { + public override fun dispatchEvent( + event: AWTEvent + ) { if (event is WindowEvent && ( event.id == WindowEvent.WINDOW_DEACTIVATED || @@ -106,24 +133,38 @@ internal object LinuxWindowMouseInput { ) ) { handlers[event.window]?.toList()?.forEach { it.handler.cancel() } + sources[event.window]?.cancel() } val mouseEvent = event as? MouseEvent val component = mouseEvent?.source as? Component val window = component as? Window ?: component?.let(SwingUtilities::getWindowAncestor) - val registrations = handlers[window]?.toList() - if (mouseEvent == null || window == null || registrations.isNullOrEmpty()) { + if (mouseEvent == null || window == null) { super.dispatchEvent(event) return } + val source = sources[window] + if (source == null) { + deliverToWindow(window, mouseEvent) + } else { + source.dispatchAwt(mouseEvent) { deliverToWindow(window, it) } + } + } + + fun deliverToWindow( + window: Window, + mouseEvent: MouseEvent + ) { + val registrations = handlers[window]?.toList().orEmpty() if (!window.isShowing || !window.isEnabled) { registrations.forEach { it.handler.cancel() } - super.dispatchEvent(event) + sources[window]?.cancel() + super.dispatchEvent(mouseEvent) return } if (registrations.any { it.handler.onMouseEvent(mouseEvent) }) { mouseEvent.consume() } else { - super.dispatchEvent(event) + super.dispatchEvent(mouseEvent) } registrations.forEach { it.handler.afterMouseEvent(mouseEvent) } } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/LinuxTouchTapRecognizer.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/LinuxTouchTapRecognizer.kt new file mode 100644 index 00000000..9c0904c4 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/LinuxTouchTapRecognizer.kt @@ -0,0 +1,110 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.input.gesture + +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Input +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Position + +/** + * Recognizes caption taps from supplied timestamps, scale and thresholds, without a platform clock. + */ +internal class LinuxTouchTapRecognizer { + data class Configuration( + val touchSlop: Float, + val longPressTimeoutMillis: Long, + val doubleTapMinTimeMillis: Long, + val doubleTapTimeoutMillis: Long + ) + + private lateinit var configuration: Configuration + private var press: Press? = null + private var previousTap: Tap? = null + + fun configure( + configuration: Configuration + ) { + this.configuration = configuration + } + + fun begin( + position: Position, + timeMillis: Long + ) { + press = Press(position, timeMillis) + } + + fun finish( + input: Input + ): Boolean { + val current = press + press = null + if (current == null || + input.timeMillis - current.timeMillis !in + 0 until configuration.longPressTimeoutMillis || + !isWithinSlop( + first = current.position, + second = input.position, + scaleX = input.scaleX, + scaleY = input.scaleY + ) + ) { + cancel() + return false + } + val doubleTap = previousTap?.let { previous -> + val interval = current.timeMillis - previous.upTimeMillis + val withinTimeout = interval in + configuration.doubleTapMinTimeMillis..configuration.doubleTapTimeoutMillis + withinTimeout && isWithinSlop( + first = previous.position, + second = current.position, + scaleX = input.scaleX, + scaleY = input.scaleY + ) + } ?: false + previousTap = if (doubleTap) null else Tap(current.position, input.timeMillis) + return doubleTap + } + + fun isWithinSlop( + first: Position, + second: Position, + scaleX: Double, + scaleY: Double + ): Boolean { + val dx = (second.x - first.x) * scaleX + val dy = (second.y - first.y) * scaleY + val slop = configuration.touchSlop + return dx * dx + dy * dy <= slop * slop + } + + fun cancel() { + press = null + previousTap = null + } + + private class Press( + val position: Position, + val timeMillis: Long + ) + + private class Tap( + val position: Position, + val upTimeMillis: Long + ) +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/LinuxWindowGestureState.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/LinuxWindowGestureState.kt new file mode 100644 index 00000000..c128f1c8 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/LinuxWindowGestureState.kt @@ -0,0 +1,520 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.input.gesture + +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.window.WindowResizeEdge + +/** + * Deterministic window-gesture policy. Inputs and commands contain values only; this state machine + * neither reads a window nor calls Compose, a clock, or a native input service. + * + * A captured touch sequence remains quarantined until every contact leaves. Caption queries carry + * an identity so a reply arriving after cancellation cannot capture a new sequence. + * + * The resize-edge indicator is state, not a command stream: each decision reports at most one + * [Command.ShowEdge], and only when the indicated edge differs from the last one reported. + */ +@OptIn(UnstableSaltUiApi::class) +internal class LinuxWindowGestureState { + data class Position( + val x: Int, + val y: Int + ) + + sealed interface Pointer { + data object Mouse : Pointer + + data class Touch( + val id: Long + ) : Pointer + } + + enum class Phase { Press, Move, Release, Click, Enter, Exit } + + enum class CaptionHit { Content, Caption, ResolveInCompose } + + data class Hit( + val edge: WindowResizeEdge = WindowResizeEdge.None, + val caption: CaptionHit = CaptionHit.Content + ) + + data class Input( + val pointer: Pointer, + val phase: Phase, + val position: Position, + val timeMillis: Long = 0, + val primary: Boolean = true, + val clickCount: Int = 0, + val scaleX: Double = 1.0, + val scaleY: Double = 1.0 + ) + + data class Configuration( + val moveable: Boolean, + val resizable: Boolean, + val canDrag: Boolean, + val canMaximize: Boolean, + val taps: LinuxTouchTapRecognizer.Configuration + ) + + sealed interface Command { + data object DeliverTouch : Command + + data class ResolveCaption( + val request: Long + ) : Command + + data object CancelCompose : Command + + data class StartMove( + val pointer: Pointer, + val start: Position, + val current: Position + ) : Command + + data class Move( + val position: Position + ) : Command + + data object EndMove : Command + + data object ToggleMaximize : Command + + data class StartResize( + val edge: WindowResizeEdge, + val position: Position + ) : Command + + data class Resize( + val position: Position + ) : Command + + data class EndResize( + val applyPending: Boolean + ) : Command + + data class ShowEdge( + val edge: WindowResizeEdge + ) : Command + + data class EnableResize( + val enabled: Boolean + ) : Command + } + + data class Decision( + val consumed: Boolean, + val commands: List + ) + + private sealed interface Gesture { + val pointer: Pointer + + data class Move( + override val pointer: Pointer, + val start: Position, + var pending: Boolean = true + ) : Gesture + + data class Resize( + override val pointer: Pointer + ) : Gesture + } + + private data class CaptionQuery( + val request: Long, + val input: Input + ) + + private enum class Click { Caption, Resize } + + private var configuration: Configuration? = null + private var gesture: Gesture? = null + private val contacts = mutableSetOf() + private var capturedTouches = false + private var suppressedClick: Click? = null + private var captionQuery: CaptionQuery? = null + private var nextRequest = 0L + private val taps = LinuxTouchTapRecognizer() + private var indicatedEdge = WindowResizeEdge.None + private var reportedEdge = WindowResizeEdge.None + + fun configure( + value: Configuration + ): Decision { + val previous = configuration + if (previous == null || previous.moveable != value.moveable || + previous.canDrag != value.canDrag || + previous.canMaximize != value.canMaximize + ) { + taps.cancel() + captionQuery = null + } + configuration = value + taps.configure(value.taps) + val commands = mutableListOf(Command.EnableResize(value.resizable)) + if ((gesture is Gesture.Move && (!value.moveable || !value.canDrag)) || + (gesture is Gesture.Resize && !value.resizable) + ) { + finish(commands) + } + if (!value.resizable) indicatedEdge = WindowResizeEdge.None + return decide(false, commands) + } + + fun onInput( + input: Input, + hit: Hit = Hit() + ): Decision { + checkNotNull(configuration) { "Configure gestures before dispatching input" } + val commands = mutableListOf() + val consumed = when (input.pointer) { + Pointer.Mouse -> handleMouse(input, hit, commands) + is Pointer.Touch -> handleTouch(input, hit, commands) + } + return decide(consumed, commands) + } + + fun resolveCaption( + request: Long, + hit: Boolean + ): Decision { + val query = captionQuery + if (query == null || query.request != request) return Decision(true, emptyList()) + captionQuery = null + val commands = mutableListOf() + val pointer = query.input.pointer as Pointer.Touch + if (hit && canMove && gesture == null && !capturedTouches && pointer.id in contacts) { + captureCaption(query.input, commands) + } else { + taps.cancel() + } + return decide(true, commands) + } + + fun cancel(): Decision { + taps.cancel() + captionQuery = null + suppressedClick = null + capturedTouches = contacts.isNotEmpty() + val commands = mutableListOf() + finish(commands) + return decide(false, commands) + } + + fun resetTouchInput(): Decision { + taps.cancel() + captionQuery = null + contacts.clear() + capturedTouches = false + val commands = mutableListOf() + // Device/session resets must leave an AWT mouse drag alone. + if (gesture?.pointer is Pointer.Touch) finish(commands) + return decide(false, commands) + } + + private val canMove: Boolean + get() = configuration?.let { it.moveable && it.canDrag } == true + + fun isPendingTouchMove( + id: Long + ): Boolean { + val active = gesture as? Gesture.Move ?: return false + return active.pointer == Pointer.Touch(id) && active.pending + } + + private fun handleTouch( + input: Input, + hit: Hit, + commands: MutableList + ): Boolean { + val pointer = input.pointer as Pointer.Touch + val firstContact = contacts.isEmpty() + if (input.phase == Phase.Press) { + if (!contacts.add(pointer.id)) return true + captionQuery = null + if (!firstContact) taps.cancel() + } + + if (pointer.id !in contacts) { + return true + } + + try { + if (capturedTouches || gesture?.pointer == Pointer.Mouse) { + capturedTouches = true + onCapturedTouch(input, commands) + return true + } + + if (firstContact && input.phase == Phase.Press) { + beginTouchGesture(input, hit, commands) + } else { + commands += Command.DeliverTouch + } + + return true + } finally { + if (input.phase == Phase.Release) { + if (captionQuery?.input?.pointer == pointer) { + captionQuery = null + } + + contacts.remove(pointer.id) + + if (contacts.isEmpty()) { + capturedTouches = false + } + } + } + } + + private fun onCapturedTouch( + input: Input, + commands: MutableList + ) { + if (gesture?.pointer != input.pointer) return + when (input.phase) { + Phase.Move -> update(input, commands) + Phase.Release -> { + val doubleTap = gesture is Gesture.Move && taps.finish(input) + finish(commands, applyPendingResize = true) + if (doubleTap) toggleMaximize(commands) + } + + else -> Unit + } + } + + private fun beginTouchGesture( + input: Input, + hit: Hit, + commands: MutableList + ) { + when { + configuration!!.resizable && hit.edge != WindowResizeEdge.None -> { + capturedTouches = true + commands += Command.CancelCompose + beginResize( + pointer = input.pointer, + edge = hit.edge, + position = input.position, + commands = commands + ) + } + + canMove && hit.caption == CaptionHit.Caption -> captureCaption(input, commands) + canMove && hit.caption == CaptionHit.ResolveInCompose -> { + val query = CaptionQuery(++nextRequest, input) + captionQuery = query + commands += Command.ResolveCaption(query.request) + } + + else -> { + taps.cancel() + commands += Command.DeliverTouch + } + } + } + + private fun captureCaption( + input: Input, + commands: MutableList + ) { + capturedTouches = true + commands += Command.CancelCompose + taps.begin(input.position, input.timeMillis) + gesture = Gesture.Move(input.pointer, input.position) + } + + private fun handleMouse( + input: Input, + hit: Hit, + commands: MutableList + ): Boolean { + if (input.phase == Phase.Press) taps.cancel() + + // Touch contacts block new mouse gestures, but cannot take ownership of an existing one. + val continuesMouseGesture = gesture?.pointer == Pointer.Mouse && + (input.phase == Phase.Move || input.phase == Phase.Release) + + if (contacts.isNotEmpty() && !continuesMouseGesture) { + if (input.phase == Phase.Click && input.primary) { + suppressedClick = null + } + + return input.phase == Phase.Press || input.phase == Phase.Move || + input.phase == Phase.Release || input.phase == Phase.Click + } + return onMouseGesture(input, hit, commands) + } + + private fun onMouseGesture( + input: Input, + hit: Hit, + commands: MutableList + ): Boolean { + val edge = if (configuration!!.resizable) hit.edge else WindowResizeEdge.None + when (input.phase) { + Phase.Enter -> indicatedEdge = edge + Phase.Exit -> if (gesture == null) indicatedEdge = WindowResizeEdge.None + Phase.Press -> return beginMouseGesture( + input = input, + edge = edge, + caption = hit.caption, + commands = commands + ) + + Phase.Move -> { + if (gesture?.pointer != Pointer.Mouse) return false + update(input, commands) + return true + } + + Phase.Release -> { + val active = gesture ?: return false + if (input.primary) { + suppressedClick = if (active is Gesture.Move) Click.Caption else Click.Resize + finish(commands, applyPendingResize = true) + indicatedEdge = edge + } + return true + } + + Phase.Click -> return onMouseClick(input, commands) + } + return false + } + + private fun onMouseClick( + input: Input, + commands: MutableList + ): Boolean { + val click = suppressedClick ?: return false + if (!input.primary) return false + suppressedClick = null + if (click == Click.Caption && input.clickCount == 2) toggleMaximize(commands) + return true + } + + private fun beginMouseGesture( + input: Input, + edge: WindowResizeEdge, + caption: CaptionHit, + commands: MutableList + ): Boolean { + if (!input.primary) return gesture != null + finish(commands) + suppressedClick = null + if (edge != WindowResizeEdge.None) { + beginResize( + pointer = Pointer.Mouse, + edge = edge, + position = input.position, + commands = commands + ) + return true + } + if (!canMove || caption != CaptionHit.Caption) return false + gesture = Gesture.Move(Pointer.Mouse, input.position) + return true + } + + private fun beginResize( + pointer: Pointer, + edge: WindowResizeEdge, + position: Position, + commands: MutableList + ) { + taps.cancel() + gesture = Gesture.Resize(pointer) + commands += Command.StartResize(edge, position) + indicatedEdge = edge + } + + private fun update( + input: Input, + commands: MutableList + ) { + when (val active = gesture) { + is Gesture.Move -> { + if (active.pending) { + if (active.pointer is Pointer.Touch) { + val withinSlop = taps.isWithinSlop( + first = active.start, + second = input.position, + scaleX = input.scaleX, + scaleY = input.scaleY + ) + if (withinSlop) return + taps.cancel() + } + active.pending = false + commands += Command.StartMove( + pointer = active.pointer, + start = active.start, + current = input.position + ) + } else { + commands += Command.Move(input.position) + } + } + + is Gesture.Resize -> commands += Command.Resize(input.position) + null -> Unit + } + } + + private fun toggleMaximize( + commands: MutableList + ) { + if (canMove && configuration!!.canMaximize) commands += Command.ToggleMaximize + } + + /** + * Releases ownership and clears the move state and resize-edge indicator. + * + * @param applyPendingResize Pass `true` on mouse or touch release to commit the last queued + * resize. Cancellation leaves it `false` so pending work cannot change the window afterward. + */ + private fun finish( + commands: MutableList, + applyPendingResize: Boolean = false + ) { + if (gesture is Gesture.Resize) commands += Command.EndResize(applyPendingResize) + gesture = null + commands += Command.EndMove + indicatedEdge = WindowResizeEdge.None + } + + /** + * Completes a decision. Indicator changes made while handling one input are intermediate: + * restarting or finishing a gesture must not report `edge -> None -> edge` to the window. + */ + private fun decide( + consumed: Boolean, + commands: MutableList + ): Decision { + if (indicatedEdge != reportedEdge) { + reportedEdge = indicatedEdge + commands += Command.ShowEdge(indicatedEdge) + } + return Decision(consumed, commands) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/WindowGestureEffects.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/WindowGestureEffects.kt new file mode 100644 index 00000000..2a2ce796 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/WindowGestureEffects.kt @@ -0,0 +1,99 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.input.gesture + +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Pointer +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Position +import com.moriafly.salt.ui.window.WindowResizeEdge + +/** + * Side effects injected into the platform dispatcher, never into the gesture state machine. + * Coordinates use logical screen units. Implementations own native resources and queued work. + */ +@OptIn(UnstableSaltUiApi::class) +internal interface WindowGestureEffects { + val mover: Mover + val resizer: Resizer + + interface Mover { + val isNativeMouseMove: Boolean + get() = false + + /** Drops ownership of a native mouse move without cancelling it in the WM. */ + fun releaseNativeMouseMove(): Boolean + + fun prepareTouchMove( + id: Long, + start: Position, + current: Position + ): Boolean + + fun commitTouchMove( + id: Long + ): Boolean + + fun install(): () -> Unit + + fun start( + pointer: Pointer, + start: Position, + current: Position + ) + + fun update( + position: Position + ) + + fun finish() + + fun toggleMaximize() + } + + interface Resizer { + fun setEnabled( + enabled: Boolean + ) + + fun start( + edge: WindowResizeEdge, + position: Position + ) + + fun update( + position: Position + ) + + fun finish( + applyPending: Boolean + ) + + fun showEdge( + edge: WindowResizeEdge + ) + } + + /** + * Activates the bound input target using native server time, independently of gesture capture. + */ + fun interface Focus { + fun onPress( + serverTime: Long + ) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarHitTest.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt similarity index 68% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarHitTest.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt index b0f8feb9..4b70bb58 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarHitTest.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt @@ -15,20 +15,15 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.window -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.tooling.ComposeToolingApi -import androidx.compose.ui.Modifier import androidx.compose.ui.awt.ComposeDialog import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.semantics.SemanticsProperties import java.awt.Window @@ -37,9 +32,16 @@ internal val LocalLinuxCaptionBarHitTest = staticCompositionLocalOf { null } /** - * Layout geometry stays available even when a Dialog stops hover events reaching the caption. + * Shared mouse/touch policy for caption geometry, window buttons and modal content. + * Layout geometry stays available even when a Dialog intercepts caption hover and press events. */ internal class LinuxCaptionBarHitTest { + enum class Result { + Caption, + Excluded, + RequiresComposeHitTest + } + class Region { var bounds = Rect.Zero } @@ -48,48 +50,39 @@ internal class LinuxCaptionBarHitTest { var buttonsBounds = Rect.Zero @OptIn(ComposeToolingApi::class) - fun contains( + fun hitTest( position: Offset, - window: Window, - isHovered: Boolean - ): Boolean { + window: Window + ): Result { if (regions.none { it.bounds.contains(position) } || buttonsBounds.contains(position)) { - return false + return Result.Excluded } val owners = when (window) { is ComposeWindow -> window.semanticsOwners - is ComposeDialog -> window.semanticsOwners - else -> return isHovered + is ComposeDialog -> window.semanticsOwners.drop(1) + else -> return Result.RequiresComposeHitTest } // Keep normal Compose hit testing for custom controls layered above CaptionBarHitTest. // For a modal scene, use its public dialog semantics rather than changing Dialog itself. val dialog = owners.toList().asReversed().firstNotNullOfOrNull { findDialog(it.rootSemanticsNode) } - if (dialog == null) return isHovered + if (dialog == null) return Result.RequiresComposeHitTest // Dialog's semantic root fills the window. Its direct layout children bound the content. - return dialog.children.none { child -> + val isDialogContent = dialog.children.any { child -> var content = child.layoutInfo while (content.parentInfo != null && content.parentInfo !== dialog.layoutInfo) { content = content.parentInfo!! } content.isAttached && content.coordinates.boundsInWindow().contains(position) } + return if (isDialogContent) Result.Excluded else Result.Caption } - private fun findDialog(node: SemanticsNode): SemanticsNode? { + private fun findDialog( + node: SemanticsNode + ): SemanticsNode? { if (node.config.contains(SemanticsProperties.IsDialog)) return node return node.children.firstNotNullOfOrNull(::findDialog) } } - -@Composable -internal fun Modifier.linuxCaptionBarHitTest(): Modifier { - val hitTest = LocalLinuxCaptionBarHitTest.current ?: return this - val region = remember(hitTest) { LinuxCaptionBarHitTest.Region() } - DisposableEffect(hitTest, region) { - hitTest.regions.add(region) - onDispose { hitTest.regions.remove(region) } - } - return onGloballyPositioned { region.bounds = it.boundsInWindow() } -} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxFallbackWindowMover.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxFallbackWindowMover.kt new file mode 100644 index 00000000..1398c77d --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxFallbackWindowMover.kt @@ -0,0 +1,100 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.window + +import com.moriafly.salt.ui.UnstableSaltUiApi +import java.awt.Cursor +import java.awt.Frame +import java.awt.Point +import java.awt.Window +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent + +/** + * Owns AWT window movement, including asynchronous restore from maximized state. + * All positions and the pointer anchor use logical AWT screen coordinates. + */ +@UnstableSaltUiApi +internal class LinuxFallbackWindowMover( + private val window: Window +) { + private var restoreWidth = window.width + private var drag: Drag? = null + + fun install(): () -> Unit { + val listener = object : ComponentAdapter() { + override fun componentResized( + event: ComponentEvent + ) = onWindowResized() + } + window.addComponentListener(listener) + return { + window.removeComponentListener(listener) + endDrag() + } + } + + fun startDrag( + start: Point, + current: Point + ) { + val frame = window as? Frame + val restoring = frame?.extendedState == Frame.MAXIMIZED_BOTH + val offsetX = start.x - window.x + val anchor = Point( + if (restoring) (restoreWidth * offsetX.toFloat() / window.width).toInt() else offsetX, + start.y - window.y + ) + drag = Drag(anchor, Point(current), restoring) + if (restoring) { + frame.extendedState = Frame.NORMAL + } else { + updateDrag(current) + } + } + + fun updateDrag( + position: Point + ) { + val active = drag ?: return + active.position = Point(position) + if (active.restoring) return + window.setLocation(position.x - active.anchor.x, position.y - active.anchor.y) + } + + fun endDrag() { + if (drag != null) window.cursor = Cursor.getDefaultCursor() + drag = null + } + + private fun onWindowResized() { + if ((window as? Frame)?.extendedState == Frame.MAXIMIZED_BOTH) return + restoreWidth = window.width + val active = drag ?: return + if (!active.restoring) return + active.restoring = false + // Apply the latest pointer only after restore preserves the WM's saved geometry. + updateDrag(active.position) + } + + private class Drag( + val anchor: Point, + var position: Point, + var restoring: Boolean + ) +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxResizeCursor.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt similarity index 83% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxResizeCursor.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt index 717674af..80984ebd 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxResizeCursor.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt @@ -15,12 +15,12 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.window -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.NativeLong +import com.moriafly.salt.ui.platform.linux.x11.LinuxX11 +import com.moriafly.salt.ui.platform.linux.x11.LinuxXcursor import com.sun.jna.Pointer +import com.sun.jna.platform.unix.X11 import java.awt.Cursor import java.awt.Toolkit import java.lang.reflect.Method @@ -46,7 +46,9 @@ internal object LinuxResizeCursor { } } - fun get(type: Int): Cursor { + fun get( + type: Int + ): Cursor { val name = when (type) { Cursor.W_RESIZE_CURSOR -> "w-resize" Cursor.E_RESIZE_CURSOR -> "e-resize" @@ -87,13 +89,15 @@ internal object LinuxResizeCursor { private val setPData = Cursor::class.java .getDeclaredMethod("setPData", Long::class.javaPrimitiveType) .accessible() - private val x11 = Native.load("X11", Xlib::class.java) - private val xcursor = Native.load("Xcursor", Xcursor::class.java) + private val x11 = LinuxX11.INSTANCE + private val xcursor = LinuxXcursor.INSTANCE private var theme: Theme? = null private val cursors = mutableMapOf() @Synchronized - fun get(name: String): Cursor? { + fun get( + name: String + ): Cursor? { val currentTheme = readTheme() ?: return null if (currentTheme != theme) { // AWT may still use old cursors; its disposer releases them when no longer referenced @@ -125,13 +129,18 @@ internal object LinuxResizeCursor { } } - private fun load(name: String, theme: Theme): Cursor? { + private fun load( + name: String, + theme: Theme + ): Cursor? { val images = xcursor.XcursorLibraryLoadImages(name, theme.name, theme.size) ?: return null try { awtLock.invoke(null) try { - val display = Pointer(getDisplay.invoke(null) as Long) + val display = X11.Display().apply { + pointer = Pointer(getDisplay.invoke(null) as Long) + } val handle = xcursor.XcursorImagesLoadCursor(display, images) if (handle.toLong() == 0L) return null try { @@ -155,28 +164,4 @@ internal object LinuxResizeCursor { private fun Method.accessible(): Method = apply { check(trySetAccessible()) { "AWT cursor access requires java.desktop module opens" } } - - private interface Xlib : Library { - fun XOpenDisplay(name: String?): Pointer? - - fun XCloseDisplay(display: Pointer): Int - - fun XFreeCursor(display: Pointer, cursor: NativeLong): Int - } - - private interface Xcursor : Library { - fun XcursorGetTheme(display: Pointer): Pointer? - - fun XcursorGetDefaultSize(display: Pointer): Int - - fun XcursorLibraryLoadImages( - name: String, - theme: String?, - size: Int - ): Pointer? - - fun XcursorImagesLoadCursor(display: Pointer, images: Pointer): NativeLong - - fun XcursorImagesDestroy(images: Pointer) - } } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowFocus.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt similarity index 66% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowFocus.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt index 5b4a682b..747ff0b0 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowFocus.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt @@ -15,8 +15,9 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.window +import com.moriafly.salt.ui.platform.linux.x11.LinuxX11 import com.sun.jna.Memory import com.sun.jna.Native import com.sun.jna.NativeLong @@ -33,11 +34,11 @@ import java.awt.event.MouseEvent import javax.swing.SwingUtilities /** - * 将 AWT 原生子窗口的输入时间同步到顶层窗口,供窗口管理器判断用户主动激活 + * Requests window-manager activation with the X11 input time shared by mouse and touch presses. */ internal class LinuxWindowFocus private constructor( private val window: Window, - private val x11: X11, + private val x11: LinuxX11, private val display: X11.Display ) : AutoCloseable { private val userTime = x11.XInternAtom( @@ -52,56 +53,69 @@ internal class LinuxWindowFocus private constructor( ) private val listener = AWTEventListener { event -> if (event.id == MouseEvent.MOUSE_PRESSED) { - try { - handleInput(event) - } catch (exception: Exception) { - log.log( - System.Logger.Level.WARNING, - "Failed to update the Linux window input time", - exception - ) - } + val component = event.source as? Component ?: return@AWTEventListener + onPointerPress(component) } } - private fun handleInput(event: AWTEvent) { - val component = event.source as? Component ?: return + /** + * Requests activation for a pointer press in this window, leaving the decision to the WM. + * + * @param timestamp The original X11 server time. When omitted, reads the time JBR recorded + * on the source's native child window. JVM and Compose event clocks cannot be used here. + */ + fun onPointerPress( + component: Component, + timestamp: Long? = null + ) { if (SwingUtilities.getWindowAncestor(component) !== window || !window.isShowing || !window.isEnabled || !window.isFocusableWindow ) { return } + try { + val inputTime = timestamp ?: run { + val nativeComponent = generateSequence(component) { it.parent } + .firstOrNull { !it.isLightweight } ?: return + if (!nativeComponent.isDisplayable) return + val source = X11.Window(Native.getComponentID(nativeComponent)) + readUserTime(source) ?: return + } + if (inputTime == 0L) return + val target = X11.Window(Native.getComponentID(window)) - val nativeComponent = generateSequence(component) { it.parent } - .firstOrNull { !it.isLightweight } ?: return - if (!nativeComponent.isDisplayable) return - val source = X11.Window(Native.getComponentID(nativeComponent)) - val target = X11.Window(Native.getComponentID(window)) - val timestamp = readUserTime(source) ?: return + // JBR records mouse input on its child window; XI2 touch supplies the server time + // directly. Publish either source on the top-level window read by the WM. + Memory(NativeLong.SIZE.toLong()).use { data -> + data.setNativeLong(0, NativeLong(inputTime)) + x11.XChangeProperty( + display, + target, + userTime, + X11.XA_CARDINAL, + 32, + X11.PropModeReplace, + data, + 1 + ) + } - // JBR 将 Skia Canvas 的点击时间写在子窗口上,KWin 只读取顶层窗口 - // 使用 X11 原始事件时间,不能用 MouseEvent.when 的 Unix 时间或当前时间代替 - Memory(NativeLong.SIZE.toLong()).use { data -> - data.setNativeLong(0, NativeLong(timestamp)) - x11.XChangeProperty( - display, - target, - userTime, - X11.XA_CARDINAL, - 32, - X11.PropModeReplace, - data, - 1 + // AWT's focus request may have been refused. Always ask the WM with this press's + // time, including during the asynchronous FocusIn/FocusOut handoff. + requestActivation(target, inputTime) + x11.XFlush(display) + } catch (exception: Exception) { + log.log( + System.Logger.Level.WARNING, + "Failed to update the Linux window input time", + exception ) } - - // AWT 的直接焦点请求可能已被拒绝,携带本次点击时间重新交由 WM 决定 - // 即使 AWT 暂时报告已聚焦也需要发送,避免 FocusIn/FocusOut 异步交接的竞态 - requestActivation(target, timestamp) - x11.XFlush(display) } - private fun readUserTime(source: X11.Window): Long? { + private fun readUserTime( + source: X11.Window + ): Long? { val type = X11.AtomByReference() val format = IntByReference() val count = NativeLongByReference() @@ -134,7 +148,10 @@ internal class LinuxWindowFocus private constructor( } } - private fun requestActivation(target: X11.Window, timestamp: Long) { + private fun requestActivation( + target: X11.Window, + timestamp: Long + ) { val event = X11.XEvent() event.setType(X11.XClientMessageEvent::class.java) event.xclient.apply { @@ -167,10 +184,12 @@ internal class LinuxWindowFocus private constructor( companion object { private val log = System.getLogger(LinuxWindowFocus::class.java.name) - fun install(window: Window): LinuxWindowFocus? { + fun install( + window: Window + ): LinuxWindowFocus? { if (Toolkit.getDefaultToolkit().javaClass.name != "sun.awt.X11.XToolkit") return null return try { - val x11 = X11.INSTANCE + val x11 = LinuxX11.INSTANCE val display = x11.XOpenDisplay(null) ?: return null try { LinuxWindowFocus( diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowGestureEffects.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowGestureEffects.kt new file mode 100644 index 00000000..694b0be9 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowGestureEffects.kt @@ -0,0 +1,185 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.window + +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Pointer +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Position +import com.moriafly.salt.ui.platform.linux.input.gesture.WindowGestureEffects +import com.moriafly.salt.ui.platform.linux.x11.LinuxX11WindowMover +import com.moriafly.salt.ui.window.WindowResizeEdge +import java.awt.Frame +import java.awt.Point +import java.awt.Window + +/** + * Executes gesture commands through native or fallback window operations. Each operation owns its + * geometry and queued work; neither can change contact ownership or interpret another pointer's input. + */ +@OptIn(UnstableSaltUiApi::class) +internal class LinuxWindowGestureEffects( + window: Window, + resizeRegions: UndecoratedWindowResizer +) : WindowGestureEffects { + override val mover: WindowGestureEffects.Mover = WindowMover(window) + override val resizer: WindowGestureEffects.Resizer = object : WindowGestureEffects.Resizer { + private var operation: LinuxWindowResizeOperation? = null + + override fun setEnabled( + enabled: Boolean + ) { + resizeRegions.enabled = enabled + } + + override fun start( + edge: WindowResizeEdge, + position: Position + ) { + operation?.finish() + operation = LinuxWindowResizeOperation( + window = window, + edge = edge, + start = Point(position.x, position.y) + ) + } + + override fun update( + position: Position + ) { + operation?.update(Point(position.x, position.y)) + } + + override fun finish( + applyPending: Boolean + ) { + val active = operation + operation = null + active?.finish(applyPending) + } + + override fun showEdge( + edge: WindowResizeEdge + ) = resizeRegions.showEdge(edge) + } + + private class WindowMover( + private val window: Window + ) : WindowGestureEffects.Mover { + private val fallback = LinuxFallbackWindowMover(window) + private var useNativeMouseDrag = true + private var nativeDrag: NativeDrag? = null + + override val isNativeMouseMove: Boolean + get() { + val active = nativeDrag ?: return false + return active.pointer == Pointer.Mouse && active.move.started + } + + override fun releaseNativeMouseMove(): Boolean { + if (!isNativeMouseMove) return false + nativeDrag?.move?.detach() + nativeDrag = null + return true + } + + override fun prepareTouchMove( + id: Long, + start: Position, + current: Position + ): Boolean { + finish() + nativeDrag = prepareNativeMove(Pointer.Touch(id), start, current) ?: return false + return true + } + + override fun commitTouchMove( + id: Long + ): Boolean { + val active = nativeDrag ?: return false + if (active.pointer != Pointer.Touch(id)) return false + if (active.move.start()) return true + finish() + return false + } + + override fun install(): () -> Unit { + val uninstallFallback = fallback.install() + return { + uninstallFallback() + finish() + } + } + + override fun start( + pointer: Pointer, + start: Position, + current: Position + ) { + finish() + if (pointer == Pointer.Mouse && useNativeMouseDrag) { + nativeDrag = prepareNativeMove(pointer, start, current) + if (nativeDrag?.move?.start() == true) return + finish() + useNativeMouseDrag = false + } + fallback.startDrag(Point(start.x, start.y), Point(current.x, current.y)) + } + + override fun update( + position: Position + ) = fallback.updateDrag(Point(position.x, position.y)) + + override fun finish() { + val active = nativeDrag + nativeDrag = null + try { + active?.move?.close() + } finally { + fallback.endDrag() + } + } + + override fun toggleMaximize() { + val frame = window as? Frame ?: return + frame.extendedState = if (frame.extendedState == Frame.MAXIMIZED_BOTH) { + Frame.NORMAL + } else { + Frame.MAXIMIZED_BOTH + } + } + + private fun prepareNativeMove( + pointer: Pointer, + start: Position, + current: Position + ): NativeDrag? { + val move = LinuxX11WindowMover.prepare( + window = window, + start = Point(start.x, start.y), + current = Point(current.x, current.y), + fromTouch = pointer is Pointer.Touch + ) ?: return null + return NativeDrag(pointer, move) + } + + private class NativeDrag( + val pointer: Pointer, + val move: LinuxX11WindowMover.Move + ) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowModality.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowModality.kt new file mode 100644 index 00000000..adf83d68 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowModality.kt @@ -0,0 +1,68 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.window + +import java.awt.Dialog +import java.awt.Dialog.ModalExclusionType +import java.awt.Dialog.ModalityType +import java.awt.Window +import java.lang.reflect.Method + +/** + * Checks native input eligibility on the EDT, using AWT's exact blocker when accessible. + * The public fallback covers the current AppContext and may over-block overlapping modal + * dialogs because AWT does not expose their blocker ordering through public methods. + */ +internal object LinuxWindowModality { + private val isModalBlockedMethod: Method? = runCatching { + val method = Window::class.java.getDeclaredMethod("isModalBlocked") + if (method.trySetAccessible()) method else null + }.getOrNull() + + fun isBlocked( + window: Window + ): Boolean { + val blocked = isModalBlockedMethod?.let { method -> + runCatching { method.invoke(window) as Boolean }.getOrNull() + } + if (blocked != null) return blocked + + val owners = generateSequence(window) { it.owner }.toList() + val exclusion = owners.maxOf { it.modalExclusionType } + val dialogs = Window.getWindows().filterIsInstance().filter { + it.isShowing && it.isModal && it !in owners + } + return dialogs.any { dialog -> + when (dialog.modalityType) { + ModalityType.MODELESS -> false + ModalityType.DOCUMENT_MODAL -> { + val dialogOwners = generateSequence(dialog) { it.owner } + if (exclusion >= ModalExclusionType.APPLICATION_EXCLUDE) { + // Document-modal children still block their excluded owners. + dialogOwners.any { it === window } + } else { + dialogOwners.last() === owners.last() + } + } + + ModalityType.APPLICATION_MODAL -> exclusion < ModalExclusionType.APPLICATION_EXCLUDE + ModalityType.TOOLKIT_MODAL -> exclusion < ModalExclusionType.TOOLKIT_EXCLUDE + } + } + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowResizeOperation.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowResizeOperation.kt new file mode 100644 index 00000000..9b57a4ec --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowResizeOperation.kt @@ -0,0 +1,106 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.window + +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.window.WindowResizeEdge +import java.awt.EventQueue +import java.awt.Point +import java.awt.Window + +/** + * Applies one mouse or touch resize from its initial window bounds, confined to the AWT event thread. + * + * Updates retain only the latest position until the queued AWT task runs. Finishing the operation + * invalidates that task without consulting the gesture owner or a subsequent gesture. + */ +@OptIn(UnstableSaltUiApi::class) +internal class LinuxWindowResizeOperation( + private val window: Window, + private val edge: WindowResizeEdge, + private val start: Point +) { + private val bounds = window.bounds + private var pendingPosition: Point? = null + private var finished = false + + fun update( + position: Point + ) { + if (finished) return + val alreadyQueued = pendingPosition != null + pendingPosition = position + if (alreadyQueued) return + EventQueue.invokeLater { + val latest = pendingPosition ?: return@invokeLater + pendingPosition = null + resize(latest) + } + } + + /** + * Ends this operation and prevents queued updates from affecting later gestures. + * + * @param applyPendingResize Pass `true` on mouse or touch release to apply the final pending + * position. Leave `false` on cancellation to discard it without changing the window bounds. + */ + fun finish( + applyPendingResize: Boolean = false + ) { + if (finished) return + finished = true + val latest = pendingPosition + pendingPosition = null + if (applyPendingResize && latest != null) resize(latest) + } + + private fun resize( + position: Point + ) { + val dx = position.x - start.x + val dy = position.y - start.y + var x = bounds.x + var y = bounds.y + var width = bounds.width + var height = bounds.height + when (edge) { + WindowResizeEdge.Left, WindowResizeEdge.TopLeft, WindowResizeEdge.BottomLeft -> { + width = (bounds.width - dx).coerceAtLeast(window.minimumSize.width) + x = bounds.x + bounds.width - width + } + + WindowResizeEdge.Right, WindowResizeEdge.TopRight, WindowResizeEdge.BottomRight -> + width = (bounds.width + dx).coerceAtLeast(window.minimumSize.width) + + else -> Unit + } + when (edge) { + WindowResizeEdge.Top, WindowResizeEdge.TopLeft, WindowResizeEdge.TopRight -> { + height = (bounds.height - dy).coerceAtLeast(window.minimumSize.height) + y = bounds.y + bounds.height - height + } + + WindowResizeEdge.Bottom, WindowResizeEdge.BottomLeft, WindowResizeEdge.BottomRight -> + height = (bounds.height + dy).coerceAtLeast(window.minimumSize.height) + + else -> Unit + } + window.setLocation(x, y) + window.setSize(width, height) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/UndecoratedWindowResizer.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/UndecoratedWindowResizer.kt new file mode 100644 index 00000000..b2a34e6b --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/UndecoratedWindowResizer.kt @@ -0,0 +1,173 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.window + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.window.WindowResizeEdge +import java.awt.Component +import java.awt.Cursor +import java.awt.event.MouseEvent + +@OptIn(UnstableSaltUiApi::class) +internal class UndecoratedWindowResizer( + private val onResizeEdgeChange: (WindowResizeEdge) -> Unit +) { + var enabled: Boolean by mutableStateOf(false) + var resizerThickness: Dp by mutableStateOf(4.dp) + + private val sideBounds = mutableMapOf() + private val sideCursors = mutableMapOf() + private var hoveredSide = WindowResizeEdge.None + private var resizeCursor: Cursor? = null + private var cursorComponent: Component? = null + private var previousCursor: Cursor? = null + + @Composable + fun Content( + modifier: Modifier = Modifier + ) { + if (enabled) { + ResizerLayout( + modifier = modifier + ) + } + } + + @Composable + private fun ResizerLayout( + modifier: Modifier = Modifier + ) { + Layout( + { + Side(Cursor.W_RESIZE_CURSOR, WindowResizeEdge.Left) + Side(Cursor.E_RESIZE_CURSOR, WindowResizeEdge.Right) + Side(Cursor.N_RESIZE_CURSOR, WindowResizeEdge.Top) + Side(Cursor.S_RESIZE_CURSOR, WindowResizeEdge.Bottom) + Side(Cursor.NW_RESIZE_CURSOR, WindowResizeEdge.TopLeft) + Side(Cursor.NE_RESIZE_CURSOR, WindowResizeEdge.TopRight) + Side(Cursor.SW_RESIZE_CURSOR, WindowResizeEdge.BottomLeft) + Side(Cursor.SE_RESIZE_CURSOR, WindowResizeEdge.BottomRight) + }, + modifier = modifier, + measurePolicy = { measurables, constraints -> + val b = resizerThickness.roundToPx() + + fun Measurable.measureSide( + width: Int, + height: Int + ) = measure( + Constraints.fixed(width.coerceAtLeast(0), height.coerceAtLeast(0)) + ) + + val left = measurables[0].measureSide(b, constraints.maxHeight - 2 * b) + val right = measurables[1].measureSide(b, constraints.maxHeight - 2 * b) + val top = measurables[2].measureSide(constraints.maxWidth - 2 * b, b) + val bottom = measurables[3].measureSide(constraints.maxWidth - 2 * b, b) + val leftTop = measurables[4].measureSide(b, b) + val rightTop = measurables[5].measureSide(b, b) + val leftBottom = measurables[6].measureSide(b, b) + val rightBottom = measurables[7].measureSide(b, b) + layout(constraints.maxWidth, constraints.maxHeight) { + left.place(0, b) + right.place(constraints.maxWidth - b, b) + top.place(b, 0) + bottom.place(b, constraints.maxHeight - b) + leftTop.place(0, 0) + rightTop.place(constraints.maxWidth - b, 0) + leftBottom.place(0, constraints.maxHeight - b) + rightBottom.place(constraints.maxWidth - b, constraints.maxHeight - b) + } + } + ) + } + + fun edgeAt( + position: Offset + ): WindowResizeEdge = if (enabled) { + sideBounds.entries.firstOrNull { it.value.contains(position) }?.key ?: WindowResizeEdge.None + } else { + WindowResizeEdge.None + } + + fun showEdge( + edge: WindowResizeEdge + ) { + if (edge == hoveredSide) return + hoveredSide = edge + onResizeEdgeChange(edge) + resizeCursor = sideCursors[edge]?.let(LinuxResizeCursor::get) + if (edge == WindowResizeEdge.None) restoreCursor() + } + + fun afterMouseEvent( + event: MouseEvent + ) { + val cursor = resizeCursor ?: return + val component = event.component + if (cursorComponent !== component) { + restoreCursor() + cursorComponent = component + previousCursor = if (component.isCursorSet) component.cursor else null + } + // Compose may update the cursor while dispatching hover events to a Dialog. + component.cursor = cursor + } + + private fun restoreCursor() { + cursorComponent?.cursor = previousCursor + cursorComponent = null + previousCursor = null + } + + @Composable + private fun Side( + cursorId: Int, + sides: WindowResizeEdge + ) { + DisposableEffect(sides) { + sideCursors[sides] = cursorId + onDispose { + sideBounds.remove(sides) + sideCursors.remove(sides) + } + } + Layout( + content = {}, + modifier = Modifier.onGloballyPositioned { + sideBounds[sides] = it.boundsInWindow() + }, + measurePolicy = { _, constraints -> + layout(constraints.maxWidth, constraints.maxHeight) {} + } + ) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxAwtInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxAwtInput.kt new file mode 100644 index 00000000..cd140886 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxAwtInput.kt @@ -0,0 +1,163 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.x11 + +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.sun.jna.Memory +import com.sun.jna.NativeLong +import com.sun.jna.Pointer +import com.sun.jna.platform.unix.X11 +import com.sun.jna.ptr.IntByReference +import java.awt.Toolkit + +/** + * Owns access to AWT's X connection and preserves its event selections across XI2 sessions. + * All native access is serialized with AWT's own lock. + */ +@OptIn(UnstableSaltUiApi::class) +internal object LinuxAwtInput { + private val bridge by lazy { runCatching { Bridge() }.getOrNull() } + + val isAvailable: Boolean + get() = bridge != null + + /** + * JBR selects touch events on native children. Its canvas listener must yield before + * SaltUI can reject a contact to the WM; the returned lease restores only those touch bits. + */ + fun suspendTouch( + canvas: Long + ): AutoCloseable? = bridge?.suspendTouch(canvas) + + fun releasePointer() = checkNotNull(bridge).release() + + private class Bridge { + private val toolkit = Toolkit.getDefaultToolkit().javaClass.also { + check(it.name == "sun.awt.X11.XToolkit") + } + private val getDisplay = toolkit.getMethod("getDisplay").apply { check(trySetAccessible()) } + private val lock = toolkit.getMethod("awtLock").apply { check(trySetAccessible()) } + private val unlock = toolkit.getMethod("awtUnlock").apply { check(trySetAccessible()) } + private val ungrab = Class.forName("sun.awt.X11.XBaseWindow") + .getMethod("ungrabInput") + .apply { check(trySetAccessible()) } + + private val lookupWindow = + toolkit.getDeclaredMethod("windowToXWindow", Long::class.javaPrimitiveType) + .apply { check(trySetAccessible()) } + + private fun display() = X11.Display().apply { + pointer = Pointer(getDisplay.invoke(null) as Long) + } + + private fun locked( + block: (X11.Display) -> T + ): T { + lock.invoke(null) + try { + return block(display()) + } finally { + unlock.invoke(null) + } + } + + fun suspendTouch( + canvas: Long + ): AutoCloseable? = locked { display -> + if (lookupWindow.invoke(null, canvas) == null) return@locked null + val xid = X11.Window(canvas) + val saved = selectedEvents(display, xid).filter { mask -> + mask.length > 2 && mask.mask.getByte(2).toInt() and TOUCH_BITS != 0 + } + try { + for (mask in saved) { + val updated = copyMask(mask) + val bits = updated.mask.getByte(2).toInt() and TOUCH_BITS.inv() + updated.mask.setByte(2, bits.toByte()) + LinuxXInput2.instance.XISelectEvents(display, xid, updated, 1) + } + LinuxX11.INSTANCE.XSync(display, false) + } catch (failure: Throwable) { + restoreTouch(display, canvas, saved) + throw failure + } + AutoCloseable { locked { restoreTouch(it, canvas, saved) } } + } + + private fun selectedEvents( + display: X11.Display, + window: X11.Window + ): List { + val count = IntByReference() + val data = LinuxXInput2.instance.XIGetSelectedEvents(display, window, count) + ?: return emptyList() + try { + val size = LinuxXInput2.EventMask(0).size() + return (0 until count.value).map { index -> + copyMask(LinuxXInput2.EventMask(data.share(index.toLong() * size))) + } + } finally { + LinuxX11.INSTANCE.XFree(data) + } + } + + private fun copyMask( + original: LinuxXInput2.EventMask + ) = LinuxXInput2.EventMask(original.device).apply { + length = maxOf(4, original.length) + mask = Memory(length.toLong()).apply { + clear() + if (original.length > 0) { + write(0, original.mask.getByteArray(0, original.length), 0, original.length) + } + } + } + + private fun restoreTouch( + display: X11.Display, + window: Long, + saved: List + ) { + // AWT may already have destroyed this peer while the reader was shutting down. + if (lookupWindow.invoke(null, window) == null) return + val xid = X11.Window(window) + val current = selectedEvents(display, xid).associateBy { it.device } + for (mask in saved) { + val restored = current[mask.device] ?: LinuxXInput2.EventMask(mask.device) + val bits = restored.mask.getByte(2).toInt() or ( + mask.mask.getByte(2) + .toInt() and TOUCH_BITS + ) + restored.mask.setByte(2, bits.toByte()) + LinuxXInput2.instance.XISelectEvents(display, xid, restored, 1) + } + LinuxX11.INSTANCE.XSync(display, false) + } + + private companion object { + const val TOUCH_BITS = (1 shl 18 - 16) or (1 shl 19 - 16) or (1 shl 20 - 16) + } + + fun release() = locked { display -> + ungrab.invoke(null) + // Release the implicit server grab even if AWT already cleared its local record. + LinuxX11.INSTANCE.XUngrabPointer(display, NativeLong(0)) + LinuxX11.INSTANCE.XSync(display, false) + } + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxX11.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxX11.kt new file mode 100644 index 00000000..da049adc --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxX11.kt @@ -0,0 +1,102 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") + +package com.moriafly.salt.ui.platform.linux.x11 + +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.platform.unix.X11 + +/** + * Shared Xlib binding, extending JNA with the functions used by Linux window and input support. + */ +@Suppress("ktlint:standard:function-naming", "FunctionName") +internal interface LinuxX11 : X11 { + fun XUngrabPointer( + display: X11.Display, + time: NativeLong + ): Int + + fun XFreeCursor( + display: X11.Display, + cursor: NativeLong + ): Int + + fun XConnectionNumber( + display: X11.Display + ): Int + + fun XGetEventData( + display: X11.Display, + cookie: EventCookie + ): Int + + fun XFreeEventData( + display: X11.Display, + cookie: EventCookie + ) + + @Structure.FieldOrder( + "type", + "serial", + "sendEvent", + "display", + "extension", + "eventType", + "cookie", + "data" + ) + class EventCookie( + pointer: Pointer + ) : Structure(pointer) { + @JvmField + var type: Int = 0 + + @JvmField + var serial: NativeLong = NativeLong() + + @JvmField + var sendEvent: Int = 0 + + @JvmField + var display: Pointer? = null + + @JvmField + var extension: Int = 0 + + @JvmField + var eventType: Int = 0 + + @JvmField + var cookie: Int = 0 + + @JvmField + var data: Pointer? = null + + init { + read() + } + } + + companion object { + val INSTANCE: LinuxX11 = Native.load("X11", LinuxX11::class.java) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxX11WindowMover.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxX11WindowMover.kt new file mode 100644 index 00000000..4f7924b8 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxX11WindowMover.kt @@ -0,0 +1,346 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.x11 + +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.Pointer +import com.sun.jna.platform.unix.X11 +import com.sun.jna.ptr.IntByReference +import com.sun.jna.ptr.NativeLongByReference +import com.sun.jna.ptr.PointerByReference +import java.awt.Frame +import java.awt.Point +import java.awt.Window +import java.awt.event.MouseEvent + +/** + * Native X11 window mover using `_NET_WM_MOVERESIZE` protocol. + * + * Delegates window dragging to the window manager, providing smooth movement + * and native features like window snapping. + */ +@UnstableSaltUiApi +internal object LinuxX11WindowMover { + private const val NET_WM_MOVERESIZE_MOVE = 8L + + /** + * Qt's XCB touch handoff is supported by KWin and Openbox. Check the actual WM and + * protocol before leaving a touch undecided; session environment variables are not enough. + */ + fun supportsTouchMove( + display: X11.Display, + window: X11.Window + ): Boolean { + if (!LinuxAwtInput.isAvailable) return false + val x11 = LinuxX11.INSTANCE + val root = queryRootWindow(x11, display, window) ?: return false + val wm = readProperty( + display, + root, + "_NET_SUPPORTING_WM_CHECK", + X11.XA_WINDOW, + 32 + ) { data, count -> + if (count == 1) data.getNativeLong(0).toLong() else null + } ?: return false + + val utf8 = x11.XInternAtom(display, "UTF8_STRING", false) + val name = readProperty( + display, + X11.Window(wm), + "_NET_WM_NAME", + utf8, + 8 + ) { data, count -> + String(data.getByteArray(0, count), Charsets.UTF_8).lowercase() + } + + if (name != "kwin" && name != "openbox") return false + val moveresize = x11.XInternAtom(display, "_NET_WM_MOVERESIZE", false).toLong() + return readProperty( + display, + root, + "_NET_SUPPORTED", + X11.XA_ATOM, + 32 + ) { data, count -> + (0 until count).any { + data.getNativeLong(it.toLong() * NativeLong.SIZE).toLong() == moveresize + } + } == true + } + + private fun readProperty( + display: X11.Display, + window: X11.Window, + name: String, + expectedType: X11.Atom, + expectedFormat: Int, + read: (Pointer, Int) -> T + ): T? { + val x11 = LinuxX11.INSTANCE + val type = X11.AtomByReference() + val format = IntByReference() + val count = NativeLongByReference() + val remaining = NativeLongByReference() + val data = PointerByReference() + val result = x11.XGetWindowProperty( + display, + window, + x11.XInternAtom(display, name, false), + NativeLong(0), + NativeLong(4096), + false, + expectedType, + type, + format, + count, + remaining, + data + ) + + try { + if (result != X11.Success || type.value != expectedType || + format.value != expectedFormat || remaining.value.toLong() != 0L + ) { + return null + } + return data.value?.let { read(it, count.value.toInt()) } + } finally { + data.value?.let { x11.XFree(it) } + } + } + + /** + * Acquires the connection and validates the request before touch ownership is handed off. + * The caller owns the returned move and must close it on release or cancellation. + */ + fun prepare( + window: Window, + start: Point, + current: Point, + fromTouch: Boolean = false + ): Move? { + if (!window.isDisplayable || !LinuxAwtInput.isAvailable) return null + val x11 = try { + LinuxX11.INSTANCE + } catch (_: UnsatisfiedLinkError) { + return null + } + + val display = x11.XOpenDisplay(null) ?: return null + var prepared: Move? = null + try { + val handle = Native.getComponentID(window) + if (handle == 0L) return null + val target = X11.Window(handle) + val root = queryRootWindow(x11, display, target) ?: return null + // The WM restores from the original caption press. For an ordinary touch move, + // rebase at handoff so motion consumed by touch slop does not jump the window. + val maximized = (window as? Frame)?.extendedState == Frame.MAXIMIZED_BOTH + val anchor = if (fromTouch && !maximized) current else start + val position = rootPosition( + display = display, + target = target, + window = window, + screenPosition = anchor, + fromTouch = fromTouch + ) ?: return null + val atom = x11.XInternAtom(display, "_NET_WM_MOVERESIZE", false) + prepared = Move( + window = window, + display = display, + target = target, + root = root, + atom = atom, + position = position, + fromTouch = fromTouch + ) + return prepared + } catch (_: Exception) { + return null + } finally { + if (prepared == null) x11.XCloseDisplay(display) + } + } + + private fun rootPosition( + display: X11.Display, + target: X11.Window, + window: Window, + screenPosition: Point, + fromTouch: Boolean + ): Point? { + val rootX = IntByReference() + val rootY = IntByReference() + val localX = IntByReference() + val localY = IntByReference() + val buttons = IntByReference() + val found = LinuxX11.INSTANCE.XQueryPointer( + display, + target, + X11.WindowByReference(), + X11.WindowByReference(), + rootX, + rootY, + localX, + localY, + buttons + ) + + if (!found || (!fromTouch && buttons.value and X11.Button1Mask == 0)) return null + + // Anchor at the event in root pixels, independently of queued pointer motion and HiDPI. + val scale = window.graphicsConfiguration.defaultTransform + return Point( + rootX.value - localX.value + ((screenPosition.x - window.x) * scale.scaleX).toInt(), + rootY.value - localY.value + ((screenPosition.y - window.y) * scale.scaleY).toInt() + ) + } + + /** + * A prepared WM request. Start and cancellation share one X connection to preserve ordering. + * A successful start means the request was sent, not an acknowledgement from the WM. + */ + class Move( + private val window: Window, + private val display: X11.Display, + private val target: X11.Window, + private val root: X11.Window, + private val atom: X11.Atom, + private val position: Point, + private val fromTouch: Boolean + ) : AutoCloseable { + var started = false + private set + private var closed = false + + fun start(): Boolean { + if (closed || started || !window.isDisplayable) return false + return try { + if (Native.getComponentID(window) != target.toLong()) return false + LinuxAwtInput.releasePointer() + started = send(NET_WM_MOVERESIZE_MOVE) + started + } catch (_: Exception) { + false + } + } + + private fun send( + direction: Long + ): Boolean { + val event = buildMoveResizeEvent( + display = display, + xWindow = target, + atom = atom, + mouseX = position.x, + mouseY = position.y, + // RejectTouch can clear Button1. RawTouchEnd ends this buttonless WM move. + button = if (fromTouch) 0 else MouseEvent.BUTTON1, + direction = direction + ) + val sent = LinuxX11.INSTANCE.XSendEvent( + display, + root, + 0, + NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()), + event + ) + LinuxX11.INSTANCE.XFlush(display) + return sent != 0 + } + + /** + * Releases this connection but lets the WM finish the drag. Cancelling an active move + * makes the WM restore the window's start position. + */ + fun detach() { + started = false + close() + } + + override fun close() { + if (closed) return + closed = true + try { + if (started) send(11L) + } finally { + started = false + LinuxX11.INSTANCE.XCloseDisplay(display) + } + } + } + + private fun queryRootWindow( + x11: LinuxX11, + display: X11.Display, + xWindow: X11.Window + ): X11.Window? { + val rootRet = X11.WindowByReference() + val parentRet = X11.WindowByReference() + val childrenRet = PointerByReference() + val numChildren = IntByReference() + + x11.XQueryTree(display, xWindow, rootRet, parentRet, childrenRet, numChildren) + + if (childrenRet.value != null) { + x11.XFree(childrenRet.value) + } + + return rootRet.value + } + + private fun buildMoveResizeEvent( + display: X11.Display, + xWindow: X11.Window, + atom: X11.Atom, + mouseX: Int, + mouseY: Int, + button: Int, + direction: Long = NET_WM_MOVERESIZE_MOVE + ): X11.XEvent { + val event = X11.XEvent() + event.type = X11.ClientMessage + event.setType(X11.XClientMessageEvent::class.java) + + val clientMsg = event.xclient + clientMsg.type = X11.ClientMessage + clientMsg.serial = NativeLong(0) + clientMsg.send_event = 1 + clientMsg.display = display + clientMsg.window = xWindow + clientMsg.message_type = atom + clientMsg.format = 32 + + clientMsg.data.setType("l") + // x_root + clientMsg.data.l[0] = NativeLong(mouseX.toLong()) + // y_root + clientMsg.data.l[1] = NativeLong(mouseY.toLong()) + // Direction + clientMsg.data.l[2] = NativeLong(direction) + clientMsg.data.l[3] = NativeLong(button.toLong()) + // Source indication (Normal application) + clientMsg.data.l[4] = NativeLong(1) + + return event + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt new file mode 100644 index 00000000..c92eea0e --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt @@ -0,0 +1,553 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") + +package com.moriafly.salt.ui.platform.linux.x11 + +import com.sun.jna.Library +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.platform.unix.X11 +import com.sun.jna.ptr.IntByReference + +/** + * XI 2.2 touch bindings, matching XInput2.h. Only direct touch is accepted; touchpads + * and pointer devices keep their existing AWT input path. + */ +@Suppress("FunctionName") +internal interface LinuxXInput2 : Library { + fun XIQueryVersion( + display: X11.Display, + major: IntByReference, + minor: IntByReference + ): Int + + fun XIQueryDevice( + display: X11.Display, + device: Int, + count: IntByReference + ): Pointer? + + fun XIFreeDeviceInfo( + info: Pointer + ) + + fun XISelectEvents( + display: X11.Display, + window: X11.Window, + masks: EventMask, + count: Int + ): Int + + fun XIGetSelectedEvents( + display: X11.Display, + window: X11.Window, + count: IntByReference + ): Pointer? + + fun XIGrabTouchBegin( + display: X11.Display, + device: Int, + window: X11.Window, + ownerEvents: Int, + mask: EventMask, + count: Int, + modifiers: GrabModifiers + ): Int + + fun XIAllowTouchEvents( + display: X11.Display, + device: Int, + touch: Int, + window: X11.Window, + mode: Int + ): Int + + @Structure.FieldOrder("device", "length", "mask") + class EventMask : Structure { + constructor( + device: Int, + vararg events: Int + ) : super() { + this.device = device + events.forEach { event -> + val offset = (event / 8).toLong() + mask.setByte(offset, (mask.getByte(offset).toInt() or (1 shl event % 8)).toByte()) + } + } + + constructor( + pointer: Pointer + ) : super(pointer) { + read() + } + + @JvmField + var device: Int = 0 + + @JvmField + var length: Int = 4 + + @JvmField + var mask: Pointer = Memory(length.toLong()).apply { + clear() + } + } + + @Structure.FieldOrder("modifiers", "status") + class GrabModifiers : Structure() { + @JvmField + var modifiers: Int = Int.MIN_VALUE + + // XIAnyModifier + @JvmField + var status: Int = 0 + } + + @Structure.FieldOrder("device", "name", "use", "attachment", "enabled", "count", "classes") + class DeviceInfo( + pointer: Pointer + ) : Structure(pointer) { + @JvmField + var device: Int = 0 + + @JvmField + var name: Pointer? = null + + @JvmField + var use: Int = 0 + + @JvmField + var attachment: Int = 0 + + @JvmField + var enabled: Int = 0 + + @JvmField + var count: Int = 0 + + @JvmField + var classes: Pointer? = null + + init { + read() + } + + val isDirectTouch: Boolean + get() = enabled != 0 && (0 until count).any { index -> + val info = classes?.getPointer(index.toLong() * Native.POINTER_SIZE) + // XIAnyClassInfo.type; XITouchClassInfo.mode + info != null && info.getInt(0) == 8 && info.getInt(8) == 1 + } + } + + @Structure.FieldOrder("length", "mask") + class ButtonState : Structure() { + @JvmField + var length: Int = 0 + + @JvmField + var mask: Pointer? = null + } + + @Structure.FieldOrder("length", "mask", "values") + class ValuatorState : Structure() { + @JvmField + var length: Int = 0 + + @JvmField + var mask: Pointer? = null + + @JvmField + var values: Pointer? = null + } + + @Structure.FieldOrder("base", "latched", "locked", "effective") + class ModifierState : Structure() { + @JvmField + var base: Int = 0 + + @JvmField + var latched: Int = 0 + + @JvmField + var locked: Int = 0 + + @JvmField + var effective: Int = 0 + } + + @Structure.FieldOrder( + "type", + "serial", + "sendEvent", + "display", + "extension", + "eventType", + "time", + "device", + "source", + "detail", + "flags", + "valuators", + "rawValues" + ) + class RawEvent( + pointer: Pointer + ) : Structure(pointer) { + @JvmField + var type: Int = 0 + + @JvmField + var serial: NativeLong = NativeLong() + + @JvmField + var sendEvent: Int = 0 + + @JvmField + var display: Pointer? = null + + @JvmField + var extension: Int = 0 + + @JvmField + var eventType: Int = 0 + + @JvmField + var time: NativeLong = NativeLong() + + @JvmField + var device: Int = 0 + + @JvmField + var source: Int = 0 + + @JvmField + var detail: Int = 0 + + @JvmField + var flags: Int = 0 + + @JvmField + var valuators: ValuatorState = ValuatorState() + + @JvmField + var rawValues: Pointer? = null + + init { + read() + } + } + + @Structure.FieldOrder( + "type", + "serial", + "sendEvent", + "display", + "extension", + "eventType", + "time", + "device", + "source", + "reason", + "classCount", + "classes" + ) + class DeviceChangedEvent( + pointer: Pointer + ) : Structure(pointer) { + @JvmField + var type: Int = 0 + + @JvmField + var serial: NativeLong = NativeLong() + + @JvmField + var sendEvent: Int = 0 + + @JvmField + var display: Pointer? = null + + @JvmField + var extension: Int = 0 + + @JvmField + var eventType: Int = 0 + + @JvmField + var time: NativeLong = NativeLong() + + @JvmField + var device: Int = 0 + + @JvmField + var source: Int = 0 + + @JvmField + var reason: Int = 0 + + @JvmField + var classCount: Int = 0 + + @JvmField + var classes: Pointer? = null + + init { + read() + } + } + + @Structure.FieldOrder( + "type", + "serial", + "sendEvent", + "display", + "extension", + "eventType", + "time", + "device", + "source", + "detail", + "root", + "event", + "child", + "rootX", + "rootY", + "eventX", + "eventY", + "flags", + "buttons", + "valuators", + "modifiers", + "group" + ) + class DeviceEvent( + pointer: Pointer + ) : Structure(pointer) { + @JvmField + var type: Int = 0 + + @JvmField + var serial: NativeLong = NativeLong() + + @JvmField + var sendEvent: Int = 0 + + @JvmField + var display: Pointer? = null + + @JvmField + var extension: Int = 0 + + @JvmField + var eventType: Int = 0 + + @JvmField + var time: NativeLong = NativeLong() + + @JvmField + var device: Int = 0 + + @JvmField + var source: Int = 0 + + @JvmField + var detail: Int = 0 + + @JvmField + var root: NativeLong = NativeLong() + + @JvmField + var event: NativeLong = NativeLong() + + @JvmField + var child: NativeLong = NativeLong() + + @JvmField + var rootX: Double = 0.0 + + @JvmField + var rootY: Double = 0.0 + + @JvmField + var eventX: Double = 0.0 + + @JvmField + var eventY: Double = 0.0 + + @JvmField + var flags: Int = 0 + + @JvmField + var buttons: ButtonState = ButtonState() + + @JvmField + var valuators: ValuatorState = ValuatorState() + + @JvmField + var modifiers: ModifierState = ModifierState() + + @JvmField + var group: ModifierState = ModifierState() + + init { + read() + } + } + + @Structure.FieldOrder( + "type", + "serial", + "sendEvent", + "display", + "extension", + "eventType", + "time", + "device", + "source", + "detail", + "root", + "event", + "child", + "rootX", + "rootY", + "eventX", + "eventY", + "mode", + "focus", + "sameScreen", + "buttons", + "modifiers", + "group" + ) + class CrossingEvent( + pointer: Pointer + ) : Structure(pointer) { + @JvmField + var type: Int = 0 + + @JvmField + var serial: NativeLong = NativeLong() + + @JvmField + var sendEvent: Int = 0 + + @JvmField + var display: Pointer? = null + + @JvmField + var extension: Int = 0 + + @JvmField + var eventType: Int = 0 + + @JvmField + var time: NativeLong = NativeLong() + + @JvmField + var device: Int = 0 + + @JvmField + var source: Int = 0 + + @JvmField + var detail: Int = 0 + + @JvmField + var root: NativeLong = NativeLong() + + @JvmField + var event: NativeLong = NativeLong() + + @JvmField + var child: NativeLong = NativeLong() + + @JvmField + var rootX: Double = 0.0 + + @JvmField + var rootY: Double = 0.0 + + @JvmField + var eventX: Double = 0.0 + + @JvmField + var eventY: Double = 0.0 + + @JvmField + var mode: Int = 0 + + @JvmField + var focus: Int = 0 + + @JvmField + var sameScreen: Int = 0 + + @JvmField + var buttons: ButtonState = ButtonState() + + @JvmField + var modifiers: ModifierState = ModifierState() + + @JvmField + var group: ModifierState = ModifierState() + + init { + read() + } + } + + companion object { + val instance: LinuxXInput2 = Native.load("Xi", LinuxXInput2::class.java) + const val ALL_DEVICES = 0 + const val ALL_MASTER_DEVICES = 1 + const val DEVICE_CHANGED = 1 + const val MOTION = 6 + const val ENTER = 7 + const val LEAVE = 8 + const val HIERARCHY_CHANGED = 11 + + // XIDeviceChangedEvent.reason + const val SLAVE_SWITCH = 1 + const val RAW_BUTTON_RELEASE = 16 + const val TOUCH_BEGIN = 18 + const val TOUCH_UPDATE = 19 + const val TOUCH_END = 20 + const val RAW_TOUCH_END = 24 + const val ACCEPT_TOUCH = 6 + const val REJECT_TOUCH = 7 + const val POINTER_EMULATED = 1 shl 16 + const val TOUCH_PENDING_END = 1 shl 16 + const val TOUCH_EMULATING_POINTER = 1 shl 17 + } +} + +internal interface LinuxTouchPoll : Library { + fun poll( + fds: Pointer, + count: NativeLong, + timeout: Int + ): Int + + companion object { + val instance: LinuxTouchPoll = Native.load("c", LinuxTouchPoll::class.java) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInputSession.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInputSession.kt new file mode 100644 index 00000000..5ad1de70 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInputSession.kt @@ -0,0 +1,648 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.x11 + +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.window.internal.SaltWindowExceptionHandler +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.platform.unix.X11 +import com.sun.jna.ptr.IntByReference +import javax.swing.SwingUtilities +import kotlin.concurrent.thread + +/** + * One XI2 connection. The reader exclusively owns its display, subscriptions, grabs and cookies. + * Listeners receive copied samples on the EDT; no native structure escapes the reader. + * requestStop invalidates queued deliveries immediately. onClosed runs only after XCloseDisplay, + * so the owner can keep a stopping session alive without blocking the EDT or overlapping grabs. + */ +@OptIn(UnstableSaltUiApi::class) +internal class LinuxXInputSession( + private val handle: Long, + private val windowHandle: Long, + private val requestTouch: Boolean, + private val listener: Listener +) { + interface Listener { + fun onReady( + capabilities: Capabilities + ) + + fun onTouch( + event: Touch, + canMoveNatively: Boolean + ): TouchAction + + fun onTouchMove( + id: Long + ) + + fun onMouseMoveEnd() + + fun onHover( + event: Hover + ) + + fun onDevicesChanged() + + fun onClosed() + } + + data class Capabilities( + val touch: Boolean, + val hover: Boolean + ) + + enum class TouchPhase { Down, Move, Up } + + enum class TouchAction { Accept, Reject, Pending, Move } + + // Only regular XI2 caption contacts remain undecided. Passive-grab contacts are + // accepted immediately, so closing the session cannot replay them as AWT clicks. + private val pendingTouches = mutableMapOf() + private var handedOffTouch: Touch? = null + + enum class HoverPhase { Move, Enter, Exit } + + enum class State { Starting, Active, Stopping, Closed } + + data class Touch( + val id: Long, + val phase: TouchPhase, + val x: Double, + val y: Double, + val rootX: Double, + val rootY: Double, + val modifiers: Int, + val timeMillis: Long, + val x11Timestamp: Long, + val directTouch: Boolean + ) + + data class Hover( + val phase: HoverPhase, + val x: Double, + val y: Double, + val rootX: Double, + val rootY: Double, + val modifiers: Int, + val directTouch: Boolean, + val emulated: Boolean, + val crossingMode: Int = 0 + ) + + // Lifecycle state is EDT-confined; stop and touch cancellation signals cross into the reader. + var state = State.Starting + private set + + @Volatile + private var stopRequested = false + + @Volatile + private var touchEpoch = 0L + + // The reader updates only a still-queued Move from the same input stream. The EDT takes + // its latest sample before calling listeners, without holding the producer lock in Compose. + private class HoverMove( + val source: Int, + var sample: Hover + ) + + private val hoverLock = Any() + private var pendingHoverMove: HoverMove? = null + + fun start() { + check(SwingUtilities.isEventDispatchThread() && state == State.Starting) + thread(name = "SaltUI-XI2-$handle", isDaemon = true) { + try { + if (!stopRequested) readConnection() + } catch (exception: Exception) { + logUnavailable(exception) + } catch (error: LinkageError) { + logUnavailable(error) + } finally { + stopRequested = true + SwingUtilities.invokeLater { + state = State.Closed + listener.onClosed() + } + } + } + } + + fun requestStop() { + check(SwingUtilities.isEventDispatchThread()) + if (state == State.Closed) return + state = State.Stopping + stopRequested = true + sealHoverMove() + } + + fun invalidateTouches() { + check(SwingUtilities.isEventDispatchThread()) + touchEpoch++ + } + + private fun readConnection() { + val x11 = LinuxX11.INSTANCE + val xi = LinuxXInput2.instance + val display = x11.XOpenDisplay(null) ?: return + var awtTouch: AutoCloseable? = null + try { + val opcode = IntByReference() + if (!x11.XQueryExtension( + display, + "XInputExtension", + opcode, + IntByReference(), + IntByReference() + ) + ) { + return + } + val major = IntByReference(2) + val minor = IntByReference(2) + if (xi.XIQueryVersion(display, major, minor) != 0 || major.value < 2) return + val xid = X11.Window(handle) + val supportsTouchVersion = major.value > 2 || minor.value >= 2 + if (!selectRootEvents(display, supportsTouchVersion)) return + val supportsTouch = requestTouch && supportsTouchVersion + if (supportsTouch && LinuxX11WindowMover.supportsTouchMove( + display, + X11.Window(windowHandle) + ) + ) { + awtTouch = LinuxAwtInput.suspendTouch(handle) + } + // Qt rejects a regular touch selection. A passive grab would replay into an + // AWT core grab instead, preventing the WM from receiving subsequent motion. + val regularTouch = awtTouch != null + val hover = selectWindowEvents(display, xid, regularTouch) + val touch = if (regularTouch) hover else supportsTouch && grabTouches(display, xid) + if (!touch && !hover) return + x11.XFlush(display) + val directDevices = queryDirectDevices(display) + dispatchAndWait { + if (!stopRequested) { + state = State.Active + listener.onReady(Capabilities(touch, hover)) + } + } + readEvents( + display = display, + xid = xid, + opcode = opcode.value, + capabilities = Capabilities(touch, hover), + initialDirectDevices = directDevices, + canMoveTouch = regularTouch + ) + } finally { + // This removes every registration and grab, including partial initialization. + x11.XCloseDisplay(display) + awtTouch?.close() + } + } + + private fun grabTouches( + display: X11.Display, + xid: X11.Window + ): Boolean = try { + val modifiers = LinuxXInput2.GrabModifiers() + val result = LinuxXInput2.instance.XIGrabTouchBegin( + display, + LinuxXInput2.ALL_MASTER_DEVICES, + xid, + 0, + LinuxXInput2.EventMask( + LinuxXInput2.ALL_MASTER_DEVICES, + LinuxXInput2.TOUCH_BEGIN, + LinuxXInput2.TOUCH_UPDATE, + LinuxXInput2.TOUCH_END + ), + 1, + modifiers + ) + result == 0 && modifiers.status == 0 + } catch (error: LinkageError) { + // A client libXi without touch-grab support can still provide XI2 hover. + System.getLogger(LinuxXInputSession::class.java.name).log( + System.Logger.Level.WARNING, + "XI2 touch grab unavailable", + error + ) + false + } + + private fun selectRootEvents( + display: X11.Display, + touch: Boolean + ): Boolean { + val events = mutableListOf( + LinuxXInput2.DEVICE_CHANGED, + LinuxXInput2.HIERARCHY_CHANGED, + LinuxXInput2.RAW_BUTTON_RELEASE + ) + if (touch) events += LinuxXInput2.RAW_TOUCH_END + return LinuxXInput2.instance.XISelectEvents( + display = display, + window = LinuxX11.INSTANCE.XDefaultRootWindow(display), + masks = LinuxXInput2.EventMask(LinuxXInput2.ALL_DEVICES, *events.toIntArray()), + count = 1 + ) == 0 + } + + private fun selectWindowEvents( + display: X11.Display, + xid: X11.Window, + touch: Boolean + ): Boolean { + val events = mutableListOf( + LinuxXInput2.MOTION, + LinuxXInput2.ENTER, + LinuxXInput2.LEAVE + ) + if (touch) { + events += listOf( + LinuxXInput2.TOUCH_BEGIN, + LinuxXInput2.TOUCH_UPDATE, + LinuxXInput2.TOUCH_END + ) + } + return LinuxXInput2.instance.XISelectEvents( + display, + xid, + LinuxXInput2.EventMask(LinuxXInput2.ALL_MASTER_DEVICES, *events.toIntArray()), + 1 + ) == 0 + } + + private fun queryDirectDevices( + display: X11.Display + ): Set { + val xi = LinuxXInput2.instance + val count = IntByReference() + val data = xi.XIQueryDevice(display, LinuxXInput2.ALL_DEVICES, count) + ?: error("Cannot query XI2 input devices") + try { + val size = LinuxXInput2.DeviceInfo(data).size() + return (0 until count.value).mapNotNull { index -> + val info = LinuxXInput2.DeviceInfo(data.share(index.toLong() * size)) + info.device.takeIf { info.isDirectTouch } + }.toSet() + } finally { + xi.XIFreeDeviceInfo(data) + } + } + + private fun readEvents( + display: X11.Display, + xid: X11.Window, + opcode: Int, + capabilities: Capabilities, + initialDirectDevices: Set, + canMoveTouch: Boolean + ) { + val x11 = LinuxX11.INSTANCE + val event = X11.XEvent() + var directDevices = initialDirectDevices + Memory(8).use { fd -> + fd.clear() + fd.setInt(0, x11.XConnectionNumber(display)) + fd.setShort(4, 1) // POLLIN + while (!stopRequested) { + pendingTouches.entries.removeAll { it.value != touchEpoch } + if (x11.XPending(display) == 0) { + pollEvents(fd) + continue + } + x11.XNextEvent(display, event) + if (event.type != 35) continue // GenericEvent + val cookie = LinuxX11.EventCookie(event.pointer) + if (cookie.extension != opcode || x11.XGetEventData(display, cookie) == 0) continue + try { + when (cookie.eventType) { + LinuxXInput2.DEVICE_CHANGED -> { + val changed = LinuxXInput2.DeviceChangedEvent( + requireNotNull(cookie.data) + ) + // A slave switch only changes which slave drives a master, e.g. on + // every mouse/touch handoff. Sources are slaves and keep their classes. + if (changed.reason != LinuxXInput2.SLAVE_SWITCH) { + directDevices = refreshDevices(display) + } + } + + LinuxXInput2.HIERARCHY_CHANGED -> directDevices = refreshDevices(display) + + LinuxXInput2.RAW_BUTTON_RELEASE -> { + // dix emulates touch pointer events only at delivery, so touches + // never yield raw button events; no direct-device filter is needed. + val raw = LinuxXInput2.RawEvent(requireNotNull(cookie.data)) + if (raw.detail == 1) dispatch { listener.onMouseMoveEnd() } + } + + LinuxXInput2.RAW_TOUCH_END -> onRawTouchEnd( + LinuxXInput2.RawEvent(requireNotNull(cookie.data)) + ) + + LinuxXInput2.TOUCH_BEGIN, + LinuxXInput2.TOUCH_UPDATE, + LinuxXInput2.TOUCH_END -> if (capabilities.touch) { + val native = LinuxXInput2.DeviceEvent(requireNotNull(cookie.data)) + onTouch( + display = display, + xid = xid, + native = native, + directTouch = native.source in directDevices, + canMoveTouch = canMoveTouch + ) + } + + LinuxXInput2.MOTION -> if (capabilities.hover) { + val native = LinuxXInput2.DeviceEvent(requireNotNull(cookie.data)) + val sample = Hover( + phase = HoverPhase.Move, + x = native.eventX, + y = native.eventY, + rootX = native.rootX, + rootY = native.rootY, + modifiers = native.modifiers.effective, + directTouch = native.source in directDevices, + emulated = native.flags and LinuxXInput2.POINTER_EMULATED != 0 + ) + dispatchHover(sample, native.source) + } + + LinuxXInput2.ENTER, LinuxXInput2.LEAVE -> if (capabilities.hover) { + val native = LinuxXInput2.CrossingEvent(requireNotNull(cookie.data)) + val sample = Hover( + phase = if (cookie.eventType == LinuxXInput2.ENTER) { + HoverPhase.Enter + } else { + HoverPhase.Exit + }, + x = native.eventX, + y = native.eventY, + rootX = native.rootX, + rootY = native.rootY, + modifiers = native.modifiers.effective, + directTouch = native.source in directDevices, + emulated = false, + crossingMode = native.mode + ) + dispatchHover(sample, native.source) + } + } + } finally { + x11.XFreeEventData(display, cookie) + } + } + } + } + + private fun refreshDevices( + display: X11.Display + ): Set { + val devices = queryDirectDevices(display) + // Finish cancellation before a new press can be accepted. + dispatchAndWait { + if (!stopRequested) listener.onDevicesChanged() + } + return devices + } + + private fun pollEvents( + fd: Memory + ) { + // Bounded stop latency, with no EDT join that could deadlock touch acceptance. + val result = LinuxTouchPoll.instance.poll(fd, NativeLong(1), 100) + if (result < 0) { + if (Native.getLastError() == 4) return // EINTR + error("XI2 connection polling failed") + } + if (result > 0 && fd.getShort(6).toInt() and 0x38 != 0) { + error("XI2 connection closed while polling") + } + } + + private fun onRawTouchEnd( + raw: LinuxXInput2.RawEvent + ) { + val id = (raw.source.toLong() shl 32) or (raw.detail.toLong() and 0xffffffffL) + val active = handedOffTouch ?: return + if (active.id != id) return + handedOffTouch = null + dispatch { listener.onTouch(active.copy(phase = TouchPhase.Up), canMoveNatively = false) } + } + + private fun onTouch( + display: X11.Display, + xid: X11.Window, + native: LinuxXInput2.DeviceEvent, + directTouch: Boolean, + canMoveTouch: Boolean + ) { + val epoch = touchEpoch + val sample = touchSample(native, directTouch) + // RejectTouch produces a synthetic end; retain capture until the physical raw end. + if (sample.phase == TouchPhase.Up && handedOffTouch?.id == sample.id) return + val pending = sample.id in pendingTouches + if (sample.phase != TouchPhase.Down && !pending) { + dispatch { if (epoch == touchEpoch) listener.onTouch(sample, canMoveNatively = false) } + return + } + val canHandoff = canMoveTouch && directTouch && sample.phase != TouchPhase.Up && + native.flags and LinuxXInput2.TOUCH_EMULATING_POINTER != 0 && + native.flags and LinuxXInput2.TOUCH_PENDING_END == 0 + val action = decideTouch( + sample, + canHandoff, + epoch, + pending + ) + if (action == TouchAction.Pending && canHandoff) { + pendingTouches[sample.id] = epoch + return + } + pendingTouches.remove(sample.id) + if (!canMoveTouch || action == TouchAction.Move || action == TouchAction.Reject) { + allowTouch( + display, + xid, + native, + action + ) + } + if (action == TouchAction.Move) { + handoffTouch( + display, + sample, + epoch + ) + } else { + LinuxX11.INSTANCE.XFlush(display) + } + } + + private fun decideTouch( + sample: Touch, + canHandoff: Boolean, + epoch: Long, + pending: Boolean + ): TouchAction { + var action = TouchAction.Accept + dispatchAndWait { + if (!stopRequested && epoch == touchEpoch) action = listener.onTouch(sample, canHandoff) + } + // A cancelled pending contact must not replay as an AWT click. Rejecting a new press + // is different: it lets AWT modality handle the contact. + return if (stopRequested || epoch != touchEpoch || + (pending && action == TouchAction.Reject) + ) { + TouchAction.Accept + } else { + action + } + } + + private fun allowTouch( + display: X11.Display, + xid: X11.Window, + native: LinuxXInput2.DeviceEvent, + action: TouchAction + ) { + val reject = action == TouchAction.Reject || action == TouchAction.Move + LinuxXInput2.instance.XIAllowTouchEvents( + display, + native.device, + native.detail, + xid, + if (reject) LinuxXInput2.REJECT_TOUCH else LinuxXInput2.ACCEPT_TOUCH + ) + } + + private fun handoffTouch( + display: X11.Display, + sample: Touch, + epoch: Long + ) { + handedOffTouch = sample + LinuxX11.INSTANCE.XUngrabPointer(display, NativeLong(0)) + // Complete rejection before the prepared WM request starts on its own connection. + LinuxX11.INSTANCE.XSync(display, false) + dispatchAndWait { + if (!stopRequested && epoch == touchEpoch) listener.onTouchMove(sample.id) + } + } + + private fun touchSample( + native: LinuxXInput2.DeviceEvent, + directTouch: Boolean + ) = Touch( + id = (native.source.toLong() shl 32) or (native.detail.toLong() and 0xffffffffL), + phase = when (native.eventType) { + LinuxXInput2.TOUCH_BEGIN -> TouchPhase.Down + LinuxXInput2.TOUCH_END -> TouchPhase.Up + else -> TouchPhase.Move + }, + x = native.eventX, + y = native.eventY, + rootX = native.rootX, + rootY = native.rootY, + modifiers = native.modifiers.effective, + timeMillis = System.nanoTime() / 1_000_000, + x11Timestamp = native.time.toLong() and 0xffffffffL, + directTouch = directTouch + ) + + private fun dispatchHover( + sample: Hover, + source: Int + ) { + if (sample.phase != HoverPhase.Move) { + dispatch { listener.onHover(sample) } + return + } + val delivery = synchronized(hoverLock) { + val pending = pendingHoverMove + val previous = pending?.sample + val sameStream = pending?.source == source && + previous?.modifiers == sample.modifiers && + previous.directTouch == sample.directTouch && + previous.emulated == sample.emulated + if (pending != null && sameStream) { + pending.sample = sample + null + } else { + HoverMove(source, sample).also { pendingHoverMove = it } + } + } ?: return + post { + val latest = synchronized(hoverLock) { + if (pendingHoverMove === delivery) pendingHoverMove = null + delivery.sample + } + listener.onHover(latest) + } + } + + private fun sealHoverMove() = synchronized(hoverLock) { + // Keep the already-queued sample, but prevent later Move events crossing this boundary. + pendingHoverMove = null + } + + private fun dispatchAndWait( + action: () -> Unit + ) { + sealHoverMove() + SwingUtilities.invokeAndWait { action() } + } + + private fun dispatch( + action: () -> Unit + ) { + sealHoverMove() + post(action) + } + + private fun post( + action: () -> Unit + ) { + SwingUtilities.invokeLater { + if (!stopRequested) { + try { + action() + } catch (exception: Throwable) { + SaltWindowExceptionHandler.onException(exception) + } + } + } + } + + private fun logUnavailable( + exception: Throwable + ) { + System.getLogger(LinuxXInputSession::class.java.name).log( + System.Logger.Level.WARNING, + "Native XI2 input stopped; restoring AWT input", + exception + ) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXShape.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXShape.kt new file mode 100644 index 00000000..29144a85 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXShape.kt @@ -0,0 +1,49 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.x11 + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.platform.unix.X11 + +/** + * JNA bindings for the X11 Shape extension in libXext. + */ +@Suppress("FunctionName") +internal interface LinuxXShape : Library { + fun XShapeCombineRectangles( + display: X11.Display, + window: X11.Window, + destKind: Int, + xOffset: Int, + yOffset: Int, + rectangles: Pointer, + rectangleCount: Int, + operation: Int, + ordering: Int + ) + + companion object { + const val SHAPE_INPUT = 2 + const val SHAPE_SET = 0 + const val UNSORTED = 0 + + val INSTANCE: LinuxXShape = Native.load("Xext", LinuxXShape::class.java) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXcursor.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXcursor.kt new file mode 100644 index 00000000..0ada3d26 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXcursor.kt @@ -0,0 +1,54 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.platform.linux.x11 + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.NativeLong +import com.sun.jna.Pointer +import com.sun.jna.platform.unix.X11 + +@Suppress("ktlint:standard:function-naming", "FunctionName") +internal interface LinuxXcursor : Library { + fun XcursorGetTheme( + display: X11.Display + ): Pointer? + + fun XcursorGetDefaultSize( + display: X11.Display + ): Int + + fun XcursorLibraryLoadImages( + name: String, + theme: String?, + size: Int + ): Pointer? + + fun XcursorImagesLoadCursor( + display: X11.Display, + images: Pointer + ): NativeLong + + fun XcursorImagesDestroy( + images: Pointer + ) + + companion object { + val INSTANCE: LinuxXcursor = Native.load("Xcursor", LinuxXcursor::class.java) + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/CaptionBar.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/CaptionBar.kt index 2d0c697e..e0555825 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/CaptionBar.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/CaptionBar.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import com.moriafly.salt.core.os.OS import com.moriafly.salt.ui.UnstableSaltUiApi -import com.moriafly.salt.ui.platform.linux.linuxCaptionBarHitTest +import com.moriafly.salt.ui.platform.linux.input.linuxCaptionBarInput import com.moriafly.salt.ui.platform.windows.windowsCaptionBarTouchInput /** @@ -98,7 +98,7 @@ fun CaptionBarHitTest( .run { when (OS.current) { is OS.Windows -> windowsCaptionBarTouchInput(saltWindowProperties.moveable) - is OS.Linux -> linuxCaptionBarHitTest() + is OS.Linux -> linuxCaptionBarInput() else -> this } } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/SaltDialogWindow.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/SaltDialogWindow.kt index 50f08cbf..e022660b 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/SaltDialogWindow.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/SaltDialogWindow.kt @@ -44,8 +44,8 @@ import androidx.compose.ui.window.WindowDecoration import androidx.compose.ui.window.rememberDialogState import com.moriafly.salt.core.os.OS import com.moriafly.salt.ui.UnstableSaltUiApi -import com.moriafly.salt.ui.platform.linux.LinuxClientShadow import com.moriafly.salt.ui.platform.linux.LinuxSaltDialogWindowFrame +import com.moriafly.salt.ui.platform.linux.decoration.LinuxClientShadow import com.moriafly.salt.ui.platform.macos.MacOSSaltDialogWindowFrame import com.moriafly.salt.ui.platform.windows.WindowsSaltDialogWindowFrame import com.moriafly.salt.ui.util.findSkiaLayer diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/SaltWindow.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/SaltWindow.kt index 291dd1c6..0fb4da25 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/SaltWindow.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/SaltWindow.kt @@ -50,8 +50,8 @@ import androidx.compose.ui.window.rememberWindowState import com.moriafly.salt.core.os.OS import com.moriafly.salt.ui.SaltTheme import com.moriafly.salt.ui.UnstableSaltUiApi -import com.moriafly.salt.ui.platform.linux.LinuxClientShadow import com.moriafly.salt.ui.platform.linux.LinuxSaltWindowFrame +import com.moriafly.salt.ui.platform.linux.decoration.LinuxClientShadow import com.moriafly.salt.ui.platform.macos.MacOSSaltWindowFrame import com.moriafly.salt.ui.platform.windows.WindowsSaltWindowFrame import com.moriafly.salt.ui.thenIf diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/internal/SaltWindowUtil.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/internal/SaltWindowUtil.kt index 5e4c086c..adab425c 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/internal/SaltWindowUtil.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/internal/SaltWindowUtil.kt @@ -32,7 +32,7 @@ import com.moriafly.salt.ui.UnstableSaltUiApi * with the unified custom window appearance across all platforms. When * [WindowDecoration.SystemDefault] is requested on Linux, a client-drawn shadow * decoration is used instead; see - * [com.moriafly.salt.ui.platform.linux.LinuxClientShadow]. + * [com.moriafly.salt.ui.platform.linux.decoration.LinuxClientShadow]. * * On other platforms, the original decoration is returned as-is. */ diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxClientShadowTest.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxClientShadowTest.kt index 38992f5d..ecf50513 100644 --- a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxClientShadowTest.kt +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxClientShadowTest.kt @@ -37,6 +37,8 @@ import com.moriafly.salt.core.os.OS import com.moriafly.salt.ui.SaltTheme import com.moriafly.salt.ui.Text import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.platform.linux.decoration.LinuxClientShadow +import com.moriafly.salt.ui.platform.linux.x11.LinuxX11 import com.moriafly.salt.ui.window.SaltWindow import com.moriafly.salt.ui.window.SaltWindowProperties import com.sun.jna.Native @@ -418,7 +420,7 @@ class LinuxClientShadowTest { if (!window.isDisplayable) return null val x11 = try { - X11Ext.INSTANCE + LinuxX11.INSTANCE } catch (_: UnsatisfiedLinkError) { return null } @@ -479,7 +481,7 @@ class LinuxClientShadowTest { if (!window.isDisplayable) return null val x11 = try { - X11Ext.INSTANCE + LinuxX11.INSTANCE } catch (_: UnsatisfiedLinkError) { return null } diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindowTest.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindowTest.kt new file mode 100644 index 00000000..8757c144 --- /dev/null +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindowTest.kt @@ -0,0 +1,233 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * Licensed under the Apache License, Version 2.0 + */ + +@file:OptIn( + androidx.compose.ui.ExperimentalComposeUiApi::class, + com.moriafly.salt.ui.UnstableSaltUiApi::class +) + +package com.moriafly.salt.ui.platform.linux + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowDecoration +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberDialogState +import androidx.compose.ui.window.rememberWindowState +import com.moriafly.salt.ui.SaltTheme +import com.moriafly.salt.ui.Text +import com.moriafly.salt.ui.platform.linux.input.LinuxWindowGestures +import com.moriafly.salt.ui.platform.linux.input.LocalLinuxWindowGestures +import com.moriafly.salt.ui.util.findSkiaLayer +import com.moriafly.salt.ui.window.CaptionBarHitTest +import com.moriafly.salt.ui.window.LocalSaltWindowInfo +import com.moriafly.salt.ui.window.SaltDialogWindow +import com.moriafly.salt.ui.window.SaltWindow +import com.moriafly.salt.ui.window.SaltWindowProperties +import com.moriafly.salt.ui.window.WindowResizeEdge +import java.awt.EventQueue +import java.awt.Frame +import java.awt.GraphicsEnvironment +import java.awt.Toolkit +import java.awt.Window +import java.util.concurrent.FutureTask +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.fail +import org.junit.Assume.assumeTrue + +internal class LinuxInputWindowTest( + private val asDialog: Boolean = false, + private val overlays: @Composable () -> Unit = {} +) : AutoCloseable { + private var exit: (() -> Unit)? = null + private val failure = AtomicReference() + private var applicationThread: Thread? = null + lateinit var window: Window + val frame: Frame + get() = window as Frame + lateinit var gestures: LinuxWindowGestures + var clicks = 0 + var edge = WindowResizeEdge.None + var caption = Rect.Zero + var content = Rect.Zero + var density = 1f + private set + private lateinit var viewConfiguration: ViewConfiguration + val systemScale: Float + get() = window.graphicsConfiguration.defaultTransform.scaleX.toFloat() + + fun configureGestures( + canDrag: Boolean = true, + resizable: Boolean = true, + touchSlop: Float = viewConfiguration.touchSlop + ) { + gestures.configure( + moveable = true, + resizable = resizable, + canDrag = canDrag, + canMaximize = true, + viewConfiguration = object : ViewConfiguration by viewConfiguration { + override val touchSlop = touchSlop + } + ) + } + + fun open() { + assumeTrue(System.getProperty("os.name").startsWith("Linux")) + assumeTrue("A desktop display is required", !GraphicsEnvironment.isHeadless()) + assumeTrue(Toolkit.getDefaultToolkit().javaClass.name == "sun.awt.X11.XToolkit") + applicationThread = thread(name = "SaltUI gesture test application", isDaemon = true) { + try { + application(exitProcessOnExit = false) { + exit = ::exitApplication + Content() + } + } catch (error: Throwable) { + failure.set(error) + } + } + await("window content", timeoutMillis = 15_000) { + ::window.isInitialized && window.isShowing && content != Rect.Zero + } + onEdt { window.toFront() } + // Focus transitions can cancel input; wait before injecting a new gesture. + await("window focus") { window.isFocused } + } + + fun await( + description: String, + timeoutMillis: Long = 5_000, + condition: () -> Boolean + ) { + val deadline = System.nanoTime() + timeoutMillis * 1_000_000 + while (!onEdt(condition)) { + failure.get()?.let { throw AssertionError("Application failed", it) } + if (System.nanoTime() >= deadline) fail("Timed out waiting for $description") + Thread.sleep(10) + } + } + + fun onEdt( + action: () -> T + ): T { + if (EventQueue.isDispatchThread()) return action() + val task = FutureTask(action) + EventQueue.invokeAndWait(task) + return task.get() + } + + override fun close() { + onEdt { exit?.invoke() } + applicationThread?.join(5_000) + check(applicationThread?.isAlive != true) { "Test application did not close" } + failure.get()?.let { throw AssertionError("Application failed", it) } + } + + @Composable + private fun Content() { + SaltTheme { + if (asDialog) { + SaltDialogWindow( + onCloseRequest = {}, + state = rememberDialogState( + position = WindowPosition.Absolute(120.dp, 120.dp), + size = DpSize(560.dp, 380.dp) + ), + title = "SaltUI dialog gesture regression", + decoration = WindowDecoration.Undecorated(resizerThickness = 4.dp), + properties = SaltWindowProperties.default( + captionButtonsVisible = false, + extraDisplayScale = 1.25f, + onResizeEdgeChange = { _, value -> edge = value } + ), + init = { window = it } + ) { + InputContent() + } + } else { + SaltWindow( + onCloseRequest = {}, + state = rememberWindowState( + position = WindowPosition.Absolute(120.dp, 120.dp), + size = DpSize(560.dp, 380.dp) + ), + title = "SaltUI window gesture regression", + decoration = WindowDecoration.Undecorated(resizerThickness = 4.dp), + properties = SaltWindowProperties.default( + captionButtonsVisible = false, + extraDisplayScale = 1.25f, + onResizeEdgeChange = { _, value -> edge = value } + ), + init = { window = it } + ) { + InputContent() + } + } + } + } + + @Composable + private fun InputContent() { + val currentGestures = checkNotNull(LocalLinuxWindowGestures.current) + val currentDensity = LocalDensity.current.density + val currentViewConfiguration = LocalViewConfiguration.current + SideEffect { + gestures = currentGestures + density = currentDensity + viewConfiguration = currentViewConfiguration + } + Column(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxWidth() + .height(LocalSaltWindowInfo.current.captionBarHeight) + ) { + CaptionBarHitTest( + Modifier.onGloballyPositioned { caption = it.boundsInWindow() } + ) + } + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { content = it.boundsInWindow() } + .clickable { clicks++ } + ) { + Text("Input target") + } + } + overlays() + } + + fun screen( + position: Offset + ): Offset { + val canvas = checkNotNull(window.findSkiaLayer()).canvas + val origin = canvas.locationOnScreen + val scale = window.graphicsConfiguration.defaultTransform + return position + Offset( + (origin.x * scale.scaleX).toFloat(), + (origin.y * scale.scaleY).toFloat() + ) + } +} diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestureStateTest.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestureStateTest.kt new file mode 100644 index 00000000..b5c19637 --- /dev/null +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestureStateTest.kt @@ -0,0 +1,237 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * Licensed under the Apache License, Version 2.0 + */ + +package com.moriafly.salt.ui.platform.linux + +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxTouchTapRecognizer +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.CaptionHit +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Command +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Hit +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Input +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Phase +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Pointer +import com.moriafly.salt.ui.platform.linux.input.gesture.LinuxWindowGestureState.Position +import com.moriafly.salt.ui.window.WindowResizeEdge +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(UnstableSaltUiApi::class) +class LinuxWindowGestureStateTest { + @Test + fun mouseCaptionReleaseDuringTouchHoldFinishesMoveAndAllowsNextTouch() { + val state = configuredState() + state.onInput(mouse(Phase.Press), Hit(caption = CaptionHit.Caption)) + state.onInput(mouse(Phase.Move, 160)) + state.onInput(touch(1, Phase.Press)) + assertEquals( + listOf(Command.Move(Position(170, 100))), + state.onInput(mouse(Phase.Move, 170)).commands + ) + + val released = state.onInput(mouse(Phase.Release, 180)) + assertTrue(released.consumed) + assertTrue(Command.EndMove in released.commands, "Mouse release must finish its move") + assertFalse(released.commands.any { it is Command.ShowEdge }, "No edge was ever indicated") + assertTouchQuarantineEndsWithLastContact(state) + } + + @Test + fun mouseEdgeResizeReportsEachIndicatorTransitionOnce() { + val state = configuredState() + val right = Hit(edge = WindowResizeEdge.Right) + val decisions = listOf( + state.onInput(mouse(Phase.Enter), right), + state.onInput(mouse(Phase.Press), right), + state.onInput(mouse(Phase.Move, 160)), + // The pointer is still over the edge after release. + state.onInput(mouse(Phase.Release, 160), right), + state.onInput(mouse(Phase.Exit)) + ) + assertEquals( + listOf(Command.ShowEdge(WindowResizeEdge.Right), Command.ShowEdge(WindowResizeEdge.None)), + decisions.flatMap { it.commands }.filterIsInstance(), + "Starting and finishing a resize must not report edge -> None -> edge" + ) + assertTrue(Command.EndResize(applyPending = true) in decisions[3].commands) + } + + @Test + fun mouseResizeReleaseDuringTouchHoldAppliesPendingBoundsAndAllowsNextTouch() { + val state = configuredState() + state.onInput(mouse(Phase.Press), Hit(edge = WindowResizeEdge.Right)) + state.onInput(mouse(Phase.Move, 160)) + state.onInput(touch(1, Phase.Press)) + assertEquals( + listOf(Command.Resize(Position(170, 100))), + state.onInput(mouse(Phase.Move, 170)).commands + ) + + val released = state.onInput(mouse(Phase.Release, 180)) + assertTrue(released.consumed) + assertTrue( + Command.EndResize(applyPending = true) in released.commands, + "The owning mouse release must commit its last resize position" + ) + assertTrue(Command.ShowEdge(WindowResizeEdge.None) in released.commands) + assertTouchQuarantineEndsWithLastContact(state) + } + + @Test + fun mouseReleaseCannotFinishATouchOwnedResize() { + val state = configuredState() + state.onInput(touch(1, Phase.Press), Hit(edge = WindowResizeEdge.Right)) + val mouseRelease = state.onInput(mouse(Phase.Release)) + assertTrue(mouseRelease.consumed) + assertFalse(mouseRelease.commands.any { it is Command.EndResize }) + val touchRelease = state.onInput(touch(1, Phase.Release)) + assertTrue(Command.EndResize(applyPending = true) in touchRelease.commands) + } + + @Test + fun touchHoldRejectsNewMousePressAndConsumesItsClickWithoutRetainingSuppression() { + val state = configuredState() + state.onInput(mouse(Phase.Press), Hit(caption = CaptionHit.Caption)) + state.onInput(touch(1, Phase.Press)) + assertTrue(state.onInput(mouse(Phase.Press), Hit(edge = WindowResizeEdge.Right)).commands.isEmpty()) + val secondaryRelease = state.onInput(mouse(Phase.Release).copy(primary = false)) + assertTrue(secondaryRelease.consumed) + assertTrue(secondaryRelease.commands.isEmpty()) + assertTrue(Command.EndMove in state.onInput(mouse(Phase.Release)).commands) + + val click = state.onInput(mouse(Phase.Click).copy(clickCount = 2)) + assertTrue(click.consumed) + assertFalse(Command.ToggleMaximize in click.commands) + assertTrue(state.onInput(mouse(Phase.Press), Hit(edge = WindowResizeEdge.Right)).commands.isEmpty()) + state.onInput(touch(1, Phase.Release)) + assertFalse(state.onInput(mouse(Phase.Click).copy(clickCount = 2)).consumed) + assertEquals(listOf(Command.DeliverTouch), state.onInput(touch(4, Phase.Press)).commands) + state.onInput(touch(4, Phase.Release)) + } + + @Test + fun releasedCaptionQueryCannotResolveOrInvalidateTheNextQuery() { + val state = configuredState() + val oldRequest = requestCaption(state, 1) + state.onInput(touch(1, Phase.Release)) + val currentRequest = requestCaption(state, 2) + + assertTrue(state.resolveCaption(oldRequest, true).commands.isEmpty()) + assertFalse(state.isPendingTouchMove(1)) + assertFalse(state.isPendingTouchMove(2)) + state.resolveCaption(currentRequest, true) + assertTrue(state.isPendingTouchMove(2), "The fresh query must still be resolvable") + state.resolveCaption(oldRequest, false) + assertTrue(state.isPendingTouchMove(2), "A late miss must not cancel the new owner") + } + + @Test + fun cancelledCaptionQueryCannotRecaptureTheHeldContact() { + val state = configuredState() + val request = requestCaption(state, 1) + state.cancel() + assertTrue(state.resolveCaption(request, true).commands.isEmpty()) + assertFalse(state.isPendingTouchMove(1)) + assertFalse(Command.DeliverTouch in state.onInput(touch(1, Phase.Move)).commands) + state.onInput(touch(1, Phase.Release)) + state.onInput(touch(2, Phase.Press), Hit(caption = CaptionHit.Caption)) + assertTrue(state.isPendingTouchMove(2), "Cancellation must not poison the next press") + } + + @Test + fun resetDropsOldContactsAndPendingQueryWithoutNeedingTheirRelease() { + val state = configuredState() + val oldRequest = requestCaption(state, 1) + state.resetTouchInput() + val newRequest = requestCaption(state, 2) + assertTrue(state.resolveCaption(oldRequest, true).commands.isEmpty()) + state.resolveCaption(newRequest, true) + assertTrue(state.isPendingTouchMove(2)) + state.onInput(touch(1, Phase.Release)) + assertTrue(state.isPendingTouchMove(2), "A stale release cannot finish the new gesture") + } + + @Test + fun disablingAndReenablingDragDoesNotReviveAnOldCaptionQuery() { + val state = configuredState() + val request = requestCaption(state, 1) + state.configure(configuration().copy(canDrag = false)) + state.configure(configuration()) + assertTrue(state.resolveCaption(request, true).commands.isEmpty()) + assertFalse(state.isPendingTouchMove(1)) + state.onInput(touch(1, Phase.Release)) + state.onInput(touch(2, Phase.Press), Hit(caption = CaptionHit.Caption)) + assertTrue(state.isPendingTouchMove(2)) + } + + @Test + fun resettingTouchDuringMouseDragPreservesMouseOwnership() { + val state = configuredState() + state.onInput(mouse(Phase.Press), Hit(caption = CaptionHit.Caption)) + state.onInput(mouse(Phase.Move, 160)) + state.onInput(touch(1, Phase.Press)) + assertFalse(Command.EndMove in state.resetTouchInput().commands) + assertTrue(Command.Move(Position(180, 100)) in state.onInput(mouse(Phase.Move, 180)).commands) + assertTrue(Command.EndMove in state.onInput(mouse(Phase.Release, 180)).commands) + assertTrue(Command.DeliverTouch in state.onInput(touch(2, Phase.Press)).commands) + } + + private fun requestCaption( + state: LinuxWindowGestureState, + id: Long + ): Long = state.onInput(touch(id, Phase.Press), Hit(caption = CaptionHit.ResolveInCompose)) + .commands.filterIsInstance().single().request + + private fun assertTouchQuarantineEndsWithLastContact( + state: LinuxWindowGestureState + ) { + // A second contact during the existing hold remains captured even after mouse release. + assertFalse(Command.DeliverTouch in state.onInput(touch(2, Phase.Press)).commands) + state.onInput(touch(1, Phase.Release)) + assertFalse(Command.DeliverTouch in state.onInput(touch(2, Phase.Move)).commands) + state.onInput(touch(2, Phase.Release)) + assertEquals(listOf(Command.DeliverTouch), state.onInput(touch(3, Phase.Press)).commands) + state.onInput(touch(3, Phase.Release)) + } + + private fun configuredState() = LinuxWindowGestureState().apply { + configure(configuration()) + } + + private fun configuration() = LinuxWindowGestureState.Configuration( + moveable = true, + resizable = true, + canDrag = true, + canMaximize = true, + taps = LinuxTouchTapRecognizer.Configuration( + touchSlop = 8f, + longPressTimeoutMillis = 500, + doubleTapMinTimeMillis = 40, + doubleTapTimeoutMillis = 300 + ) + ) + + private fun mouse( + phase: Phase, + x: Int = 100 + ) = Input( + pointer = Pointer.Mouse, + phase = phase, + position = Position(x, 100) + ) + + private fun touch( + id: Long, + phase: Phase + ) = Input( + pointer = Pointer.Touch(id), + phase = phase, + position = Position(300, 200) + ) +} diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGesturesTest.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGesturesTest.kt new file mode 100644 index 00000000..d3fa9ca3 --- /dev/null +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGesturesTest.kt @@ -0,0 +1,752 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * Licensed under the Apache License, Version 2.0 + */ + +@file:OptIn( + androidx.compose.ui.InternalComposeUiApi::class, + androidx.compose.ui.ExperimentalComposeUiApi::class, + androidx.compose.runtime.tooling.ComposeToolingApi::class, + com.moriafly.salt.ui.UnstableSaltUiApi::class +) + +package com.moriafly.salt.ui.platform.linux + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeDialog +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.moriafly.salt.ui.Text +import com.moriafly.salt.ui.platform.input.DesktopTouchScene +import com.moriafly.salt.ui.platform.linux.input.LinuxTouchInput +import com.moriafly.salt.ui.platform.linux.input.LinuxWindowGestures +import com.moriafly.salt.ui.platform.linux.x11.LinuxX11 +import com.moriafly.salt.ui.platform.linux.x11.LinuxXInputSession +import com.moriafly.salt.ui.platform.linux.x11.LinuxXInputSession.TouchPhase +import com.moriafly.salt.ui.util.findSkiaLayer +import com.moriafly.salt.ui.window.WindowResizeEdge +import com.sun.jna.NativeLong +import com.sun.jna.Pointer +import com.sun.jna.platform.unix.X11 +import com.sun.jna.ptr.IntByReference +import com.sun.jna.ptr.NativeLongByReference +import com.sun.jna.ptr.PointerByReference +import java.awt.Frame +import java.awt.Point +import java.awt.Robot +import java.awt.Toolkit +import java.awt.event.InputEvent +import java.awt.event.MouseEvent +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.junit.Assume.assumeTrue + +/** + * Mouse tests use native pointer input through the complete window dispatch path. Touch tests + * inject at the platform adapter and assert real SaltWindow bounds and Compose clicks; they do + * not emulate a physical touchscreen. Both routes use the same production gesture implementation. + */ +class LinuxWindowGesturesTest { + @Test + fun nativeMouseCaptionDragMovesWindowAndReleaseStopsIt() = withWindow { + NativeMouse(this).use { mouse -> + val before = onEdt { window.bounds } + val start = mouse.screenPoint(onEdt { caption.center }) + mouse.move(start) + mouse.press() + mouse.move(Point(start.x + 30, start.y + 20)) + mouse.settle() + mouse.move(Point(start.x + 110, start.y + 70)) + mouse.release() + // Openbox reports a WM move to the client only when the move ends. + await("window moved by the drag") { + abs(window.x - before.x - 110) <= 1 && abs(window.y - before.y - 70) <= 1 + } + val released = onEdt { window.bounds } + mouse.move(Point(start.x + 160, start.y + 100)) + mouse.settle() + onEdt { assertEquals(released, window.bounds, "Released drag must not follow the pointer") } + } + } + + @Test + fun nativeDialogCaptionRespectsTopmostDialogAndRestoresAfterEachDismissal() { + var depth by mutableStateOf(0) + var lower = Rect.Zero + var upper = Rect.Zero + var lowerClicks = 0 + var upperClicks = 0 + val properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false, + usePlatformInsets = false, + animateTransition = false + ) + LinuxInputWindowTest(asDialog = true) { + if (depth > 0) { + Dialog(onDismissRequest = {}, properties = properties) { + Box( + Modifier + .width(260.dp) + .fillMaxHeight() + .onGloballyPositioned { lower = it.boundsInWindow() } + ) { + Text( + text = "Close lower dialog", + modifier = Modifier.align(Alignment.Center).clickable { + lowerClicks++ + depth = 0 + } + ) + } + if (depth > 1) { + Dialog(onDismissRequest = {}, properties = properties) { + Box( + Modifier + .width(120.dp) + .fillMaxHeight() + .onGloballyPositioned { upper = it.boundsInWindow() } + ) { + Text( + text = "Close top dialog", + modifier = Modifier.align(Alignment.Center).clickable { + upperClicks++ + depth = 1 + } + ) + } + } + } + } + } + }.use { fixture -> + fixture.open() + val dialog = fixture.window as ComposeDialog + val baseOwners = fixture.onEdt { dialog.semanticsOwners.size } + NativeMouse(fixture).use { mouse -> + fun dragCaptionAt( + x: Float, + moves: Boolean, + description: String + ) { + val before = fixture.onEdt { dialog.bounds } + val start = mouse.screenPoint(fixture.onEdt { Offset(x, fixture.caption.center.y) }) + mouse.move(start) + mouse.press() + mouse.move(Point(start.x + 20, start.y)) + mouse.settle() + mouse.move(Point(start.x + 60, start.y)) + mouse.release() + mouse.settle() + fixture.onEdt { + assertEquals(before.x + if (moves) 60 else 0, dialog.x, description) + assertEquals(before.y, dialog.y, description) + assertEquals(before.size, dialog.size, description) + } + } + + dragCaptionAt(fixture.onEdt { fixture.caption.center.x }, true, "Native dialog's own owner") + fixture.onEdt { depth = 2 } + fixture.await("two nested dialog owners and content") { + dialog.semanticsOwners.size >= baseOwners + 2 && lower != Rect.Zero && upper != Rect.Zero + } + fixture.onEdt { + assertTrue(lower.contains(Offset(lower.left + 4 * fixture.density, fixture.caption.center.y))) + assertTrue(upper.contains(Offset(upper.center.x, fixture.caption.center.y))) + assertTrue(lower.left < upper.left, "Lower dialog must extend outside the top dialog") + } + dragCaptionAt(upper.center.x, false, "Topmost dialog content blocks caption dragging") + val lowerOnly = fixture.onEdt { lower.left + 4 * fixture.density } + dragCaptionAt(lowerOnly, true, "Only the topmost dialog controls caption exclusion") + mouse.move(mouse.screenPoint(fixture.onEdt { upper.center })) + mouse.press() + mouse.release() + fixture.await("upper dialog dismissal restores lower owner") { + upperClicks == 1 && lowerClicks == 0 && dialog.semanticsOwners.size == baseOwners + 1 + } + dragCaptionAt(lowerOnly, false, "Remaining dialog blocks its own content") + mouse.move(mouse.screenPoint(fixture.onEdt { lower.center })) + mouse.press() + mouse.release() + fixture.await("all overlay dialogs dismissed") { + lowerClicks == 1 && dialog.semanticsOwners.size == baseOwners + } + dragCaptionAt(lowerOnly, true, "Caption dragging resumes after both dialogs close") + } + } + } + + @Test + fun mouseReleaseDuringTouchHoldCommitsResizeAndRestoresTouchClicks() = withWindow { touch -> + val before = onEdt { window.bounds } + val edgePoint = onEdt { Offset(content.right - 2f, content.center.y) } + onEdt { + val scale = window.graphicsConfiguration.defaultTransform.scaleX.toFloat() + assertTrue(gestures.onMouseEvent(mouse(MouseEvent.MOUSE_PRESSED, edgePoint))) + assertTrue(gestures.onMouseEvent(mouse(MouseEvent.MOUSE_DRAGGED, edgePoint + Offset(40f * scale, 0f)))) + touch.send(TouchPhase.Down, content.center) + assertTrue(gestures.onMouseEvent(mouse(MouseEvent.MOUSE_DRAGGED, edgePoint + Offset(100f * scale, 0f)))) + assertTrue(gestures.onMouseEvent(mouse(MouseEvent.MOUSE_RELEASED, edgePoint + Offset(100f * scale, 0f)))) + assertEquals(before.width + 100, window.width, "Owning mouse release must apply pending bounds") + touch.send(TouchPhase.Up, content.center) + assertEquals(WindowResizeEdge.None, edge) + } + await("committed resize layout") { + window.width == before.width + 100 && + content.width == (window.width * window.graphicsConfiguration.defaultTransform.scaleX).toFloat() + } + val resized = onEdt { window.bounds } + touch.tap(onEdt { content.center }) + await("fresh Compose touch click after mouse release") { clicks == 1 } + onEdt { assertEquals(resized, window.bounds, "The old drag must not resume") } + } + + @Test + fun touchCaptionDoubleTapMaximizesAndRestoresWindow() = withWindow { touch -> + val before = onEdt { window.bounds } + val captionWidth = onEdt { caption.width } + touch.doubleTap(onEdt { Offset(content.center.x, content.top + 2f) }) + onEdt { assertEquals(Frame.NORMAL, frame.extendedState, "Content is not a caption target") } + touch.doubleTap(onEdt { caption.center }) + await("caption maximize and layout") { + frame.extendedState == Frame.MAXIMIZED_BOTH && caption.width > captionWidth + } + touch.doubleTap(onEdt { caption.center }) + await("caption restore") { frame.extendedState == Frame.NORMAL && window.bounds == before } + } + + @Test + fun touchResizeCancelDiscardsPendingBoundsWithoutAffectingNextResize() = withWindow { touch -> + val before = onEdt { window.bounds } + onEdt { + val point = Offset(content.right - 2f, content.center.y) + val scale = window.graphicsConfiguration.defaultTransform.scaleX.toFloat() + // Keep update and cancel in one EDT turn so the queued bounds update cannot run first. + touch.send(TouchPhase.Down, point) + assertEquals(WindowResizeEdge.Right, edge) + touch.send(TouchPhase.Move, point + Offset(100f * scale, 0f)) + gestures.cancel() + touch.send(TouchPhase.Up, point + Offset(100f * scale, 0f)) + assertEquals(before, window.bounds) + assertEquals(WindowResizeEdge.None, edge) + + // A normally released resize must still commit, and the old queued task must not + // overwrite it after the current EDT turn ends. + touch.send(TouchPhase.Down, point) + touch.send(TouchPhase.Move, point + Offset(60f * scale, 0f)) + touch.send(TouchPhase.Up, point + Offset(60f * scale, 0f)) + assertEquals(before.width + 60, window.width) + } + await("released resize after cancelled queued work") { + window.width == before.width + 60 && + content.width == (window.width * window.graphicsConfiguration.defaultTransform.scaleX).toFloat() + } + touch.tap(onEdt { content.center }) + await("Compose touch click after cancellation") { clicks == 1 } + } + + @Test + fun nativeMouseCaptionBottomBoundaryExcludesContent() = withWindow { + NativeMouse(this).use { mouse -> + for (offset in listOf(-1, 0, 1)) { + val before = onEdt { window.bounds } + val point = mouse.screenPoint(onEdt { + Offset(caption.center.x, caption.bottom + offset * systemScale) + }) + mouse.move(point) + mouse.press() + mouse.move(Point(point.x + 20, point.y)) + mouse.settle() + mouse.move(Point(point.x + 60, point.y)) + mouse.release() + mouse.settle() + onEdt { + assertEquals(before.x + if (offset < 0) 60 else 0, window.x, "Caption boundary offset $offset") + assertEquals(before.y, window.y) + assertEquals(before.size, window.size) + } + } + } + } + + @Test + fun nativeMouseDoubleClickOnlyMaximizesCaptionAndRestoresBounds() = withWindow { + assumeTrue(Toolkit.getDefaultToolkit().isFrameStateSupported(Frame.MAXIMIZED_BOTH)) + NativeMouse(this).use { mouse -> + val before = onEdt { window.bounds } + mouse.doubleClick(mouse.screenPoint(onEdt { content.center })) + onEdt { assertEquals(Frame.NORMAL, frame.extendedState, "Content double click must not maximize") } + mouse.doubleClick(mouse.screenPoint(onEdt { caption.center })) + await("mouse caption maximize") { + frame.extendedState == Frame.MAXIMIZED_BOTH && window.width > before.width + } + mouse.doubleClick(mouse.screenPoint(onEdt { caption.center })) + await("mouse caption restore") { frame.extendedState == Frame.NORMAL && window.bounds == before } + } + } + + @Test + fun nativeMouseDragFromMaximizedCaptionKeepsPointerInsideRestoredCaption() = withWindow { + assumeTrue(Toolkit.getDefaultToolkit().isFrameStateSupported(Frame.MAXIMIZED_BOTH)) + // Restoring a maximized window on move is WM policy; Openbox keeps it maximized. + assumeTrue( + "Requires a WM that restores maximized windows on move", + windowManagerName().equals("KWin", ignoreCase = true) + ) + NativeMouse(this).use { mouse -> + val before = onEdt { window.bounds } + mouse.doubleClick(mouse.screenPoint(onEdt { caption.center })) + await("maximized caption layout") { + frame.extendedState == Frame.MAXIMIZED_BOTH && caption.width > before.width * systemScale + } + val start = mouse.screenPoint(onEdt { + Offset(caption.left + caption.width / 4, caption.center.y) + }) + mouse.move(start) + mouse.press() + mouse.move(Point(start.x + 120, start.y + 120)) + mouse.move(Point(start.x + 240, start.y + 240)) + mouse.settle() + val held = Point(start.x + 248, start.y + 248) + mouse.move(held) + await("restored window under pointer") { + val topLeft = mouse.screenPoint(caption.topLeft) + val bottomRight = mouse.screenPoint(caption.bottomRight) + frame.extendedState == Frame.NORMAL && window.size == before.size && + held.x in topLeft.x until bottomRight.x && held.y in topLeft.y until bottomRight.y + } + val restored = onEdt { window.location } + mouse.move(Point(held.x + 60, held.y + 30)) + await("restored window following pointer") { + abs(window.x - restored.x - 60) <= 2 && abs(window.y - restored.y - 30) <= 2 + } + mouse.release() + val released = onEdt { window.bounds } + mouse.move(Point(held.x + 100, held.y + 60)) + mouse.settle() + onEdt { assertEquals(released, window.bounds) } + } + } + + @Test + fun nativeMouseResizeIncludesFourDpBoundaryButExcludesFiveDp() = withWindow { + NativeMouse(this).use { mouse -> + for (distance in listOf(3, 4, 5)) { + val before = onEdt { window.bounds } + val point = mouse.screenPoint(onEdt { + Offset(content.right - distance * density, content.center.y) + }) + mouse.move(point) + mouse.press() + mouse.move(Point(point.x + 20, point.y)) + mouse.move(Point(point.x + 60, point.y)) + mouse.release() + mouse.settle() + onEdt { + assertEquals(before.width + if (distance <= 4) 60 else 0, window.width, "Resize at $distance dp") + assertEquals(before.location, window.location) + } + val expectedWidth = before.width + if (distance <= 4) 60 else 0 + await("resize boundary layout") { + window.width == expectedWidth && content.width == expectedWidth * systemScale + } + } + } + } + + @Test + fun touchResizeIncludesFourDpBoundaryButExcludesFiveDp() = withWindow { touch -> + for (distance in listOf(3, 4, 5)) { + val before = onEdt { window.bounds } + onEdt { + val point = Offset(content.right - distance * density, content.center.y) + touch.send(TouchPhase.Down, point) + touch.send(TouchPhase.Move, point + Offset(60f * systemScale, 0f)) + touch.send(TouchPhase.Up, point + Offset(60f * systemScale, 0f)) + assertEquals(before.width + if (distance <= 4) 60 else 0, window.width, "Resize at $distance dp") + assertEquals(before.location, window.location) + } + val expectedWidth = before.width + if (distance <= 4) 60 else 0 + await("touch resize boundary layout") { + window.width == expectedWidth && content.width == expectedWidth * systemScale + } + } + } + + @Test + fun touchCaptionStartsMovingOnlyAfterExceedingSlop() = withWindow { touch -> + val before = onEdt { window.bounds } + onEdt { + configureGestures(touchSlop = 8f * systemScale) + val point = caption.center + touch.send(TouchPhase.Down, point) + touch.send(TouchPhase.Move, point + Offset(7f * systemScale, 0f)) + assertEquals(before, window.bounds, "Below slop") + touch.send(TouchPhase.Move, point + Offset(8f * systemScale, 0f)) + assertEquals(before, window.bounds, "Exactly at slop") + touch.send(TouchPhase.Move, point + Offset(9f * systemScale, 0f)) + assertEquals(before.x + 9, window.x, "The first sample beyond slop starts movement") + touch.send(TouchPhase.Up, point + Offset(9f * systemScale, 0f)) + } + val released = onEdt { window.bounds } + touch.tap(onEdt { content.center }) + await("click after slop gesture") { clicks == 1 } + onEdt { assertEquals(released, window.bounds) } + } + + @Test + fun resettingTouchCancelsMovementAndAllowsFreshClickWithoutOldRelease() = withWindow { touch -> + onEdt { + val point = caption.center + touch.send(TouchPhase.Down, point) + touch.send(TouchPhase.Move, point + Offset(60f * systemScale, 0f)) + touch.reset() + val stopped = window.bounds + touch.send(TouchPhase.Move, point + Offset(120f * systemScale, 0f)) + assertEquals(stopped, window.bounds, "Reset contact cannot keep moving the window") + } + touch.tap(onEdt { content.center }) + await("fresh click after session reset") { clicks == 1 } + } + + @Test + fun disablingDragDuringTouchHoldStopsMovementUntilFreshPress() = withWindow { touch -> + val point = onEdt { caption.center } + val origin = onEdt { checkNotNull(window.findSkiaLayer()).canvas.locationOnScreen } + val stopped = onEdt { + touch.send(TouchPhase.Down, point) + touch.send(TouchPhase.Move, point + Offset(60f * systemScale, 0f)) + configureGestures(canDrag = false) + val stopped = window.bounds + touch.send(TouchPhase.Move, point + Offset(120f * systemScale, 0f)) + assertEquals(stopped, window.bounds) + configureGestures() + touch.send(TouchPhase.Move, point + Offset(180f * systemScale, 0f)) + assertEquals(stopped, window.bounds, "Reenable must not recapture the old contact") + touch.send(TouchPhase.Up, point) + stopped + } + // The WM applies and reports a move asynchronously. Starting the fresh contact before the + // first move lands lets a late report or the shifting native origin leak into it. + await("first move applied by the WM") { + checkNotNull(window.findSkiaLayer()).canvas.locationOnScreen == + Point(origin.x + 60, origin.y) + } + onEdt { + touch.send(TouchPhase.Down, point) + touch.send(TouchPhase.Move, point + Offset(60f * systemScale, 0f)) + touch.send(TouchPhase.Up, point) + } + await("fresh caption press moving again") { window.x == stopped.x + 60 } + } + + @Test + fun disablingResizeDiscardsPendingBoundsAndNextResizeWorks() = withWindow { touch -> + val before = onEdt { window.bounds } + onEdt { + val point = Offset(content.right - 3 * density, content.center.y) + touch.send(TouchPhase.Down, point) + touch.send(TouchPhase.Move, point + Offset(60f * systemScale, 0f)) + configureGestures(resizable = false) + touch.send(TouchPhase.Up, point) + assertEquals(before, window.bounds, "Disabling resize must discard its queued update") + configureGestures() + } + // Let the disabled/enabled resizer layout settle before a fresh press. + await("resizer reenabled") { content.width == window.width * systemScale } + onEdt { + val point = Offset(content.right - 3 * density, content.center.y) + touch.send(TouchPhase.Down, point) + touch.send(TouchPhase.Move, point + Offset(60f * systemScale, 0f)) + touch.send(TouchPhase.Up, point) + assertEquals(before.width + 60, window.width) + } + } + + @Test + fun secondaryTouchCannotFinishOrTakeOverResizeInEitherReleaseOrder() = withWindow { touch -> + for (ownerFirst in listOf(true, false)) { + val before = onEdt { window.bounds } + onEdt { + val point = Offset(content.right - 3 * density, content.center.y) + touch.send(101, TouchPhase.Down, point) + touch.send(102, TouchPhase.Down, content.center) + touch.send(101, TouchPhase.Move, point + Offset(30f * systemScale, 0f)) + if (ownerFirst) { + touch.send(101, TouchPhase.Up, point) + assertEquals(before.width + 30, window.width) + touch.send(102, TouchPhase.Move, content.center + Offset(100f * systemScale, 0f)) + touch.send(102, TouchPhase.Up, content.center) + assertEquals(before.width + 30, window.width, "Secondary contact must not take over") + } else { + touch.send(102, TouchPhase.Up, content.center) + touch.send(101, TouchPhase.Move, point + Offset(60f * systemScale, 0f)) + touch.send(101, TouchPhase.Up, point) + assertEquals(before.width + 60, window.width, "Secondary release must not stop the owner") + } + } + val expectedWidth = before.width + if (ownerFirst) 30 else 60 + await("multi-touch resize layout") { + window.width == expectedWidth && content.width == expectedWidth * systemScale + } + val previousClicks = onEdt { clicks } + touch.tap(onEdt { content.center }) + await("click after all contacts release") { clicks == previousClicks + 1 } + } + } + + /** Reads the EWMH name of the running window manager, or `null` if none is advertised. */ + private fun windowManagerName(): String? { + val x11 = LinuxX11.INSTANCE + val display = x11.XOpenDisplay(null) ?: return null + try { + val root = x11.XDefaultRootWindow(display) + val check = readProperty(display, root, "_NET_SUPPORTING_WM_CHECK") { data, _ -> + X11.Window(data.getNativeLong(0).toLong()) + } ?: return null + return readProperty(display, check, "_NET_WM_NAME") { data, count -> + String(data.getByteArray(0, count), Charsets.UTF_8) + } + } finally { + x11.XCloseDisplay(display) + } + } + + private fun readProperty( + display: X11.Display, + window: X11.Window, + name: String, + read: (Pointer, Int) -> T + ): T? { + val x11 = LinuxX11.INSTANCE + val count = NativeLongByReference() + val data = PointerByReference() + val result = x11.XGetWindowProperty( + display, + window, + x11.XInternAtom(display, name, false), + NativeLong(0), + NativeLong(1024), + false, + X11.Atom(0), // AnyPropertyType + X11.AtomByReference(), + IntByReference(), + count, + NativeLongByReference(), + data + ) + try { + if (result != X11.Success || count.value.toInt() == 0) return null + return data.value?.let { read(it, count.value.toInt()) } + } finally { + data.value?.let { x11.XFree(it) } + } + } + + private fun withWindow( + block: LinuxInputWindowTest.(TouchDriver) -> Unit + ) { + LinuxInputWindowTest().use { fixture -> + fixture.open() + fixture.block(TouchDriver(fixture)) + } + } + + private fun LinuxInputWindowTest.mouse( + id: Int, + position: Offset + ): MouseEvent { + val canvas = checkNotNull(window.findSkiaLayer()).canvas + val scale = window.graphicsConfiguration.defaultTransform + val screen = screen(position) + val dragging = id == MouseEvent.MOUSE_DRAGGED + return MouseEvent( + canvas, + id, + System.currentTimeMillis(), + if (dragging || id == MouseEvent.MOUSE_PRESSED) InputEvent.BUTTON1_DOWN_MASK else 0, + (position.x / scale.scaleX).toInt(), + (position.y / scale.scaleY).toInt(), + (screen.x / scale.scaleX).toInt(), + (screen.y / scale.scaleY).toInt(), + 1, + false, + if (dragging) MouseEvent.NOBUTTON else MouseEvent.BUTTON1 + ) + } + + private class NativeMouse( + private val fixture: LinuxInputWindowTest + ) : AutoCloseable { + private val robot = Robot().apply { autoDelay = 30 } + private val helper = System.getenv("SALT_TEST_NATIVE_POINTER")?.let { + ProcessBuilder(it).redirectError(ProcessBuilder.Redirect.INHERIT).start() + } + private val commands = helper?.outputStream?.bufferedWriter() + private val replies = helper?.inputStream?.bufferedReader() + + init { + if (helper != null) assertEquals("ready", replies!!.readLine()) + } + + private fun send( + command: String + ) { + commands!!.apply { + write(command) + newLine() + flush() + } + assertEquals("ok", replies!!.readLine(), "Native pointer command: $command") + robot.delay(30) + } + + fun screenPoint( + position: Offset + ): Point = fixture.onEdt { + val point = fixture.screen(position) + val transform = fixture.window.graphicsConfiguration.defaultTransform + Point((point.x / transform.scaleX).toInt(), (point.y / transform.scaleY).toInt()) + } + + fun move( + point: Point + ) { + if (helper == null) { + robot.mouseMove(point.x, point.y) + } else { + val scale = fixture.onEdt { fixture.window.graphicsConfiguration.defaultTransform } + send("move ${point.x * scale.scaleX} ${point.y * scale.scaleY}") + } + robot.waitForIdle() + } + + fun press() { + // Native crossings and Compose hover may arrive after the AWT queue barrier. + settle() + pressButton() + } + + private fun pressButton() { + if (helper == null) robot.mousePress(InputEvent.BUTTON1_DOWN_MASK) else send("press") + } + + fun release() { + if (helper == null) robot.mouseRelease(InputEvent.BUTTON1_DOWN_MASK) else send("release") + robot.waitForIdle() + } + + fun doubleClick( + point: Point + ) { + val interval = Toolkit.getDefaultToolkit().getDesktopProperty("awt.multiClickInterval") as Int + robot.delay(interval + 50) + move(point) + press() + release() + robot.delay(40) + pressButton() + release() + settle() + } + + fun settle() { + robot.delay(150) + robot.waitForIdle() + } + + override fun close() { + try { + release() + } finally { + commands?.close() + if (helper != null && !helper.waitFor(2, TimeUnit.SECONDS)) helper.destroyForcibly() + replies?.close() + } + } + } + + private class TouchDriver( + private val fixture: LinuxInputWindowTest + ) { + private var id = 0L + private var time = 0L + private val input = fixture.onEdt { + LinuxTouchInput( + canvas = checkNotNull(fixture.window.findSkiaLayer()).canvas, + target = checkNotNull(DesktopTouchScene.create(fixture.window)), + gestures = fixture.gestures, + focus = {} + ) + } + + fun send( + phase: TouchPhase, + position: Offset + ) { + if (phase == TouchPhase.Down) id++ + send(id, phase, position) + } + + fun reset() = fixture.onEdt { input.cancel() } + + fun send( + id: Long, + phase: TouchPhase, + position: Offset + ) = fixture.onEdt { + time += 80 + val screen = fixture.screen(position) + assertEquals( + LinuxWindowGestures.TouchResult.Handled, + input.dispatch( + LinuxXInputSession.Touch( + id = id, + phase = phase, + x = position.x.toDouble(), + y = position.y.toDouble(), + rootX = screen.x.toDouble(), + rootY = screen.y.toDouble(), + modifiers = 0, + timeMillis = time, + x11Timestamp = 0, + directTouch = true + ), + canMoveNatively = false + ) + ) + } + + fun tap( + position: Offset + ) = fixture.onEdt { + // Keep the synthetic tap together instead of interleaving unrelated native events. + send(TouchPhase.Down, position) + send(TouchPhase.Up, position) + } + + fun doubleTap( + position: Offset + ) { + tap(position) + tap(position) + } + } +} diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInputTest.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInputTest.kt new file mode 100644 index 00000000..d5d7baee --- /dev/null +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInputTest.kt @@ -0,0 +1,254 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * Licensed under the Apache License, Version 2.0 + */ + +@file:OptIn( + androidx.compose.ui.test.ExperimentalTestApi::class, + androidx.compose.ui.ExperimentalComposeUiApi::class, + com.moriafly.salt.ui.UnstableSaltUiApi::class +) + +package com.moriafly.salt.ui.platform.linux + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.platform.ViewConfiguration +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.DesktopComposeUiTest +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.v2.runDesktopComposeUiTest +import androidx.compose.ui.window.Window +import com.moriafly.salt.ui.platform.linux.input.LinuxMouseInput +import com.moriafly.salt.ui.platform.linux.input.LinuxWindowGestures +import com.moriafly.salt.ui.platform.linux.input.LinuxWindowInput +import com.moriafly.salt.ui.platform.linux.window.LinuxCaptionBarHitTest +import com.moriafly.salt.ui.platform.linux.window.LinuxWindowGestureEffects +import com.moriafly.salt.ui.platform.linux.window.UndecoratedWindowResizer +import com.moriafly.salt.ui.platform.linux.x11.LinuxXInputSession +import com.moriafly.salt.ui.util.findSkiaLayer +import org.junit.Assume.assumeTrue +import java.awt.Dialog +import java.awt.EventQueue +import java.awt.GraphicsEnvironment +import java.awt.Toolkit +import java.awt.event.InputEvent +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.MouseWheelEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Exercises the real window delivery path with samples injected at the native callback / AWT + * source boundary. Native connection negotiation and hardware delivery are outside these tests. + */ +class LinuxWindowInputTest { + @Test + fun applicationModalBlocksNativeOwnerHoverAndClosingRestoresIt() = withWindow { fixture -> + var input: LinuxWindowInput? = null + val dialog = runOnUiThread { + Dialog(fixture.window, "Modal input regression", Dialog.ModalityType.APPLICATION_MODAL).apply { + setBounds(fixture.window.x + fixture.window.width + 20, fixture.window.y, 200, 120) + } + } + try { + runOnUiThread { + input = assertNotNull(LinuxWindowInput.create(fixture.window, fixture.gestures, null)) + fixture.received.clear() + fixture.hover(assertNotNull(input)) + assertTrue(MouseEvent.MOUSE_ENTERED in fixture.received) + assertTrue(MouseEvent.MOUSE_MOVED in fixture.received) + } + EventQueue.invokeLater { dialog.isVisible = true } + waitUntil(timeoutMillis = 5_000) { dialog.isShowing } + runOnUiThread { + fixture.received.clear() + fixture.hover(assertNotNull(input)) + assertTrue(fixture.received.isEmpty(), "Blocked owner received hover: ${fixture.received}") + dialog.dispose() + fixture.hover(assertNotNull(input)) + assertTrue(MouseEvent.MOUSE_ENTERED in fixture.received) + assertTrue(MouseEvent.MOUSE_MOVED in fixture.received) + } + } finally { + runOnUiThread { + dialog.dispose() + input?.close() + } + } + } + + @Test + fun unavailableXi2RestoresAwtHoverClickWheelAndDragDelivery() = withWindow { fixture -> + runOnUiThread { + // Window setup can deliver crossings before the native source is attached. + fixture.canvas.dispatchEvent(fixture.mouse(MouseEvent.MOUSE_ENTERED)) + assertTrue(MouseEvent.MOUSE_ENTERED in fixture.received) + LinuxMouseInput(fixture.window, fixture.canvas).use { source -> + // Start measuring in this EDT turn so setup events cannot pollute the result. + fixture.received.clear() + val hover = fixture.mouse(MouseEvent.MOUSE_MOVED) + source.dispatchAwt(hover, fixture.canvas::dispatchEvent) + assertTrue(fixture.received.isEmpty(), "AWT hover waits for native negotiation: ${fixture.received}") + + // This is the capability result published by LinuxWindowInput.onClosed after + // native initialization fails. Do not fake or replace a system libXi library. + source.updateNativeCapabilities(LinuxXInputSession.Capabilities(touch = false, hover = false)) + val expected = listOf( + MouseEvent.MOUSE_ENTERED, + MouseEvent.MOUSE_MOVED, + MouseEvent.MOUSE_PRESSED, + MouseEvent.MOUSE_DRAGGED, + MouseEvent.MOUSE_RELEASED, + MouseEvent.MOUSE_CLICKED, + MouseEvent.MOUSE_WHEEL, + MouseEvent.MOUSE_EXITED + ) + expected.forEach { id -> + source.dispatchAwt(fixture.mouse(id)) { event -> + if (event.id == MouseEvent.MOUSE_RELEASED) { + assertFalse(event.modifiersEx and InputEvent.BUTTON1_DOWN_MASK != 0) + } + fixture.canvas.dispatchEvent(event) + } + } + assertEquals(expected, fixture.received) + } + } + } + + private fun withWindow(block: DesktopComposeUiTest.(InputFixture) -> Unit) { + assumeTrue(System.getProperty("os.name").startsWith("Linux")) + assumeTrue(!GraphicsEnvironment.isHeadless()) + assumeTrue(Toolkit.getDefaultToolkit().javaClass.name == "sun.awt.X11.XToolkit") + runDesktopComposeUiTest { + lateinit var window: ComposeWindow + setContent { + Window(onCloseRequest = {}, title = "SaltUI input delivery regression") { + window = this.window + Box(Modifier.fillMaxSize().testTag("input-window")) + } + } + onNodeWithTag("input-window").assertExists() + val fixture = runOnUiThread { InputFixture(window) } + try { + block(fixture) + } finally { + runOnUiThread { fixture.close() } + } + } + } + + private class InputFixture(val window: ComposeWindow) : AutoCloseable { + val canvas = checkNotNull(window.findSkiaLayer()).canvas + val received = mutableListOf() + private val listener = object : MouseAdapter() { + override fun mouseEntered(event: MouseEvent) { received += event.id } + override fun mouseExited(event: MouseEvent) { received += event.id } + override fun mouseMoved(event: MouseEvent) { received += event.id } + override fun mousePressed(event: MouseEvent) { received += event.id } + override fun mouseDragged(event: MouseEvent) { received += event.id } + override fun mouseReleased(event: MouseEvent) { received += event.id } + override fun mouseClicked(event: MouseEvent) { received += event.id } + override fun mouseWheelMoved(event: MouseWheelEvent) { received += event.id } + } + private val regions = UndecoratedWindowResizer {} + val gestures = LinuxWindowGestures( + window = window, + caption = LinuxCaptionBarHitTest(), + resizer = regions, + captionHovered = mutableStateOf(false), + effects = LinuxWindowGestureEffects(window, regions) + ) + private val uninstall = gestures.install() + + init { + gestures.configure( + moveable = false, + resizable = false, + canDrag = false, + canMaximize = false, + viewConfiguration = object : ViewConfiguration { + override val longPressTimeoutMillis = 500L + override val doubleTapTimeoutMillis = 300L + override val doubleTapMinTimeMillis = 40L + override val touchSlop = 8f + } + ) + canvas.addMouseListener(listener) + canvas.addMouseMotionListener(listener) + canvas.addMouseWheelListener(listener) + } + + fun hover(input: LinuxWindowInput) { + // Select the callback's source explicitly; readiness of the host XI2 reader is not + // part of this deterministic modality test. All calls happen in the same EDT turn. + input.onReady(LinuxXInputSession.Capabilities(touch = false, hover = true)) + val origin = canvas.locationOnScreen + val scale = canvas.graphicsConfiguration.defaultTransform + for (phase in listOf(LinuxXInputSession.HoverPhase.Enter, LinuxXInputSession.HoverPhase.Move)) { + input.onHover( + LinuxXInputSession.Hover( + phase = phase, + x = 20.0, + y = 20.0, + rootX = origin.x * scale.scaleX + 20, + rootY = origin.y * scale.scaleY + 20, + modifiers = 0, + directTouch = false, + emulated = false + ) + ) + } + } + + fun mouse(id: Int): MouseEvent { + if (id == MouseEvent.MOUSE_WHEEL) { + return MouseWheelEvent( + canvas, + id, + 0, + 0, + 20, + 20, + 0, + false, + MouseWheelEvent.WHEEL_UNIT_SCROLL, + 3, + 1 + ) + } + val button = if (id in listOf(MouseEvent.MOUSE_PRESSED, MouseEvent.MOUSE_RELEASED, MouseEvent.MOUSE_CLICKED)) { + MouseEvent.BUTTON1 + } else { + MouseEvent.NOBUTTON + } + return MouseEvent( + canvas, + id, + 0, + InputEvent.BUTTON1_DOWN_MASK, + 20, + 20, + 0, + false, + button + ) + } + + override fun close() { + canvas.removeMouseListener(listener) + canvas.removeMouseMotionListener(listener) + canvas.removeMouseWheelListener(listener) + uninstall() + } + } +} diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/window/SaltWindowTest.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/window/SaltWindowTest.kt index 0708aa96..311de775 100644 --- a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/window/SaltWindowTest.kt +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/window/SaltWindowTest.kt @@ -39,7 +39,7 @@ import com.moriafly.salt.core.os.OS import com.moriafly.salt.ui.SaltTheme import com.moriafly.salt.ui.Text import com.moriafly.salt.ui.UnstableSaltUiApi -import com.moriafly.salt.ui.platform.linux.LinuxClientShadow +import com.moriafly.salt.ui.platform.linux.decoration.LinuxClientShadow import com.moriafly.salt.ui.platform.windows.BasicWindowProc import com.moriafly.salt.ui.platform.windows.ComposeWindowProc import com.moriafly.salt.ui.platform.windows.HitTestResult diff --git a/ui2/src/desktopTest/native/linux-window-pointer.cpp b/ui2/src/desktopTest/native/linux-window-pointer.cpp new file mode 100644 index 00000000..2e5d4851 --- /dev/null +++ b/ui2/src/desktopTest/native/linux-window-pointer.cpp @@ -0,0 +1,49 @@ +// Native pointer input for the same JVM tests on an isolated KWin Wayland desktop. +// Build: c++ linux-window-pointer.cpp -o native-pointer $(pkg-config --cflags --libs Qt6Core wayland-client) -I/usr/include/KWayland -lKWaylandClient +// Run desktopTest with SALT_TEST_NATIVE_POINTER pointing to this executable. +// X11 CI uses java.awt.Robot and does not require this helper. +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) { + QCoreApplication app(argc, argv); + auto display = wl_display_connect(nullptr); + if (!display) return 1; + KWayland::Client::Registry registry; + KWayland::Client::FakeInput *input = nullptr; + QObject::connect(®istry, &KWayland::Client::Registry::fakeInputAnnounced, + [&](quint32 name, quint32 version) { input = registry.createFakeInput(name, version); }); + registry.create(display); + registry.setup(); + wl_display_roundtrip(display); + if (!input) return 2; + input->authenticate("SaltUI window gesture tests", "Isolated native pointer input"); + wl_display_roundtrip(display); + std::cout << "ready" << std::endl; + std::string line; + while (std::getline(std::cin, line)) { + std::istringstream args(line); + std::string command; + args >> command; + if (command == "move") { + double x, y; + if (!(args >> x >> y)) return 3; + input->requestPointerMoveAbsolute(QPointF(x, y)); + } else if (command == "press") { + input->requestPointerButtonPress(Qt::LeftButton); + } else if (command == "release") { + input->requestPointerButtonRelease(Qt::LeftButton); + } else { + return 3; + } + if (wl_display_roundtrip(display) < 0) return 4; + std::cout << "ok" << std::endl; + } + input->requestPointerButtonRelease(Qt::LeftButton); + wl_display_roundtrip(display); +}