From 37d2622bd105f1260c723e13eed15a30beb13ffa Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 14:45:46 +0800 Subject: [PATCH 01/35] feat(Linux): implement Linux touch input handling and refactor caption bar input --- .../linux/LinuxCaptionBarDragHandler.kt | 175 ------- .../platform/linux/LinuxCaptionBarHitTest.kt | 16 - .../ui/platform/linux/LinuxCaptionBarInput.kt | 55 +++ .../linux/LinuxFallbackWindowMover.kt | 16 +- .../salt/ui/platform/linux/LinuxMouseInput.kt | 174 +++++++ .../linux/LinuxSaltDialogWindowFrame.kt | 28 +- .../ui/platform/linux/LinuxSaltWindowFrame.kt | 32 +- .../salt/ui/platform/linux/LinuxTouchEvent.kt | 33 ++ .../salt/ui/platform/linux/LinuxTouchInput.kt | 123 +++++ .../ui/platform/linux/LinuxWindowGestures.kt | 407 +++++++++++++++ .../ui/platform/linux/LinuxWindowInput.kt | 208 ++++++++ .../platform/linux/LinuxWindowMouseInput.kt | 45 +- .../salt/ui/platform/linux/LinuxXInput2.kt | 461 +++++++++++++++++ .../ui/platform/linux/LinuxXInputSession.kt | 464 ++++++++++++++++++ .../linux/UndecoratedWindowResizer.kt | 168 +------ .../com/moriafly/salt/ui/window/CaptionBar.kt | 4 +- 16 files changed, 2029 insertions(+), 380 deletions(-) delete mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarDragHandler.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxMouseInput.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInput2.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt 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/LinuxCaptionBarHitTest.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarHitTest.kt index b0f8feb9..a16cbdb6 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/LinuxCaptionBarHitTest.kt @@ -17,18 +17,13 @@ package com.moriafly.salt.ui.platform.linux -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 @@ -82,14 +77,3 @@ internal class LinuxCaptionBarHitTest { 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/LinuxCaptionBarInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt new file mode 100644 index 00000000..68d888b7 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt @@ -0,0 +1,55 @@ +/* + * 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.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 + +/** + * Registers caption geometry and forwards touch drag requests to the window gesture owner. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun Modifier.linuxCaptionBarInput(moveable: Boolean): Modifier { + val hitTest = LocalLinuxCaptionBarHitTest.current + val modifier = if (hitTest != null) { + val region = remember(hitTest) { LinuxCaptionBarHitTest.Region() } + DisposableEffect(hitTest, region) { + hitTest.regions.add(region) + onDispose { hitTest.regions.remove(region) } + } + onGloballyPositioned { region.bounds = it.boundsInWindow() } + } else { + this + } + 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 (moveable && gestures.requestCaptionDrag(native)) { + event.changes.forEach { it.consume() } + } + } +} 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 index dc6ae85a..3edd9229 100644 --- 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 @@ -21,10 +21,9 @@ 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. + * Fallback window movement in AWT screen coordinates, shared by mouse and touch. * * From `androidx.compose.foundation.window.StandardMoveHandler` */ @@ -36,20 +35,20 @@ internal class LinuxFallbackWindowMover( private var dragStartWindowPos: Point? = null private var isDragging = false - fun startDrag(e: MouseEvent) { - dragStartScreenPos = Point(e.xOnScreen, e.yOnScreen) + fun startDrag(screenPosition: Point) { + dragStartScreenPos = Point(screenPosition) dragStartWindowPos = window.location isDragging = true } - fun updateDrag(e: MouseEvent) { + fun updateDrag(screenPosition: Point) { 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) + val newX = startWindow.x + (screenPosition.x - startScreen.x) + val newY = startWindow.y + (screenPosition.y - startScreen.y) window.location = Point(newX, newY) } @@ -62,7 +61,4 @@ internal class LinuxFallbackWindowMover( dragStartScreenPos = null dragStartWindowPos = null } - - val dragging: Boolean - get() = isDragging } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxMouseInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxMouseInput.kt new file mode 100644 index 00000000..31f9a83e --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxMouseInput.kt @@ -0,0 +1,174 @@ +/* + * 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 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. + * 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 val unregister = LinuxWindowMouseInput.registerSource(window, this) + + 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 + } + 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 || + !canvas.isShowing || 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/LinuxSaltDialogWindowFrame.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxSaltDialogWindowFrame.kt index 836231f6..054e0a25 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 @@ -75,7 +75,6 @@ internal fun DialogWindowScope.LinuxSaltDialogWindowFrame( val captionBarHitTest = remember { LinuxCaptionBarHitTest() } val undecoratedWindowResizer = remember { UndecoratedWindowResizer( - window = window, onResizeEdgeChange = { edge -> currentProperties.onResizeEdgeChange(window, edge) } @@ -85,6 +84,15 @@ 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 + ) + } + CompositionLocalProvider( LocalSaltWindowInfo provides SaltWindowInfo( captionBarHeight = properties.captionBarHeight, @@ -96,16 +104,14 @@ 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( + gestures = windowGestures, + moveable = properties.moveable, + resizable = resizable + ) LaunchedEffect(decoration) { undecoratedWindowResizer.resizerThickness = @@ -124,10 +130,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..0b84b01a 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 @@ -77,7 +77,6 @@ internal fun FrameWindowScope.LinuxSaltWindowFrame( val captionBarHitTest = remember { LinuxCaptionBarHitTest() } val undecoratedWindowResizer = remember { UndecoratedWindowResizer( - window = window, onResizeEdgeChange = { edge -> currentProperties.onResizeEdgeChange(window, edge) } @@ -94,6 +93,15 @@ internal fun FrameWindowScope.LinuxSaltWindowFrame( 0.dp } + val windowGestures = remember(window) { + LinuxWindowGestures( + window = window, + caption = captionBarHitTest, + resizer = undecoratedWindowResizer, + captionHovered = isHitTestInCaptionBar + ) + } + CompositionLocalProvider( LocalSaltWindowInfo provides SaltWindowInfo( captionBarHeight = properties.captionBarHeight, @@ -105,18 +113,16 @@ 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( + gestures = windowGestures, + moveable = properties.moveable, + resizable = resizable && !isMaximizedOrFullScreen, + canDrag = windowState.placement != WindowPlacement.Fullscreen, + canMaximize = resizable && properties.maximizeOrRestoreButtonEnabled + ) if (clientShadow) { LinuxClientShadowEffect( @@ -137,10 +143,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/LinuxTouchEvent.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt new file mode 100644 index 00000000..95b3821b --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt @@ -0,0 +1,33 @@ +/* + * 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.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. + * 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 +) diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt new file mode 100644 index 00000000..e3c78849 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt @@ -0,0 +1,123 @@ +/* + * 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.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 java.awt.Component +import java.awt.Dialog +import java.awt.Window +import kotlin.math.roundToInt + +/** + * EDT-confined adaptation from native contact samples to Compose and window gestures. + * Native connections, registration and mouse routing belong to LinuxWindowInput. + */ +@OptIn(InternalComposeUiApi::class) +internal class LinuxTouchInput( + private val window: Window, + private val canvas: Component, + private val target: DesktopTouchScene, + private val gestures: LinuxWindowGestures +) { + private val pointers = TouchPointerState() + + fun onTouch(sample: LinuxXInputSession.Touch): Boolean { + if (!sample.directTouch) return false + if (!canReceiveTouch()) { + cancel() + return false + } + 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 + ) + if (type == PointerEventType.Press) canvas.requestFocusInWindow() + 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 + ) + return true + } + + fun cancel() { + gestures.resetTouchInput() + cancelCompose() + } + + private fun cancelCompose() { + if (pointers.clear() && !target.isDisposed) target.scene.cancelPointerInput() + } + + private fun canReceiveTouch(): Boolean = canvas.isShowing && window.isEnabled && + !target.isDisposed && Window.getWindows().none { candidate -> + candidate is Dialog && candidate.isShowing && candidate.isModal && + generateSequence(window) { it.owner }.none { it === candidate } && + when (candidate.modalityType) { + Dialog.ModalityType.DOCUMENT_MODAL -> + generateSequence(window) { it.owner }.last() === + generateSequence(candidate as Window) { it.owner }.last() + + Dialog.ModalityType.APPLICATION_MODAL -> + window.modalExclusionType == Dialog.ModalExclusionType.NO_EXCLUDE + + Dialog.ModalityType.TOOLKIT_MODAL -> + window.modalExclusionType != Dialog.ModalExclusionType.TOOLKIT_EXCLUDE + + else -> false + } + } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt new file mode 100644 index 00000000..b4c8a1d7 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt @@ -0,0 +1,407 @@ +/* + * 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.SideEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.input.pointer.PointerEventType +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.window.WindowResizeEdge +import java.awt.Frame +import java.awt.Point +import java.awt.Rectangle +import java.awt.Window +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.MouseEvent + +internal val LocalLinuxWindowGestures = staticCompositionLocalOf { null } + +@Composable +internal fun LinuxWindowInputEffect( + gestures: LinuxWindowGestures, + moveable: Boolean, + resizable: Boolean, + canDrag: Boolean = true, + canMaximize: Boolean = false +) { + SideEffect { + gestures.configure( + moveable = moveable, + resizable = resizable, + canDrag = canDrag, + canMaximize = canMaximize + ) + } + DisposableEffect(gestures) { + val uninstall = gestures.install() + onDispose { uninstall() } + } +} + +/** + * The window's single move/resize owner, confined to the AWT event thread. + * + * Mouse and touch use the same geometry and movement policy. Borders take precedence over the + * caption. Compose resolves caption/control overlap, but can only request a drag during the first + * press dispatched here. This owner cancels Compose after dispatch returns, then captures that + * contact until release. Other fingers never inherit a window gesture, even after its owner lifts. + */ +@OptIn(UnstableSaltUiApi::class) +internal class LinuxWindowGestures( + private val window: Window, + private val caption: LinuxCaptionBarHitTest, + private val resizer: UndecoratedWindowResizer, + private val captionHovered: State +) : LinuxWindowMouseInput.Handler { + private var moveable = false + private var canDrag = true + private var canMaximize = false + private var gesture: Gesture? = null + private val fallbackMover = LinuxFallbackWindowMover(window) + private var useNativeDrag = true + private var restoreWidth = window.width + private var suppressedClick: Click? = null + private val contacts = mutableSetOf() + private var capturedTouches = false + private var dispatchingPress: LinuxTouchEvent? = null + private var captionRequested = false + + fun configure( + moveable: Boolean, + resizable: Boolean, + canDrag: Boolean, + canMaximize: Boolean + ) { + resizer.enabled = resizable + this.moveable = moveable + this.canDrag = canDrag + this.canMaximize = canMaximize + if ( + (gesture is Gesture.Move && (!moveable || !canDrag)) || + (gesture is Gesture.Resize && !resizer.enabled) + ) { + finishGesture() + } + if (!resizer.enabled) resizer.showEdge(WindowResizeEdge.None) + } + + fun install(): () -> Unit { + val listener = object : ComponentAdapter() { + override fun componentResized(event: ComponentEvent) = onWindowResized() + } + window.addComponentListener(listener) + val unregister = LinuxWindowMouseInput.register(window, this, priority = 1) + val input = LinuxWindowInput.create(window, this) + return { + input?.close() + unregister() + window.removeComponentListener(listener) + } + } + + /** + * A request from a caption that passed Compose hit testing, not an executable event callback. + * Requests outside this owner's first-press dispatch cannot take over an existing gesture. + */ + fun requestCaptionDrag(event: LinuxTouchEvent): Boolean { + if (dispatchingPress !== event || !moveable || !canDrag) return false + captionRequested = true + return true + } + + fun dispatchTouch( + event: LinuxTouchEvent, + dispatchToCompose: () -> Unit, + cancelCompose: () -> Unit + ) { + val firstContact = contacts.isEmpty() + if (event.type == PointerEventType.Press) { + if (!contacts.add(event.id)) return + } else if (event.id !in contacts) { + return + } + try { + if (capturedTouches || gesture?.pointer == Pointer.Mouse) { + capturedTouches = true + if (gesture?.pointer == Pointer.Touch(event.id)) { + when (event.type) { + PointerEventType.Move -> updateGesture(event.screenPoint()) + PointerEventType.Release -> finishGesture() + } + } + return + } + if (firstContact && event.type == PointerEventType.Press) { + val edge = resizer.edgeAt(event.contentPosition) + if (edge != WindowResizeEdge.None) { + capturedTouches = true + cancelCompose() + beginResize(Pointer.Touch(event.id), edge, event.screenPoint()) + return + } + dispatchingPress = event + captionRequested = false + try { + dispatchToCompose() + if (captionRequested) { + capturedTouches = true + cancelCompose() + beginMove(Pointer.Touch(event.id), event.screenPoint()) + } + } finally { + dispatchingPress = null + captionRequested = false + } + } else { + dispatchToCompose() + } + } catch (exception: Throwable) { + cancel() + cancelCompose() + throw exception + } finally { + if (event.type == PointerEventType.Release) { + contacts.remove(event.id) + if (contacts.isEmpty()) capturedTouches = false + } + } + } + + override fun onMouseEvent(event: MouseEvent): Boolean { + // A real mouse cannot steal a touch sequence, and vice versa. + if (contacts.isNotEmpty()) { + return event.id == MouseEvent.MOUSE_PRESSED || event.id == MouseEvent.MOUSE_DRAGGED || + event.id == MouseEvent.MOUSE_RELEASED || event.id == MouseEvent.MOUSE_CLICKED + } + val edge = resizer.edgeAt(LinuxWindowMouseInput.positionInContent(event, window)) + when (event.id) { + MouseEvent.MOUSE_MOVED, MouseEvent.MOUSE_ENTERED -> resizer.showEdge(edge) + MouseEvent.MOUSE_EXITED -> if (gesture == null) resizer.showEdge(WindowResizeEdge.None) + MouseEvent.MOUSE_PRESSED -> { + if (event.button != MouseEvent.BUTTON1) return gesture != null + finishGesture() + suppressedClick = null + if (edge != WindowResizeEdge.None) { + beginResize(Pointer.Mouse, edge, event.locationOnScreen) + return true + } + if (!moveable || !canDrag || !caption.contains( + position = LinuxWindowMouseInput.positionInContent(event, window), + window = window, + // Enter and press can arrive before recomposition. + isHovered = captionHovered.value + ) + ) { + return false + } + beginMove(Pointer.Mouse, event.locationOnScreen, event) + return true + } + + MouseEvent.MOUSE_DRAGGED -> { + if (gesture?.pointer != Pointer.Mouse) return false + updateGesture(event.locationOnScreen, event) + return true + } + + MouseEvent.MOUSE_RELEASED -> { + val active = gesture ?: return false + if (event.button == MouseEvent.BUTTON1) { + suppressedClick = if (active is Gesture.Move) Click.Caption else Click.Resize + finishGesture() + resizer.showEdge(edge) + } + return true + } + + MouseEvent.MOUSE_CLICKED -> { + val click = suppressedClick ?: return false + if (event.button != MouseEvent.BUTTON1) return false + suppressedClick = null + if (click == Click.Caption && event.clickCount == 2 && + moveable && canDrag && canMaximize + ) { + (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 afterMouseEvent(event: MouseEvent) = resizer.afterMouseEvent(event) + + override fun cancel() { + finishGesture() + suppressedClick = null + dispatchingPress = null + captionRequested = false + // Keep known contacts quarantined until release or an explicit native input reset. + capturedTouches = contacts.isNotEmpty() + } + + fun resetTouchInput() { + // XI2 device changes and touch-channel shutdown must not cancel an AWT mouse drag. + if (gesture?.pointer is Pointer.Touch) finishGesture() + dispatchingPress = null + captionRequested = false + contacts.clear() + capturedTouches = false + } + + private fun beginMove(pointer: Pointer, position: Point, event: MouseEvent? = null) { + gesture = Gesture.Move(pointer, position, event) + } + + private fun beginResize(pointer: Pointer, edge: WindowResizeEdge, position: Point) { + gesture = Gesture.Resize(pointer, position, edge, window.bounds) + resizer.showEdge(edge) + } + + private fun updateGesture(position: Point, event: MouseEvent? = null) { + when (val active = gesture) { + is Gesture.Move -> { + active.latestPosition = position + active.latestMouseEvent = event + if (active.restoreOffset != null) return + if (active.pending) { + active.pending = false + val frame = window as? Frame + if (frame?.extendedState == Frame.MAXIMIZED_BOTH) { + val localX = active.start.x - window.x + active.restoreOffset = Point( + (restoreWidth * localX.toFloat() / window.width).toInt(), + active.start.y - window.y + ) + frame.extendedState = Frame.NORMAL + return + } + startMover(active.start, active.startMouseEvent) + } + fallbackMover.updateDrag(position) + } + + is Gesture.Resize -> resize(active, position) + null -> Unit + } + } + + private fun startMover(position: Point, event: MouseEvent?) { + if (event != null && useNativeDrag) { + if (LinuxX11WindowMover.tryMove(window, event)) return + useNativeDrag = false + } + fallbackMover.startDrag(position) + } + + private fun onWindowResized() { + if ((window as? Frame)?.extendedState == Frame.MAXIMIZED_BOTH) return + restoreWidth = window.width + val active = gesture as? Gesture.Move ?: return + val offset = active.restoreOffset ?: return + active.restoreOffset = null + // Position only after native restore; moving earlier overwrites the WM's saved geometry. + window.setLocation(active.latestPosition.x - offset.x, active.latestPosition.y - offset.y) + startMover(active.latestPosition, active.latestMouseEvent) + } + + private fun resize(active: Gesture.Resize, position: Point) { + val dx = position.x - active.start.x + val dy = position.y - active.start.y + val initial = active.bounds + var x = initial.x + var y = initial.y + var width = initial.width + var height = initial.height + when (active.edge) { + WindowResizeEdge.Left, WindowResizeEdge.TopLeft, WindowResizeEdge.BottomLeft -> { + width = (initial.width - dx).coerceAtLeast(window.minimumSize.width) + x = initial.x + initial.width - width + } + + WindowResizeEdge.Right, WindowResizeEdge.TopRight, WindowResizeEdge.BottomRight -> + width = (initial.width + dx).coerceAtLeast(window.minimumSize.width) + + else -> Unit + } + when (active.edge) { + WindowResizeEdge.Top, WindowResizeEdge.TopLeft, WindowResizeEdge.TopRight -> { + height = (initial.height - dy).coerceAtLeast(window.minimumSize.height) + y = initial.y + initial.height - height + } + + WindowResizeEdge.Bottom, WindowResizeEdge.BottomLeft, WindowResizeEdge.BottomRight -> + height = (initial.height + dy).coerceAtLeast(window.minimumSize.height) + + else -> Unit + } + window.setLocation(x, y) + window.setSize(width, height) + } + + private fun finishGesture() { + gesture = null + fallbackMover.endDrag() + resizer.showEdge(WindowResizeEdge.None) + } + + private fun LinuxTouchEvent.screenPoint() = Point(screenPosition.x, screenPosition.y) + + private sealed interface Pointer { + data object Mouse : Pointer + + data class Touch( + val id: Long + ) : Pointer + } + + private sealed class Gesture( + val pointer: Pointer, + val start: Point + ) { + class Move( + pointer: Pointer, + start: Point, + val startMouseEvent: MouseEvent? + ) : Gesture(pointer, start) { + var pending = true + var latestPosition = start + var latestMouseEvent = startMouseEvent + var restoreOffset: Point? = null + } + + class Resize( + pointer: Pointer, + start: Point, + val edge: WindowResizeEdge, + val bounds: Rectangle + ) : Gesture(pointer, start) + } + + private enum class Click { Caption, Resize } +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt new file mode 100644 index 00000000..2f2ac157 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt @@ -0,0 +1,208 @@ +/* + * 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.ui.InternalComposeUiApi +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.platform.input.DesktopTouchScene +import com.moriafly.salt.ui.util.findSkiaLayer +import com.sun.jna.Native +import java.awt.Component +import java.awt.Toolkit +import java.awt.Window +import java.awt.event.HierarchyEvent +import java.awt.event.HierarchyListener +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent + +/** + * Attaches the native session to the canvas lifetime and connects its independent input channels. + * 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 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 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, + 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() { + 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): Boolean = touch?.onTouch(event) == true + + override fun onHover(event: LinuxXInputSession.Hover) = mouse.onHover(event) + + 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() + 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): LinuxWindowInput? { + // XIDs and libXi do not apply to JBR's native Wayland toolkit. + if (Toolkit.getDefaultToolkit().javaClass.name != "sun.awt.X11.XToolkit") return null + val canvas = window.findSkiaLayer()?.canvas ?: return null + val target = DesktopTouchScene.create(window) + val touch = target?.let { + LinuxTouchInput( + window = window, + canvas = canvas, + target = target, + gestures = gestures + ) + } + // Hover remains usable even when Compose's private scene adapter is unavailable. + return LinuxWindowInput( + window = window, + canvas = canvas, + 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/LinuxWindowMouseInput.kt index 6285a3fb..02e10b19 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/LinuxWindowMouseInput.kt @@ -30,7 +30,7 @@ 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 { @@ -48,6 +48,7 @@ internal object LinuxWindowMouseInput { ) private val handlers = mutableMapOf>() + private val sources = mutableMapOf() private var queue: WindowEventQueue? = null fun register( @@ -73,10 +74,29 @@ 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 { // Compose coordinates start at its canvas, which can be inset by AWT decoration. @@ -98,7 +118,7 @@ internal object LinuxWindowMouseInput { } 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 +126,35 @@ 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/LinuxXInput2.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInput2.kt new file mode 100644 index 00000000..b49ec667 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInput2.kt @@ -0,0 +1,461 @@ +/* + * 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.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 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( + device: Int, + vararg events: Int + ) : Structure() { + @JvmField + var device: Int = device + + @JvmField + var length: Int = 4 + + @JvmField + var mask: Pointer = Memory(length.toLong()).apply { + clear() + events.forEach { event -> + val offset = (event / 8).toLong() + setByte(offset, (getByte(offset).toInt() or (1 shl (event % 8))).toByte()) + } + } + } + + @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", + "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 + const val TOUCH_BEGIN = 18 + const val TOUCH_UPDATE = 19 + const val TOUCH_END = 20 + const val ACCEPT_TOUCH = 6 + const val REJECT_TOUCH = 7 + const val POINTER_EMULATED = 1 shl 16 + } +} + +@Suppress("FunctionName") +internal interface LinuxTouchX11 : X11 { + 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: LinuxTouchX11 = Native.load("X11", LinuxTouchX11::class.java) + } +} + +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/LinuxXInputSession.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt new file mode 100644 index 00000000..084edce4 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt @@ -0,0 +1,464 @@ +/* + * 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.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 requestTouch: Boolean, + private val listener: Listener +) { + interface Listener { + fun onReady(capabilities: Capabilities) + + fun onTouch(event: Touch): Boolean + + 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 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 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 = LinuxTouchX11.instance + val xi = LinuxXInput2.instance + val display = x11.XOpenDisplay(null) ?: return + 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) + // Both channels need an up-to-date device inventory. Neither depends on the other + // channel's registration succeeding; XI 2.0 can still supply hover without touch. + if (xi.XISelectEvents( + display, + x11.XDefaultRootWindow(display), + LinuxXInput2.EventMask( + LinuxXInput2.ALL_DEVICES, + LinuxXInput2.DEVICE_CHANGED, + LinuxXInput2.HIERARCHY_CHANGED + ), + 1 + ) != 0 + ) { + return + } + val supportsTouch = major.value > 2 || minor.value >= 2 + val touch = requestTouch && supportsTouch && grabTouches(display, xid) + val hover = selectHover(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 + ) + } finally { + // This removes every registration and grab, including partial initialization. + x11.XCloseDisplay(display) + } + } + + 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 selectHover(display: X11.Display, xid: X11.Window): Boolean = + LinuxXInput2.instance.XISelectEvents( + display, + xid, + LinuxXInput2.EventMask( + LinuxXInput2.ALL_MASTER_DEVICES, + LinuxXInput2.MOTION, + LinuxXInput2.ENTER, + LinuxXInput2.LEAVE + ), + 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 + ) { + val x11 = LinuxTouchX11.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) { + if (x11.XPending(display) == 0) { + // 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) continue // EINTR + error("XI2 connection polling failed") + } + if (result > 0 && fd.getShort(6).toInt() and 0x38 != 0) { + error("XI2 connection closed while polling") + } + continue + } + x11.XNextEvent(display, event) + if (event.type != 35) continue // GenericEvent + val cookie = LinuxTouchX11.EventCookie(event.pointer) + if (cookie.extension != opcode || x11.XGetEventData(display, cookie) == 0) continue + try { + when (cookie.eventType) { + LinuxXInput2.DEVICE_CHANGED, LinuxXInput2.HIERARCHY_CHANGED -> { + directDevices = queryDirectDevices(display) + // Finish cancellation before a new press can be accepted. + dispatchAndWait { + if (!stopRequested) listener.onDevicesChanged() + } + } + + 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 + ) + } + + 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 onTouch( + display: X11.Display, + xid: X11.Window, + native: LinuxXInput2.DeviceEvent, + directTouch: Boolean + ) { + val epoch = touchEpoch + val sample = 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, + directTouch = directTouch + ) + if (sample.phase == TouchPhase.Down) { + var accept = false + dispatchAndWait { + if (!stopRequested) { + // A stale direct-touch press is still claimed, so cancellation cannot + // promote it into an AWT click. It must not reach Compose or request focus. + accept = if (epoch == touchEpoch) { + listener.onTouch(sample) + } else { + sample.directTouch + } + } + } + LinuxXInput2.instance.XIAllowTouchEvents( + display, + native.device, + native.detail, + xid, + if (accept) LinuxXInput2.ACCEPT_TOUCH else LinuxXInput2.REJECT_TOUCH + ) + LinuxTouchX11.instance.XFlush(display) + } else { + dispatch { + if (epoch == touchEpoch) listener.onTouch(sample) + } + } + } + + 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/UndecoratedWindowResizer.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/UndecoratedWindowResizer.kt index d14881cf..319e6e83 100644 --- 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 @@ -22,6 +22,7 @@ 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 @@ -34,53 +35,36 @@ 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 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 - 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) + 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 -> @@ -112,51 +96,21 @@ internal class UndecoratedWindowResizer( ) } - 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 - } + fun edgeAt(position: Offset): WindowResizeEdge = if (enabled) { + sideBounds.entries.firstOrNull { it.value.contains(position) }?.key ?: WindowResizeEdge.None + } else { + WindowResizeEdge.None + } - MouseEvent.MOUSE_CLICKED -> if (suppressClick && event.button == MouseEvent.BUTTON1) { - suppressClick = false - return true - } - } - return false + fun showEdge(edge: WindowResizeEdge) { + if (edge == hoveredSide) return + hoveredSide = edge + onResizeEdgeChange(edge) + resizeCursor = sideCursors[edge]?.let(LinuxResizeCursor::get) + if (edge == WindowResizeEdge.None) restoreCursor() } - override fun afterMouseEvent(event: MouseEvent) { + fun afterMouseEvent(event: MouseEvent) { val cursor = resizeCursor ?: return val component = event.component if (cursorComponent !== component) { @@ -168,21 +122,6 @@ internal class UndecoratedWindowResizer( 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 @@ -190,7 +129,7 @@ internal class UndecoratedWindowResizer( } @Composable - private fun Side(cursorId: Int, sides: Int) { + private fun Side(cursorId: Int, sides: WindowResizeEdge) { DisposableEffect(sides) { sideCursors[sides] = cursorId onDispose { @@ -208,59 +147,4 @@ internal class UndecoratedWindowResizer( } ) } - - 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/window/CaptionBar.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/window/CaptionBar.kt index 26f8c9c9..a3bef854 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 @@ -29,7 +29,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.linuxCaptionBarInput import com.moriafly.salt.ui.platform.windows.windowsCaptionBarTouchInput /** @@ -56,7 +56,7 @@ fun CaptionBarHitTest( .run { when (OS.current) { is OS.Windows -> windowsCaptionBarTouchInput(saltWindowProperties.moveable) - is OS.Linux -> linuxCaptionBarHitTest() + is OS.Linux -> linuxCaptionBarInput(saltWindowProperties.moveable) else -> this } } From 1a1945bbc9351919d95b15cdf58ce7109a82c495 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 15:09:45 +0800 Subject: [PATCH 02/35] refactor(Linux): simplify gesture handling by removing unused MouseEvent parameters --- .../ui/platform/linux/LinuxWindowGestures.kt | 26 +++++++++--------- .../ui/platform/linux/LinuxX11WindowMover.kt | 27 ++++++++++++------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt index b4c8a1d7..771db808 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt @@ -213,13 +213,13 @@ internal class LinuxWindowGestures( ) { return false } - beginMove(Pointer.Mouse, event.locationOnScreen, event) + beginMove(Pointer.Mouse, event.locationOnScreen) return true } MouseEvent.MOUSE_DRAGGED -> { if (gesture?.pointer != Pointer.Mouse) return false - updateGesture(event.locationOnScreen, event) + updateGesture(event.locationOnScreen) return true } @@ -274,8 +274,8 @@ internal class LinuxWindowGestures( capturedTouches = false } - private fun beginMove(pointer: Pointer, position: Point, event: MouseEvent? = null) { - gesture = Gesture.Move(pointer, position, event) + private fun beginMove(pointer: Pointer, position: Point) { + gesture = Gesture.Move(pointer, position) } private fun beginResize(pointer: Pointer, edge: WindowResizeEdge, position: Point) { @@ -283,11 +283,10 @@ internal class LinuxWindowGestures( resizer.showEdge(edge) } - private fun updateGesture(position: Point, event: MouseEvent? = null) { + private fun updateGesture(position: Point) { when (val active = gesture) { is Gesture.Move -> { active.latestPosition = position - active.latestMouseEvent = event if (active.restoreOffset != null) return if (active.pending) { active.pending = false @@ -301,7 +300,7 @@ internal class LinuxWindowGestures( frame.extendedState = Frame.NORMAL return } - startMover(active.start, active.startMouseEvent) + startMover(active.start, active.pointer) } fallbackMover.updateDrag(position) } @@ -311,9 +310,10 @@ internal class LinuxWindowGestures( } } - private fun startMover(position: Point, event: MouseEvent?) { - if (event != null && useNativeDrag) { - if (LinuxX11WindowMover.tryMove(window, event)) return + private fun startMover(position: Point, pointer: Pointer) { + if (useNativeDrag) { + val button = if (pointer == Pointer.Mouse) MouseEvent.BUTTON1 else MouseEvent.NOBUTTON + if (LinuxX11WindowMover.tryMove(window, position, button)) return useNativeDrag = false } fallbackMover.startDrag(position) @@ -327,7 +327,7 @@ internal class LinuxWindowGestures( active.restoreOffset = null // Position only after native restore; moving earlier overwrites the WM's saved geometry. window.setLocation(active.latestPosition.x - offset.x, active.latestPosition.y - offset.y) - startMover(active.latestPosition, active.latestMouseEvent) + startMover(active.latestPosition, active.pointer) } private fun resize(active: Gesture.Resize, position: Point) { @@ -386,12 +386,10 @@ internal class LinuxWindowGestures( ) { class Move( pointer: Pointer, - start: Point, - val startMouseEvent: MouseEvent? + start: Point ) : Gesture(pointer, start) { var pending = true var latestPosition = start - var latestMouseEvent = startMouseEvent var restoreOffset: Point? = null } 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 index c0a03214..0d992cb0 100644 --- 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 @@ -23,8 +23,8 @@ 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.Point import java.awt.Window -import java.awt.event.MouseEvent /** * Native X11 window mover using `_NET_WM_MOVERESIZE` protocol. @@ -37,12 +37,17 @@ internal object LinuxX11WindowMover { private const val NET_WM_MOVERESIZE_MOVE = 8L /** - * Try to initiate a native X11 window move. + * Try to initiate a native X11 window move from mouse or touch input. + * [screenPosition] uses AWT screen coordinates; [button] is zero for touch. * * @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 { + fun tryMove( + window: Window, + screenPosition: Point, + button: Int + ): Boolean { if (!window.isDisplayable) return false val x11: X11Ext @@ -85,13 +90,16 @@ internal object LinuxX11WindowMover { // 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 transform = window.graphicsConfiguration.defaultTransform + val windowXPosition = ((screenPosition.x - window.x) * transform.scaleX).toInt() + val windowYPosition = ((screenPosition.y - window.y) * transform.scaleY).toInt() 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() + mouseX = rootX.value - windowX.value + windowXPosition, + mouseY = rootY.value - windowY.value + windowYPosition, + button = button ) // Release AWT exclusive mouse lock to allow window manager takeover @@ -135,7 +143,8 @@ internal object LinuxX11WindowMover { xWindow: X11.Window, atom: X11.Atom, mouseX: Int, - mouseY: Int + mouseY: Int, + button: Int ): X11.XEvent { val event = X11.XEvent() event.type = X11.ClientMessage @@ -157,8 +166,8 @@ internal object LinuxX11WindowMover { 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) + // Touch has no pressed mouse button; let the window manager track its native sequence. + clientMsg.data.l[3] = NativeLong(button.toLong()) // Source indication (Normal application) clientMsg.data.l[4] = NativeLong(1) From db65b268feb0b456dae979ea3ec92e8b91d169a6 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 19:26:37 +0800 Subject: [PATCH 03/35] refactor(Linux): enhance gesture handling by applying pending resize on gesture finish --- .../ui/platform/linux/LinuxResizeCursor.kt | 2 + .../ui/platform/linux/LinuxWindowGestures.kt | 40 ++++++++++++++++--- 2 files changed, 37 insertions(+), 5 deletions(-) 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/LinuxResizeCursor.kt index 717674af..a0c5be2c 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/LinuxResizeCursor.kt @@ -156,6 +156,7 @@ internal object LinuxResizeCursor { check(trySetAccessible()) { "AWT cursor access requires java.desktop module opens" } } + @Suppress("ktlint:standard:function-naming", "FunctionName") private interface Xlib : Library { fun XOpenDisplay(name: String?): Pointer? @@ -164,6 +165,7 @@ internal object LinuxResizeCursor { fun XFreeCursor(display: Pointer, cursor: NativeLong): Int } + @Suppress("ktlint:standard:function-naming", "FunctionName") private interface Xcursor : Library { fun XcursorGetTheme(display: Pointer): Pointer? diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt index 771db808..63b489f6 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.input.pointer.PointerEventType import com.moriafly.salt.ui.UnstableSaltUiApi import com.moriafly.salt.ui.window.WindowResizeEdge +import java.awt.EventQueue import java.awt.Frame import java.awt.Point import java.awt.Rectangle @@ -145,7 +146,7 @@ internal class LinuxWindowGestures( if (gesture?.pointer == Pointer.Touch(event.id)) { when (event.type) { PointerEventType.Move -> updateGesture(event.screenPoint()) - PointerEventType.Release -> finishGesture() + PointerEventType.Release -> finishGesture(applyPendingResize = true) } } return @@ -227,7 +228,7 @@ internal class LinuxWindowGestures( val active = gesture ?: return false if (event.button == MouseEvent.BUTTON1) { suppressedClick = if (active is Gesture.Move) Click.Caption else Click.Resize - finishGesture() + finishGesture(applyPendingResize = true) resizer.showEdge(edge) } return true @@ -305,7 +306,7 @@ internal class LinuxWindowGestures( fallbackMover.updateDrag(position) } - is Gesture.Resize -> resize(active, position) + is Gesture.Resize -> scheduleResize(active, position) null -> Unit } } @@ -330,6 +331,18 @@ internal class LinuxWindowGestures( startMover(active.latestPosition, active.pointer) } + private fun scheduleResize(active: Gesture.Resize, position: Point) { + val alreadyQueued = active.pendingPosition != null + active.pendingPosition = position + if (alreadyQueued) return + EventQueue.invokeLater { + val latest = active.pendingPosition ?: return@invokeLater + active.pendingPosition = null + // A queued update must not outlive its gesture or affect the next pointer's drag. + if (gesture === active) resize(active, latest) + } + } + private fun resize(active: Gesture.Resize, position: Point) { val dx = position.x - active.start.x val dy = position.y - active.start.y @@ -364,7 +377,22 @@ internal class LinuxWindowGestures( window.setSize(width, height) } - private fun finishGesture() { + /** + * Ends the current window gesture and clears the drag state and resize-edge indicator. + * + * @param applyPendingResize Pass `true` on mouse or touch release to apply the last pending + * resize before ending the gesture. Leave `false` on cancellation to discard the pending + * update and prevent further window size changes from that gesture. + */ + private fun finishGesture( + applyPendingResize: Boolean = false + ) { + val active = gesture + if (active is Gesture.Resize) { + val latest = active.pendingPosition + active.pendingPosition = null + if (applyPendingResize && latest != null) resize(active, latest) + } gesture = null fallbackMover.endDrag() resizer.showEdge(WindowResizeEdge.None) @@ -398,7 +426,9 @@ internal class LinuxWindowGestures( start: Point, val edge: WindowResizeEdge, val bounds: Rectangle - ) : Gesture(pointer, start) + ) : Gesture(pointer, start) { + var pendingPosition: Point? = null + } } private enum class Click { Caption, Resize } From b1361e5511d948324f95f78726ace1d7e97c8332 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 20:32:51 +0800 Subject: [PATCH 04/35] refactor(Linux): streamline input handling by integrating LinuxWindowFocus and enhancing gesture management --- .../linux/LinuxSaltDialogWindowFrame.kt | 6 +- .../ui/platform/linux/LinuxSaltWindowFrame.kt | 6 +- .../salt/ui/platform/linux/LinuxTouchInput.kt | 8 +- .../ui/platform/linux/LinuxWindowFocus.kt | 80 ++++++++++--------- .../ui/platform/linux/LinuxWindowGestures.kt | 27 ------- .../ui/platform/linux/LinuxWindowInput.kt | 45 ++++++++++- .../ui/platform/linux/LinuxXInputSession.kt | 2 + 7 files changed, 97 insertions(+), 77 deletions(-) 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 054e0a25..887018f0 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 @@ -65,11 +65,6 @@ 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() } @@ -108,6 +103,7 @@ internal fun DialogWindowScope.LinuxSaltDialogWindowFrame( LocalIsHitTestInCaptionBarState provides isHitTestInCaptionBar ) { LinuxWindowInputEffect( + window = window, gestures = windowGestures, moveable = properties.moveable, resizable = resizable 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 0b84b01a..ff4cf2db 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 @@ -67,11 +67,6 @@ 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() } @@ -117,6 +112,7 @@ internal fun FrameWindowScope.LinuxSaltWindowFrame( LocalIsHitTestInCaptionBarState provides isHitTestInCaptionBar, ) { LinuxWindowInputEffect( + window = window, gestures = windowGestures, moveable = properties.moveable, resizable = resizable && !isMaximizedOrFullScreen, diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt index e3c78849..e9879275 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt @@ -38,7 +38,8 @@ internal class LinuxTouchInput( private val window: Window, private val canvas: Component, private val target: DesktopTouchScene, - private val gestures: LinuxWindowGestures + private val gestures: LinuxWindowGestures, + private val focus: LinuxWindowFocus? ) { private val pointers = TouchPointerState() @@ -64,7 +65,10 @@ internal class LinuxTouchInput( ), contentPosition = position ) - if (type == PointerEventType.Press) canvas.requestFocusInWindow() + if (type == PointerEventType.Press) { + canvas.requestFocusInWindow() + focus?.onPointerPress(canvas, sample.x11Timestamp) + } gestures.dispatchTouch( event = touchEvent, dispatchToCompose = { 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/LinuxWindowFocus.kt index 5b4a682b..e912bce0 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/LinuxWindowFocus.kt @@ -33,7 +33,7 @@ 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, @@ -52,53 +52,61 @@ 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? { diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt index 63b489f6..49084f78 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt @@ -17,9 +17,6 @@ package com.moriafly.salt.ui.platform.linux -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.State import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.input.pointer.PointerEventType @@ -36,28 +33,6 @@ import java.awt.event.MouseEvent internal val LocalLinuxWindowGestures = staticCompositionLocalOf { null } -@Composable -internal fun LinuxWindowInputEffect( - gestures: LinuxWindowGestures, - moveable: Boolean, - resizable: Boolean, - canDrag: Boolean = true, - canMaximize: Boolean = false -) { - SideEffect { - gestures.configure( - moveable = moveable, - resizable = resizable, - canDrag = canDrag, - canMaximize = canMaximize - ) - } - DisposableEffect(gestures) { - val uninstall = gestures.install() - onDispose { uninstall() } - } -} - /** * The window's single move/resize owner, confined to the AWT event thread. * @@ -111,9 +86,7 @@ internal class LinuxWindowGestures( } window.addComponentListener(listener) val unregister = LinuxWindowMouseInput.register(window, this, priority = 1) - val input = LinuxWindowInput.create(window, this) return { - input?.close() unregister() window.removeComponentListener(listener) } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt index 2f2ac157..5d953be2 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt @@ -17,6 +17,9 @@ package com.moriafly.salt.ui.platform.linux +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.ui.InternalComposeUiApi import com.moriafly.salt.ui.UnstableSaltUiApi import com.moriafly.salt.ui.platform.input.DesktopTouchScene @@ -30,6 +33,39 @@ 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 +) { + SideEffect { + gestures.configure( + moveable = moveable, + resizable = resizable, + canDrag = canDrag, + canMaximize = canMaximize + ) + } + 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. * A stopping session retains its slot until the reader confirms that native resources are closed. @@ -182,7 +218,11 @@ internal class LinuxWindowInput private constructor( } @OptIn(UnstableSaltUiApi::class) - fun create(window: Window, gestures: LinuxWindowGestures): LinuxWindowInput? { + fun create( + window: Window, + gestures: LinuxWindowGestures, + focus: LinuxWindowFocus? + ): LinuxWindowInput? { // XIDs and libXi do not apply to JBR's native Wayland toolkit. if (Toolkit.getDefaultToolkit().javaClass.name != "sun.awt.X11.XToolkit") return null val canvas = window.findSkiaLayer()?.canvas ?: return null @@ -192,7 +232,8 @@ internal class LinuxWindowInput private constructor( window = window, canvas = canvas, target = target, - gestures = gestures + gestures = gestures, + focus = focus ) } // Hover remains usable even when Compose's private scene adapter is unavailable. diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt index 084edce4..cf8eadb4 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt @@ -71,6 +71,7 @@ internal class LinuxXInputSession( val rootY: Double, val modifiers: Int, val timeMillis: Long, + val x11Timestamp: Long, val directTouch: Boolean ) @@ -369,6 +370,7 @@ internal class LinuxXInputSession( rootY = native.rootY, modifiers = native.modifiers.effective, timeMillis = System.nanoTime() / 1_000_000, + x11Timestamp = native.time.toLong() and 0xffffffffL, directTouch = directTouch ) if (sample.phase == TouchPhase.Down) { From a35c01b237f06ab6aba23ad9191344c5afdd4947 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 20:52:48 +0800 Subject: [PATCH 05/35] refactor(Linux): simplify caption bar input handling and enhance hit testing logic --- .../platform/linux/LinuxCaptionBarHitTest.kt | 25 ++++-- .../ui/platform/linux/LinuxCaptionBarInput.kt | 6 +- .../ui/platform/linux/LinuxWindowGestures.kt | 85 ++++++++++++------- .../com/moriafly/salt/ui/window/CaptionBar.kt | 2 +- 4 files changed, 75 insertions(+), 43 deletions(-) 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/LinuxCaptionBarHitTest.kt index a16cbdb6..d15c68db 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/LinuxCaptionBarHitTest.kt @@ -32,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 } @@ -43,33 +50,33 @@ 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 + 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? { diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt index 68d888b7..18a18691 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt @@ -28,11 +28,11 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned /** - * Registers caption geometry and forwards touch drag requests to the window gesture owner. + * Registers caption geometry and reports Compose touch hits to the window gesture owner. */ @OptIn(ExperimentalComposeUiApi::class) @Composable -internal fun Modifier.linuxCaptionBarInput(moveable: Boolean): Modifier { +internal fun Modifier.linuxCaptionBarInput(): Modifier { val hitTest = LocalLinuxCaptionBarHitTest.current val modifier = if (hitTest != null) { val region = remember(hitTest) { LinuxCaptionBarHitTest.Region() } @@ -48,7 +48,7 @@ internal fun Modifier.linuxCaptionBarInput(moveable: Boolean): 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 (moveable && gestures.requestCaptionDrag(native)) { + if (gestures.onCaptionPress(native)) { event.changes.forEach { it.consume() } } } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt index 49084f78..ecd150d3 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt @@ -19,6 +19,7 @@ package com.moriafly.salt.ui.platform.linux import androidx.compose.runtime.State import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import com.moriafly.salt.ui.UnstableSaltUiApi import com.moriafly.salt.ui.window.WindowResizeEdge @@ -37,9 +38,10 @@ internal val LocalLinuxWindowGestures = staticCompositionLocalOf() private var capturedTouches = false private var dispatchingPress: LinuxTouchEvent? = null - private var captionRequested = false + private var captionHitByCompose = false fun configure( moveable: Boolean, @@ -93,12 +95,12 @@ internal class LinuxWindowGestures( } /** - * A request from a caption that passed Compose hit testing, not an executable event callback. - * Requests outside this owner's first-press dispatch cannot take over an existing gesture. + * Records a Compose caption hit only while resolving the first contact's target. + * Gesture eligibility, capture and movement belong to this owner. */ - fun requestCaptionDrag(event: LinuxTouchEvent): Boolean { - if (dispatchingPress !== event || !moveable || !canDrag) return false - captionRequested = true + fun onCaptionPress(event: LinuxTouchEvent): Boolean { + if (dispatchingPress !== event) return false + captionHitByCompose = true return true } @@ -132,18 +134,20 @@ internal class LinuxWindowGestures( beginResize(Pointer.Touch(event.id), edge, event.screenPoint()) return } - dispatchingPress = event - captionRequested = false - try { - dispatchToCompose() - if (captionRequested) { - capturedTouches = true - cancelCompose() - beginMove(Pointer.Touch(event.id), event.screenPoint()) + val isCaption = when (captionHitAt(event.contentPosition)) { + LinuxCaptionBarHitTest.Result.Caption -> true + LinuxCaptionBarHitTest.Result.Excluded -> { + dispatchToCompose() + false } - } finally { - dispatchingPress = null - captionRequested = false + + LinuxCaptionBarHitTest.Result.RequiresComposeHitTest -> + dispatchCaptionPress(event, dispatchToCompose) + } + if (isCaption) { + capturedTouches = true + cancelCompose() + beginMove(Pointer.Touch(event.id), event.screenPoint()) } } else { dispatchToCompose() @@ -178,15 +182,14 @@ internal class LinuxWindowGestures( beginResize(Pointer.Mouse, edge, event.locationOnScreen) return true } - if (!moveable || !canDrag || !caption.contains( - position = LinuxWindowMouseInput.positionInContent(event, window), - window = window, - // Enter and press can arrive before recomposition. - isHovered = captionHovered.value - ) - ) { - return false + val position = LinuxWindowMouseInput.positionInContent(event, window) + val isCaption = when (captionHitAt(position)) { + LinuxCaptionBarHitTest.Result.Caption -> true + LinuxCaptionBarHitTest.Result.Excluded -> false + // Enter and press can arrive before recomposition. + LinuxCaptionBarHitTest.Result.RequiresComposeHitTest -> captionHovered.value } + if (!isCaption) return false beginMove(Pointer.Mouse, event.locationOnScreen) return true } @@ -234,7 +237,7 @@ internal class LinuxWindowGestures( finishGesture() suppressedClick = null dispatchingPress = null - captionRequested = false + captionHitByCompose = false // Keep known contacts quarantined until release or an explicit native input reset. capturedTouches = contacts.isNotEmpty() } @@ -243,11 +246,33 @@ internal class LinuxWindowGestures( // XI2 device changes and touch-channel shutdown must not cancel an AWT mouse drag. if (gesture?.pointer is Pointer.Touch) finishGesture() dispatchingPress = null - captionRequested = false + captionHitByCompose = false contacts.clear() capturedTouches = false } + private fun captionHitAt(position: Offset): LinuxCaptionBarHitTest.Result = + if (moveable && canDrag) { + caption.hitTest(position, window) + } else { + LinuxCaptionBarHitTest.Result.Excluded + } + + private fun dispatchCaptionPress( + event: LinuxTouchEvent, + dispatchToCompose: () -> Unit + ): Boolean { + dispatchingPress = event + captionHitByCompose = false + try { + dispatchToCompose() + return captionHitByCompose + } finally { + dispatchingPress = null + captionHitByCompose = false + } + } + private fun beginMove(pointer: Pointer, position: Point) { gesture = Gesture.Move(pointer, position) } 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 a3bef854..79c4eb3d 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 @@ -56,7 +56,7 @@ fun CaptionBarHitTest( .run { when (OS.current) { is OS.Windows -> windowsCaptionBarTouchInput(saltWindowProperties.moveable) - is OS.Linux -> linuxCaptionBarInput(saltWindowProperties.moveable) + is OS.Linux -> linuxCaptionBarInput() else -> this } } From 4e30c6dbe6cce2250b4d600cbf3a9cdbf6153087 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 21:11:20 +0800 Subject: [PATCH 06/35] refactor(Linux): enhance touch input handling by adding time tracking and improving gesture recognition --- .../salt/ui/platform/linux/LinuxTouchEvent.kt | 4 +- .../salt/ui/platform/linux/LinuxTouchInput.kt | 3 +- .../ui/platform/linux/LinuxWindowGestures.kt | 109 +++++++++++++++--- .../ui/platform/linux/LinuxWindowInput.kt | 5 +- 4 files changed, 102 insertions(+), 19 deletions(-) diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt index 95b3821b..d187fd1d 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt @@ -23,11 +23,13 @@ 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 contentPosition: Offset, + val timeMillis: Long ) diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt index e9879275..8202757a 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt @@ -63,7 +63,8 @@ internal class LinuxTouchInput( (sample.rootX / transform.scaleX).roundToInt(), (sample.rootY / transform.scaleY).roundToInt() ), - contentPosition = position + contentPosition = position, + timeMillis = sample.timeMillis ) if (type == PointerEventType.Press) { canvas.requestFocusInWindow() diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt index ecd150d3..ba7799b4 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt @@ -21,6 +21,7 @@ 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.window.WindowResizeEdge import java.awt.EventQueue @@ -62,13 +63,20 @@ internal class LinuxWindowGestures( private var capturedTouches = false private var dispatchingPress: LinuxTouchEvent? = null private var captionHitByCompose = false + private lateinit var viewConfiguration: ViewConfiguration + private var previousTouchTap: TouchTap? = null fun configure( moveable: Boolean, resizable: Boolean, canDrag: Boolean, - canMaximize: Boolean + canMaximize: Boolean, + viewConfiguration: ViewConfiguration ) { + if (this.moveable != moveable || this.canDrag != canDrag || this.canMaximize != canMaximize) { + cancelTouchTap() + } + this.viewConfiguration = viewConfiguration resizer.enabled = resizable this.moveable = moveable this.canDrag = canDrag @@ -112,6 +120,7 @@ internal class LinuxWindowGestures( val firstContact = contacts.isEmpty() if (event.type == PointerEventType.Press) { if (!contacts.add(event.id)) return + if (!firstContact) cancelTouchTap() } else if (event.id !in contacts) { return } @@ -121,7 +130,12 @@ internal class LinuxWindowGestures( if (gesture?.pointer == Pointer.Touch(event.id)) { when (event.type) { PointerEventType.Move -> updateGesture(event.screenPoint()) - PointerEventType.Release -> finishGesture(applyPendingResize = true) + PointerEventType.Release -> { + val active = gesture + val doubleTap = active is Gesture.Move && recognizeTouchTap(active, event) + finishGesture(applyPendingResize = true) + if (doubleTap) toggleMaximizeOrRestore() + } } } return @@ -147,7 +161,13 @@ internal class LinuxWindowGestures( if (isCaption) { capturedTouches = true cancelCompose() - beginMove(Pointer.Touch(event.id), event.screenPoint()) + beginMove( + pointer = Pointer.Touch(event.id), + position = event.screenPoint(), + touchDownTimeMillis = event.timeMillis + ) + } else { + cancelTouchTap() } } else { dispatchToCompose() @@ -165,6 +185,7 @@ internal class LinuxWindowGestures( } override fun onMouseEvent(event: MouseEvent): Boolean { + if (event.id == MouseEvent.MOUSE_PRESSED) cancelTouchTap() // A real mouse cannot steal a touch sequence, and vice versa. if (contacts.isNotEmpty()) { return event.id == MouseEvent.MOUSE_PRESSED || event.id == MouseEvent.MOUSE_DRAGGED || @@ -214,16 +235,8 @@ internal class LinuxWindowGestures( val click = suppressedClick ?: return false if (event.button != MouseEvent.BUTTON1) return false suppressedClick = null - if (click == Click.Caption && event.clickCount == 2 && - moveable && canDrag && canMaximize - ) { - (window as? Frame)?.let { frame -> - frame.extendedState = if (frame.extendedState == Frame.MAXIMIZED_BOTH) { - Frame.NORMAL - } else { - Frame.MAXIMIZED_BOTH - } - } + if (click == Click.Caption && event.clickCount == 2) { + toggleMaximizeOrRestore() } return true } @@ -234,6 +247,7 @@ internal class LinuxWindowGestures( override fun afterMouseEvent(event: MouseEvent) = resizer.afterMouseEvent(event) override fun cancel() { + cancelTouchTap() finishGesture() suppressedClick = null dispatchingPress = null @@ -243,6 +257,7 @@ internal class LinuxWindowGestures( } fun resetTouchInput() { + cancelTouchTap() // XI2 device changes and touch-channel shutdown must not cancel an AWT mouse drag. if (gesture?.pointer is Pointer.Touch) finishGesture() dispatchingPress = null @@ -273,11 +288,30 @@ internal class LinuxWindowGestures( } } - private fun beginMove(pointer: Pointer, position: Point) { - gesture = Gesture.Move(pointer, position) + private fun toggleMaximizeOrRestore() { + if (!moveable || !canDrag || !canMaximize) return + val frame = window as? Frame ?: return + frame.extendedState = if (frame.extendedState == Frame.MAXIMIZED_BOTH) { + Frame.NORMAL + } else { + Frame.MAXIMIZED_BOTH + } + } + + private fun beginMove( + pointer: Pointer, + position: Point, + touchDownTimeMillis: Long? = null + ) { + gesture = Gesture.Move( + pointer = pointer, + start = position, + touchDownTimeMillis = touchDownTimeMillis + ) } private fun beginResize(pointer: Pointer, edge: WindowResizeEdge, position: Point) { + cancelTouchTap() gesture = Gesture.Resize(pointer, position, edge, window.bounds) resizer.showEdge(edge) } @@ -288,6 +322,12 @@ internal class LinuxWindowGestures( active.latestPosition = position if (active.restoreOffset != null) return if (active.pending) { + // Small touch jitter remains a tap, including while maximized. Starting the + // native mover earlier would restore the window before a double tap resolves. + if (active.pointer is Pointer.Touch) { + if (withinTouchSlop(active.start, position)) return + cancelTouchTap() + } active.pending = false val frame = window as? Frame if (frame?.extendedState == Frame.MAXIMIZED_BOTH) { @@ -398,6 +438,41 @@ internal class LinuxWindowGestures( private fun LinuxTouchEvent.screenPoint() = Point(screenPosition.x, screenPosition.y) + private fun recognizeTouchTap(active: Gesture.Move, event: LinuxTouchEvent): Boolean { + val downTime = active.touchDownTimeMillis + if (downTime == null || !active.pending || contacts.size != 1 || + event.timeMillis - downTime !in 0 until viewConfiguration.longPressTimeoutMillis || + !withinTouchSlop(active.start, event.screenPoint()) + ) { + cancelTouchTap() + return false + } + val doubleTap = previousTouchTap?.let { previous -> + val interval = downTime - previous.upTimeMillis + val withinTimeout = interval in + viewConfiguration.doubleTapMinTimeMillis..viewConfiguration.doubleTapTimeoutMillis + withinTimeout && withinTouchSlop(previous.position, active.start) + } ?: false + previousTouchTap = if (doubleTap) null else TouchTap(active.start, event.timeMillis) + return doubleTap + } + + private fun withinTouchSlop(first: Point, second: Point): Boolean { + // AWT screen coordinates are logical units; Compose's touch slop is in physical pixels. + val transform = window.graphicsConfiguration.defaultTransform + val dx = (second.x - first.x) * transform.scaleX + val dy = (second.y - first.y) * transform.scaleY + val slop = viewConfiguration.touchSlop + return dx * dx + dy * dy <= slop * slop + } + + private fun cancelTouchTap() { + previousTouchTap = null + (gesture as? Gesture.Move)?.touchDownTimeMillis = null + } + + private class TouchTap(val position: Point, val upTimeMillis: Long) + private sealed interface Pointer { data object Mouse : Pointer @@ -412,7 +487,9 @@ internal class LinuxWindowGestures( ) { class Move( pointer: Pointer, - start: Point + start: Point, + // Retained only while this contact can still become a tap. + var touchDownTimeMillis: Long? ) : Gesture(pointer, start) { var pending = true var latestPosition = start diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt index 5d953be2..faafebd7 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt @@ -21,6 +21,7 @@ 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.util.findSkiaLayer @@ -42,12 +43,14 @@ internal fun LinuxWindowInputEffect( canDrag: Boolean = true, canMaximize: Boolean = false ) { + val viewConfiguration = LocalViewConfiguration.current SideEffect { gestures.configure( moveable = moveable, resizable = resizable, canDrag = canDrag, - canMaximize = canMaximize + canMaximize = canMaximize, + viewConfiguration = viewConfiguration ) } DisposableEffect(window, gestures) { From 4ac48b3aa4778c376abe6a8255a42521af210c22 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 21:17:26 +0800 Subject: [PATCH 07/35] fix(Linux): cancel window gestures when input detaches Cancel the shared gesture owner during window input cancellation so mouse moves and queued resize updates cannot survive canvas detachment. Keep native device resets scoped to touch input. Validated the before/after behavior in an isolated KWin/XWayland window with native mouse input and debugger-controlled canvas hiding. Confirmed that the device-reset callback preserves mouse resizing. Desktop compilation, UI text validation, and diff checks passed. --- .../com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt index faafebd7..22c8db62 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt @@ -77,6 +77,7 @@ internal fun LinuxWindowInputEffect( 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 @@ -163,6 +164,7 @@ internal class LinuxWindowInput private constructor( } private fun cancelInput() { + gestures.cancel() cancelTouch() mouse.cancel() // Window lifetime cancels every Compose pointer source, including AWT compatibility input. @@ -243,6 +245,7 @@ internal class LinuxWindowInput private constructor( return LinuxWindowInput( window = window, canvas = canvas, + gestures = gestures, target = target, touch = touch, mouse = LinuxMouseInput(window, canvas) From 58f3ed606fe752471ae91e81be859d32edefd2af Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 21:38:40 +0800 Subject: [PATCH 08/35] refactor(linux): split gesture logic and organize platform packages Extract touch tap recognition and resize execution from LinuxWindowGestures while retaining a single gesture owner and shared mouse/touch window operations. Keep resize coalescing, release flushing, cancellation, and tap thresholds within the objects that own their state. Group Linux implementation files into input, window, decoration, and x11 packages. Keep the two window frame entry points in the root package and update callers, existing test imports, and documentation references without changing behavior. The resulting package dependency graph is acyclic. Validation: desktop library, existing test sources, and example classes compiled with JBR 25.0.4.1 b583.48. The extracted implementation passed 86 resize checks at UI scales 1 and 2 and 10 caption checks in isolated KWin/XWayland with Voxzen. After package relocation, 43 resize checks and 4 caption checks passed again. Diff and UI text checks passed. Physical touchscreen acceptance was not run; previously recorded runtime limitations remain outside this refactor. --- .../linux/LinuxSaltDialogWindowFrame.kt | 11 ++ .../ui/platform/linux/LinuxSaltWindowFrame.kt | 13 ++ .../linux/{ => decoration}/LinuxCaptionBar.kt | 2 +- .../{ => decoration}/LinuxClientShadow.kt | 3 +- .../linux/{ => input}/LinuxCaptionBarInput.kt | 4 +- .../linux/{ => input}/LinuxMouseInput.kt | 3 +- .../linux/{ => input}/LinuxTouchEvent.kt | 2 +- .../linux/{ => input}/LinuxTouchInput.kt | 4 +- .../linux/input/LinuxTouchTapRecognizer.kt | 91 +++++++++ .../linux/{ => input}/LinuxWindowGestures.kt | 177 +++++------------- .../linux/{ => input}/LinuxWindowInput.kt | 4 +- .../{ => input}/LinuxWindowMouseInput.kt | 2 +- .../{ => window}/LinuxCaptionBarHitTest.kt | 2 +- .../{ => window}/LinuxFallbackWindowMover.kt | 2 +- .../linux/{ => window}/LinuxResizeCursor.kt | 2 +- .../linux/{ => window}/LinuxWindowFocus.kt | 2 +- .../window/LinuxWindowResizeOperation.kt | 100 ++++++++++ .../{ => window}/UndecoratedWindowResizer.kt | 2 +- .../linux/{ => x11}/LinuxX11WindowMover.kt | 2 +- .../platform/linux/{ => x11}/LinuxXInput2.kt | 2 +- .../linux/{ => x11}/LinuxXInputSession.kt | 2 +- .../com/moriafly/salt/ui/window/CaptionBar.kt | 2 +- .../salt/ui/window/SaltDialogWindow.kt | 2 +- .../com/moriafly/salt/ui/window/SaltWindow.kt | 2 +- .../salt/ui/window/internal/SaltWindowUtil.kt | 2 +- .../platform/linux/LinuxClientShadowTest.kt | 2 + .../moriafly/salt/ui/window/SaltWindowTest.kt | 2 +- 27 files changed, 292 insertions(+), 152 deletions(-) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => decoration}/LinuxCaptionBar.kt (99%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => decoration}/LinuxClientShadow.kt (99%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => input}/LinuxCaptionBarInput.kt (91%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => input}/LinuxMouseInput.kt (98%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => input}/LinuxTouchEvent.kt (95%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => input}/LinuxTouchInput.kt (96%) create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchTapRecognizer.kt rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => input}/LinuxWindowGestures.kt (72%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => input}/LinuxWindowInput.kt (97%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => input}/LinuxWindowMouseInput.kt (99%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => window}/LinuxCaptionBarHitTest.kt (98%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => window}/LinuxFallbackWindowMover.kt (97%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => window}/LinuxResizeCursor.kt (99%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => window}/LinuxWindowFocus.kt (99%) create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowResizeOperation.kt rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => window}/UndecoratedWindowResizer.kt (99%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => x11}/LinuxX11WindowMover.kt (99%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => x11}/LinuxXInput2.kt (99%) rename ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/{ => x11}/LinuxXInputSession.kt (99%) 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 887018f0..d664a25f 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,17 @@ 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.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 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 ff4cf2db..b607e10e 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,19 @@ 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.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 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 99% 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..274ab245 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,6 +37,7 @@ 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.moriafly.salt.ui.platform.linux.x11.X11Ext import com.sun.jna.Library import com.sun.jna.Memory import com.sun.jna.Native diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxCaptionBarInput.kt similarity index 91% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxCaptionBarInput.kt index 18a18691..ab5aa36d 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxCaptionBarInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxCaptionBarInput.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.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -26,6 +26,8 @@ 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. diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxMouseInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxMouseInput.kt similarity index 98% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxMouseInput.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxMouseInput.kt index 31f9a83e..067017c2 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxMouseInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxMouseInput.kt @@ -15,8 +15,9 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +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 diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchEvent.kt similarity index 95% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchEvent.kt index d187fd1d..3500638d 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchEvent.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchEvent.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 androidx.compose.ui.input.pointer.PointerEventType diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchInput.kt similarity index 96% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchInput.kt index 8202757a..2f09c0a7 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxTouchInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchInput.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.InternalComposeUiApi import androidx.compose.ui.geometry.Offset @@ -24,6 +24,8 @@ 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.window.LinuxWindowFocus +import com.moriafly.salt.ui.platform.linux.x11.LinuxXInputSession import java.awt.Component import java.awt.Dialog import java.awt.Window diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchTapRecognizer.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchTapRecognizer.kt new file mode 100644 index 00000000..3519f159 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchTapRecognizer.kt @@ -0,0 +1,91 @@ +/* + * 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.platform.ViewConfiguration +import java.awt.Point +import java.awt.Window + +/** + * Recognizes caption touch taps using Compose's timing and distance thresholds. + * + * The gesture owner supplies the captured contact and cancels recognition on another contact, + * mouse press or gesture cancellation. This recognizer never captures or moves a window. + */ +internal class LinuxTouchTapRecognizer( + private val window: Window +) { + private lateinit var viewConfiguration: ViewConfiguration + private var press: Press? = null + private var previousTap: Tap? = null + + fun configure(viewConfiguration: ViewConfiguration) { + this.viewConfiguration = viewConfiguration + } + + fun begin(position: Point, timeMillis: Long) { + press = Press(position, timeMillis) + } + + /** + * Completes the current contact and returns whether it completes a double tap. + */ + fun finish(position: Point, timeMillis: Long): Boolean { + val current = press + press = null + if (current == null || + timeMillis - current.timeMillis !in 0 until viewConfiguration.longPressTimeoutMillis || + !isWithinSlop(current.position, position) + ) { + cancel() + return false + } + val doubleTap = previousTap?.let { previous -> + val interval = current.timeMillis - previous.upTimeMillis + val withinTimeout = interval in + viewConfiguration.doubleTapMinTimeMillis..viewConfiguration.doubleTapTimeoutMillis + withinTimeout && isWithinSlop(previous.position, current.position) + } ?: false + previousTap = if (doubleTap) null else Tap(current.position, timeMillis) + return doubleTap + } + + fun isWithinSlop(first: Point, second: Point): Boolean { + // AWT screen coordinates are logical units; Compose's touch slop is in physical pixels. + val transform = window.graphicsConfiguration.defaultTransform + val dx = (second.x - first.x) * transform.scaleX + val dy = (second.y - first.y) * transform.scaleY + val slop = viewConfiguration.touchSlop + return dx * dx + dy * dy <= slop * slop + } + + fun cancel() { + press = null + previousTap = null + } + + private class Press( + val position: Point, + val timeMillis: Long + ) + + private class Tap( + val position: Point, + val upTimeMillis: Long + ) +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowGestures.kt similarity index 72% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowGestures.kt index ba7799b4..f45a2e09 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestures.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowGestures.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.runtime.State import androidx.compose.runtime.staticCompositionLocalOf @@ -23,11 +23,14 @@ 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.window.LinuxCaptionBarHitTest +import com.moriafly.salt.ui.platform.linux.window.LinuxFallbackWindowMover +import com.moriafly.salt.ui.platform.linux.window.LinuxWindowResizeOperation +import com.moriafly.salt.ui.platform.linux.window.UndecoratedWindowResizer +import com.moriafly.salt.ui.platform.linux.x11.LinuxX11WindowMover import com.moriafly.salt.ui.window.WindowResizeEdge -import java.awt.EventQueue import java.awt.Frame import java.awt.Point -import java.awt.Rectangle import java.awt.Window import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent @@ -63,8 +66,7 @@ internal class LinuxWindowGestures( private var capturedTouches = false private var dispatchingPress: LinuxTouchEvent? = null private var captionHitByCompose = false - private lateinit var viewConfiguration: ViewConfiguration - private var previousTouchTap: TouchTap? = null + private val touchTapRecognizer = LinuxTouchTapRecognizer(window) fun configure( moveable: Boolean, @@ -73,10 +75,12 @@ internal class LinuxWindowGestures( canMaximize: Boolean, viewConfiguration: ViewConfiguration ) { - if (this.moveable != moveable || this.canDrag != canDrag || this.canMaximize != canMaximize) { - cancelTouchTap() + if (this.moveable != moveable || this.canDrag != canDrag || + this.canMaximize != canMaximize + ) { + touchTapRecognizer.cancel() } - this.viewConfiguration = viewConfiguration + touchTapRecognizer.configure(viewConfiguration) resizer.enabled = resizable this.moveable = moveable this.canDrag = canDrag @@ -120,7 +124,7 @@ internal class LinuxWindowGestures( val firstContact = contacts.isEmpty() if (event.type == PointerEventType.Press) { if (!contacts.add(event.id)) return - if (!firstContact) cancelTouchTap() + if (!firstContact) touchTapRecognizer.cancel() } else if (event.id !in contacts) { return } @@ -132,7 +136,8 @@ internal class LinuxWindowGestures( PointerEventType.Move -> updateGesture(event.screenPoint()) PointerEventType.Release -> { val active = gesture - val doubleTap = active is Gesture.Move && recognizeTouchTap(active, event) + val doubleTap = active is Gesture.Move && + touchTapRecognizer.finish(event.screenPoint(), event.timeMillis) finishGesture(applyPendingResize = true) if (doubleTap) toggleMaximizeOrRestore() } @@ -161,13 +166,10 @@ internal class LinuxWindowGestures( if (isCaption) { capturedTouches = true cancelCompose() - beginMove( - pointer = Pointer.Touch(event.id), - position = event.screenPoint(), - touchDownTimeMillis = event.timeMillis - ) + touchTapRecognizer.begin(event.screenPoint(), event.timeMillis) + beginMove(Pointer.Touch(event.id), event.screenPoint()) } else { - cancelTouchTap() + touchTapRecognizer.cancel() } } else { dispatchToCompose() @@ -185,7 +187,7 @@ internal class LinuxWindowGestures( } override fun onMouseEvent(event: MouseEvent): Boolean { - if (event.id == MouseEvent.MOUSE_PRESSED) cancelTouchTap() + if (event.id == MouseEvent.MOUSE_PRESSED) touchTapRecognizer.cancel() // A real mouse cannot steal a touch sequence, and vice versa. if (contacts.isNotEmpty()) { return event.id == MouseEvent.MOUSE_PRESSED || event.id == MouseEvent.MOUSE_DRAGGED || @@ -247,7 +249,7 @@ internal class LinuxWindowGestures( override fun afterMouseEvent(event: MouseEvent) = resizer.afterMouseEvent(event) override fun cancel() { - cancelTouchTap() + touchTapRecognizer.cancel() finishGesture() suppressedClick = null dispatchingPress = null @@ -257,7 +259,7 @@ internal class LinuxWindowGestures( } fun resetTouchInput() { - cancelTouchTap() + touchTapRecognizer.cancel() // XI2 device changes and touch-channel shutdown must not cancel an AWT mouse drag. if (gesture?.pointer is Pointer.Touch) finishGesture() dispatchingPress = null @@ -300,19 +302,25 @@ internal class LinuxWindowGestures( private fun beginMove( pointer: Pointer, - position: Point, - touchDownTimeMillis: Long? = null + position: Point ) { - gesture = Gesture.Move( - pointer = pointer, - start = position, - touchDownTimeMillis = touchDownTimeMillis - ) + gesture = Gesture.Move(pointer, position) } - private fun beginResize(pointer: Pointer, edge: WindowResizeEdge, position: Point) { - cancelTouchTap() - gesture = Gesture.Resize(pointer, position, edge, window.bounds) + private fun beginResize( + pointer: Pointer, + edge: WindowResizeEdge, + position: Point + ) { + touchTapRecognizer.cancel() + gesture = Gesture.Resize( + pointer, + LinuxWindowResizeOperation( + window = window, + edge = edge, + start = position + ) + ) resizer.showEdge(edge) } @@ -325,8 +333,8 @@ internal class LinuxWindowGestures( // Small touch jitter remains a tap, including while maximized. Starting the // native mover earlier would restore the window before a double tap resolves. if (active.pointer is Pointer.Touch) { - if (withinTouchSlop(active.start, position)) return - cancelTouchTap() + if (touchTapRecognizer.isWithinSlop(active.start, position)) return + touchTapRecognizer.cancel() } active.pending = false val frame = window as? Frame @@ -344,7 +352,7 @@ internal class LinuxWindowGestures( fallbackMover.updateDrag(position) } - is Gesture.Resize -> scheduleResize(active, position) + is Gesture.Resize -> active.operation.update(position) null -> Unit } } @@ -369,52 +377,6 @@ internal class LinuxWindowGestures( startMover(active.latestPosition, active.pointer) } - private fun scheduleResize(active: Gesture.Resize, position: Point) { - val alreadyQueued = active.pendingPosition != null - active.pendingPosition = position - if (alreadyQueued) return - EventQueue.invokeLater { - val latest = active.pendingPosition ?: return@invokeLater - active.pendingPosition = null - // A queued update must not outlive its gesture or affect the next pointer's drag. - if (gesture === active) resize(active, latest) - } - } - - private fun resize(active: Gesture.Resize, position: Point) { - val dx = position.x - active.start.x - val dy = position.y - active.start.y - val initial = active.bounds - var x = initial.x - var y = initial.y - var width = initial.width - var height = initial.height - when (active.edge) { - WindowResizeEdge.Left, WindowResizeEdge.TopLeft, WindowResizeEdge.BottomLeft -> { - width = (initial.width - dx).coerceAtLeast(window.minimumSize.width) - x = initial.x + initial.width - width - } - - WindowResizeEdge.Right, WindowResizeEdge.TopRight, WindowResizeEdge.BottomRight -> - width = (initial.width + dx).coerceAtLeast(window.minimumSize.width) - - else -> Unit - } - when (active.edge) { - WindowResizeEdge.Top, WindowResizeEdge.TopLeft, WindowResizeEdge.TopRight -> { - height = (initial.height - dy).coerceAtLeast(window.minimumSize.height) - y = initial.y + initial.height - height - } - - WindowResizeEdge.Bottom, WindowResizeEdge.BottomLeft, WindowResizeEdge.BottomRight -> - height = (initial.height + dy).coerceAtLeast(window.minimumSize.height) - - else -> Unit - } - window.setLocation(x, y) - window.setSize(width, height) - } - /** * Ends the current window gesture and clears the drag state and resize-edge indicator. * @@ -425,12 +387,7 @@ internal class LinuxWindowGestures( private fun finishGesture( applyPendingResize: Boolean = false ) { - val active = gesture - if (active is Gesture.Resize) { - val latest = active.pendingPosition - active.pendingPosition = null - if (applyPendingResize && latest != null) resize(active, latest) - } + (gesture as? Gesture.Resize)?.operation?.finish(applyPendingResize) gesture = null fallbackMover.endDrag() resizer.showEdge(WindowResizeEdge.None) @@ -438,41 +395,6 @@ internal class LinuxWindowGestures( private fun LinuxTouchEvent.screenPoint() = Point(screenPosition.x, screenPosition.y) - private fun recognizeTouchTap(active: Gesture.Move, event: LinuxTouchEvent): Boolean { - val downTime = active.touchDownTimeMillis - if (downTime == null || !active.pending || contacts.size != 1 || - event.timeMillis - downTime !in 0 until viewConfiguration.longPressTimeoutMillis || - !withinTouchSlop(active.start, event.screenPoint()) - ) { - cancelTouchTap() - return false - } - val doubleTap = previousTouchTap?.let { previous -> - val interval = downTime - previous.upTimeMillis - val withinTimeout = interval in - viewConfiguration.doubleTapMinTimeMillis..viewConfiguration.doubleTapTimeoutMillis - withinTimeout && withinTouchSlop(previous.position, active.start) - } ?: false - previousTouchTap = if (doubleTap) null else TouchTap(active.start, event.timeMillis) - return doubleTap - } - - private fun withinTouchSlop(first: Point, second: Point): Boolean { - // AWT screen coordinates are logical units; Compose's touch slop is in physical pixels. - val transform = window.graphicsConfiguration.defaultTransform - val dx = (second.x - first.x) * transform.scaleX - val dy = (second.y - first.y) * transform.scaleY - val slop = viewConfiguration.touchSlop - return dx * dx + dy * dy <= slop * slop - } - - private fun cancelTouchTap() { - previousTouchTap = null - (gesture as? Gesture.Move)?.touchDownTimeMillis = null - } - - private class TouchTap(val position: Point, val upTimeMillis: Long) - private sealed interface Pointer { data object Mouse : Pointer @@ -482,15 +404,12 @@ internal class LinuxWindowGestures( } private sealed class Gesture( - val pointer: Pointer, - val start: Point + val pointer: Pointer ) { class Move( pointer: Pointer, - start: Point, - // Retained only while this contact can still become a tap. - var touchDownTimeMillis: Long? - ) : Gesture(pointer, start) { + val start: Point + ) : Gesture(pointer) { var pending = true var latestPosition = start var restoreOffset: Point? = null @@ -498,12 +417,8 @@ internal class LinuxWindowGestures( class Resize( pointer: Pointer, - start: Point, - val edge: WindowResizeEdge, - val bounds: Rectangle - ) : Gesture(pointer, start) { - var pendingPosition: Point? = null - } + val operation: LinuxWindowResizeOperation + ) : Gesture(pointer) } private enum class Click { Caption, Resize } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowInput.kt similarity index 97% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowInput.kt index 22c8db62..48f9ecb4 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowInput.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.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -24,6 +24,8 @@ 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.x11.LinuxXInputSession import com.moriafly.salt.ui.util.findSkiaLayer import com.sun.jna.Native import java.awt.Component 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 99% 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 02e10b19..c223c4cc 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 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 98% 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 d15c68db..6b9dbcb1 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,7 +15,7 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.window import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.tooling.ComposeToolingApi 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/window/LinuxFallbackWindowMover.kt similarity index 97% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxFallbackWindowMover.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxFallbackWindowMover.kt index 3edd9229..e160fbf7 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxFallbackWindowMover.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxFallbackWindowMover.kt @@ -15,7 +15,7 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.window import com.moriafly.salt.ui.UnstableSaltUiApi import java.awt.Cursor 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 99% 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 a0c5be2c..4e9401f9 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,7 +15,7 @@ * 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 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 99% 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 e912bce0..1bb3ece8 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,7 +15,7 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.window import com.sun.jna.Memory import com.sun.jna.Native 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..fc293485 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowResizeOperation.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 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/UndecoratedWindowResizer.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/UndecoratedWindowResizer.kt similarity index 99% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/UndecoratedWindowResizer.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/UndecoratedWindowResizer.kt index 319e6e83..3f8aa18c 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/UndecoratedWindowResizer.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/UndecoratedWindowResizer.kt @@ -14,7 +14,7 @@ * 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 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/x11/LinuxX11WindowMover.kt similarity index 99% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxX11WindowMover.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxX11WindowMover.kt index 0d992cb0..00314103 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxX11WindowMover.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxX11WindowMover.kt @@ -15,7 +15,7 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.x11 import com.moriafly.salt.ui.UnstableSaltUiApi import com.sun.jna.Native diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInput2.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt similarity index 99% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInput2.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt index b49ec667..8193150b 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInput2.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt @@ -15,7 +15,7 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.x11 import com.sun.jna.Library import com.sun.jna.Memory diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInputSession.kt similarity index 99% rename from ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt rename to ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInputSession.kt index cf8eadb4..59c3dda7 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/LinuxXInputSession.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInputSession.kt @@ -15,7 +15,7 @@ * limitations under the License. */ -package com.moriafly.salt.ui.platform.linux +package com.moriafly.salt.ui.platform.linux.x11 import com.moriafly.salt.ui.UnstableSaltUiApi import com.moriafly.salt.ui.window.internal.SaltWindowExceptionHandler 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 79c4eb3d..c5281cc8 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 @@ -29,7 +29,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.linuxCaptionBarInput +import com.moriafly.salt.ui.platform.linux.input.linuxCaptionBarInput import com.moriafly.salt.ui.platform.windows.windowsCaptionBarTouchInput /** 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 4b2e2cf0..1a21df12 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 @@ -43,8 +43,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 e0d9e134..17df83af 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 @@ -49,8 +49,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..8fbf9659 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.X11Ext import com.moriafly.salt.ui.window.SaltWindow import com.moriafly.salt.ui.window.SaltWindowProperties import com.sun.jna.Native 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 From 16a1b7129eb016282e15b0018d42bde4cb0649f4 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 23:14:31 +0800 Subject: [PATCH 09/35] refactor(linux): isolate gesture state and synchronize sheet tests Extract contact ownership, capture, tap recognition, click suppression and cancellation into a deterministic state machine without AWT or Compose. Keep input conversion and explicit caption-hit replies in the dispatcher, and inject window move, resize and focus effects while preserving their native resource lifecycles and shared mouse/touch behavior. Expand parameter declarations throughout the Linux package. Synchronize the existing SheetWindowTest with close-button removal and restoration on the native window frame clock, retaining its original assertions. Validation: compiled desktop library and existing test sources with JBR 25.0.4.1 b583.48. Fifteen pure-state scenarios passed without java.desktop; 101 native interaction checks passed in isolated KWin/XWayland Voxzen windows, including resize at 100% and 200% scale, caption gestures and focus. All 177 parameterized declarations across 24 Linux files passed syntax/style checks. Physical touchscreen acceptance remains unverified. --- .../linux/LinuxSaltDialogWindowFrame.kt | 4 +- .../ui/platform/linux/LinuxSaltWindowFrame.kt | 4 +- .../linux/decoration/LinuxClientShadow.kt | 18 +- .../platform/linux/input/LinuxMouseInput.kt | 21 +- .../platform/linux/input/LinuxTouchInput.kt | 11 +- .../linux/input/LinuxTouchTapRecognizer.kt | 91 ---- .../linux/input/LinuxWindowGestures.kt | 491 ++++++------------ .../platform/linux/input/LinuxWindowInput.kt | 29 +- .../linux/input/LinuxWindowMouseInput.kt | 40 +- .../input/gesture/LinuxTouchTapRecognizer.kt | 110 ++++ .../input/gesture/LinuxWindowGestureState.kt | 434 ++++++++++++++++ .../input/gesture/WindowGestureEffects.kt | 83 +++ .../linux/window/LinuxCaptionBarHitTest.kt | 4 +- .../linux/window/LinuxFallbackWindowMover.kt | 8 +- .../linux/window/LinuxResizeCursor.kt | 43 +- .../platform/linux/window/LinuxWindowFocus.kt | 18 +- .../linux/window/LinuxWindowGestureEffects.kt | 186 +++++++ .../window/LinuxWindowResizeOperation.kt | 12 +- .../linux/window/UndecoratedWindowResizer.kt | 26 +- .../platform/linux/x11/LinuxX11WindowMover.kt | 5 +- .../ui/platform/linux/x11/LinuxXInput2.kt | 18 +- .../platform/linux/x11/LinuxXInputSession.kt | 47 +- .../moriafly/salt/ui/sheet/SheetWindowTest.kt | 7 + 23 files changed, 1211 insertions(+), 499 deletions(-) delete mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchTapRecognizer.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/LinuxTouchTapRecognizer.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/LinuxWindowGestureState.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/WindowGestureEffects.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowGestureEffects.kt 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 d664a25f..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 @@ -55,6 +55,7 @@ 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 @@ -95,7 +96,8 @@ internal fun DialogWindowScope.LinuxSaltDialogWindowFrame( window = window, caption = captionBarHitTest, resizer = undecoratedWindowResizer, - captionHovered = isHitTestInCaptionBar + captionHovered = isHitTestInCaptionBar, + effects = LinuxWindowGestureEffects(window, undecoratedWindowResizer) ) } 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 b607e10e..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 @@ -58,6 +58,7 @@ 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 @@ -106,7 +107,8 @@ internal fun FrameWindowScope.LinuxSaltWindowFrame( window = window, caption = captionBarHitTest, resizer = undecoratedWindowResizer, - captionHovered = isHitTestInCaptionBar + captionHovered = isHitTestInCaptionBar, + effects = LinuxWindowGestureEffects(window, undecoratedWindowResizer) ) } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt index 274ab245..a7e6ef83 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt @@ -130,7 +130,9 @@ internal object LinuxClientShadow { * fully undecorated. */ @OptIn(ExperimentalComposeUiApi::class) - fun shouldUseClientShadow(decoration: WindowDecoration): Boolean = + fun shouldUseClientShadow( + decoration: WindowDecoration + ): Boolean = decoration == WindowDecoration.SystemDefault && isTranslucencySupported /** @@ -140,7 +142,10 @@ 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 { @@ -197,7 +202,10 @@ internal object LinuxClientShadow { * each side) via the XFixes 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 { @@ -339,7 +347,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) 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 index 067017c2..7d45d770 100644 --- 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 @@ -43,7 +43,9 @@ internal class LinuxMouseInput( awaitingNativeCapabilities = true } - fun updateNativeCapabilities(capabilities: LinuxXInputSession.Capabilities) { + fun updateNativeCapabilities( + capabilities: LinuxXInputSession.Capabilities + ) { nativeCapabilities = capabilities awaitingNativeCapabilities = false } @@ -53,7 +55,10 @@ internal class LinuxMouseInput( private val nativeHoverSelected: Boolean get() = awaitingNativeCapabilities || nativeCapabilities.hover - fun dispatchAwt(event: MouseEvent, deliver: (MouseEvent) -> Unit) { + fun dispatchAwt( + event: MouseEvent, + deliver: (MouseEvent) -> Unit + ) { if (event.component !== canvas) { deliver(event) return @@ -95,7 +100,9 @@ internal class LinuxMouseInput( deliver(normalizeRelease(event)) } - private fun normalizeRelease(event: MouseEvent): MouseEvent { + private fun normalizeRelease( + event: MouseEvent + ): MouseEvent { if (event.id != MouseEvent.MOUSE_RELEASED || event.button == MouseEvent.NOBUTTON ) { @@ -120,7 +127,9 @@ internal class LinuxMouseInput( ) } - fun onHover(event: LinuxXInputSession.Hover) { + 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) @@ -162,7 +171,9 @@ internal class LinuxMouseInput( 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 { + 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 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 index 2f09c0a7..1a3eb650 100644 --- 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 @@ -24,7 +24,7 @@ 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.window.LinuxWindowFocus +import com.moriafly.salt.ui.platform.linux.input.gesture.WindowGestureEffects import com.moriafly.salt.ui.platform.linux.x11.LinuxXInputSession import java.awt.Component import java.awt.Dialog @@ -41,11 +41,13 @@ internal class LinuxTouchInput( private val canvas: Component, private val target: DesktopTouchScene, private val gestures: LinuxWindowGestures, - private val focus: LinuxWindowFocus? + private val focus: WindowGestureEffects.Focus ) { private val pointers = TouchPointerState() - fun onTouch(sample: LinuxXInputSession.Touch): Boolean { + fun onTouch( + sample: LinuxXInputSession.Touch + ): Boolean { if (!sample.directTouch) return false if (!canReceiveTouch()) { cancel() @@ -69,8 +71,7 @@ internal class LinuxTouchInput( timeMillis = sample.timeMillis ) if (type == PointerEventType.Press) { - canvas.requestFocusInWindow() - focus?.onPointerPress(canvas, sample.x11Timestamp) + focus.onPress(sample.x11Timestamp) } gestures.dispatchTouch( event = touchEvent, diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchTapRecognizer.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchTapRecognizer.kt deleted file mode 100644 index 3519f159..00000000 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxTouchTapRecognizer.kt +++ /dev/null @@ -1,91 +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.input - -import androidx.compose.ui.platform.ViewConfiguration -import java.awt.Point -import java.awt.Window - -/** - * Recognizes caption touch taps using Compose's timing and distance thresholds. - * - * The gesture owner supplies the captured contact and cancels recognition on another contact, - * mouse press or gesture cancellation. This recognizer never captures or moves a window. - */ -internal class LinuxTouchTapRecognizer( - private val window: Window -) { - private lateinit var viewConfiguration: ViewConfiguration - private var press: Press? = null - private var previousTap: Tap? = null - - fun configure(viewConfiguration: ViewConfiguration) { - this.viewConfiguration = viewConfiguration - } - - fun begin(position: Point, timeMillis: Long) { - press = Press(position, timeMillis) - } - - /** - * Completes the current contact and returns whether it completes a double tap. - */ - fun finish(position: Point, timeMillis: Long): Boolean { - val current = press - press = null - if (current == null || - timeMillis - current.timeMillis !in 0 until viewConfiguration.longPressTimeoutMillis || - !isWithinSlop(current.position, position) - ) { - cancel() - return false - } - val doubleTap = previousTap?.let { previous -> - val interval = current.timeMillis - previous.upTimeMillis - val withinTimeout = interval in - viewConfiguration.doubleTapMinTimeMillis..viewConfiguration.doubleTapTimeoutMillis - withinTimeout && isWithinSlop(previous.position, current.position) - } ?: false - previousTap = if (doubleTap) null else Tap(current.position, timeMillis) - return doubleTap - } - - fun isWithinSlop(first: Point, second: Point): Boolean { - // AWT screen coordinates are logical units; Compose's touch slop is in physical pixels. - val transform = window.graphicsConfiguration.defaultTransform - val dx = (second.x - first.x) * transform.scaleX - val dy = (second.y - first.y) * transform.scaleY - val slop = viewConfiguration.touchSlop - return dx * dx + dy * dy <= slop * slop - } - - fun cancel() { - press = null - previousTap = null - } - - private class Press( - val position: Point, - val timeMillis: Long - ) - - private class Tap( - val position: Point, - val upTimeMillis: Long - ) -} 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 index f45a2e09..af6a2dce 100644 --- 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 @@ -23,50 +23,37 @@ 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.LinuxFallbackWindowMover -import com.moriafly.salt.ui.platform.linux.window.LinuxWindowResizeOperation import com.moriafly.salt.ui.platform.linux.window.UndecoratedWindowResizer -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 -import java.awt.event.ComponentAdapter -import java.awt.event.ComponentEvent import java.awt.event.MouseEvent internal val LocalLinuxWindowGestures = staticCompositionLocalOf { null } /** - * The window's single move/resize owner, confined to the AWT event thread. - * - * Mouse and touch use the same geometry and movement policy. Borders take precedence over the - * caption. Modal content is checked before dispatch so its scrim cannot swallow a caption press. - * Otherwise Compose resolves caption/control overlap during the first press dispatched here. - * This owner cancels Compose before capturing that contact until release. Other fingers never - * inherit a window gesture, even after its owner lifts. + * 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 captionHovered: State, + private val effects: WindowGestureEffects ) : LinuxWindowMouseInput.Handler { - private var moveable = false - private var canDrag = true - private var canMaximize = false - private var gesture: Gesture? = null - private val fallbackMover = LinuxFallbackWindowMover(window) - private var useNativeDrag = true - private var restoreWidth = window.width - private var suppressedClick: Click? = null - private val contacts = mutableSetOf() - private var capturedTouches = false - private var dispatchingPress: LinuxTouchEvent? = null - private var captionHitByCompose = false - private val touchTapRecognizer = LinuxTouchTapRecognizer(window) + private val state = LinuxWindowGestureState() + private var captionQuery: CaptionQuery? = null fun configure( moveable: Boolean, @@ -74,45 +61,42 @@ internal class LinuxWindowGestures( canDrag: Boolean, canMaximize: Boolean, viewConfiguration: ViewConfiguration - ) { - if (this.moveable != moveable || this.canDrag != canDrag || - this.canMaximize != canMaximize - ) { - touchTapRecognizer.cancel() - } - touchTapRecognizer.configure(viewConfiguration) - resizer.enabled = resizable - this.moveable = moveable - this.canDrag = canDrag - this.canMaximize = canMaximize - if ( - (gesture is Gesture.Move && (!moveable || !canDrag)) || - (gesture is Gesture.Resize && !resizer.enabled) - ) { - finishGesture() - } - if (!resizer.enabled) resizer.showEdge(WindowResizeEdge.None) - } + ) = 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 listener = object : ComponentAdapter() { - override fun componentResized(event: ComponentEvent) = onWindowResized() - } - window.addComponentListener(listener) - val unregister = LinuxWindowMouseInput.register(window, this, priority = 1) + val uninstallMover = effects.mover.install() + val unregister = LinuxWindowMouseInput.register( + window = window, + handler = this, + priority = 1 + ) return { unregister() - window.removeComponentListener(listener) + uninstallMover() } } - /** - * Records a Compose caption hit only while resolving the first contact's target. - * Gesture eligibility, capture and movement belong to this owner. - */ - fun onCaptionPress(event: LinuxTouchEvent): Boolean { - if (dispatchingPress !== event) return false - captionHitByCompose = true + fun onCaptionPress( + event: LinuxTouchEvent + ): Boolean { + val query = captionQuery ?: return false + if (query.dispatch.event !== event) return false + query.hit = true return true } @@ -121,305 +105,156 @@ internal class LinuxWindowGestures( dispatchToCompose: () -> Unit, cancelCompose: () -> Unit ) { - val firstContact = contacts.isEmpty() - if (event.type == PointerEventType.Press) { - if (!contacts.add(event.id)) return - if (!firstContact) touchTapRecognizer.cancel() - } else if (event.id !in contacts) { - return + val phase = when (event.type) { + PointerEventType.Press -> Phase.Press + PointerEventType.Move -> Phase.Move + PointerEventType.Release -> Phase.Release + else -> return + } + 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 { - if (capturedTouches || gesture?.pointer == Pointer.Mouse) { - capturedTouches = true - if (gesture?.pointer == Pointer.Touch(event.id)) { - when (event.type) { - PointerEventType.Move -> updateGesture(event.screenPoint()) - PointerEventType.Release -> { - val active = gesture - val doubleTap = active is Gesture.Move && - touchTapRecognizer.finish(event.screenPoint(), event.timeMillis) - finishGesture(applyPendingResize = true) - if (doubleTap) toggleMaximizeOrRestore() - } - } - } - return - } - if (firstContact && event.type == PointerEventType.Press) { - val edge = resizer.edgeAt(event.contentPosition) - if (edge != WindowResizeEdge.None) { - capturedTouches = true - cancelCompose() - beginResize(Pointer.Touch(event.id), edge, event.screenPoint()) - return - } - val isCaption = when (captionHitAt(event.contentPosition)) { - LinuxCaptionBarHitTest.Result.Caption -> true - LinuxCaptionBarHitTest.Result.Excluded -> { - dispatchToCompose() - false - } - - LinuxCaptionBarHitTest.Result.RequiresComposeHitTest -> - dispatchCaptionPress(event, dispatchToCompose) - } - if (isCaption) { - capturedTouches = true - cancelCompose() - touchTapRecognizer.begin(event.screenPoint(), event.timeMillis) - beginMove(Pointer.Touch(event.id), event.screenPoint()) - } else { - touchTapRecognizer.cancel() - } - } else { - dispatchToCompose() - } + val dispatch = TouchDispatch( + event = event, + deliver = dispatchToCompose, + cancel = cancelCompose + ) + execute(state.onInput(input, hit), dispatch) } catch (exception: Throwable) { cancel() cancelCompose() throw exception - } finally { - if (event.type == PointerEventType.Release) { - contacts.remove(event.id) - if (contacts.isEmpty()) capturedTouches = false - } } } - override fun onMouseEvent(event: MouseEvent): Boolean { - if (event.id == MouseEvent.MOUSE_PRESSED) touchTapRecognizer.cancel() - // A real mouse cannot steal a touch sequence, and vice versa. - if (contacts.isNotEmpty()) { - return event.id == MouseEvent.MOUSE_PRESSED || event.id == MouseEvent.MOUSE_DRAGGED || - event.id == MouseEvent.MOUSE_RELEASED || event.id == MouseEvent.MOUSE_CLICKED + 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 edge = resizer.edgeAt(LinuxWindowMouseInput.positionInContent(event, window)) - when (event.id) { - MouseEvent.MOUSE_MOVED, MouseEvent.MOUSE_ENTERED -> resizer.showEdge(edge) - MouseEvent.MOUSE_EXITED -> if (gesture == null) resizer.showEdge(WindowResizeEdge.None) - MouseEvent.MOUSE_PRESSED -> { - if (event.button != MouseEvent.BUTTON1) return gesture != null - finishGesture() - suppressedClick = null - if (edge != WindowResizeEdge.None) { - beginResize(Pointer.Mouse, edge, event.locationOnScreen) - return true - } - val position = LinuxWindowMouseInput.positionInContent(event, window) - val isCaption = when (captionHitAt(position)) { - LinuxCaptionBarHitTest.Result.Caption -> true - LinuxCaptionBarHitTest.Result.Excluded -> false - // Enter and press can arrive before recomposition. - LinuxCaptionBarHitTest.Result.RequiresComposeHitTest -> captionHovered.value - } - if (!isCaption) return false - beginMove(Pointer.Mouse, event.locationOnScreen) - return true - } - - MouseEvent.MOUSE_DRAGGED -> { - if (gesture?.pointer != Pointer.Mouse) return false - updateGesture(event.locationOnScreen) - return true - } - - MouseEvent.MOUSE_RELEASED -> { - val active = gesture ?: return false - if (event.button == MouseEvent.BUTTON1) { - suppressedClick = if (active is Gesture.Move) Click.Caption else Click.Resize - finishGesture(applyPendingResize = true) - resizer.showEdge(edge) - } - return true - } - - MouseEvent.MOUSE_CLICKED -> { - val click = suppressedClick ?: return false - if (event.button != MouseEvent.BUTTON1) return false - suppressedClick = null - if (click == Click.Caption && event.clickCount == 2) { - toggleMaximizeOrRestore() - } - return true + 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)) } - return false + 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 afterMouseEvent( + event: MouseEvent + ) = resizer.afterMouseEvent(event) override fun cancel() { - touchTapRecognizer.cancel() - finishGesture() - suppressedClick = null - dispatchingPress = null - captionHitByCompose = false - // Keep known contacts quarantined until release or an explicit native input reset. - capturedTouches = contacts.isNotEmpty() + captionQuery = null + execute(state.cancel()) } fun resetTouchInput() { - touchTapRecognizer.cancel() - // XI2 device changes and touch-channel shutdown must not cancel an AWT mouse drag. - if (gesture?.pointer is Pointer.Touch) finishGesture() - dispatchingPress = null - captionHitByCompose = false - contacts.clear() - capturedTouches = false + captionQuery = null + execute(state.resetTouchInput()) } - private fun captionHitAt(position: Offset): LinuxCaptionBarHitTest.Result = - if (moveable && canDrag) { - caption.hitTest(position, window) - } else { - LinuxCaptionBarHitTest.Result.Excluded - } - - private fun dispatchCaptionPress( - event: LinuxTouchEvent, - dispatchToCompose: () -> Unit - ): Boolean { - dispatchingPress = event - captionHitByCompose = false - try { - dispatchToCompose() - return captionHitByCompose - } finally { - dispatchingPress = null - captionHitByCompose = false - } - } - - private fun toggleMaximizeOrRestore() { - if (!moveable || !canDrag || !canMaximize) return - val frame = window as? Frame ?: return - frame.extendedState = if (frame.extendedState == Frame.MAXIMIZED_BOTH) { - Frame.NORMAL - } else { - Frame.MAXIMIZED_BOTH + 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 beginMove( - pointer: Pointer, - position: Point + private fun execute( + decision: LinuxWindowGestureState.Decision, + touch: TouchDispatch? = null ) { - gesture = Gesture.Move(pointer, position) - } - - private fun beginResize( - pointer: Pointer, - edge: WindowResizeEdge, - position: Point - ) { - touchTapRecognizer.cancel() - gesture = Gesture.Resize( - pointer, - LinuxWindowResizeOperation( - window = window, - edge = edge, - start = position - ) - ) - resizer.showEdge(edge) - } - - private fun updateGesture(position: Point) { - when (val active = gesture) { - is Gesture.Move -> { - active.latestPosition = position - if (active.restoreOffset != null) return - if (active.pending) { - // Small touch jitter remains a tap, including while maximized. Starting the - // native mover earlier would restore the window before a double tap resolves. - if (active.pointer is Pointer.Touch) { - if (touchTapRecognizer.isWithinSlop(active.start, position)) return - touchTapRecognizer.cancel() - } - active.pending = false - val frame = window as? Frame - if (frame?.extendedState == Frame.MAXIMIZED_BOTH) { - val localX = active.start.x - window.x - active.restoreOffset = Point( - (restoreWidth * localX.toFloat() / window.width).toInt(), - active.start.y - window.y - ) - frame.extendedState = Frame.NORMAL - return - } - startMover(active.start, active.pointer) - } - fallbackMover.updateDrag(position) + for (command in decision.commands) { + when (command) { + Command.DeliverTouch -> checkNotNull(touch).deliver() + Command.CancelCompose -> checkNotNull(touch).cancel() + is Command.ResolveCaption -> resolveCaption(command.request, checkNotNull(touch)) + is Command.StartMove -> effects.mover.start( + pointer = command.pointer, + start = command.start, + current = command.current + ) + + 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) } - - is Gesture.Resize -> active.operation.update(position) - null -> Unit } } - private fun startMover(position: Point, pointer: Pointer) { - if (useNativeDrag) { - val button = if (pointer == Pointer.Mouse) MouseEvent.BUTTON1 else MouseEvent.NOBUTTON - if (LinuxX11WindowMover.tryMove(window, position, button)) return - useNativeDrag = false - } - fallbackMover.startDrag(position) - } - - private fun onWindowResized() { - if ((window as? Frame)?.extendedState == Frame.MAXIMIZED_BOTH) return - restoreWidth = window.width - val active = gesture as? Gesture.Move ?: return - val offset = active.restoreOffset ?: return - active.restoreOffset = null - // Position only after native restore; moving earlier overwrites the WM's saved geometry. - window.setLocation(active.latestPosition.x - offset.x, active.latestPosition.y - offset.y) - startMover(active.latestPosition, active.pointer) - } - - /** - * Ends the current window gesture and clears the drag state and resize-edge indicator. - * - * @param applyPendingResize Pass `true` on mouse or touch release to apply the last pending - * resize before ending the gesture. Leave `false` on cancellation to discard the pending - * update and prevent further window size changes from that gesture. - */ - private fun finishGesture( - applyPendingResize: Boolean = false - ) { - (gesture as? Gesture.Resize)?.operation?.finish(applyPendingResize) - gesture = null - fallbackMover.endDrag() - resizer.showEdge(WindowResizeEdge.None) - } - - private fun LinuxTouchEvent.screenPoint() = Point(screenPosition.x, screenPosition.y) - - private sealed interface Pointer { - data object Mouse : Pointer - - data class Touch( - val id: Long - ) : Pointer - } - - private sealed class Gesture( - val pointer: Pointer + private fun resolveCaption( + request: Long, + dispatch: TouchDispatch ) { - class Move( - pointer: Pointer, - val start: Point - ) : Gesture(pointer) { - var pending = true - var latestPosition = start - var restoreOffset: Point? = null + val query = CaptionQuery(dispatch) + captionQuery = query + try { + dispatch.deliver() + } finally { + if (captionQuery === query) captionQuery = null } - - class Resize( - pointer: Pointer, - val operation: LinuxWindowResizeOperation - ) : Gesture(pointer) + execute(state.resolveCaption(request, query.hit), dispatch) } - private enum class Click { Caption, Resize } + private class CaptionQuery( + val dispatch: TouchDispatch, + var hit: Boolean = false + ) + + private class TouchDispatch( + val event: LinuxTouchEvent, + val deliver: () -> Unit, + val cancel: () -> Unit + ) } 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 index 48f9ecb4..1bf6bde7 100644 --- 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 @@ -90,9 +90,13 @@ internal class LinuxWindowInput private constructor( private var closed = false private val windowListener = object : WindowAdapter() { - override fun windowLostFocus(event: WindowEvent) = cancelInput() + override fun windowLostFocus( + event: WindowEvent + ) = cancelInput() - override fun windowIconified(event: WindowEvent) = cancelInput() + override fun windowIconified( + event: WindowEvent + ) = cancelInput() } private val hierarchyListener = HierarchyListener { event -> @@ -173,13 +177,19 @@ internal class LinuxWindowInput private constructor( if (target != null && !target.isDisposed) target.scene.cancelPointerInput() } - override fun onReady(capabilities: LinuxXInputSession.Capabilities) { + override fun onReady( + capabilities: LinuxXInputSession.Capabilities + ) { mouse.updateNativeCapabilities(capabilities) } - override fun onTouch(event: LinuxXInputSession.Touch): Boolean = touch?.onTouch(event) == true + override fun onTouch( + event: LinuxXInputSession.Touch + ): Boolean = touch?.onTouch(event) == true - override fun onHover(event: LinuxXInputSession.Hover) = mouse.onHover(event) + override fun onHover( + event: LinuxXInputSession.Hover + ) = mouse.onHover(event) override fun onDevicesChanged() { cancelTouch() @@ -216,7 +226,9 @@ internal class LinuxWindowInput private constructor( } companion object { - private fun logUnavailable(exception: Throwable) { + private fun logUnavailable( + exception: Throwable + ) { System.getLogger(LinuxWindowInput::class.java.name).log( System.Logger.Level.WARNING, "Cannot attach native XI2 input; keeping AWT input", @@ -240,7 +252,10 @@ internal class LinuxWindowInput private constructor( canvas = canvas, target = target, gestures = gestures, - focus = focus + focus = { timestamp -> + canvas.requestFocusInWindow() + focus?.onPointerPress(canvas, timestamp) + } ) } // Hover remains usable even when Compose's private scene adapter is unavailable. diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowMouseInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowMouseInput.kt index c223c4cc..bb7d36ae 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowMouseInput.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/LinuxWindowMouseInput.kt @@ -35,9 +35,13 @@ import javax.swing.SwingUtilities */ 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() {} } @@ -78,7 +82,10 @@ internal object LinuxWindowMouseInput { } } - fun registerSource(window: Window, source: LinuxMouseInput): () -> Unit { + fun registerSource( + window: Window, + source: LinuxMouseInput + ): () -> Unit { check(EventQueue.isDispatchThread()) check(window in handlers && window !in sources) sources[window] = source @@ -90,7 +97,10 @@ internal object LinuxWindowMouseInput { } } - fun dispatchToWindow(window: Window, event: MouseEvent) { + 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. @@ -98,7 +108,10 @@ internal object LinuxWindowMouseInput { } @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) @@ -109,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() { - public override fun dispatchEvent(event: AWTEvent) { + public override fun dispatchEvent( + event: AWTEvent + ) { if (event is WindowEvent && ( event.id == WindowEvent.WINDOW_DEACTIVATED || @@ -143,7 +150,10 @@ internal object LinuxWindowMouseInput { } } - fun deliverToWindow(window: Window, mouseEvent: MouseEvent) { + fun deliverToWindow( + window: Window, + mouseEvent: MouseEvent + ) { val registrations = handlers[window]?.toList().orEmpty() if (!window.isShowing || !window.isEnabled) { registrations.forEach { it.handler.cancel() } 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..78f1a716 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/LinuxWindowGestureState.kt @@ -0,0 +1,434 @@ +/* + * 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. + */ +@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() + + 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) commands += Command.ShowEdge(WindowResizeEdge.None) + return Decision(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 -> onMouse(input, hit, commands) + is Pointer.Touch -> onTouch(input, hit, commands) + } + return Decision(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 Decision(true, commands) + } + + fun cancel(): Decision { + taps.cancel() + captionQuery = null + suppressedClick = null + capturedTouches = contacts.isNotEmpty() + val commands = mutableListOf() + finish(commands) + return Decision(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 Decision(false, commands) + } + + private val canMove: Boolean + get() = configuration?.let { it.moveable && it.canDrag } == true + + private fun onTouch( + 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() + } else if (pointer.id !in contacts) { + return true + } + try { + if (capturedTouches || gesture?.pointer == Pointer.Mouse) { + capturedTouches = true + if (gesture?.pointer == pointer) { + 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 + } + } + return true + } + if (firstContact && input.phase == Phase.Press) { + when { + configuration!!.resizable && hit.edge != WindowResizeEdge.None -> { + capturedTouches = true + commands += Command.CancelCompose + beginResize( + pointer = 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 + } + } + } 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 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 onMouse( + input: Input, + hit: Hit, + commands: MutableList + ): Boolean { + if (input.phase == Phase.Press) taps.cancel() + if (contacts.isNotEmpty()) { + return input.phase == Phase.Press || input.phase == Phase.Move || + input.phase == Phase.Release || input.phase == Phase.Click + } + val edge = if (configuration!!.resizable) hit.edge else WindowResizeEdge.None + when (input.phase) { + Phase.Enter -> commands += Command.ShowEdge(edge) + Phase.Exit -> if (gesture == null) commands += Command.ShowEdge(WindowResizeEdge.None) + Phase.Press -> { + 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 || hit.caption != CaptionHit.Caption) return false + gesture = Gesture.Move(Pointer.Mouse, input.position) + return true + } + + 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) + commands += Command.ShowEdge(edge) + } + return true + } + + Phase.Click -> { + val click = suppressedClick ?: return false + if (!input.primary) return false + suppressedClick = null + if (click == Click.Caption && input.clickCount == 2) toggleMaximize(commands) + return true + } + } + return false + } + + private fun beginResize( + pointer: Pointer, + edge: WindowResizeEdge, + position: Position, + commands: MutableList + ) { + taps.cancel() + gesture = Gesture.Resize(pointer) + commands += Command.StartResize(edge, position) + commands += Command.ShowEdge(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 + commands += Command.ShowEdge(WindowResizeEdge.None) + } +} 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..d7dfbde6 --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/input/gesture/WindowGestureEffects.kt @@ -0,0 +1,83 @@ +/* + * 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 { + 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/window/LinuxCaptionBarHitTest.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt index 6b9dbcb1..83e19daf 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt @@ -79,7 +79,9 @@ internal class LinuxCaptionBarHitTest { 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) } 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 index e160fbf7..90f3e018 100644 --- 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 @@ -35,13 +35,17 @@ internal class LinuxFallbackWindowMover( private var dragStartWindowPos: Point? = null private var isDragging = false - fun startDrag(screenPosition: Point) { + fun startDrag( + screenPosition: Point + ) { dragStartScreenPos = Point(screenPosition) dragStartWindowPos = window.location isDragging = true } - fun updateDrag(screenPosition: Point) { + fun updateDrag( + screenPosition: Point + ) { if (!isDragging) return val startScreen = dragStartScreenPos ?: return diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt index 4e9401f9..5b9113eb 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt @@ -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" @@ -93,7 +95,9 @@ internal object LinuxResizeCursor { 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,7 +129,10 @@ 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 { @@ -158,18 +165,29 @@ internal object LinuxResizeCursor { @Suppress("ktlint:standard:function-naming", "FunctionName") private interface Xlib : Library { - fun XOpenDisplay(name: String?): Pointer? + fun XOpenDisplay( + name: String? + ): Pointer? - fun XCloseDisplay(display: Pointer): Int + fun XCloseDisplay( + display: Pointer + ): Int - fun XFreeCursor(display: Pointer, cursor: NativeLong): Int + fun XFreeCursor( + display: Pointer, + cursor: NativeLong + ): Int } @Suppress("ktlint:standard:function-naming", "FunctionName") private interface Xcursor : Library { - fun XcursorGetTheme(display: Pointer): Pointer? + fun XcursorGetTheme( + display: Pointer + ): Pointer? - fun XcursorGetDefaultSize(display: Pointer): Int + fun XcursorGetDefaultSize( + display: Pointer + ): Int fun XcursorLibraryLoadImages( name: String, @@ -177,8 +195,13 @@ internal object LinuxResizeCursor { size: Int ): Pointer? - fun XcursorImagesLoadCursor(display: Pointer, images: Pointer): NativeLong + fun XcursorImagesLoadCursor( + display: Pointer, + images: Pointer + ): NativeLong - fun XcursorImagesDestroy(images: Pointer) + fun XcursorImagesDestroy( + images: Pointer + ) } } diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt index 1bb3ece8..105d3582 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt @@ -63,7 +63,10 @@ internal class LinuxWindowFocus private constructor( * @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) { + fun onPointerPress( + component: Component, + timestamp: Long? = null + ) { if (SwingUtilities.getWindowAncestor(component) !== window || !window.isShowing || !window.isEnabled || !window.isFocusableWindow ) { @@ -109,7 +112,9 @@ internal class LinuxWindowFocus private constructor( } } - private fun readUserTime(source: X11.Window): Long? { + private fun readUserTime( + source: X11.Window + ): Long? { val type = X11.AtomByReference() val format = IntByReference() val count = NativeLongByReference() @@ -142,7 +147,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 { @@ -175,7 +183,9 @@ 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 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..185fa8cf --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowGestureEffects.kt @@ -0,0 +1,186 @@ +/* + * 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 +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent +import java.awt.event.MouseEvent + +/** + * Executes gesture commands through the WM and AWT. Native restore notifications and queued resize + * work stay here; 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 useNativeDrag = true + private var restoreWidth = window.width + private var drag: Drag? = null + + override fun install(): () -> Unit { + val listener = object : ComponentAdapter() { + override fun componentResized( + event: ComponentEvent + ) = onWindowResized() + } + window.addComponentListener(listener) + return { + window.removeComponentListener(listener) + finish() + } + } + + override fun start( + pointer: Pointer, + start: Position, + current: Position + ) { + val active = Drag(pointer, current) + drag = active + val frame = window as? Frame + if (frame?.extendedState == Frame.MAXIMIZED_BOTH) { + val localX = start.x - window.x + active.restoreOffset = Position( + (restoreWidth * localX.toFloat() / window.width).toInt(), + start.y - window.y + ) + frame.extendedState = Frame.NORMAL + } else { + startMover(start, pointer) + fallback.updateDrag(Point(current.x, current.y)) + } + } + + override fun update( + position: Position + ) { + val active = drag ?: return + active.latestPosition = position + if (active.restoreOffset == null) fallback.updateDrag(Point(position.x, position.y)) + } + + override fun finish() { + drag = null + 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 startMover( + position: Position, + pointer: Pointer + ) { + val point = Point(position.x, position.y) + if (useNativeDrag) { + val button = + if (pointer == Pointer.Mouse) MouseEvent.BUTTON1 else MouseEvent.NOBUTTON + val started = LinuxX11WindowMover.tryMove( + window = window, + screenPosition = point, + button = button + ) + if (started) return + useNativeDrag = false + } + fallback.startDrag(point) + } + + private fun onWindowResized() { + if ((window as? Frame)?.extendedState == Frame.MAXIMIZED_BOTH) return + restoreWidth = window.width + val active = drag ?: return + val offset = active.restoreOffset ?: return + active.restoreOffset = null + // Moving before native restore would overwrite the WM's saved geometry. + window.setLocation( + active.latestPosition.x - offset.x, + active.latestPosition.y - offset.y + ) + startMover(active.latestPosition, active.pointer) + } + + private class Drag( + val pointer: Pointer, + var latestPosition: Position, + var restoreOffset: Position? = null + ) + } +} 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 index fc293485..9b57a4ec 100644 --- 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 @@ -39,7 +39,9 @@ internal class LinuxWindowResizeOperation( private var pendingPosition: Point? = null private var finished = false - fun update(position: Point) { + fun update( + position: Point + ) { if (finished) return val alreadyQueued = pendingPosition != null pendingPosition = position @@ -57,7 +59,9 @@ internal class LinuxWindowResizeOperation( * @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) { + fun finish( + applyPendingResize: Boolean = false + ) { if (finished) return finished = true val latest = pendingPosition @@ -65,7 +69,9 @@ internal class LinuxWindowResizeOperation( if (applyPendingResize && latest != null) resize(latest) } - private fun resize(position: Point) { + private fun resize( + position: Point + ) { val dx = position.x - start.x val dy = position.y - start.y var x = bounds.x 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 index 3f8aa18c..4860b753 100644 --- 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 @@ -52,7 +52,9 @@ internal class UndecoratedWindowResizer( private var previousCursor: Cursor? = null @Composable - fun Content(modifier: Modifier = Modifier) { + fun Content( + modifier: Modifier = Modifier + ) { if (!enabled) return Layout( @@ -70,7 +72,10 @@ internal class UndecoratedWindowResizer( measurePolicy = { measurables, constraints -> val b = resizerThickness.roundToPx() - fun Measurable.measureSide(width: Int, height: Int) = measure( + fun Measurable.measureSide( + width: Int, + height: Int + ) = measure( Constraints.fixed(width.coerceAtLeast(0), height.coerceAtLeast(0)) ) @@ -96,13 +101,17 @@ internal class UndecoratedWindowResizer( ) } - fun edgeAt(position: Offset): WindowResizeEdge = if (enabled) { + fun edgeAt( + position: Offset + ): WindowResizeEdge = if (enabled) { sideBounds.entries.firstOrNull { it.value.contains(position) }?.key ?: WindowResizeEdge.None } else { WindowResizeEdge.None } - fun showEdge(edge: WindowResizeEdge) { + fun showEdge( + edge: WindowResizeEdge + ) { if (edge == hoveredSide) return hoveredSide = edge onResizeEdgeChange(edge) @@ -110,7 +119,9 @@ internal class UndecoratedWindowResizer( if (edge == WindowResizeEdge.None) restoreCursor() } - fun afterMouseEvent(event: MouseEvent) { + fun afterMouseEvent( + event: MouseEvent + ) { val cursor = resizeCursor ?: return val component = event.component if (cursorComponent !== component) { @@ -129,7 +140,10 @@ internal class UndecoratedWindowResizer( } @Composable - private fun Side(cursorId: Int, sides: WindowResizeEdge) { + private fun Side( + cursorId: Int, + sides: WindowResizeEdge + ) { DisposableEffect(sides) { sideCursors[sides] = cursorId onDispose { 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 index 00314103..088c6242 100644 --- 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 @@ -183,7 +183,10 @@ internal object LinuxX11WindowMover { @UnstableSaltUiApi @Suppress("FunctionName") internal interface X11Ext : X11 { - fun XUngrabPointer(display: X11.Display?, time: NativeLong?): Int + 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/x11/LinuxXInput2.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt index 8193150b..25037f2f 100644 --- 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 @@ -44,7 +44,9 @@ internal interface LinuxXInput2 : Library { count: IntByReference ): Pointer? - fun XIFreeDeviceInfo(info: Pointer) + fun XIFreeDeviceInfo( + info: Pointer + ) fun XISelectEvents( display: X11.Display, @@ -395,11 +397,19 @@ internal interface LinuxXInput2 : Library { @Suppress("FunctionName") internal interface LinuxTouchX11 : X11 { - fun XConnectionNumber(display: X11.Display): Int + fun XConnectionNumber( + display: X11.Display + ): Int - fun XGetEventData(display: X11.Display, cookie: EventCookie): Int + fun XGetEventData( + display: X11.Display, + cookie: EventCookie + ): Int - fun XFreeEventData(display: X11.Display, cookie: EventCookie) + fun XFreeEventData( + display: X11.Display, + cookie: EventCookie + ) @Structure.FieldOrder( "type", 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 index 59c3dda7..9f3db82b 100644 --- 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 @@ -40,11 +40,17 @@ internal class LinuxXInputSession( private val listener: Listener ) { interface Listener { - fun onReady(capabilities: Capabilities) + fun onReady( + capabilities: Capabilities + ) - fun onTouch(event: Touch): Boolean + fun onTouch( + event: Touch + ): Boolean - fun onHover(event: Hover) + fun onHover( + event: Hover + ) fun onDevicesChanged() @@ -199,7 +205,10 @@ internal class LinuxXInputSession( } } - private fun grabTouches(display: X11.Display, xid: X11.Window): Boolean = try { + private fun grabTouches( + display: X11.Display, + xid: X11.Window + ): Boolean = try { val modifiers = LinuxXInput2.GrabModifiers() val result = LinuxXInput2.instance.XIGrabTouchBegin( display, @@ -226,7 +235,10 @@ internal class LinuxXInputSession( false } - private fun selectHover(display: X11.Display, xid: X11.Window): Boolean = + private fun selectHover( + display: X11.Display, + xid: X11.Window + ): Boolean = LinuxXInput2.instance.XISelectEvents( display, xid, @@ -239,7 +251,9 @@ internal class LinuxXInputSession( 1 ) == 0 - private fun queryDirectDevices(display: X11.Display): Set { + private fun queryDirectDevices( + display: X11.Display + ): Set { val xi = LinuxXInput2.instance val count = IntByReference() val data = xi.XIQueryDevice(display, LinuxXInput2.ALL_DEVICES, count) @@ -401,7 +415,10 @@ internal class LinuxXInputSession( } } - private fun dispatchHover(sample: Hover, source: Int) { + private fun dispatchHover( + sample: Hover, + source: Int + ) { if (sample.phase != HoverPhase.Move) { dispatch { listener.onHover(sample) } return @@ -434,17 +451,23 @@ internal class LinuxXInputSession( pendingHoverMove = null } - private fun dispatchAndWait(action: () -> Unit) { + private fun dispatchAndWait( + action: () -> Unit + ) { sealHoverMove() SwingUtilities.invokeAndWait { action() } } - private fun dispatch(action: () -> Unit) { + private fun dispatch( + action: () -> Unit + ) { sealHoverMove() post(action) } - private fun post(action: () -> Unit) { + private fun post( + action: () -> Unit + ) { SwingUtilities.invokeLater { if (!stopRequested) { try { @@ -456,7 +479,9 @@ internal class LinuxXInputSession( } } - private fun logUnavailable(exception: Throwable) { + private fun logUnavailable( + exception: Throwable + ) { System.getLogger(LinuxXInputSession::class.java.name).log( System.Logger.Level.WARNING, "Native XI2 input stopped; restoring AWT input", diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/sheet/SheetWindowTest.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/sheet/SheetWindowTest.kt index 52837dcd..5b81f8f0 100644 --- a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/sheet/SheetWindowTest.kt +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/sheet/SheetWindowTest.kt @@ -391,6 +391,10 @@ class SheetWindowTest { assertEquals(0, fixture.requests) fixture.properties = fixture.properties.copy(dismissible = false) } + // The native window recomposes on its own frame clock, outside the test clock. + waitUntil(timeoutMillis = 5_000) { + onAllNodes(sheetCloseButton()).fetchSemanticsNodes().isEmpty() + } onNode(sheetCloseButton()).assertDoesNotExist() fixture.click(robot, "Background") robot.pressKey(KeyEvent.VK_ESCAPE) @@ -400,6 +404,9 @@ class SheetWindowTest { assertEquals(0, fixture.backgroundClicks) fixture.properties = fixture.properties.copy(dismissible = true) } + waitUntil(timeoutMillis = 5_000) { + onAllNodes(sheetCloseButton()).fetchSemanticsNodes().size == 1 + } onNode(sheetCloseButton()).assertIsDisplayed() val enabled = fixture.bounds.getValue("panel") fixture.drag(robot, enabled.center.x, enabled.top + 36f, enabled.height * 0.35f) From e3b79b555cd0a41345c4b03960eaff3a45b165f2 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Sun, 18 Oct 2026 23:42:05 +0800 Subject: [PATCH 10/35] fix(Linux): respect AWT modality for native XI2 input XI2 hover bypassed AWT modal filtering, allowing blocked owner windows to receive synthetic mouse events and display hover feedback despite blocked clicks. Gate native touch and hover together in LinuxWindowInput using AWT's actual modal-blocked state. Cancel rejected input and remove the touch adapter's separate modality rules. Resolve AWT access before selecting XI2, retaining the complete AWT input path when the required module access is unavailable. Validation: desktop compilation, UI text checks, and whitespace checks passed. Verified real SaltWindow input in isolated KWin/XWayland with AWT application- and document-modal dialogs: owner events were blocked, hover resumed after close, modal exclusion worked, and the AWT fallback retained modal blocking. --- .../platform/linux/input/LinuxMouseInput.kt | 3 +- .../platform/linux/input/LinuxTouchInput.kt | 26 +------------ .../platform/linux/input/LinuxWindowInput.kt | 38 +++++++++++++++++-- 3 files changed, 39 insertions(+), 28 deletions(-) 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 index 7d45d770..8933b928 100644 --- 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 @@ -26,6 +26,7 @@ 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. @@ -135,7 +136,7 @@ internal class LinuxMouseInput( val touchOwnsPointer = nativeCapabilities.touch && (event.directTouch || event.emulated) if (awaitingNativeCapabilities || !nativeCapabilities.hover || touchOwnsPointer || event.crossingMode != 0 || - !canvas.isShowing || pressedButtons.isNotEmpty() + pressedButtons.isNotEmpty() ) { return } 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 index 1a3eb650..9457c313 100644 --- 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 @@ -27,17 +27,14 @@ 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 java.awt.Dialog -import java.awt.Window import kotlin.math.roundToInt /** * EDT-confined adaptation from native contact samples to Compose and window gestures. - * Native connections, registration and mouse routing belong to LinuxWindowInput. + * Window eligibility, native connections and mouse routing belong to LinuxWindowInput. */ @OptIn(InternalComposeUiApi::class) internal class LinuxTouchInput( - private val window: Window, private val canvas: Component, private val target: DesktopTouchScene, private val gestures: LinuxWindowGestures, @@ -49,7 +46,7 @@ internal class LinuxTouchInput( sample: LinuxXInputSession.Touch ): Boolean { if (!sample.directTouch) return false - if (!canReceiveTouch()) { + if (target.isDisposed) { cancel() return false } @@ -109,23 +106,4 @@ internal class LinuxTouchInput( private fun cancelCompose() { if (pointers.clear() && !target.isDisposed) target.scene.cancelPointerInput() } - - private fun canReceiveTouch(): Boolean = canvas.isShowing && window.isEnabled && - !target.isDisposed && Window.getWindows().none { candidate -> - candidate is Dialog && candidate.isShowing && candidate.isModal && - generateSequence(window) { it.owner }.none { it === candidate } && - when (candidate.modalityType) { - Dialog.ModalityType.DOCUMENT_MODAL -> - generateSequence(window) { it.owner }.last() === - generateSequence(candidate as Window) { it.owner }.last() - - Dialog.ModalityType.APPLICATION_MODAL -> - window.modalExclusionType == Dialog.ModalExclusionType.NO_EXCLUDE - - Dialog.ModalityType.TOOLKIT_MODAL -> - window.modalExclusionType != Dialog.ModalExclusionType.TOOLKIT_EXCLUDE - - else -> false - } - } } 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 index 1bf6bde7..b09fcfce 100644 --- 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 @@ -35,6 +35,7 @@ import java.awt.event.HierarchyEvent import java.awt.event.HierarchyListener import java.awt.event.WindowAdapter import java.awt.event.WindowEvent +import java.lang.reflect.Method @Composable internal fun LinuxWindowInputEffect( @@ -73,12 +74,14 @@ internal fun LinuxWindowInputEffect( /** * 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 isModalBlocked: Method, private val gestures: LinuxWindowGestures, private val target: DesktopTouchScene?, private val touch: LinuxTouchInput?, @@ -185,11 +188,28 @@ internal class LinuxWindowInput private constructor( override fun onTouch( event: LinuxXInputSession.Touch - ): Boolean = touch?.onTouch(event) == true + ): Boolean { + if (!acceptNativeInput()) return false + return touch?.onTouch(event) == true + } override fun onHover( event: LinuxXInputSession.Hover - ) = mouse.onHover(event) + ) { + if (acceptNativeInput()) mouse.onHover(event) + } + + private fun acceptNativeInput(): Boolean { + // XI2 callbacks run on the EDT but bypass AWT's modal event filter. Read AWT's actual + // blocking decision, including nested dialogs, inherited exclusions and AppContext. + if (!closed && canvas.isShowing && window.isEnabled && + !(isModalBlocked.invoke(window) as Boolean) + ) { + return true + } + cancelInput() + return false + } override fun onDevicesChanged() { cancelTouch() @@ -245,10 +265,21 @@ internal class LinuxWindowInput private constructor( // XIDs and libXi do not apply to JBR's native Wayland toolkit. if (Toolkit.getDefaultToolkit().javaClass.name != "sun.awt.X11.XToolkit") return null val canvas = window.findSkiaLayer()?.canvas ?: return null + // Resolve access before selecting XI2 or grabbing touch. Without the existing + // java.desktop/java.awt module open, retain AWT's complete, modality-aware path. + val isModalBlocked = try { + Window::class.java.getDeclaredMethod("isModalBlocked").apply { + check(trySetAccessible()) { + "Native XI2 input requires --add-opens=java.desktop/java.awt=ALL-UNNAMED" + } + } + } catch (exception: Exception) { + logUnavailable(exception) + return null + } val target = DesktopTouchScene.create(window) val touch = target?.let { LinuxTouchInput( - window = window, canvas = canvas, target = target, gestures = gestures, @@ -262,6 +293,7 @@ internal class LinuxWindowInput private constructor( return LinuxWindowInput( window = window, canvas = canvas, + isModalBlocked = isModalBlocked, gestures = gestures, target = target, touch = touch, From b4ed81634dd514b7177bfbf67114a9f53a69a486 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 00:38:46 +0800 Subject: [PATCH 11/35] fix(Linux): finish mouse gestures while touch contacts are held Allow an active mouse gesture to keep receiving movement and release while blocking new mouse gestures during a touch hold. The owning release now finishes caption moves and commits pending resize bounds. Clear click suppression when the corresponding click is consumed by held contacts. Add standard desktopTest coverage for mixed mouse/touch ownership, modal owner hover blocking, caption double-tap maximize/restore, resize cancellation, and AWT delivery after native capabilities become unavailable. Tests use existing input adapters and real windows without external native input tools or Wayland-specific tests. Validation: all 9 targeted desktopTest cases passed with no skips using JBR 25.0.4.1 b583.48. UI text and whitespace checks passed. Adapter-injected input tests do not verify physical touchscreen or compositor delivery. --- .../input/gesture/LinuxWindowGestureState.kt | 12 +- .../ui/platform/linux/LinuxInputWindow.kt | 162 ++++++++++++ .../linux/LinuxWindowGestureStateTest.kt | 136 ++++++++++ .../platform/linux/LinuxWindowGesturesTest.kt | 173 ++++++++++++ .../ui/platform/linux/LinuxWindowInputTest.kt | 248 ++++++++++++++++++ 5 files changed, 730 insertions(+), 1 deletion(-) create mode 100644 ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt create mode 100644 ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestureStateTest.kt create mode 100644 ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGesturesTest.kt create mode 100644 ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInputTest.kt 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 index 78f1a716..dccea228 100644 --- 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 @@ -312,10 +312,20 @@ internal class LinuxWindowGestureState { commands: MutableList ): Boolean { if (input.phase == Phase.Press) taps.cancel() - if (contacts.isNotEmpty()) { + + // 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 } + val edge = if (configuration!!.resizable) hit.edge else WindowResizeEdge.None when (input.phase) { Phase.Enter -> commands += Command.ShowEdge(edge) diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt new file mode 100644 index 00000000..948403d0 --- /dev/null +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt @@ -0,0 +1,162 @@ +/* + * 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.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.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.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.SaltWindow +import com.moriafly.salt.ui.window.SaltWindowProperties +import com.moriafly.salt.ui.window.WindowResizeEdge +import java.awt.EventQueue +import java.awt.GraphicsEnvironment +import java.awt.Toolkit +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 LinuxInputWindow : AutoCloseable { + private var exit: (() -> Unit)? = null + private val failure = AtomicReference() + private var applicationThread: Thread? = null + lateinit var window: ComposeWindow + lateinit var gestures: LinuxWindowGestures + var clicks = 0 + var edge = WindowResizeEdge.None + var caption = Rect.Zero + var content = Rect.Zero + + 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") { ::window.isInitialized && window.isShowing && content != Rect.Zero } + onEdt { window.toFront() } + } + + 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 { + val state = rememberWindowState( + position = WindowPosition.Absolute(120.dp, 120.dp), + size = DpSize(560.dp, 380.dp) + ) + SaltWindow( + onCloseRequest = {}, + state = state, + title = "SaltUI window gesture regression", + decoration = WindowDecoration.Undecorated(), + properties = SaltWindowProperties.default( + captionButtonsVisible = false, + extraDisplayScale = 1.25f, + onResizeEdgeChange = { _, value -> edge = value } + ), + init = { window = it } + ) { + val currentGestures = checkNotNull(LocalLinuxWindowGestures.current) + SideEffect { gestures = currentGestures } + 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") + } + } + } + } + } + + 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..43d10f2a --- /dev/null +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGestureStateTest.kt @@ -0,0 +1,136 @@ +/* + * 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") + assertTrue(Command.ShowEdge(WindowResizeEdge.None) in released.commands) + assertTouchQuarantineEndsWithLastContact(state) + } + + @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)) + } + + 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( + 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..bff6d236 --- /dev/null +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowGesturesTest.kt @@ -0,0 +1,173 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * Licensed under the Apache License, Version 2.0 + */ + +@file:OptIn( + androidx.compose.ui.InternalComposeUiApi::class, + com.moriafly.salt.ui.UnstableSaltUiApi::class +) + +package com.moriafly.salt.ui.platform.linux + +import androidx.compose.ui.geometry.Offset +import com.moriafly.salt.ui.platform.input.DesktopTouchScene +import com.moriafly.salt.ui.platform.linux.input.LinuxTouchInput +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 java.awt.Frame +import java.awt.event.InputEvent +import java.awt.event.MouseEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Standard desktopTest integration tests: inject samples at the platform adapter and assert real + * SaltWindow bounds and Compose clicks. These do not emulate a native touchscreen or compositor. + */ +class LinuxWindowGesturesTest { + @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, window.extendedState, "Content is not a caption target") } + touch.doubleTap(onEdt { caption.center }) + await("caption maximize and layout") { + window.extendedState == Frame.MAXIMIZED_BOTH && caption.width > captionWidth + } + touch.doubleTap(onEdt { caption.center }) + await("caption restore") { window.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 } + } + + private fun withWindow(block: LinuxInputWindow.(TouchDriver) -> Unit) { + LinuxInputWindow().use { fixture -> + fixture.open() + fixture.block(TouchDriver(fixture)) + } + } + + private fun LinuxInputWindow.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 TouchDriver(private val fixture: LinuxInputWindow) { + 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) = fixture.onEdt { + if (phase == TouchPhase.Down) id++ + time += 80 + val screen = fixture.screen(position) + assertTrue( + input.onTouch( + 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 + ) + ) + ) + } + + fun tap(position: Offset) { + 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..6154df04 --- /dev/null +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxWindowInputTest.kt @@ -0,0 +1,248 @@ +/* + * 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.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 { + LinuxMouseInput(fixture.window, fixture.canvas).use { source -> + val hover = fixture.mouse(MouseEvent.MOUSE_MOVED) + source.dispatchAwt(hover, fixture.canvas::dispatchEvent) + assertTrue(fixture.received.isEmpty(), "AWT hover waits for native negotiation") + + // 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() + } + } +} From 4eabc623d61f8ad08eb1cd7cb9baaa37a4d740cd Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 00:52:41 +0800 Subject: [PATCH 12/35] fix(Linux): add JVM arguments for test tasks to open AWT packages --- ui2/build.gradle.kts | 7 +++++++ 1 file changed, 7 insertions(+) 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 } From d6438030addbfc5d01039ab6646fcf3107394df6 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 01:04:31 +0800 Subject: [PATCH 13/35] fix(Linux): isolate input assertions from window setup events Clear recorded mouse events in the same EDT turn as the input under test. This prevents window setup crossings from failing the AWT fallback check or satisfying the initial native hover assertions with stale events. Seed a mouse-enter event before attaching the fallback source to reproduce the CI timing case deterministically, and include received event IDs in failed negotiation assertions. Validation: the seeded setup event reproduced the original assertion failure before the fix. All 9 targeted desktopTest cases then passed with no skips on JBR 25.0.4.1 b583.48. UI text and whitespace checks passed. Remote CI has not been rerun with this change. --- .../salt/ui/platform/linux/LinuxWindowInputTest.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 6154df04..d5d7baee 100644 --- 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 @@ -62,6 +62,7 @@ class LinuxWindowInputTest { 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) @@ -88,10 +89,15 @@ class LinuxWindowInputTest { @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") + 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. From e44e7ee0bc02c239ed7cd3bfcb647736e84ac9e9 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 01:26:42 +0800 Subject: [PATCH 14/35] refactor(Linux): separate XI2 polling from event dispatch Extract the bounded poll wait and connection error handling from readEvents into pollEvents to reduce the event loop complexity flagged by CodeFactor. Preserve EINTR retries, the 100 ms wait limit, and event-cookie cleanup. Validation: the existing 9 targeted desktopTest cases passed without skips on JBR 25.0.4.1 b583.48. Local Lizard analysis reports a maximum complexity of 14 in LinuxXInputSession. UI text and whitespace checks passed. --- .../platform/linux/x11/LinuxXInputSession.kt | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) 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 index 9f3db82b..5ed7c10f 100644 --- 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 @@ -285,15 +285,7 @@ internal class LinuxXInputSession( fd.setShort(4, 1) // POLLIN while (!stopRequested) { if (x11.XPending(display) == 0) { - // 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) continue // EINTR - error("XI2 connection polling failed") - } - if (result > 0 && fd.getShort(6).toInt() and 0x38 != 0) { - error("XI2 connection closed while polling") - } + pollEvents(fd) continue } x11.XNextEvent(display, event) @@ -364,6 +356,20 @@ internal class LinuxXInputSession( } } + 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 onTouch( display: X11.Display, xid: X11.Window, From ec5594c79ef331561ca278dc2cc6912889b97a5d Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 01:41:41 +0800 Subject: [PATCH 15/35] refactor(Linux): separate gesture ownership from phase handling Split captured-touch updates and first-touch gesture selection from contact lifecycle handling. Keep mouse/touch arbitration separate from mouse phase dispatch, gesture initiation, and click suppression to reduce the method complexity reported by CodeFactor. Preserve gesture ownership, release cleanup, pending resize application, and caption double-tap behavior through the existing state interface. Validation: all 9 targeted desktopTest cases passed without skips on JBR 25.0.4.1 b583.48. Local complexity, UI text, and whitespace checks passed. --- .../input/gesture/LinuxWindowGestureState.kt | 175 +++++++++++------- 1 file changed, 112 insertions(+), 63 deletions(-) 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 index dccea228..7d0bc98f 100644 --- 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 @@ -238,60 +238,85 @@ internal class LinuxWindowGestureState { if (!contacts.add(pointer.id)) return true captionQuery = null if (!firstContact) taps.cancel() - } else if (pointer.id !in contacts) { + } + + if (pointer.id !in contacts) { return true } + try { if (capturedTouches || gesture?.pointer == Pointer.Mouse) { capturedTouches = true - if (gesture?.pointer == pointer) { - 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 - } - } + onCapturedTouch(input, commands) return true } - if (firstContact && input.phase == Phase.Press) { - when { - configuration!!.resizable && hit.edge != WindowResizeEdge.None -> { - capturedTouches = true - commands += Command.CancelCompose - beginResize( - pointer = 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 - } - } + 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 + if (captionQuery?.input?.pointer == pointer) { + captionQuery = null + } + contacts.remove(pointer.id) - if (contacts.isEmpty()) capturedTouches = false + + 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 } } } @@ -325,28 +350,24 @@ internal class LinuxWindowGestureState { 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 -> commands += Command.ShowEdge(edge) Phase.Exit -> if (gesture == null) commands += Command.ShowEdge(WindowResizeEdge.None) - Phase.Press -> { - 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 || hit.caption != CaptionHit.Caption) return false - gesture = Gesture.Move(Pointer.Mouse, input.position) - return true - } + Phase.Press -> return beginMouseGesture( + input = input, + edge = edge, + caption = hit.caption, + commands = commands + ) Phase.Move -> { if (gesture?.pointer != Pointer.Mouse) return false @@ -364,17 +385,45 @@ internal class LinuxWindowGestureState { return true } - Phase.Click -> { - val click = suppressedClick ?: return false - if (!input.primary) return false - suppressedClick = null - if (click == Click.Caption && input.clickCount == 2) toggleMaximize(commands) - 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, From aedcab2458e8f7fd6252a26720fb23c3b8cf9ebb Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 01:43:59 +0800 Subject: [PATCH 16/35] fix(Linux): correct bitwise operation in event handling --- .../com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 25037f2f..4f94fa4d 100644 --- 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 @@ -89,7 +89,7 @@ internal interface LinuxXInput2 : Library { clear() events.forEach { event -> val offset = (event / 8).toLong() - setByte(offset, (getByte(offset).toInt() or (1 shl (event % 8))).toByte()) + setByte(offset, (getByte(offset).toInt() or (1 shl event % 8)).toByte()) } } } From 83a1e30f1efb18e8931739cd7782659b324f8b9c Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 12:02:56 +0800 Subject: [PATCH 17/35] fix(Linux): correct touch handoff to the window manager On supported X11 window managers, defer caption touch ownership until the drag threshold is crossed, then follow Qt's handoff order to reject the XI2 contact and start the native window move. Prevent the window from following the mouse after touch release while retaining the existing fallback when native handoff is unavailable. Let Mover own native move preparation, startup and cleanup across release, cancellation, configuration changes and startup failures. Use raw release events to end handed-off moves, suppress AWT touch-to-mouse replay, and centralize AWT connection access and touch selection restoration. Remove the compatibility overload, clarify method names by responsibility, and update existing test callers. Validation: production and test sources compile on JBR 25; all 9 targeted tests passed. All 30 native input checks passed in isolated KWin X11 at 100% and 200% scaling. The later API cleanup was recompiled, and all 3 existing window tests passed after migrating the caller. Physical touchscreens and the native Wayland toolkit remain unverified. --- .../platform/linux/input/LinuxMouseInput.kt | 24 +- .../platform/linux/input/LinuxTouchInput.kt | 17 +- .../linux/input/LinuxWindowGestures.kt | 100 ++++-- .../platform/linux/input/LinuxWindowInput.kt | 27 +- .../input/gesture/LinuxWindowGestureState.kt | 15 +- .../input/gesture/WindowGestureEffects.kt | 12 + .../linux/window/LinuxWindowGestureEffects.kt | 67 +++- .../ui/platform/linux/x11/LinuxAwtInput.kt | 163 ++++++++++ .../platform/linux/x11/LinuxX11WindowMover.kt | 287 ++++++++++++++---- .../ui/platform/linux/x11/LinuxXInput2.kt | 102 ++++++- .../platform/linux/x11/LinuxXInputSession.kt | 265 ++++++++++++---- .../platform/linux/LinuxWindowGesturesTest.kt | 9 +- 12 files changed, 885 insertions(+), 203 deletions(-) create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxAwtInput.kt 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 index 8933b928..063867e8 100644 --- 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 @@ -38,8 +38,17 @@ internal class LinuxMouseInput( 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 } @@ -64,6 +73,17 @@ internal class LinuxMouseInput( 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()) { @@ -163,7 +183,9 @@ internal class LinuxMouseInput( ) } - fun cancel() = pressedButtons.clear() + fun cancel() { + pressedButtons.clear() + } override fun close() = unregister() 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 index 9457c313..f58930db 100644 --- 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 @@ -42,13 +42,14 @@ internal class LinuxTouchInput( ) { private val pointers = TouchPointerState() - fun onTouch( - sample: LinuxXInputSession.Touch - ): Boolean { - if (!sample.directTouch) return false + fun dispatch( + sample: LinuxXInputSession.Touch, + canMoveNatively: Boolean + ): LinuxWindowGestures.TouchResult? { + if (!sample.directTouch) return null if (target.isDisposed) { cancel() - return false + return null } val type = when (sample.phase) { LinuxXInputSession.TouchPhase.Down -> PointerEventType.Press @@ -70,7 +71,7 @@ internal class LinuxTouchInput( if (type == PointerEventType.Press) { focus.onPress(sample.x11Timestamp) } - gestures.dispatchTouch( + return gestures.dispatchTouch( event = touchEvent, dispatchToCompose = { // XI coordinates include system scale; extraDisplayScale only affects density. @@ -93,9 +94,9 @@ internal class LinuxTouchInput( ) } }, - cancelCompose = ::cancelCompose + cancelCompose = ::cancelCompose, + canMoveNatively = canMoveNatively ) - return true } fun cancel() { 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 index af6a2dce..fc6490fa 100644 --- 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 @@ -52,6 +52,8 @@ internal class LinuxWindowGestures( 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 @@ -61,22 +63,24 @@ internal class LinuxWindowGestures( 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 + ) { + 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() @@ -103,13 +107,14 @@ internal class LinuxWindowGestures( fun dispatchTouch( event: LinuxTouchEvent, dispatchToCompose: () -> Unit, - cancelCompose: () -> 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 + else -> return TouchResult.Handled } val scale = window.graphicsConfiguration.defaultTransform val input = Input( @@ -129,9 +134,15 @@ internal class LinuxWindowGestures( val dispatch = TouchDispatch( event = event, deliver = dispatchToCompose, - cancel = cancelCompose + cancel = cancelCompose, + canMoveNatively = canMoveNatively ) - execute(state.onInput(input, hit), dispatch) + val result = execute(state.onInput(input, hit), dispatch) + return if (canMoveNatively && state.isPendingTouchMove(event.id)) { + TouchResult.PendingNativeMove + } else { + result + } } catch (exception: Throwable) { cancel() cancelCompose() @@ -188,11 +199,24 @@ internal class LinuxWindowGestures( execute(state.cancel()) } + fun endNativeMouseMove(): Boolean { + if (!effects.mover.isNativeMouseMove) 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 { @@ -209,18 +233,19 @@ internal class LinuxWindowGestures( 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 -> resolveCaption(command.request, checkNotNull(touch)) - is Command.StartMove -> effects.mover.start( - pointer = command.pointer, - start = command.start, - current = command.current - ) + 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() @@ -231,12 +256,30 @@ internal class LinuxWindowGestures( 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(touch.event.id, 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 { @@ -244,7 +287,7 @@ internal class LinuxWindowGestures( } finally { if (captionQuery === query) captionQuery = null } - execute(state.resolveCaption(request, query.hit), dispatch) + return execute(state.resolveCaption(request, query.hit), dispatch) } private class CaptionQuery( @@ -255,6 +298,7 @@ internal class LinuxWindowGestures( private class TouchDispatch( val event: LinuxTouchEvent, val deliver: () -> Unit, - val cancel: () -> 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 index b09fcfce..5200f6d9 100644 --- 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 @@ -139,6 +139,7 @@ internal class LinuxWindowInput private constructor( mouse.awaitNativeCapabilities() session = LinuxXInputSession( handle = handle, + windowHandle = Native.getComponentID(window), requestTouch = touch != null, listener = this ).also { it.start() } @@ -187,10 +188,28 @@ internal class LinuxWindowInput private constructor( } override fun onTouch( - event: LinuxXInputSession.Touch - ): Boolean { - if (!acceptNativeInput()) return false - return touch?.onTouch(event) == true + 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( 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 index 7d0bc98f..e62b758f 100644 --- 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 @@ -180,8 +180,8 @@ internal class LinuxWindowGestureState { checkNotNull(configuration) { "Configure gestures before dispatching input" } val commands = mutableListOf() val consumed = when (input.pointer) { - Pointer.Mouse -> onMouse(input, hit, commands) - is Pointer.Touch -> onTouch(input, hit, commands) + Pointer.Mouse -> handleMouse(input, hit, commands) + is Pointer.Touch -> handleTouch(input, hit, commands) } return Decision(consumed, commands) } @@ -227,7 +227,14 @@ internal class LinuxWindowGestureState { private val canMove: Boolean get() = configuration?.let { it.moveable && it.canDrag } == true - private fun onTouch( + 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 @@ -331,7 +338,7 @@ internal class LinuxWindowGestureState { gesture = Gesture.Move(input.pointer, input.position) } - private fun onMouse( + private fun handleMouse( input: Input, hit: Hit, commands: MutableList 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 index d7dfbde6..5e11ad67 100644 --- 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 @@ -32,6 +32,18 @@ internal interface WindowGestureEffects { val resizer: Resizer interface Mover { + val isNativeMouseMove: Boolean + get() = false + + fun prepareTouchMove( + id: Long, + position: Position + ): Boolean + + fun commitTouchMove( + id: Long + ): Boolean + fun install(): () -> Unit fun start( 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 index 185fa8cf..366e22d2 100644 --- 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 @@ -28,7 +28,6 @@ import java.awt.Point import java.awt.Window import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent -import java.awt.event.MouseEvent /** * Executes gesture commands through the WM and AWT. Native restore notifications and queued resize @@ -84,10 +83,44 @@ internal class LinuxWindowGestureEffects( private val window: Window ) : WindowGestureEffects.Mover { private val fallback = LinuxFallbackWindowMover(window) - private var useNativeDrag = true + private var useNativeMouseDrag = true private var restoreWidth = window.width private var drag: Drag? = null + override val isNativeMouseMove: Boolean + get() { + val active = drag ?: return false + return active.pointer == Pointer.Mouse && active.nativeMove?.started == true + } + + override fun prepareTouchMove( + id: Long, + position: Position + ): Boolean { + finish() + val move = LinuxX11WindowMover.prepare( + window = window, + screenPosition = Point(position.x, position.y), + fromTouch = true + ) ?: return false + drag = Drag( + Pointer.Touch(id), + position, + nativeMove = move + ) + return true + } + + override fun commitTouchMove( + id: Long + ): Boolean { + val active = drag ?: return false + if (active.pointer != Pointer.Touch(id)) return false + if (active.nativeMove?.start() == true) return true + finish() + return false + } + override fun install(): () -> Unit { val listener = object : ComponentAdapter() { override fun componentResized( @@ -106,6 +139,7 @@ internal class LinuxWindowGestureEffects( start: Position, current: Position ) { + finish() val active = Drag(pointer, current) drag = active val frame = window as? Frame @@ -131,8 +165,13 @@ internal class LinuxWindowGestureEffects( } override fun finish() { + val active = drag drag = null - fallback.endDrag() + try { + active?.nativeMove?.close() + } finally { + fallback.endDrag() + } } override fun toggleMaximize() { @@ -149,16 +188,15 @@ internal class LinuxWindowGestureEffects( pointer: Pointer ) { val point = Point(position.x, position.y) - if (useNativeDrag) { - val button = - if (pointer == Pointer.Mouse) MouseEvent.BUTTON1 else MouseEvent.NOBUTTON - val started = LinuxX11WindowMover.tryMove( - window = window, - screenPosition = point, - button = button - ) - if (started) return - useNativeDrag = false + // Native touch movement is handed off by the XI2 session before ownership is accepted. + if (pointer == Pointer.Mouse && useNativeMouseDrag) { + val move = LinuxX11WindowMover.prepare(window, point) + if (move?.start() == true) { + checkNotNull(drag).nativeMove = move + return + } + move?.close() + useNativeMouseDrag = false } fallback.startDrag(point) } @@ -180,7 +218,8 @@ internal class LinuxWindowGestureEffects( private class Drag( val pointer: Pointer, var latestPosition: Position, - var restoreOffset: Position? = null + var restoreOffset: Position? = null, + var nativeMove: LinuxX11WindowMover.Move? = null ) } } 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..d73532b7 --- /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) + } + X11Ext.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 { + X11Ext.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) + } + X11Ext.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. + X11Ext.INSTANCE.XUngrabPointer(display, NativeLong(0)) + X11Ext.INSTANCE.XSync(display, false) + } + } +} 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 index 088c6242..fdc252ba 100644 --- 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 @@ -20,11 +20,14 @@ 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.Point import java.awt.Window +import java.awt.event.MouseEvent /** * Native X11 window mover using `_NET_WM_MOVERESIZE` protocol. @@ -37,85 +40,237 @@ internal object LinuxX11WindowMover { private const val NET_WM_MOVERESIZE_MOVE = 8L /** - * Try to initiate a native X11 window move from mouse or touch input. - * [screenPosition] uses AWT screen coordinates; [button] is zero for touch. - * - * @return `true` if the native move was successfully initiated, `false` otherwise - * (e.g., on Wayland or if X11 calls fail). + * 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 tryMove( - window: Window, - screenPosition: Point, - button: Int + fun supportsTouchMove( + display: X11.Display, + window: X11.Window ): Boolean { - if (!window.isDisplayable) return false + if (!LinuxAwtInput.isAvailable) return false + val x11 = X11Ext.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 x11: X11Ext - try { - x11 = X11Ext.INSTANCE - } catch (_: UnsatisfiedLinkError) { - 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() } - val display = x11.XOpenDisplay(null) ?: return false + 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 = X11Ext.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 { - 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() - ) + if (result != X11.Success || type.value != expectedType || + format.value != expectedFormat || remaining.value.toLong() != 0L ) { - return false + return null } + return data.value?.let { read(it, count.value.toInt()) } + } finally { + data.value?.let { x11.XFree(it) } + } + } - // 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 transform = window.graphicsConfiguration.defaultTransform - val windowXPosition = ((screenPosition.x - window.x) * transform.scaleX).toInt() - val windowYPosition = ((screenPosition.y - window.y) * transform.scaleY).toInt() - val event = buildMoveResizeEvent( + /** + * 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, + screenPosition: Point, + fromTouch: Boolean = false + ): Move? { + if (!window.isDisplayable || !LinuxAwtInput.isAvailable) return null + val x11 = try { + X11Ext.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 + val position = rootPosition( display = display, - xWindow = xWindow, - atom = netWmMoveresize, - mouseX = rootX.value - windowX.value + windowXPosition, - mouseY = rootY.value - windowY.value + windowYPosition, - button = button + target = target, + window = window, + screenPosition = screenPosition, + 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 = X11Ext.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 - // Release AWT exclusive mouse lock to allow window manager takeover - x11.XUngrabPointer(display, NativeLong(0)) + 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 + } + } - val mask = NativeLong( - (X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong() + 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 ) - x11.XSendEvent(display, rootWindow, 0, mask, event) - x11.XFlush(display) + val sent = X11Ext.INSTANCE.XSendEvent( + display, + root, + 0, + NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()), + event + ) + X11Ext.INSTANCE.XFlush(display) + return sent != 0 + } - return true - } catch (_: Exception) { - return false - } finally { - x11.XCloseDisplay(display) + override fun close() { + if (closed) return + closed = true + try { + if (started) send(11L) + } finally { + started = false + X11Ext.INSTANCE.XCloseDisplay(display) + } } } @@ -144,7 +299,8 @@ internal object LinuxX11WindowMover { atom: X11.Atom, mouseX: Int, mouseY: Int, - button: Int + button: Int, + direction: Long = NET_WM_MOVERESIZE_MOVE ): X11.XEvent { val event = X11.XEvent() event.type = X11.ClientMessage @@ -165,8 +321,7 @@ internal object LinuxX11WindowMover { // y_root clientMsg.data.l[1] = NativeLong(mouseY.toLong()) // Direction - clientMsg.data.l[2] = NativeLong(NET_WM_MOVERESIZE_MOVE) - // Touch has no pressed mouse button; let the window manager track its native sequence. + clientMsg.data.l[2] = NativeLong(direction) clientMsg.data.l[3] = NativeLong(button.toLong()) // Source indication (Normal application) clientMsg.data.l[4] = NativeLong(1) 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 index 4f94fa4d..a2dc6f9f 100644 --- 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 @@ -15,6 +15,8 @@ * limitations under the License. */ +@file:Suppress("unused") + package com.moriafly.salt.ui.platform.linux.x11 import com.sun.jna.Library @@ -55,6 +57,12 @@ internal interface LinuxXInput2 : Library { count: Int ): Int + fun XIGetSelectedEvents( + display: X11.Display, + window: X11.Window, + count: IntByReference + ): Pointer? + fun XIGrabTouchBegin( display: X11.Display, device: Int, @@ -74,12 +82,26 @@ internal interface LinuxXInput2 : Library { ): Int @Structure.FieldOrder("device", "length", "mask") - class EventMask( - device: Int, - vararg events: Int - ) : Structure() { + 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 = device + var device: Int = 0 @JvmField var length: Int = 4 @@ -87,10 +109,6 @@ internal interface LinuxXInput2 : Library { @JvmField var mask: Pointer = Memory(length.toLong()).apply { clear() - events.forEach { event -> - val offset = (event / 8).toLong() - setByte(offset, (getByte(offset).toInt() or (1 shl event % 8)).toByte()) - } } } @@ -177,6 +195,68 @@ internal interface LinuxXInput2 : Library { 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", @@ -386,12 +466,16 @@ internal interface LinuxXInput2 : Library { const val ENTER = 7 const val LEAVE = 8 const val HIERARCHY_CHANGED = 11 + 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 } } 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 index 5ed7c10f..9c83b3f3 100644 --- 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 @@ -36,6 +36,7 @@ import kotlin.concurrent.thread @OptIn(UnstableSaltUiApi::class) internal class LinuxXInputSession( private val handle: Long, + private val windowHandle: Long, private val requestTouch: Boolean, private val listener: Listener ) { @@ -45,8 +46,15 @@ internal class LinuxXInputSession( ) fun onTouch( - event: Touch - ): Boolean + event: Touch, + canMoveNatively: Boolean + ): TouchAction + + fun onTouchMove( + id: Long + ) + + fun onMouseMoveEnd() fun onHover( event: Hover @@ -64,6 +72,13 @@ internal class LinuxXInputSession( 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 } @@ -149,6 +164,7 @@ internal class LinuxXInputSession( val x11 = LinuxTouchX11.instance val xi = LinuxXInput2.instance val display = x11.XOpenDisplay(null) ?: return + var awtTouch: AutoCloseable? = null try { val opcode = IntByReference() if (!x11.XQueryExtension( @@ -165,24 +181,21 @@ internal class LinuxXInputSession( val minor = IntByReference(2) if (xi.XIQueryVersion(display, major, minor) != 0 || major.value < 2) return val xid = X11.Window(handle) - // Both channels need an up-to-date device inventory. Neither depends on the other - // channel's registration succeeding; XI 2.0 can still supply hover without touch. - if (xi.XISelectEvents( + val supportsTouchVersion = major.value > 2 || minor.value >= 2 + if (!selectRootEvents(display, supportsTouchVersion)) return + val supportsTouch = requestTouch && supportsTouchVersion + if (supportsTouch && LinuxX11WindowMover.supportsTouchMove( display, - x11.XDefaultRootWindow(display), - LinuxXInput2.EventMask( - LinuxXInput2.ALL_DEVICES, - LinuxXInput2.DEVICE_CHANGED, - LinuxXInput2.HIERARCHY_CHANGED - ), - 1 - ) != 0 + X11.Window(windowHandle) + ) ) { - return + awtTouch = LinuxAwtInput.suspendTouch(handle) } - val supportsTouch = major.value > 2 || minor.value >= 2 - val touch = requestTouch && supportsTouch && grabTouches(display, xid) - val hover = selectHover(display, xid) + // 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) @@ -197,11 +210,13 @@ internal class LinuxXInputSession( xid = xid, opcode = opcode.value, capabilities = Capabilities(touch, hover), - initialDirectDevices = directDevices + initialDirectDevices = directDevices, + canMoveTouch = regularTouch ) } finally { // This removes every registration and grab, including partial initialization. x11.XCloseDisplay(display) + awtTouch?.close() } } @@ -235,21 +250,48 @@ internal class LinuxXInputSession( false } - private fun selectHover( + private fun selectRootEvents( display: X11.Display, - xid: X11.Window - ): Boolean = - LinuxXInput2.instance.XISelectEvents( + 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 = LinuxTouchX11.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, - LinuxXInput2.MOTION, - LinuxXInput2.ENTER, - LinuxXInput2.LEAVE - ), + LinuxXInput2.EventMask(LinuxXInput2.ALL_MASTER_DEVICES, *events.toIntArray()), 1 ) == 0 + } private fun queryDirectDevices( display: X11.Display @@ -274,7 +316,8 @@ internal class LinuxXInputSession( xid: X11.Window, opcode: Int, capabilities: Capabilities, - initialDirectDevices: Set + initialDirectDevices: Set, + canMoveTouch: Boolean ) { val x11 = LinuxTouchX11.instance val event = X11.XEvent() @@ -284,6 +327,7 @@ internal class LinuxXInputSession( 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 @@ -302,6 +346,15 @@ internal class LinuxXInputSession( } } + LinuxXInput2.RAW_BUTTON_RELEASE -> { + 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) { @@ -310,7 +363,8 @@ internal class LinuxXInputSession( display = display, xid = xid, native = native, - directTouch = native.source in directDevices + directTouch = native.source in directDevices, + canMoveTouch = canMoveTouch ) } @@ -370,57 +424,136 @@ internal class LinuxXInputSession( } } + 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 + directTouch: Boolean, + canMoveTouch: Boolean ) { val epoch = touchEpoch - val sample = 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 + 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 (sample.phase == TouchPhase.Down) { - var accept = false - dispatchAndWait { - if (!stopRequested) { - // A stale direct-touch press is still claimed, so cancellation cannot - // promote it into an AWT click. It must not reach Compose or request focus. - accept = if (epoch == touchEpoch) { - listener.onTouch(sample) - } else { - sample.directTouch - } - } - } - LinuxXInput2.instance.XIAllowTouchEvents( + if (action == TouchAction.Pending && canHandoff) { + pendingTouches[sample.id] = epoch + return + } + pendingTouches.remove(sample.id) + if (!canMoveTouch || action == TouchAction.Move || action == TouchAction.Reject) { + allowTouch( display, - native.device, - native.detail, xid, - if (accept) LinuxXInput2.ACCEPT_TOUCH else LinuxXInput2.REJECT_TOUCH + native, + action ) + } + if (action == TouchAction.Move) { + handoffTouch( + display, + sample, + epoch + ) + } else { LinuxTouchX11.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 { - dispatch { - if (epoch == touchEpoch) listener.onTouch(sample) - } + 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 + X11Ext.INSTANCE.XUngrabPointer(display, NativeLong(0)) + // Complete rejection before the prepared WM request starts on its own connection. + LinuxTouchX11.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 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 index bff6d236..e2043fed 100644 --- 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 @@ -14,6 +14,7 @@ package com.moriafly.salt.ui.platform.linux import androidx.compose.ui.geometry.Offset 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.LinuxXInputSession import com.moriafly.salt.ui.platform.linux.x11.LinuxXInputSession.TouchPhase import com.moriafly.salt.ui.util.findSkiaLayer @@ -142,8 +143,9 @@ class LinuxWindowGesturesTest { if (phase == TouchPhase.Down) id++ time += 80 val screen = fixture.screen(position) - assertTrue( - input.onTouch( + assertEquals( + LinuxWindowGestures.TouchResult.Handled, + input.dispatch( LinuxXInputSession.Touch( id = id, phase = phase, @@ -155,7 +157,8 @@ class LinuxWindowGesturesTest { timeMillis = time, x11Timestamp = 0, directTouch = true - ) + ), + canMoveNatively = false ) ) } From dd11731d25fa3ff35426e1f1f6969f4afcfcd33e Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 12:34:29 +0800 Subject: [PATCH 18/35] fix(Linux): fall back to public AWT modality checks Keep XI2 touch and hover enabled when Window.isModalBlocked cannot be accessed. Centralize modality checks and retain AWT's exact result when reflection is available; otherwise use visible dialogs, owner hierarchies, modality types and inherited modal exclusions through public AWT methods. The fallback covers the current AppContext and conservatively blocks extra native input for overlapping modal dialogs whose blocker order is private. Validation: production and existing test sources compile on JBR 25. All 9 existing Linux input tests passed both with and without java.awt reflective access. All 13 native input checks passed in isolated KWin X11, covering modal scopes, inherited exclusions, nested ownership and recovery after dialogs close. Physical touchscreens and native Wayland were not tested. --- .../platform/linux/input/LinuxWindowInput.kt | 21 +----- .../linux/window/LinuxWindowModality.kt | 67 +++++++++++++++++++ 2 files changed, 70 insertions(+), 18 deletions(-) create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowModality.kt 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 index 5200f6d9..31ea5593 100644 --- 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 @@ -25,6 +25,7 @@ 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 @@ -35,7 +36,6 @@ import java.awt.event.HierarchyEvent import java.awt.event.HierarchyListener import java.awt.event.WindowAdapter import java.awt.event.WindowEvent -import java.lang.reflect.Method @Composable internal fun LinuxWindowInputEffect( @@ -81,7 +81,6 @@ internal fun LinuxWindowInputEffect( internal class LinuxWindowInput private constructor( private val window: Window, private val canvas: Component, - private val isModalBlocked: Method, private val gestures: LinuxWindowGestures, private val target: DesktopTouchScene?, private val touch: LinuxTouchInput?, @@ -219,10 +218,9 @@ internal class LinuxWindowInput private constructor( } private fun acceptNativeInput(): Boolean { - // XI2 callbacks run on the EDT but bypass AWT's modal event filter. Read AWT's actual - // blocking decision, including nested dialogs, inherited exclusions and AppContext. + // XI2 callbacks run on the EDT but bypass AWT's modal event filter. if (!closed && canvas.isShowing && window.isEnabled && - !(isModalBlocked.invoke(window) as Boolean) + !LinuxWindowModality.isBlocked(window) ) { return true } @@ -284,18 +282,6 @@ internal class LinuxWindowInput private constructor( // XIDs and libXi do not apply to JBR's native Wayland toolkit. if (Toolkit.getDefaultToolkit().javaClass.name != "sun.awt.X11.XToolkit") return null val canvas = window.findSkiaLayer()?.canvas ?: return null - // Resolve access before selecting XI2 or grabbing touch. Without the existing - // java.desktop/java.awt module open, retain AWT's complete, modality-aware path. - val isModalBlocked = try { - Window::class.java.getDeclaredMethod("isModalBlocked").apply { - check(trySetAccessible()) { - "Native XI2 input requires --add-opens=java.desktop/java.awt=ALL-UNNAMED" - } - } - } catch (exception: Exception) { - logUnavailable(exception) - return null - } val target = DesktopTouchScene.create(window) val touch = target?.let { LinuxTouchInput( @@ -312,7 +298,6 @@ internal class LinuxWindowInput private constructor( return LinuxWindowInput( window = window, canvas = canvas, - isModalBlocked = isModalBlocked, gestures = gestures, target = target, touch = touch, 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..05dba31e --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowModality.kt @@ -0,0 +1,67 @@ +/* + * 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 + +/** + * 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 modalBlocked = runCatching { + Window::class.java.getDeclaredMethod("isModalBlocked") + .takeIf { it.trySetAccessible() } + }.getOrNull() + + fun isBlocked( + window: Window + ): Boolean { + val blocked = modalBlocked?.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 + } + } + } +} From 0b8f135abecd1cace71d83d08c2262385c4a4c2f Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 12:35:58 +0800 Subject: [PATCH 19/35] refactor(Linux): remove unused Toolkit import and related comment --- .../moriafly/salt/ui/platform/linux/input/LinuxWindowInput.kt | 3 --- 1 file changed, 3 deletions(-) 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 index 31ea5593..1b3e947f 100644 --- 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 @@ -30,7 +30,6 @@ 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.Toolkit import java.awt.Window import java.awt.event.HierarchyEvent import java.awt.event.HierarchyListener @@ -279,8 +278,6 @@ internal class LinuxWindowInput private constructor( gestures: LinuxWindowGestures, focus: LinuxWindowFocus? ): LinuxWindowInput? { - // XIDs and libXi do not apply to JBR's native Wayland toolkit. - if (Toolkit.getDefaultToolkit().javaClass.name != "sun.awt.X11.XToolkit") return null val canvas = window.findSkiaLayer()?.canvas ?: return null val target = DesktopTouchScene.create(window) val touch = target?.let { From 2ee9e7d74f3ab88d2f26dc9f29ed55e7fb3d7adf Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 15:33:44 +1000 Subject: [PATCH 20/35] fix(Linux): reduce redundant resize-edge reports and input cancellations - Keep the resize-edge indicator as gesture state and emit at most one ShowEdge per decision, so starting or finishing a resize no longer reports edge -> None -> edge through onResizeEdgeChange. - Cancel native input once on the eligible-to-blocked transition instead of for every rejected XI2 sample while a modal dialog is open. - Ignore XI2 slave-switch DeviceChanged events. They fire on every mouse/touch handoff without changing source device classes, and used to cost an EDT round trip and cancel an active touch drag. --- .../platform/linux/input/LinuxWindowInput.kt | 13 ++-- .../input/gesture/LinuxWindowGestureState.kt | 42 +++++++++---- .../ui/platform/linux/x11/LinuxXInput2.kt | 61 +++++++++++++++++++ .../platform/linux/x11/LinuxXInputSession.kt | 26 ++++++-- .../linux/LinuxWindowGestureStateTest.kt | 22 ++++++- 5 files changed, 141 insertions(+), 23 deletions(-) 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 index 1b3e947f..6e80e63d 100644 --- 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 @@ -89,6 +89,7 @@ internal class LinuxWindowInput private constructor( 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( @@ -218,13 +219,13 @@ internal class LinuxWindowInput private constructor( private fun acceptNativeInput(): Boolean { // XI2 callbacks run on the EDT but bypass AWT's modal event filter. - if (!closed && canvas.isShowing && window.isEnabled && + val eligible = !closed && canvas.isShowing && window.isEnabled && !LinuxWindowModality.isBlocked(window) - ) { - return true - } - cancelInput() - return false + // 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() { 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 index e62b758f..c128f1c8 100644 --- 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 @@ -26,6 +26,9 @@ import com.moriafly.salt.ui.window.WindowResizeEdge * * 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 { @@ -149,6 +152,8 @@ internal class LinuxWindowGestureState { 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 @@ -169,8 +174,8 @@ internal class LinuxWindowGestureState { ) { finish(commands) } - if (!value.resizable) commands += Command.ShowEdge(WindowResizeEdge.None) - return Decision(false, commands) + if (!value.resizable) indicatedEdge = WindowResizeEdge.None + return decide(false, commands) } fun onInput( @@ -183,7 +188,7 @@ internal class LinuxWindowGestureState { Pointer.Mouse -> handleMouse(input, hit, commands) is Pointer.Touch -> handleTouch(input, hit, commands) } - return Decision(consumed, commands) + return decide(consumed, commands) } fun resolveCaption( @@ -200,7 +205,7 @@ internal class LinuxWindowGestureState { } else { taps.cancel() } - return Decision(true, commands) + return decide(true, commands) } fun cancel(): Decision { @@ -210,7 +215,7 @@ internal class LinuxWindowGestureState { capturedTouches = contacts.isNotEmpty() val commands = mutableListOf() finish(commands) - return Decision(false, commands) + return decide(false, commands) } fun resetTouchInput(): Decision { @@ -221,7 +226,7 @@ internal class LinuxWindowGestureState { val commands = mutableListOf() // Device/session resets must leave an AWT mouse drag alone. if (gesture?.pointer is Pointer.Touch) finish(commands) - return Decision(false, commands) + return decide(false, commands) } private val canMove: Boolean @@ -367,8 +372,8 @@ internal class LinuxWindowGestureState { ): Boolean { val edge = if (configuration!!.resizable) hit.edge else WindowResizeEdge.None when (input.phase) { - Phase.Enter -> commands += Command.ShowEdge(edge) - Phase.Exit -> if (gesture == null) commands += Command.ShowEdge(WindowResizeEdge.None) + Phase.Enter -> indicatedEdge = edge + Phase.Exit -> if (gesture == null) indicatedEdge = WindowResizeEdge.None Phase.Press -> return beginMouseGesture( input = input, edge = edge, @@ -387,7 +392,7 @@ internal class LinuxWindowGestureState { if (input.primary) { suppressedClick = if (active is Gesture.Move) Click.Caption else Click.Resize finish(commands, applyPendingResize = true) - commands += Command.ShowEdge(edge) + indicatedEdge = edge } return true } @@ -440,7 +445,7 @@ internal class LinuxWindowGestureState { taps.cancel() gesture = Gesture.Resize(pointer) commands += Command.StartResize(edge, position) - commands += Command.ShowEdge(edge) + indicatedEdge = edge } private fun update( @@ -495,6 +500,21 @@ internal class LinuxWindowGestureState { if (gesture is Gesture.Resize) commands += Command.EndResize(applyPendingResize) gesture = null commands += Command.EndMove - commands += Command.ShowEdge(WindowResizeEdge.None) + 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/x11/LinuxXInput2.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt index a2dc6f9f..d8454f72 100644 --- 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 @@ -257,6 +257,64 @@ internal interface LinuxXInput2 : Library { } } + @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", @@ -466,6 +524,9 @@ internal interface LinuxXInput2 : Library { 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 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 index 9c83b3f3..c1c57134 100644 --- 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 @@ -338,14 +338,19 @@ internal class LinuxXInputSession( if (cookie.extension != opcode || x11.XGetEventData(display, cookie) == 0) continue try { when (cookie.eventType) { - LinuxXInput2.DEVICE_CHANGED, LinuxXInput2.HIERARCHY_CHANGED -> { - directDevices = queryDirectDevices(display) - // Finish cancellation before a new press can be accepted. - dispatchAndWait { - if (!stopRequested) listener.onDevicesChanged() + 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 -> { val raw = LinuxXInput2.RawEvent(requireNotNull(cookie.data)) if (raw.detail == 1) dispatch { listener.onMouseMoveEnd() } @@ -410,6 +415,17 @@ internal class LinuxXInputSession( } } + 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 ) { 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 index 43d10f2a..1350bcb0 100644 --- 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 @@ -38,10 +38,30 @@ class LinuxWindowGestureStateTest { val released = state.onInput(mouse(Phase.Release, 180)) assertTrue(released.consumed) assertTrue(Command.EndMove in released.commands, "Mouse release must finish its move") - assertTrue(Command.ShowEdge(WindowResizeEdge.None) in released.commands) + 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() From 3baa51e800642fa764b3d57f4e0b0f3589e967dc Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 15:56:47 +1000 Subject: [PATCH 21/35] refactor(Linux): replace X11Ext with LinuxX11 and clean up unused code --- .../linux/decoration/LinuxClientShadow.kt | 45 ++------ .../linux/window/LinuxResizeCursor.kt | 56 ++-------- .../platform/linux/window/LinuxWindowFocus.kt | 5 +- .../ui/platform/linux/x11/LinuxAwtInput.kt | 10 +- .../salt/ui/platform/linux/x11/LinuxX11.kt | 102 ++++++++++++++++++ .../platform/linux/x11/LinuxX11WindowMover.kt | 34 ++---- .../ui/platform/linux/x11/LinuxXInput2.kt | 63 ----------- .../platform/linux/x11/LinuxXInputSession.kt | 14 +-- .../salt/ui/platform/linux/x11/LinuxXShape.kt | 49 +++++++++ .../ui/platform/linux/x11/LinuxXcursor.kt | 54 ++++++++++ .../platform/linux/LinuxClientShadowTest.kt | 6 +- 11 files changed, 248 insertions(+), 190 deletions(-) create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxX11.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXShape.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXcursor.kt diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt index a7e6ef83..cf2eb8b2 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/decoration/LinuxClientShadow.kt @@ -37,12 +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.moriafly.salt.ui.platform.linux.x11.X11Ext -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 @@ -149,7 +148,7 @@ internal object LinuxClientShadow { if (!window.isDisplayable) return false val x11 = try { - X11Ext.INSTANCE + LinuxX11.INSTANCE } catch (_: UnsatisfiedLinkError) { return false } @@ -199,7 +198,7 @@ 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( @@ -209,12 +208,12 @@ internal object LinuxClientShadow { 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 } @@ -246,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) @@ -364,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/window/LinuxResizeCursor.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt index 5b9113eb..80984ebd 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxResizeCursor.kt @@ -17,10 +17,10 @@ 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 @@ -89,8 +89,8 @@ 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() @@ -138,7 +138,9 @@ internal object LinuxResizeCursor { 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 { @@ -162,46 +164,4 @@ internal object LinuxResizeCursor { private fun Method.accessible(): Method = apply { check(trySetAccessible()) { "AWT cursor access requires java.desktop module opens" } } - - @Suppress("ktlint:standard:function-naming", "FunctionName") - private interface Xlib : Library { - fun XOpenDisplay( - name: String? - ): Pointer? - - fun XCloseDisplay( - display: Pointer - ): Int - - fun XFreeCursor( - display: Pointer, - cursor: NativeLong - ): Int - } - - @Suppress("ktlint:standard:function-naming", "FunctionName") - 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/window/LinuxWindowFocus.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt index 105d3582..747ff0b0 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowFocus.kt @@ -17,6 +17,7 @@ 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 @@ -37,7 +38,7 @@ import javax.swing.SwingUtilities */ 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( @@ -188,7 +189,7 @@ internal class LinuxWindowFocus private constructor( ): 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/x11/LinuxAwtInput.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxAwtInput.kt index d73532b7..17927d14 100644 --- 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 @@ -91,7 +91,7 @@ internal object LinuxAwtInput { updated.mask.setByte(2, bits.toByte()) LinuxXInput2.instance.XISelectEvents(display, xid, updated, 1) } - X11Ext.INSTANCE.XSync(display, false) + LinuxX11.INSTANCE.XSync(display, false) } catch (failure: Throwable) { restoreTouch(display, canvas, saved) throw failure @@ -112,7 +112,7 @@ internal object LinuxAwtInput { copyMask(LinuxXInput2.EventMask(data.share(index.toLong() * size))) } } finally { - X11Ext.INSTANCE.XFree(data) + LinuxX11.INSTANCE.XFree(data) } } @@ -146,7 +146,7 @@ internal object LinuxAwtInput { restored.mask.setByte(2, bits.toByte()) LinuxXInput2.instance.XISelectEvents(display, xid, restored, 1) } - X11Ext.INSTANCE.XSync(display, false) + LinuxX11.INSTANCE.XSync(display, false) } private companion object { @@ -156,8 +156,8 @@ internal object LinuxAwtInput { fun release() = locked { display -> ungrab.invoke(null) // Release the implicit server grab even if AWT already cleared its local record. - X11Ext.INSTANCE.XUngrabPointer(display, NativeLong(0)) - X11Ext.INSTANCE.XSync(display, false) + 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 index fdc252ba..4379d1f2 100644 --- 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 @@ -48,7 +48,7 @@ internal object LinuxX11WindowMover { window: X11.Window ): Boolean { if (!LinuxAwtInput.isAvailable) return false - val x11 = X11Ext.INSTANCE + val x11 = LinuxX11.INSTANCE val root = queryRootWindow(x11, display, window) ?: return false val wm = readProperty( display, @@ -94,7 +94,7 @@ internal object LinuxX11WindowMover { expectedFormat: Int, read: (Pointer, Int) -> T ): T? { - val x11 = X11Ext.INSTANCE + val x11 = LinuxX11.INSTANCE val type = X11.AtomByReference() val format = IntByReference() val count = NativeLongByReference() @@ -138,7 +138,7 @@ internal object LinuxX11WindowMover { ): Move? { if (!window.isDisplayable || !LinuxAwtInput.isAvailable) return null val x11 = try { - X11Ext.INSTANCE + LinuxX11.INSTANCE } catch (_: UnsatisfiedLinkError) { return null } @@ -187,7 +187,7 @@ internal object LinuxX11WindowMover { val localX = IntByReference() val localY = IntByReference() val buttons = IntByReference() - val found = X11Ext.INSTANCE.XQueryPointer( + val found = LinuxX11.INSTANCE.XQueryPointer( display, target, X11.WindowByReference(), @@ -251,14 +251,14 @@ internal object LinuxX11WindowMover { button = if (fromTouch) 0 else MouseEvent.BUTTON1, direction = direction ) - val sent = X11Ext.INSTANCE.XSendEvent( + val sent = LinuxX11.INSTANCE.XSendEvent( display, root, 0, NativeLong((X11.SubstructureRedirectMask or X11.SubstructureNotifyMask).toLong()), event ) - X11Ext.INSTANCE.XFlush(display) + LinuxX11.INSTANCE.XFlush(display) return sent != 0 } @@ -269,13 +269,13 @@ internal object LinuxX11WindowMover { if (started) send(11L) } finally { started = false - X11Ext.INSTANCE.XCloseDisplay(display) + LinuxX11.INSTANCE.XCloseDisplay(display) } } } private fun queryRootWindow( - x11: X11Ext, + x11: LinuxX11, display: X11.Display, xWindow: X11.Window ): X11.Window? { @@ -329,21 +329,3 @@ internal object LinuxX11WindowMover { 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/x11/LinuxXInput2.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/x11/LinuxXInput2.kt index d8454f72..c92eea0e 100644 --- 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 @@ -540,69 +540,6 @@ internal interface LinuxXInput2 : Library { } } -@Suppress("FunctionName") -internal interface LinuxTouchX11 : X11 { - 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: LinuxTouchX11 = Native.load("X11", LinuxTouchX11::class.java) - } -} - internal interface LinuxTouchPoll : Library { fun poll( fds: Pointer, 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 index c1c57134..d2fcd9c6 100644 --- 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 @@ -161,7 +161,7 @@ internal class LinuxXInputSession( } private fun readConnection() { - val x11 = LinuxTouchX11.instance + val x11 = LinuxX11.INSTANCE val xi = LinuxXInput2.instance val display = x11.XOpenDisplay(null) ?: return var awtTouch: AutoCloseable? = null @@ -262,7 +262,7 @@ internal class LinuxXInputSession( if (touch) events += LinuxXInput2.RAW_TOUCH_END return LinuxXInput2.instance.XISelectEvents( display = display, - window = LinuxTouchX11.instance.XDefaultRootWindow(display), + window = LinuxX11.INSTANCE.XDefaultRootWindow(display), masks = LinuxXInput2.EventMask(LinuxXInput2.ALL_DEVICES, *events.toIntArray()), count = 1 ) == 0 @@ -319,7 +319,7 @@ internal class LinuxXInputSession( initialDirectDevices: Set, canMoveTouch: Boolean ) { - val x11 = LinuxTouchX11.instance + val x11 = LinuxX11.INSTANCE val event = X11.XEvent() var directDevices = initialDirectDevices Memory(8).use { fd -> @@ -334,7 +334,7 @@ internal class LinuxXInputSession( } x11.XNextEvent(display, event) if (event.type != 35) continue // GenericEvent - val cookie = LinuxTouchX11.EventCookie(event.pointer) + val cookie = LinuxX11.EventCookie(event.pointer) if (cookie.extension != opcode || x11.XGetEventData(display, cookie) == 0) continue try { when (cookie.eventType) { @@ -495,7 +495,7 @@ internal class LinuxXInputSession( epoch ) } else { - LinuxTouchX11.instance.XFlush(display) + LinuxX11.INSTANCE.XFlush(display) } } @@ -542,9 +542,9 @@ internal class LinuxXInputSession( epoch: Long ) { handedOffTouch = sample - X11Ext.INSTANCE.XUngrabPointer(display, NativeLong(0)) + LinuxX11.INSTANCE.XUngrabPointer(display, NativeLong(0)) // Complete rejection before the prepared WM request starts on its own connection. - LinuxTouchX11.instance.XSync(display, false) + LinuxX11.INSTANCE.XSync(display, false) dispatchAndWait { if (!stopRequested && epoch == touchEpoch) listener.onTouchMove(sample.id) } 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/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxClientShadowTest.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxClientShadowTest.kt index 8fbf9659..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 @@ -38,7 +38,7 @@ 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.X11Ext +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 @@ -420,7 +420,7 @@ class LinuxClientShadowTest { if (!window.isDisplayable) return null val x11 = try { - X11Ext.INSTANCE + LinuxX11.INSTANCE } catch (_: UnsatisfiedLinkError) { return null } @@ -481,7 +481,7 @@ class LinuxClientShadowTest { if (!window.isDisplayable) return null val x11 = try { - X11Ext.INSTANCE + LinuxX11.INSTANCE } catch (_: UnsatisfiedLinkError) { return null } From cd29e421f31b8f419247f8c05eaadba0e1a6e3d5 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 16:00:57 +1000 Subject: [PATCH 22/35] fix(Linux): correct TOUCH_BITS calculation for accurate bitwise operations --- .../com/moriafly/salt/ui/platform/linux/x11/LinuxAwtInput.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 17927d14..cd140886 100644 --- 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 @@ -150,7 +150,7 @@ internal object LinuxAwtInput { } private companion object { - const val TOUCH_BITS = (1 shl (18 - 16)) or (1 shl (19 - 16)) or (1 shl (20 - 16)) + const val TOUCH_BITS = (1 shl 18 - 16) or (1 shl 19 - 16) or (1 shl 20 - 16) } fun release() = locked { display -> From 6af4660a30c65506fad6cc3bd22adecffd400160 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 17:25:01 +1000 Subject: [PATCH 23/35] fix(linux): keep native dialog captions draggable Skip ComposeDialog's own semantics owner when looking for modal overlays. This prevents native dialog content from excluding the entire caption, while retaining hit testing for modal scenes layered above the window. Validation: :ui2:compileKotlinDesktop; native touch dragging and modal interior, boundary, exterior, and click checks in isolated KWin/XWayland with Steam Runtime 4. --- .../salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt index 83e19daf..4b70bb58 100644 --- a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxCaptionBarHitTest.kt @@ -59,7 +59,7 @@ internal class LinuxCaptionBarHitTest { } val owners = when (window) { is ComposeWindow -> window.semanticsOwners - is ComposeDialog -> window.semanticsOwners + is ComposeDialog -> window.semanticsOwners.drop(1) else -> return Result.RequiresComposeHitTest } // Keep normal Compose hit testing for custom controls layered above CaptionBarHitTest. From 114b8aa8ae131901af90936a26b0e5d2345ee0fa Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Mon, 19 Oct 2026 18:31:23 +1000 Subject: [PATCH 24/35] fix(linux): preserve caption anchors when restoring maximized windows Let the window manager own native restore and movement throughout a drag. Keep AWT restore handling and pointer anchors inside LinuxFallbackWindowMover so a resize callback cannot hand a partially restored window to the native mover. Pass both press and current positions to native preparation. Preserve the original caption press for maximized restore while retaining the current-contact handoff for ordinary touch dragging. Reuse finish() for failed native mouse moves. Validation: desktop compilation with JBR 25, 10 existing gesture/input tests, and real SaltWindow checks with native input in isolated KWin/XWayland under Steam Runtime 4. Covered mouse and touch, system 200% and app 150% scaling, ordinary dragging, release, and fallback when native AWT access is unavailable. --- .../linux/input/LinuxWindowGestures.kt | 6 +- .../input/gesture/WindowGestureEffects.kt | 3 +- .../linux/window/LinuxFallbackWindowMover.kt | 84 +++++++++---- .../linux/window/LinuxWindowGestureEffects.kt | 119 ++++++------------ .../platform/linux/x11/LinuxX11WindowMover.kt | 10 +- 5 files changed, 109 insertions(+), 113 deletions(-) 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 index fc6490fa..82a3a155 100644 --- 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 @@ -264,7 +264,11 @@ internal class LinuxWindowGestures( touch: TouchDispatch? ): TouchResult { if (touch?.canMoveNatively == true && - effects.mover.prepareTouchMove(touch.event.id, command.current) + effects.mover.prepareTouchMove( + id = touch.event.id, + start = command.start, + current = command.current + ) ) { return TouchResult.PreparedNativeMove } 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 index 5e11ad67..8b8220b3 100644 --- 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 @@ -37,7 +37,8 @@ internal interface WindowGestureEffects { fun prepareTouchMove( id: Long, - position: Position + start: Position, + current: Position ): Boolean fun commitTouchMove( 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 index 90f3e018..1398c77d 100644 --- 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 @@ -19,50 +19,82 @@ 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 /** - * Fallback window movement in AWT screen coordinates, shared by mouse and touch. - * - * From `androidx.compose.foundation.window.StandardMoveHandler` + * 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 dragStartScreenPos: Point? = null - private var dragStartWindowPos: Point? = null - private var isDragging = false + 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( - screenPosition: Point + start: Point, + current: Point ) { - dragStartScreenPos = Point(screenPosition) - dragStartWindowPos = window.location - isDragging = true + 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( - screenPosition: Point + position: Point ) { - if (!isDragging) return - - val startScreen = dragStartScreenPos ?: return - val startWindow = dragStartWindowPos ?: return - - val newX = startWindow.x + (screenPosition.x - startScreen.x) - val newY = startWindow.y + (screenPosition.y - startScreen.y) - - window.location = Point(newX, newY) + 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 (isDragging) { - window.cursor = Cursor.getDefaultCursor() - } - isDragging = false - dragStartScreenPos = null - dragStartWindowPos = null + 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/window/LinuxWindowGestureEffects.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/platform/linux/window/LinuxWindowGestureEffects.kt index 366e22d2..f9c329c2 100644 --- 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 @@ -26,12 +26,10 @@ import com.moriafly.salt.ui.window.WindowResizeEdge import java.awt.Frame import java.awt.Point import java.awt.Window -import java.awt.event.ComponentAdapter -import java.awt.event.ComponentEvent /** - * Executes gesture commands through the WM and AWT. Native restore notifications and queued resize - * work stay here; neither can change contact ownership or interpret another pointer's input. + * 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( @@ -84,52 +82,38 @@ internal class LinuxWindowGestureEffects( ) : WindowGestureEffects.Mover { private val fallback = LinuxFallbackWindowMover(window) private var useNativeMouseDrag = true - private var restoreWidth = window.width - private var drag: Drag? = null + private var nativeDrag: NativeDrag? = null override val isNativeMouseMove: Boolean get() { - val active = drag ?: return false - return active.pointer == Pointer.Mouse && active.nativeMove?.started == true + val active = nativeDrag ?: return false + return active.pointer == Pointer.Mouse && active.move.started } override fun prepareTouchMove( id: Long, - position: Position + start: Position, + current: Position ): Boolean { finish() - val move = LinuxX11WindowMover.prepare( - window = window, - screenPosition = Point(position.x, position.y), - fromTouch = true - ) ?: return false - drag = Drag( - Pointer.Touch(id), - position, - nativeMove = move - ) + nativeDrag = prepareNativeMove(Pointer.Touch(id), start, current) ?: return false return true } override fun commitTouchMove( id: Long ): Boolean { - val active = drag ?: return false + val active = nativeDrag ?: return false if (active.pointer != Pointer.Touch(id)) return false - if (active.nativeMove?.start() == true) return true + if (active.move.start()) return true finish() return false } override fun install(): () -> Unit { - val listener = object : ComponentAdapter() { - override fun componentResized( - event: ComponentEvent - ) = onWindowResized() - } - window.addComponentListener(listener) + val uninstallFallback = fallback.install() return { - window.removeComponentListener(listener) + uninstallFallback() finish() } } @@ -140,35 +124,24 @@ internal class LinuxWindowGestureEffects( current: Position ) { finish() - val active = Drag(pointer, current) - drag = active - val frame = window as? Frame - if (frame?.extendedState == Frame.MAXIMIZED_BOTH) { - val localX = start.x - window.x - active.restoreOffset = Position( - (restoreWidth * localX.toFloat() / window.width).toInt(), - start.y - window.y - ) - frame.extendedState = Frame.NORMAL - } else { - startMover(start, pointer) - fallback.updateDrag(Point(current.x, current.y)) + 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 - ) { - val active = drag ?: return - active.latestPosition = position - if (active.restoreOffset == null) fallback.updateDrag(Point(position.x, position.y)) - } + ) = fallback.updateDrag(Point(position.x, position.y)) override fun finish() { - val active = drag - drag = null + val active = nativeDrag + nativeDrag = null try { - active?.nativeMove?.close() + active?.move?.close() } finally { fallback.endDrag() } @@ -183,43 +156,23 @@ internal class LinuxWindowGestureEffects( } } - private fun startMover( - position: Position, - pointer: Pointer - ) { - val point = Point(position.x, position.y) - // Native touch movement is handed off by the XI2 session before ownership is accepted. - if (pointer == Pointer.Mouse && useNativeMouseDrag) { - val move = LinuxX11WindowMover.prepare(window, point) - if (move?.start() == true) { - checkNotNull(drag).nativeMove = move - return - } - move?.close() - useNativeMouseDrag = false - } - fallback.startDrag(point) - } - - private fun onWindowResized() { - if ((window as? Frame)?.extendedState == Frame.MAXIMIZED_BOTH) return - restoreWidth = window.width - val active = drag ?: return - val offset = active.restoreOffset ?: return - active.restoreOffset = null - // Moving before native restore would overwrite the WM's saved geometry. - window.setLocation( - active.latestPosition.x - offset.x, - active.latestPosition.y - offset.y - ) - startMover(active.latestPosition, active.pointer) + 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 Drag( + private class NativeDrag( val pointer: Pointer, - var latestPosition: Position, - var restoreOffset: Position? = null, - var nativeMove: LinuxX11WindowMover.Move? = null + val move: LinuxX11WindowMover.Move ) } } 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 index 4379d1f2..db9c1208 100644 --- 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 @@ -25,6 +25,7 @@ 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 @@ -133,7 +134,8 @@ internal object LinuxX11WindowMover { */ fun prepare( window: Window, - screenPosition: Point, + start: Point, + current: Point, fromTouch: Boolean = false ): Move? { if (!window.isDisplayable || !LinuxAwtInput.isAvailable) return null @@ -150,11 +152,15 @@ internal object LinuxX11WindowMover { 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 = screenPosition, + screenPosition = anchor, fromTouch = fromTouch ) ?: return null val atom = x11.XInternAtom(display, "_NET_WM_MOVERESIZE", false) From 5b53e033366a5719be9f1e30959af78fbf739005 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Tue, 20 Oct 2026 20:07:57 +0800 Subject: [PATCH 25/35] docs(linux): explain why raw button release is not filtered by device Touch pointer emulation in dix happens at delivery time (TouchConvertToPointerEvent), so touches only produce RawTouch* events and never RawButtonRelease. Filtering by direct-touch devices would be a no-op for touch and could only drop a real button release. --- .../moriafly/salt/ui/platform/linux/x11/LinuxXInputSession.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index d2fcd9c6..5ad1de70 100644 --- 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 @@ -352,6 +352,8 @@ internal class LinuxXInputSession( 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() } } From d633dfeda159e3ed7b7b6d665fdc6638d4a4e5fd Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Tue, 20 Oct 2026 20:26:09 +0800 Subject: [PATCH 26/35] test(linux): cover window gestures and layered dialog captions Add native mouse coverage for caption dragging, maximize/restore, resize boundaries, and layered Dialog hit testing on real SaltWindow and SaltDialogWindow instances. Verify that the topmost dialog blocks its content, exposed captions remain draggable, and closing each overlay restores the remaining layer's behavior. Cover exact touch slop, reset and configuration changes, contact release order, and stale caption replies. Give the fixture an explicit 4dp resize region, wait for native focus and expected resize layouts, and share its content between frame and dialog windows. Keep synthetic taps in one EDT turn to prevent unrelated native events from splitting their samples. Add an optional KWayland pointer helper while retaining Robot for X11 test runs. Validation: all 27 targeted tests passed with JBR 25 under isolated KWin/XWayland at 100% and 200% AWT scaling, with no skipped tests. Temporary mutations of slop, caption request identity, native dialog owner exclusion, and overlay priority were detected by the regression tests. Physical touchscreen input and remote CI were not exercised. --- .../ui/platform/linux/LinuxInputWindow.kt | 155 ++++-- .../linux/LinuxWindowGestureStateTest.kt | 115 +++- .../platform/linux/LinuxWindowGesturesTest.kt | 527 +++++++++++++++++- .../native/linux-window-pointer.cpp | 49 ++ 4 files changed, 776 insertions(+), 70 deletions(-) create mode 100644 ui2/src/desktopTest/native/linux-window-pointer.cpp diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt index 948403d0..16da7567 100644 --- a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt @@ -20,16 +20,19 @@ 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.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.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 @@ -38,28 +41,57 @@ 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 LinuxInputWindow : AutoCloseable { +internal class LinuxInputWindow( + 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: ComposeWindow + 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")) @@ -75,8 +107,12 @@ internal class LinuxInputWindow : AutoCloseable { failure.set(error) } } - await("window content") { ::window.isInitialized && window.isShowing && content != Rect.Zero } + 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( @@ -92,7 +128,9 @@ internal class LinuxInputWindow : AutoCloseable { } } - fun onEdt(action: () -> T): T { + fun onEdt( + action: () -> T + ): T { if (EventQueue.isDispatchThread()) return action() val task = FutureTask(action) EventQueue.invokeAndWait(task) @@ -109,48 +147,81 @@ internal class LinuxInputWindow : AutoCloseable { @Composable private fun Content() { SaltTheme { - val state = rememberWindowState( - position = WindowPosition.Absolute(120.dp, 120.dp), - size = DpSize(560.dp, 380.dp) - ) - SaltWindow( - onCloseRequest = {}, - state = state, - title = "SaltUI window gesture regression", - decoration = WindowDecoration.Undecorated(), - properties = SaltWindowProperties.default( - captionButtonsVisible = false, - extraDisplayScale = 1.25f, - onResizeEdgeChange = { _, value -> edge = value } - ), - init = { window = it } - ) { - val currentGestures = checkNotNull(LocalLinuxWindowGestures.current) - SideEffect { gestures = currentGestures } - 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") - } + 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 { + fun screen( + position: Offset + ): Offset { val canvas = checkNotNull(window.findSkiaLayer()).canvas val origin = canvas.locationOnScreen val scale = window.graphicsConfiguration.defaultTransform 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 index 1350bcb0..b5c19637 100644 --- 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 @@ -115,7 +115,82 @@ class LinuxWindowGestureStateTest { state.onInput(touch(4, Phase.Release)) } - private fun assertTouchQuarantineEndsWithLastContact(state: LinuxWindowGestureState) { + @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)) @@ -126,29 +201,35 @@ class LinuxWindowGestureStateTest { } private fun configuredState() = LinuxWindowGestureState().apply { - configure( - LinuxWindowGestureState.Configuration( - moveable = true, - resizable = true, - canDrag = true, - canMaximize = true, - taps = LinuxTouchTapRecognizer.Configuration( - touchSlop = 8f, - longPressTimeoutMillis = 500, - doubleTapMinTimeMillis = 40, - doubleTapTimeoutMillis = 300 - ) - ) - ) + configure(configuration()) } - private fun mouse(phase: Phase, x: Int = 100) = Input( + 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( + 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 index e2043fed..3b6d222e 100644 --- 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 @@ -6,12 +6,31 @@ @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 @@ -20,17 +39,152 @@ 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 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 /** - * Standard desktopTest integration tests: inject samples at the platform adapter and assert real - * SaltWindow bounds and Compose clicks. These do not emulate a native touchscreen or compositor. + * 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)) + await("window following mouse") { + abs(window.x - before.x - 110) <= 1 && abs(window.y - before.y - 70) <= 1 + } + mouse.release() + 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 + ) + LinuxInputWindow(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 } @@ -61,13 +215,13 @@ class LinuxWindowGesturesTest { val before = onEdt { window.bounds } val captionWidth = onEdt { caption.width } touch.doubleTap(onEdt { Offset(content.center.x, content.top + 2f) }) - onEdt { assertEquals(Frame.NORMAL, window.extendedState, "Content is not a caption target") } + onEdt { assertEquals(Frame.NORMAL, frame.extendedState, "Content is not a caption target") } touch.doubleTap(onEdt { caption.center }) await("caption maximize and layout") { - window.extendedState == Frame.MAXIMIZED_BOTH && caption.width > captionWidth + frame.extendedState == Frame.MAXIMIZED_BOTH && caption.width > captionWidth } touch.doubleTap(onEdt { caption.center }) - await("caption restore") { window.extendedState == Frame.NORMAL && window.bounds == before } + await("caption restore") { frame.extendedState == Frame.NORMAL && window.bounds == before } } @Test @@ -100,14 +254,254 @@ class LinuxWindowGesturesTest { await("Compose touch click after cancellation") { clicks == 1 } } - private fun withWindow(block: LinuxInputWindow.(TouchDriver) -> Unit) { + @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)) + 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 -> + onEdt { + val point = caption.center + 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) + touch.send(TouchPhase.Down, point) + touch.send(TouchPhase.Move, point + Offset(60f * systemScale, 0f)) + touch.send(TouchPhase.Up, point) + assertEquals(stopped.x + 60, window.x, "Fresh caption press must move again") + } + } + + @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 } + } + } + + private fun withWindow( + block: LinuxInputWindow.(TouchDriver) -> Unit + ) { LinuxInputWindow().use { fixture -> fixture.open() fixture.block(TouchDriver(fixture)) } } - private fun LinuxInputWindow.mouse(id: Int, position: Offset): MouseEvent { + private fun LinuxInputWindow.mouse( + id: Int, + position: Offset + ): MouseEvent { val canvas = checkNotNull(window.findSkiaLayer()).canvas val scale = window.graphicsConfiguration.defaultTransform val screen = screen(position) @@ -127,7 +521,100 @@ class LinuxWindowGesturesTest { ) } - private class TouchDriver(private val fixture: LinuxInputWindow) { + private class NativeMouse( + private val fixture: LinuxInputWindow + ) : 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: LinuxInputWindow + ) { private var id = 0L private var time = 0L private val input = fixture.onEdt { @@ -139,8 +626,21 @@ class LinuxWindowGesturesTest { ) } - fun send(phase: TouchPhase, position: Offset) = fixture.onEdt { + 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( @@ -163,12 +663,17 @@ class LinuxWindowGesturesTest { ) } - fun tap(position: Offset) { + 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) { + fun doubleTap( + position: Offset + ) { tap(position) tap(position) } 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); +} From 29d17aa079ccd444d21acb98c047822d6bed1bda Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Tue, 20 Oct 2026 20:59:41 +0800 Subject: [PATCH 27/35] fix(linux): release native mouse move when the XI2 session fails A failed XI2 reader can no longer report the raw button release that ends a native mouse move, leaving a stale mouse gesture that held an X connection and swallowed the next non-primary press. On spontaneous close, drop ownership of the move without sending _NET_WM_MOVERESIZE_CANCEL: the WM still ends the drag on release, while a cancel would restore the window's start position mid-drag. Requested stops already cancel input, and AWT or Compose input is left running. --- .../salt/ui/platform/linux/input/LinuxWindowGestures.kt | 7 +++++++ .../salt/ui/platform/linux/input/LinuxWindowInput.kt | 3 +++ .../platform/linux/input/gesture/WindowGestureEffects.kt | 3 +++ .../platform/linux/window/LinuxWindowGestureEffects.kt | 7 +++++++ .../salt/ui/platform/linux/x11/LinuxX11WindowMover.kt | 9 +++++++++ 5 files changed, 29 insertions(+) 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 index 82a3a155..8e80ceb0 100644 --- 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 @@ -205,6 +205,13 @@ internal class LinuxWindowGestures( 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()) 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 index 6e80e63d..c853b08e 100644 --- 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 @@ -242,6 +242,9 @@ internal class LinuxWindowInput private constructor( ) ) 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) { 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 index 8b8220b3..2a2ce796 100644 --- 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 @@ -35,6 +35,9 @@ internal interface WindowGestureEffects { 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, 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 index f9c329c2..694b0be9 100644 --- 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 @@ -90,6 +90,13 @@ internal class LinuxWindowGestureEffects( 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, 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 index db9c1208..4f7924b8 100644 --- 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 @@ -268,6 +268,15 @@ internal object LinuxX11WindowMover { 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 From bce0f2d2ff735558e68b1cd98c2d7dcd322c9b16 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Tue, 20 Oct 2026 21:28:15 +0800 Subject: [PATCH 28/35] test(linux): make native drag tests independent of Openbox move reporting Openbox sends the client its new position only when an interactive move ends, so AWT bounds stay stale while the button is held. Check the dragged position after release instead; the later no-follow check still proves the WM move ended. Restoring a maximized window on move is WM policy, and Openbox keeps the window maximized. Run the restore-anchor test only under KWin, where that behavior was verified. --- .../platform/linux/LinuxWindowGesturesTest.kt | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) 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 index 3b6d222e..dfa894cd 100644 --- 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 @@ -34,10 +34,17 @@ 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 @@ -67,10 +74,11 @@ class LinuxWindowGesturesTest { mouse.move(Point(start.x + 30, start.y + 20)) mouse.settle() mouse.move(Point(start.x + 110, start.y + 70)) - await("window following mouse") { + 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 } - mouse.release() val released = onEdt { window.bounds } mouse.move(Point(start.x + 160, start.y + 100)) mouse.settle() @@ -297,6 +305,11 @@ class LinuxWindowGesturesTest { @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 })) @@ -489,6 +502,54 @@ class LinuxWindowGesturesTest { } } + /** 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: LinuxInputWindow.(TouchDriver) -> Unit ) { From b1a0fb08bca3ed61ac47dab51a11de4c161d161a Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Tue, 20 Oct 2026 21:38:26 +0800 Subject: [PATCH 29/35] test(linux): keep synthetic touch contacts fixed on screen TouchDriver recomputed each event's root position from the canvas's native screen origin, which lags AWT bounds until the WM applies an earlier move. When Openbox caught up between touch-down and the next move, the synthetic finger shifted by the window's offset and the drag moved too far. Capture the screen origin at touch-down and reuse it for that contact, as a real finger keeps its screen position while the window moves under it. --- .../salt/ui/platform/linux/LinuxWindowGesturesTest.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 index dfa894cd..0cbd15d4 100644 --- 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 @@ -678,6 +678,7 @@ class LinuxWindowGesturesTest { ) { private var id = 0L private var time = 0L + private val origins = mutableMapOf() private val input = fixture.onEdt { LinuxTouchInput( canvas = checkNotNull(fixture.window.findSkiaLayer()).canvas, @@ -703,7 +704,14 @@ class LinuxWindowGesturesTest { position: Offset ) = fixture.onEdt { time += 80 - val screen = fixture.screen(position) + // A finger keeps its screen origin from touch-down. The native origin lags AWT + // bounds until the WM applies a move, so re-reading it would shift the contact. + val origin = if (phase == TouchPhase.Down) { + fixture.screen(Offset.Zero).also { origins[id] = it } + } else { + origins.getOrPut(id) { fixture.screen(Offset.Zero) } + } + val screen = origin + position assertEquals( LinuxWindowGestures.TouchResult.Handled, input.dispatch( From 22c46c9cb62dddad5eb9c00b753592ec9b7de2c1 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Tue, 20 Oct 2026 22:00:22 +0800 Subject: [PATCH 30/35] test(linux): wait for the WM move before a fresh caption press Revert the fixed per-contact screen origin from b1a0fb0. It did not change the synthetic coordinates for multi-touch resize, yet that test failed on both CI attempts with it and passed on both runs without it. AWT applies the WM's position reports from the toolkit thread, so a late report of the first move can overwrite the second move, and the native origin that synthetic contacts use can shift between press and move. Wait until the WM has applied the first move before the fresh press, and await the final position instead of reading it once. --- .../platform/linux/LinuxWindowGesturesTest.kt | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) 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 index 0cbd15d4..139dc3fb 100644 --- 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 @@ -428,8 +428,9 @@ class LinuxWindowGesturesTest { @Test fun disablingDragDuringTouchHoldStopsMovementUntilFreshPress() = withWindow { touch -> - onEdt { - val point = caption.center + 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) @@ -440,11 +441,20 @@ class LinuxWindowGesturesTest { 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) - assertEquals(stopped.x + 60, window.x, "Fresh caption press must move again") } + await("fresh caption press moving again") { window.x == stopped.x + 60 } } @Test @@ -678,7 +688,6 @@ class LinuxWindowGesturesTest { ) { private var id = 0L private var time = 0L - private val origins = mutableMapOf() private val input = fixture.onEdt { LinuxTouchInput( canvas = checkNotNull(fixture.window.findSkiaLayer()).canvas, @@ -704,14 +713,7 @@ class LinuxWindowGesturesTest { position: Offset ) = fixture.onEdt { time += 80 - // A finger keeps its screen origin from touch-down. The native origin lags AWT - // bounds until the WM applies a move, so re-reading it would shift the contact. - val origin = if (phase == TouchPhase.Down) { - fixture.screen(Offset.Zero).also { origins[id] = it } - } else { - origins.getOrPut(id) { fixture.screen(Offset.Zero) } - } - val screen = origin + position + val screen = fixture.screen(position) assertEquals( LinuxWindowGestures.TouchResult.Handled, input.dispatch( From d3641905bdb556ad59688d00a746d68481b5b05d Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Thu, 22 Oct 2026 23:54:42 +0800 Subject: [PATCH 31/35] refactor(test): rename LinuxInputWindow to LinuxInputWindowTest for clarity --- .../{LinuxInputWindow.kt => LinuxInputWindowTest.kt} | 2 +- .../ui/platform/linux/LinuxWindowGesturesTest.kt | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) rename ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/{LinuxInputWindow.kt => LinuxInputWindowTest.kt} (99%) diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindowTest.kt similarity index 99% rename from ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt rename to ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindowTest.kt index 16da7567..8757c144 100644 --- a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindow.kt +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/platform/linux/LinuxInputWindowTest.kt @@ -56,7 +56,7 @@ import kotlin.concurrent.thread import kotlin.test.fail import org.junit.Assume.assumeTrue -internal class LinuxInputWindow( +internal class LinuxInputWindowTest( private val asDialog: Boolean = false, private val overlays: @Composable () -> Unit = {} ) : AutoCloseable { 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 index 139dc3fb..d3fa9ca3 100644 --- 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 @@ -99,7 +99,7 @@ class LinuxWindowGesturesTest { usePlatformInsets = false, animateTransition = false ) - LinuxInputWindow(asDialog = true) { + LinuxInputWindowTest(asDialog = true) { if (depth > 0) { Dialog(onDismissRequest = {}, properties = properties) { Box( @@ -561,15 +561,15 @@ class LinuxWindowGesturesTest { } private fun withWindow( - block: LinuxInputWindow.(TouchDriver) -> Unit + block: LinuxInputWindowTest.(TouchDriver) -> Unit ) { - LinuxInputWindow().use { fixture -> + LinuxInputWindowTest().use { fixture -> fixture.open() fixture.block(TouchDriver(fixture)) } } - private fun LinuxInputWindow.mouse( + private fun LinuxInputWindowTest.mouse( id: Int, position: Offset ): MouseEvent { @@ -593,7 +593,7 @@ class LinuxWindowGesturesTest { } private class NativeMouse( - private val fixture: LinuxInputWindow + private val fixture: LinuxInputWindowTest ) : AutoCloseable { private val robot = Robot().apply { autoDelay = 30 } private val helper = System.getenv("SALT_TEST_NATIVE_POINTER")?.let { @@ -684,7 +684,7 @@ class LinuxWindowGesturesTest { } private class TouchDriver( - private val fixture: LinuxInputWindow + private val fixture: LinuxInputWindowTest ) { private var id = 0L private var time = 0L From c9bc2316b52f9d8ca1d83661997b140030b15fb5 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Fri, 23 Oct 2026 00:17:20 +0800 Subject: [PATCH 32/35] refactor(linux): simplify caption bar input modifier setup Return the existing Modifier early when caption hit testing is unavailable. Move region registration and cleanup out of the conditional modifier assignment so lifecycle handling and modifier construction read in order. This relies on the current window and dialog providers supplying caption hit testing and gesture handling together. Validation: desktop Kotlin compilation, UI text check, and git diff --check passed. --- .../linux/input/LinuxCaptionBarInput.kt | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) 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 index ab5aa36d..db4efd84 100644 --- 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 @@ -35,17 +35,14 @@ import com.moriafly.salt.ui.platform.linux.window.LocalLinuxCaptionBarHitTest @OptIn(ExperimentalComposeUiApi::class) @Composable internal fun Modifier.linuxCaptionBarInput(): Modifier { - val hitTest = LocalLinuxCaptionBarHitTest.current - val modifier = if (hitTest != null) { - val region = remember(hitTest) { LinuxCaptionBarHitTest.Region() } - DisposableEffect(hitTest, region) { - hitTest.regions.add(region) - onDispose { hitTest.regions.remove(region) } - } - onGloballyPositioned { region.bounds = it.boundsInWindow() } - } else { - this + 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 -> From 8e0c56936b0260d598ebca6f9c73f49bead606b9 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Fri, 23 Oct 2026 18:12:29 +0800 Subject: [PATCH 33/35] refactor(linux): improve conditional rendering in Content function --- .../linux/window/UndecoratedWindowResizer.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index 4860b753..803c82e6 100644 --- 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 @@ -55,8 +55,18 @@ internal class UndecoratedWindowResizer( fun Content( modifier: Modifier = Modifier ) { - if (!enabled) return + if (enabled) { + ResizerLayout( + modifier = modifier + ) + } + + } + @Composable + private fun ResizerLayout( + modifier: Modifier = Modifier + ) { Layout( { Side(Cursor.W_RESIZE_CURSOR, WindowResizeEdge.Left) From 0c378160d84719703aabfbde353848109bfd2d83 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Fri, 23 Oct 2026 18:12:43 +0800 Subject: [PATCH 34/35] refactor(linux): remove unnecessary blank line in UndecoratedWindowResizer --- .../salt/ui/platform/linux/window/UndecoratedWindowResizer.kt | 1 - 1 file changed, 1 deletion(-) 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 index 803c82e6..b2a34e6b 100644 --- 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 @@ -60,7 +60,6 @@ internal class UndecoratedWindowResizer( modifier = modifier ) } - } @Composable From c9660188ebd4872c8f7bac9c10d17385b63e60d6 Mon Sep 17 00:00:00 2001 From: GaodaGG Date: Fri, 23 Oct 2026 19:10:52 +0800 Subject: [PATCH 35/35] refactor(linux): rename modalBlocked to isModalBlockedMethod for clarity --- .../salt/ui/platform/linux/window/LinuxWindowModality.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 05dba31e..adf83d68 100644 --- 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 @@ -21,6 +21,7 @@ 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. @@ -28,15 +29,15 @@ import java.awt.Window * dialogs because AWT does not expose their blocker ordering through public methods. */ internal object LinuxWindowModality { - private val modalBlocked = runCatching { - Window::class.java.getDeclaredMethod("isModalBlocked") - .takeIf { it.trySetAccessible() } + 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 = modalBlocked?.let { method -> + val blocked = isModalBlockedMethod?.let { method -> runCatching { method.invoke(window) as Boolean }.getOrNull() } if (blocked != null) return blocked