From a21996269be6fc54587b686d8e1361cfc1b21fcb Mon Sep 17 00:00:00 2001 From: gagik894 Date: Mon, 30 Mar 2026 22:24:11 +0200 Subject: [PATCH 1/2] Add experimental `components:input` library for hardware-aware pointer clicks Introduce `onPointerClick` and `onPrimaryClick` modifiers to provide rich interaction metadata, including support for secondary/tertiary buttons and keyboard modifiers. - Add `PointerClickEvent` to encapsulate click position, button state, pointer type, and keyboard modifiers. - Implement `onPointerClick` modifier with support for custom press interaction triggers and visual indications. - Implement `onPrimaryClick` convenience modifier for standard primary interactions. - Ensure proper handling of touch slop, cancellation (e.g., during parent scrolling), and disabled states. - Support synthesized clicks via semantics for accessibility and keyboard navigation. - Include comprehensive test suite covering hardware buttons, modifiers, ripple lifecycles, and edge cases. ## Release Notes Experimental Compose Multiplatform input library providing advanced hardware-aware pointer click modifiers. --- components/input/library/build.gradle.kts | 105 ++ .../components/input/PointerClickEvent.kt | 624 +++++++++ .../components/input/PointerClickEventTest.kt | 1213 +++++++++++++++++ components/settings.gradle.kts | 1 + 4 files changed, 1943 insertions(+) create mode 100644 components/input/library/build.gradle.kts create mode 100644 components/input/library/src/commonMain/kotlin/org/jetbrains/compose/components/input/PointerClickEvent.kt create mode 100644 components/input/library/src/commonTest/kotlin/org/jetbrains/compose/components/input/PointerClickEventTest.kt diff --git a/components/input/library/build.gradle.kts b/components/input/library/build.gradle.kts new file mode 100644 index 00000000000..67230537641 --- /dev/null +++ b/components/input/library/build.gradle.kts @@ -0,0 +1,105 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + kotlin("multiplatform") + id("maven-publish") + id("com.android.library") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") +} + +kotlin { + jvm("desktop") + androidTarget { + publishLibraryVariants("release") + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + } + } + iosArm64() + iosSimulatorArm64() + js { + browser { + } + } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser { + } + } + macosArm64() + + applyDefaultHierarchyTemplate() + + sourceSets { + all { + languageSettings { + optIn("kotlin.RequiresOptIn") + optIn("androidx.compose.foundation.ExperimentalFoundationApi") + } + } + val commonMain by getting { + dependencies { + // Expose foundation, runtime, and ui to consumers of this library + api(libs.compose.foundation) + api(libs.compose.runtime) + api(libs.compose.ui) + } + } + + // Keep their exact source set hierarchy for CI compatibility + val nonAndroidMain by creating { + dependsOn(commonMain) + } + + val appleMain by getting + appleMain.dependsOn(nonAndroidMain) + + val desktopMain by getting + desktopMain.dependsOn(nonAndroidMain) + + val jsMain by getting + jsMain.dependsOn(nonAndroidMain) + + val wasmJsMain by getting + wasmJsMain.dependsOn(nonAndroidMain) + + // Ensure tests have access to the UI testing frameworks + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + @OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + } + } + } +} + +android { + compileSdk = 35 + namespace = "org.jetbrains.compose.components.input" + defaultConfig { + minSdk = 23 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +configureMavenPublication( + groupId = "org.jetbrains.compose.components", + artifactId = "components-input", + name = "Experimental Compose Multiplatform input library API. This library provides advanced hardware-aware pointer click modifiers." +) \ No newline at end of file diff --git a/components/input/library/src/commonMain/kotlin/org/jetbrains/compose/components/input/PointerClickEvent.kt b/components/input/library/src/commonMain/kotlin/org/jetbrains/compose/components/input/PointerClickEvent.kt new file mode 100644 index 00000000000..0f776b995e5 --- /dev/null +++ b/components/input/library/src/commonMain/kotlin/org/jetbrains/compose/components/input/PointerClickEvent.kt @@ -0,0 +1,624 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.components.input + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Indication +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.focusable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.PointerButtons +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isOutOfBounds +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.node.CompositionLocalConsumerModifierNode +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.LayoutAwareModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.PointerInputModifierNode +import androidx.compose.ui.node.SemanticsModifierNode +import androidx.compose.ui.node.currentValueOf +import androidx.compose.ui.node.invalidateSemantics +import androidx.compose.ui.node.requireDensity +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.toOffset +import kotlin.math.max +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +// Standard Material delay before showing a ripple to avoid flashing during scrolls +private const val TapIndicationDelay = 40L + +/** + * Helper to extract the hardware tool type (Touch, Mouse, etc.) from a [PointerEvent]. + */ +val PointerEvent.pointerType: PointerType + get() = changes.firstOrNull()?.type ?: PointerType.Unknown + +/** + * Evaluates to `true` if this [PointerEvent] represents a primary interaction. + */ +val PointerEvent.isPrimaryAction: Boolean + get() = isPrimaryPointerAction(pointerType, buttons) + +/** + * Evaluates to `true` if this [PointerEvent] represents a secondary interaction. + */ +val PointerEvent.isSecondaryAction: Boolean + get() = isSecondaryPointerAction(buttons) + +/** + * Evaluates to `true` if this [PointerEvent] represents a middle-click interaction. + */ +val PointerEvent.isTertiaryAction: Boolean + get() = isTertiaryPointerAction(buttons) + + +/** + * Represents a pointer click event, encapsulating hardware-specific metadata. + * + * This payload provides rich context about the interaction, enabling advanced multiplatform + * behaviors such as secondary clicks (context menus) or keyboard-modified clicks (shift-click selection). + * + * @property position The coordinate of the click, relative to the bounds of the component. + * @property buttons The state of the pointer buttons (e.g., Primary/Left, Secondary/Right) active during the click. + * This value is `null` if the click was synthesized via software (such as a keyboard action or accessibility service). + * @property type The type of pointer device that triggered the click. + * @property keyboardModifiers The state of the keyboard modifiers (e.g., Shift, Ctrl, Alt) active during the click. + */ +@ExperimentalFoundationApi +class PointerClickEvent( + val position: Offset, + val buttons: PointerButtons?, + val type: PointerType, + val keyboardModifiers: PointerKeyboardModifiers +) { + /** + * Evaluates to `true` if this event represents a primary interaction. + * This includes physical primary clicks (Touch, Mouse-Left, Stylus-Tip) + * as well as synthesized semantic clicks (Keyboard Enter, Accessibility). + */ + val isPrimaryAction: Boolean + get() = isPrimaryPointerAction(type, buttons) + + /** + * Evaluates to `true` if this event represents a physical secondary click. + * This supports both Right-Mouse clicks and Stylus Barrel-Button clicks. + */ + val isSecondaryAction: Boolean + get() = isSecondaryPointerAction(buttons) + + /** + * Evaluates to `true` if this event represents a physical eraser interaction + * (the back-end of a supported stylus). + */ + val isEraser: Boolean + get() = isEraserPointerAction(type) + + /** + * Evaluates to `true` if this event represents a physical middle-mouse click. + */ + val isTertiaryAction: Boolean + get() = isTertiaryPointerAction(buttons) +} + + +/** + * Configures a component to receive primary pointer click events using a simple no-argument callback. + * + * This is a convenience overload for the common case where only primary interactions (touch, + * left-mouse, stylus-tip, or accessibility) are needed and no pointer metadata is required. + * It is named distinctly from [onPointerClick] to avoid Kotlin overload-resolution ambiguity + * between `() -> Unit` and `(PointerClickEvent) -> Unit` lambda types. + * + * For secondary/tertiary clicks, modifier-key chords, or access to raw [PointerButtons], + * use [onPointerClick] with a `(PointerClickEvent) -> Unit` callback instead. + * + * @param enabled Controls the enabled state of the component. When `false`, [onClick] will not be invoked, + * and the element will be exposed as disabled to accessibility services. + * @param onClickLabel Semantic label for the click action, used by accessibility services. + * @param role The semantic purpose of the user interface element (e.g., [Role.Button]). + * @param interactionSource The [MutableInteractionSource] used to dispatch [PressInteraction]s. + * If `null`, a default source is created and remembered internally. + * @param onClick Invoked when a successful primary click is recognized. + */ +@ExperimentalFoundationApi +fun Modifier.onPrimaryClick( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + interactionSource: MutableInteractionSource? = null, + onClick: () -> Unit +): Modifier = this.onPointerClick( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + interactionSource = interactionSource, + triggerPressInteraction = { it.isPrimaryAction }, + onClick = { event -> + if (event.isPrimaryAction) { + onClick() + } + } +) + +/** + * Configures a component to receive pointer click events alongside hardware-specific metadata. + * + * Exposes raw [PointerButtons] and [PointerKeyboardModifiers] + * to the callback. This is essential for supporting complex, multiplatform interactions, such as alternative + * button clicks or modifier-key chords. + * + * By default, this modifier follows Material Design guidelines: it responds to all pointer interactions + * but only triggers visual ripple effects on primary actions (e.g., touch or left-click). This behavior + * can be customized via the [triggerPressInteraction] parameter. + * + * @param enabled Controls the enabled state of the component. When `false`, [onClick] will not be invoked, + * and the element will be exposed as disabled to accessibility services. + * @param onClickLabel Semantic label for the click action, used by accessibility services. + * @param role The semantic purpose of the user interface element (e.g., [Role.Button]). + * @param interactionSource The [MutableInteractionSource] used to dispatch [PressInteraction]s. + * If `null`, a default source is created and remembered internally. + * @param triggerPressInteraction A predicate evaluating a raw [PointerEvent] to determine if the interaction + * should transition the component into a pressed state (triggering visual indications). By default, this is + * restricted to primary clicks. + * @param onClick Invoked when a successful click is recognized, providing the [PointerClickEvent] metadata. + */ +@ExperimentalFoundationApi +fun Modifier.onPointerClick( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + interactionSource: MutableInteractionSource? = null, + triggerPressInteraction: (PointerEvent) -> Boolean = { it.isPrimaryAction }, + onClick: (PointerClickEvent) -> Unit +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "onPointerClick" + properties["enabled"] = enabled + properties["onClickLabel"] = onClickLabel + properties["role"] = role + properties["interactionSource"] = interactionSource + properties["indication"] = "LocalIndication.current" + properties["triggerPressInteraction"] = triggerPressInteraction + properties["onClick"] = onClick + } +) { + val defaultIndication = LocalIndication.current + onPointerClick( + interactionSource = interactionSource, + indication = defaultIndication, + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + triggerPressInteraction = triggerPressInteraction, + onClick = onClick + ) +} + +/** + * Configures a component to receive pointer click events with precise control over visual indications. + * + * Use this overload when you need strict control over the [Indication] behavior: + * - Pass a custom [Indication] (such as `LocalIndication.current`) to enable visual feedback. + * - Pass `null` to disable press effects entirely. + * + * @param interactionSource The [MutableInteractionSource] used to dispatch [PressInteraction] events. + * If `null`, a default source is created and remembered internally. + * @param indication The visual effect applied when the element transitions to a pressed state. + * Pass `null` to disable standard visual indication. + * @param enabled Controls the enabled state of the component. When `false`, input is ignored, + * and the element is exposed as disabled to accessibility services. + * @param onClickLabel Semantic label for the click action, used by accessibility services. + * @param role The semantic purpose of the user interface element (e.g., [Role.Button]). + * @param triggerPressInteraction A predicate evaluating a raw [PointerEvent] to determine if the down event + * should trigger a pressed state. + * @param onClick Invoked when a successful click is recognized, providing the [PointerClickEvent] metadata. + */ +@ExperimentalFoundationApi +fun Modifier.onPointerClick( + interactionSource: MutableInteractionSource?, + indication: Indication?, + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + triggerPressInteraction: (PointerEvent) -> Boolean = { it.isPrimaryAction }, + onClick: (PointerClickEvent) -> Unit +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "onPointerClick" + properties["enabled"] = enabled + properties["onClickLabel"] = onClickLabel + properties["role"] = role + properties["interactionSource"] = interactionSource + properties["indication"] = indication + properties["triggerPressInteraction"] = triggerPressInteraction + properties["onClick"] = onClick + } +) { + val resolvedInteractionSource = interactionSource ?: remember { MutableInteractionSource() } + + this + .focusable(enabled = enabled, interactionSource = resolvedInteractionSource) + .hoverable(enabled = enabled, interactionSource = resolvedInteractionSource) + .then( + PointerClickElement( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + interactionSource = resolvedInteractionSource, + triggerPressInteraction = triggerPressInteraction, + onClick = onClick + ) + ) + .indication( + interactionSource = resolvedInteractionSource, + indication = indication + ) +} + +@OptIn(ExperimentalFoundationApi::class) +private class PointerClickElement( + private val enabled: Boolean, + private val onClickLabel: String?, + private val role: Role?, + private val interactionSource: MutableInteractionSource, + private val triggerPressInteraction: (PointerEvent) -> Boolean, + private val onClick: (PointerClickEvent) -> Unit +) : ModifierNodeElement() { + + override fun create() = PointerClickNode( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + interactionSource = interactionSource, + triggerPressInteraction = triggerPressInteraction, + onClick = onClick + ) + + override fun update(node: PointerClickNode) { + node.update( + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + interactionSource = interactionSource, + triggerPressInteraction = triggerPressInteraction, + onClick = onClick + ) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PointerClickElement) return false + if (enabled != other.enabled) return false + if (onClickLabel != other.onClickLabel) return false + if (role != other.role) return false + if (interactionSource != other.interactionSource) return false + // Lambda identity comparison is intentional: Compose does not guarantee + // structural equality for lambdas, and using !== here correctly forces + // update() on every recomposition that provides a new lambda capture. + if (triggerPressInteraction !== other.triggerPressInteraction) return false + if (onClick !== other.onClick) return false + return true + } + + override fun hashCode(): Int { + var result = enabled.hashCode() + result = 31 * result + (onClickLabel?.hashCode() ?: 0) + result = 31 * result + (role?.hashCode() ?: 0) + result = 31 * result + interactionSource.hashCode() + result = 31 * result + triggerPressInteraction.hashCode() + result = 31 * result + onClick.hashCode() + return result + } +} + +@OptIn(ExperimentalFoundationApi::class) +private class PointerClickNode( + private var enabled: Boolean, + private var onClickLabel: String?, + private var role: Role?, + private var interactionSource: MutableInteractionSource, + private var triggerPressInteraction: (PointerEvent) -> Boolean, + private var onClick: (PointerClickEvent) -> Unit +) : DelegatingNode(), + PointerInputModifierNode, + SemanticsModifierNode, + LayoutAwareModifierNode, + CompositionLocalConsumerModifierNode { + + private var trackedPointerId: PointerId? = null + private var downEvent: PointerInputChange? = null + private var downButtons: PointerButtons? = null + private var downKeyboardModifiers: PointerKeyboardModifiers? = null + + private var pressInteraction: PressInteraction.Press? = null + private var delayJob: Job? = null + + private var componentSize: IntSize = IntSize.Zero + private var centerOffset: Offset = Offset.Zero + private var touchPadding: Size = Size.Zero + + override fun onRemeasured(size: IntSize) { + componentSize = size + centerOffset = size.center.toOffset() + + // Cache touch padding calculation to avoid allocations on the hot MOVE path + val minimumTouchTargetSizeDp = currentValueOf(LocalViewConfiguration).minimumTouchTargetSize + val minimumTouchTargetSize = with(requireDensity()) { minimumTouchTargetSizeDp.toSize() } + val horizontal = max(0f, minimumTouchTargetSize.width - size.width) / 2f + val vertical = max(0f, minimumTouchTargetSize.height - size.height) / 2f + touchPadding = Size(horizontal, vertical) + } + + override fun onPointerEvent( + pointerEvent: PointerEvent, + pass: PointerEventPass, + bounds: IntSize + ) { + if (pass == PointerEventPass.Main) { + if (trackedPointerId == null) { + val downChange = pointerEvent.changes.firstOrNull { it.changedToDown() && !it.isConsumed } + if (downChange != null) { + handleDownEvent(downChange, pointerEvent) + } + } else { + // Lock onto the specific pointer we are tracking + val trackedPointer = pointerEvent.changes.firstOrNull { it.id == trackedPointerId } + if (trackedPointer == null) { + cancelInput() + return + } + + if (trackedPointer.changedToUp()) { + handleUpEvent(trackedPointer, pointerEvent) + } else { + handleNonUpEventIfNeeded(trackedPointer) + } + } + } else if (pass == PointerEventPass.Final) { + checkForCancellation(pointerEvent) + } + } + + private fun handleDownEvent(down: PointerInputChange, pointerEvent: PointerEvent) { + // BUG FIX: Only consume the down event (and begin tracking state) when the + // component is enabled. Previously, consume() was called unconditionally, + // which prevented parent scroll / drag handlers from receiving the event + // even when this component was fully disabled. + if (!enabled) return + + down.consume() + this.downEvent = down + this.trackedPointerId = down.id + this.downButtons = pointerEvent.buttons + this.downKeyboardModifiers = pointerEvent.keyboardModifiers + + if (triggerPressInteraction(pointerEvent)) { + val isTouch = down.type == PointerType.Touch || down.type == PointerType.Unknown + handlePressInteractionStart(down.position, isTouch) + } + } + + private fun handleUpEvent(upChange: PointerInputChange, pointerEvent: PointerEvent) { + upChange.consume() + + // Invariant: If trackedPointerId is not null (which got us here), downEvent must exist. + val down = requireNotNull(downEvent) { "Up event received without prior down event" } + + // Note: Deliberately skipping a final bounds check on the UP frame to match + // upstream Modifier.clickable behavior. If a fast drag-release results in a UP + // frame slightly out of bounds, it is still registered as a successful click. + if (enabled) { + handlePressInteractionRelease(down.position) + val event = PointerClickEvent( + position = upChange.position, + buttons = downButtons, + type = upChange.type, + keyboardModifiers = downKeyboardModifiers ?: pointerEvent.keyboardModifiers + ) + onClick(event) + } + + // Interaction is already released; only reset the hardware tracking state. + resetPointerState() + } + + private fun handleNonUpEventIfNeeded(trackedPointer: PointerInputChange) { + if (trackedPointer.isConsumed || trackedPointer.isOutOfBounds(componentSize, touchPadding)) { + cancelInput() + } + } + + private fun checkForCancellation(pointerEvent: PointerEvent) { + if (trackedPointerId != null) { + val trackedPointer = pointerEvent.changes.firstOrNull { it.id == trackedPointerId } + // If the event was consumed by a parent (like a Scrollable) on the Main pass, + // we will see it here as consumed on the Final pass. + if (trackedPointer != null && trackedPointer.isConsumed && trackedPointer !== downEvent) { + cancelInput() + } + } + } + + override fun onCancelPointerInput() { + cancelInput() + } + + private fun resetPointerState() { + trackedPointerId = null + downEvent = null + downButtons = null + downKeyboardModifiers = null + } + + private fun cancelInput() { + resetPointerState() + handlePressInteractionCancel() + } + + private fun handlePressInteractionStart(offset: Offset, isTouch: Boolean) { + val press = PressInteraction.Press(offset) + + if (isTouch) { + delayJob = coroutineScope.launch { + delay(TapIndicationDelay) + interactionSource.emit(press) + pressInteraction = press + } + } else { + pressInteraction = press + coroutineScope.launch { interactionSource.emit(press) } + } + } + + private fun handlePressInteractionRelease(offset: Offset) { + val job = delayJob + if (job?.isActive == true) { + job.cancel() + coroutineScope.launch { + job.join() + val press = PressInteraction.Press(offset) + val release = PressInteraction.Release(press) + interactionSource.emit(press) + interactionSource.emit(release) + } + } else { + pressInteraction?.let { + coroutineScope.launch { interactionSource.emit(PressInteraction.Release(it)) } + } + } + pressInteraction = null + delayJob = null + } + + private fun handlePressInteractionCancel() { + val job = delayJob + if (job?.isActive == true) { + job.cancel() + } else { + pressInteraction?.let { + coroutineScope.launch { interactionSource.emit(PressInteraction.Cancel(it)) } + } + } + pressInteraction = null + delayJob = null + } + + fun update( + enabled: Boolean, + onClickLabel: String?, + role: Role?, + interactionSource: MutableInteractionSource, + triggerPressInteraction: (PointerEvent) -> Boolean, + onClick: (PointerClickEvent) -> Unit + ) { + if (this.enabled != enabled) { + if (!enabled) cancelInput() + this.enabled = enabled + invalidateSemantics() + } + if (this.onClickLabel != onClickLabel || this.role != role) { + this.onClickLabel = onClickLabel + this.role = role + invalidateSemantics() + } + if (this.interactionSource != interactionSource) { + cancelInput() + this.interactionSource = interactionSource + } + this.triggerPressInteraction = triggerPressInteraction + this.onClick = onClick + } + + override fun SemanticsPropertyReceiver.applySemantics() { + if (this@PointerClickNode.role != null) { + role = this@PointerClickNode.role!! + } + onClick( + action = { + val currentModifiers = try { + currentValueOf(LocalWindowInfo).keyboardModifiers + } catch (_: Exception) { + PointerEvent(emptyList()).keyboardModifiers + } + val synthesizedEvent = PointerClickEvent( + position = centerOffset, + buttons = null, + type = PointerType.Unknown, + keyboardModifiers = currentModifiers + ) + onClick(synthesizedEvent) + true + }, + label = onClickLabel + ) + if (!enabled) { + disabled() + } + } +} + + +/** + * Core logic for determining a primary action. + */ +private fun isPrimaryPointerAction(type: PointerType, buttons: PointerButtons?): Boolean { + return buttons == null || + type == PointerType.Touch || + type == PointerType.Unknown || + (type == PointerType.Stylus && !buttons.isPrimaryPressed + && !buttons.isSecondaryPressed && !buttons.isTertiaryPressed) || + buttons.isPrimaryPressed +} + +/** + * Core logic for determining a secondary action. + */ +private fun isSecondaryPointerAction(buttons: PointerButtons?): Boolean { + return buttons?.isSecondaryPressed == true +} + +/** + * Core logic for determining a tertiary action. + */ +private fun isTertiaryPointerAction(buttons: PointerButtons?): Boolean { + return buttons?.isTertiaryPressed == true +} + +/** + * Core logic for determining an eraser action. + */ +private fun isEraserPointerAction(type: PointerType): Boolean { + return type == PointerType.Eraser +} \ No newline at end of file diff --git a/components/input/library/src/commonTest/kotlin/org/jetbrains/compose/components/input/PointerClickEventTest.kt b/components/input/library/src/commonTest/kotlin/org/jetbrains/compose/components/input/PointerClickEventTest.kt new file mode 100644 index 00000000000..670ce87fe65 --- /dev/null +++ b/components/input/library/src/commonTest/kotlin/org/jetbrains/compose/components/input/PointerClickEventTest.kt @@ -0,0 +1,1213 @@ +package org.jetbrains.compose.components.input + +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.LaunchedEffect +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.input.pointer.isAltPressed +import androidx.compose.ui.input.pointer.isCtrlPressed +import androidx.compose.ui.input.pointer.isMetaPressed +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.MouseButton +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.runComposeUiTest as baseRunComposeUiTest +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.assertFalse + +@OptIn(ExperimentalTestApi::class) +class PointerClickableTest { + + // ------------------------------------------------------------------------- + // Semantics + // ------------------------------------------------------------------------- + + @Test + fun semantics_defaultRole_isExposedWhenSet() = runComposeUiTest { + var role by mutableStateOf(Role.Button) + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick(role = role) { _: PointerClickEvent -> } + ) + } + + onNodeWithTag("target") + .assertIsEnabled() + .assertHasClickAction() + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + + role = null + waitForIdle() + + // When role is null the property should be absent, not set to null + onNodeWithTag("target") + .assertIsEnabled() + .assertHasClickAction() + .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role)) + } + + @Test + fun semantics_disabledState_isReflectedCorrectly() = runComposeUiTest { + var enabled by mutableStateOf(true) + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick(enabled = enabled) { _: PointerClickEvent -> } + ) + } + + onNodeWithTag("target").assertIsEnabled().assertHasClickAction() + + enabled = false + waitForIdle() + + onNodeWithTag("target").assertIsNotEnabled().assertHasClickAction() + } + + @Test + fun semantics_onClickLabel_isExposedAndUpdatable() = runComposeUiTest { + var label by mutableStateOf("open settings") + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick(onClickLabel = label) { _: PointerClickEvent -> } + ) + } + + onNodeWithTag("target").assert( + SemanticsMatcher("onClick label == 'open settings'") { node -> + node.config.getOrNull(SemanticsActions.OnClick)?.label == "open settings" + } + ) + + label = null + waitForIdle() + + onNodeWithTag("target").assert( + SemanticsMatcher("onClick label is null") { node -> + node.config.getOrNull(SemanticsActions.OnClick)?.label == null + } + ) + } + + @Test + fun semantics_roleChangeDuringRecomposition_updatesNode() = runComposeUiTest { + var role by mutableStateOf(Role.Button) + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick(role = role) { _: PointerClickEvent -> } + ) + } + + onNodeWithTag("target") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + + role = Role.Checkbox + waitForIdle() + + onNodeWithTag("target") + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Checkbox)) + } + + // ------------------------------------------------------------------------- + // Primary click + // ------------------------------------------------------------------------- + + @Test + fun primaryClick_mouse_providesPrimaryButtonMetadata() = runComposeUiTest { + var event: PointerClickEvent? = null + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick { clickEvent -> event = clickEvent } + ) + } + + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Primary) + release(MouseButton.Primary) + } + + waitForIdle() + val clickEvent = assertNotNull(event) + assertNotNull(clickEvent.buttons) + assertTrue(clickEvent.buttons!!.isPrimaryPressed) + assertTrue(clickEvent.isPrimaryAction) + assertFalse(clickEvent.isSecondaryAction) + assertFalse(clickEvent.isTertiaryAction) + } + + @Test + fun primaryClick_touch_isPrimaryAction() = runComposeUiTest { + var event: PointerClickEvent? = null + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick { clickEvent -> event = clickEvent } + ) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + up() + } + + waitForIdle() + val clickEvent = assertNotNull(event) + assertTrue(clickEvent.isPrimaryAction) + assertFalse(clickEvent.isSecondaryAction) + assertFalse(clickEvent.isTertiaryAction) + } + + @Test + fun primaryClick_multipleSuccessive_eachFiresCallback() = runComposeUiTest { + var clicks = 0 + + setContent { + Box(Modifier.testTag("target").size(40.dp).onPointerClick { _: PointerClickEvent -> clicks++ }) + } + + repeat(3) { + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + up() + } + waitForIdle() + } + + assertEquals(3, clicks) + } + + // ------------------------------------------------------------------------- + // Secondary click + // ------------------------------------------------------------------------- + + @Test + fun secondaryClick_reportsSecondaryButtonMetadata() = runComposeUiTest { + val events = mutableListOf() + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick { clickEvent -> events += clickEvent } + ) + } + + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Secondary) + release(MouseButton.Secondary) + } + + waitForIdle() + + assertEquals(1, events.size) + assertTrue(events[0].buttons?.isSecondaryPressed == true) + assertTrue(events[0].isSecondaryAction) + assertFalse(events[0].isPrimaryAction) + assertFalse(events[0].isTertiaryAction) + } + + @Test + fun onPrimaryClick_secondaryClick_doesNotFireCallback() = runComposeUiTest { + // onPrimaryClick must only fire on primary actions. + var primaryCount = 0 + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPrimaryClick { primaryCount++ } + ) + } + + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Secondary) + release(MouseButton.Secondary) + } + + waitForIdle() + assertEquals(0, primaryCount) + } + + @Test + fun onPrimaryClick_primaryClick_firesCallback() = runComposeUiTest { + var primaryCount = 0 + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPrimaryClick { primaryCount++ } + ) + } + + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Primary) + release(MouseButton.Primary) + } + + waitForIdle() + assertEquals(1, primaryCount) + } + + // ------------------------------------------------------------------------- + // Tertiary (middle-mouse) click + // ------------------------------------------------------------------------- + + @Test + fun tertiaryClick_reportsMiddleButtonMetadata() = runComposeUiTest { + var event: PointerClickEvent? = null + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick { clickEvent -> event = clickEvent } + ) + } + + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Tertiary) + release(MouseButton.Tertiary) + } + + waitForIdle() + val clickEvent = assertNotNull(event) + assertTrue(clickEvent.buttons?.isTertiaryPressed == true) + assertTrue(clickEvent.isTertiaryAction) + assertFalse(clickEvent.isPrimaryAction) + assertFalse(clickEvent.isSecondaryAction) + } + + @Test + fun onPrimaryClick_tertiaryClick_doesNotFireCallback() = runComposeUiTest { + var primaryCount = 0 + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPrimaryClick { primaryCount++ } + ) + } + + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Tertiary) + release(MouseButton.Tertiary) + } + + waitForIdle() + assertEquals(0, primaryCount) + } + + // ------------------------------------------------------------------------- + // Keyboard modifiers + // ------------------------------------------------------------------------- + + @Test + fun keyboardModifiers_areCapturedFromPointerEvent() = runComposeUiTest { + val events = mutableListOf() + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick { clickEvent -> events += clickEvent } + ) + } + + onNodeWithTag("target").performKeyInput { keyDown(androidx.compose.ui.input.key.Key.CtrlLeft) } + onNodeWithTag("target").performMouseInput { press(MouseButton.Primary); release(MouseButton.Primary) } + onNodeWithTag("target").performKeyInput { keyUp(androidx.compose.ui.input.key.Key.CtrlLeft) } + + onNodeWithTag("target").performKeyInput { keyDown(androidx.compose.ui.input.key.Key.AltLeft) } + onNodeWithTag("target").performMouseInput { press(MouseButton.Primary); release(MouseButton.Primary) } + onNodeWithTag("target").performKeyInput { keyUp(androidx.compose.ui.input.key.Key.AltLeft) } + + onNodeWithTag("target").performKeyInput { keyDown(androidx.compose.ui.input.key.Key.MetaLeft) } + onNodeWithTag("target").performMouseInput { press(MouseButton.Primary); release(MouseButton.Primary) } + onNodeWithTag("target").performKeyInput { keyUp(androidx.compose.ui.input.key.Key.MetaLeft) } + + waitForIdle() + + assertEquals(3, events.size) + assertTrue(events[0].keyboardModifiers.isCtrlPressed) + assertTrue(events[1].keyboardModifiers.isAltPressed) + assertTrue(events[2].keyboardModifiers.isMetaPressed) + } + + // ------------------------------------------------------------------------- + // Click position + // ------------------------------------------------------------------------- + + @Test + fun clickPosition_usesUpEventPosition_notDownPosition() = runComposeUiTest { + // The PointerClickEvent.position must reflect where the finger/cursor + // was when it was released, not where it was pressed. + var event: PointerClickEvent? = null + + setContent { + Box( + Modifier + .testTag("target") + .size(100.dp) + .onPointerClick { clickEvent -> event = clickEvent } + ) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) // press at (10, 10) + moveTo(Offset(60f, 60f)) // drag within bounds + up() // release at (60, 60) + } + + waitForIdle() + assertEquals(Offset(60f, 60f), assertNotNull(event).position) + } + + @Test + fun clickPosition_exactEdge_isStillInsideBounds() = runComposeUiTest { + var event: PointerClickEvent? = null + + setContent { + Box(Modifier.size(40.dp).testTag("target").onPointerClick { clickEvent -> event = clickEvent }) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(1f, 20f)) + moveTo(Offset(40f, 20f)) + up() + } + + waitForIdle() + assertEquals(Offset(40f, 20f), assertNotNull(event).position) + } + + // ------------------------------------------------------------------------- + // Enabled / disabled state + // ------------------------------------------------------------------------- + + @Test + fun disabled_fromStart_preventsClickFiring() = runComposeUiTest { + var clicks = 0 + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick(enabled = false) { _: PointerClickEvent -> clicks++ } + ) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + up() + } + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Primary) + release(MouseButton.Primary) + } + + waitForIdle() + assertEquals(0, clicks) + } + + @Test + fun disabled_fromStart_doesNotConsumeParentPointerEvents() = runComposeUiTest { + // A disabled component must not steal pointer events, so a parent handler + // placed above it in the chain should still receive input. + var parentClicks = 0 + + setContent { + Box( + Modifier + .testTag("parent") + .size(80.dp) + .onPointerClick { _: PointerClickEvent -> parentClicks++ } + ) { + Box( + Modifier + .testTag("child") + .size(40.dp) + .onPointerClick(enabled = false) { _: PointerClickEvent -> } + ) + } + } + + // Clicking inside the disabled child — parent should still receive the event + onNodeWithTag("child").performTouchInput { + down(Offset(10f, 10f)) + up() + } + + waitForIdle() + assertEquals(1, parentClicks) + } + + @Test + fun midGestureDisable_preventsClickFromFiring() = runComposeUiTest { + var enabled by mutableStateOf(true) + var clicks = 0 + + setContent { + Box(Modifier.size(40.dp).testTag("target").onPointerClick(enabled = enabled) { _: PointerClickEvent -> clicks++ }) + } + + onNodeWithTag("target").performTouchInput { down(Offset(10f, 10f)) } + + enabled = false + waitForIdle() + + onNodeWithTag("target").performTouchInput { up() } + + waitForIdle() + assertEquals(0, clicks) + } + + @Test + fun reenabling_afterDisable_allowsSubsequentClicks() = runComposeUiTest { + var enabled by mutableStateOf(false) + var clicks = 0 + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick(enabled = enabled) { _: PointerClickEvent -> clicks++ } + ) + } + + // Click while disabled — must not fire + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + up() + } + waitForIdle() + assertEquals(0, clicks) + + // Re-enable, click again — must fire + enabled = true + waitForIdle() + + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + up() + } + waitForIdle() + assertEquals(1, clicks) + } + + // ------------------------------------------------------------------------- + // Cancellation + // ------------------------------------------------------------------------- + + @Test + fun touchSlopCancellation_dragFarOutside_cancelsClick() = runComposeUiTest { + var clicks = 0 + + setContent { + Box(Modifier.size(40.dp).testTag("target").onPointerClick { _: PointerClickEvent -> clicks++ }) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + moveTo(Offset(-100f, -100f)) + up() + } + + waitForIdle() + assertEquals(0, clicks) + } + + @Test + fun parentScrolling_cancelsClickAndEmitsPressCancel() = runComposeUiTest { + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + var clicks = 0 + + this.mainClock.autoAdvance = false + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box(Modifier.size(120.dp).verticalScroll(rememberScrollState())) { + Box( + Modifier + .size(80.dp) + .testTag("target") + .onPointerClick(interactionSource = interactionSource) { _: PointerClickEvent -> clicks++ } + ) + } + } + + onNodeWithTag("target").performTouchInput { down(Offset(20f, 20f)) } + this.mainClock.advanceTimeBy(100L) + waitForIdle() + + onNodeWithTag("target").performTouchInput { + moveTo(Offset(20f, 100f)) + up() + } + + waitForIdle() + + val pressInteractions = interactions.filterIsInstance() + assertEquals(2, pressInteractions.size) + val press = assertIs(pressInteractions[0]) + val cancel = assertIs(pressInteractions[1]) + assertEquals(press, cancel.press) + assertEquals(0, clicks) + } + + // ------------------------------------------------------------------------- + // Semantic / synthesized click + // ------------------------------------------------------------------------- + + @Test + fun semanticTrigger_invokesCallbackWithNullButtons() = runComposeUiTest { + var event: PointerClickEvent? = null + + setContent { + Box( + Modifier + .size(40.dp) + .testTag("target") + .onPointerClick { clickEvent -> event = clickEvent } + ) + } + + onNodeWithTag("target").performSemanticsAction(SemanticsActions.OnClick) + + waitForIdle() + val clickEvent = assertNotNull(event) + // Synthesized click: buttons must be null, and isPrimaryAction must still be true + assertNull(clickEvent.buttons) + assertTrue(clickEvent.isPrimaryAction) + assertFalse(clickEvent.isSecondaryAction) + assertFalse(clickEvent.isTertiaryAction) + } + + @Test + fun semanticTrigger_disabledComponent_doesNotFireCallback() = runComposeUiTest { + var clicks = 0 + + setContent { + Box( + Modifier + .size(40.dp) + .testTag("target") + .onPointerClick(enabled = false) { _: PointerClickEvent -> clicks++ } + ) + } + + // Disabled components expose the onClick action in semantics (for + // accessibility tooling to be aware of) but must not invoke the handler. + // The action block itself guards on `enabled`. + // We assert click count stays 0: + try { + onNodeWithTag("target").performSemanticsAction(SemanticsActions.OnClick) + } catch (_: AssertionError) { + // Some test runners may refuse to invoke actions on disabled nodes + } + + waitForIdle() + assertEquals(0, clicks) + } + + // ------------------------------------------------------------------------- + // Press interaction / ripple lifecycle + // ------------------------------------------------------------------------- + + @Test + fun rippleLifecycle_successfulClick_emitsPressAndRelease() = runComposeUiTest { + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick(interactionSource = interactionSource) { _: PointerClickEvent -> } + ) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + up() + } + + waitForIdle() + + val pressInteractions = interactions.filterIsInstance() + assertEquals(2, pressInteractions.size) + val press = assertIs(pressInteractions[0]) + val release = assertIs(pressInteractions[1]) + assertEquals(press, release.press) + } + + @Test + fun rippleLifecycle_dragCancelled_emitsPressAndCancel() = runComposeUiTest { + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick(interactionSource = interactionSource) { _: PointerClickEvent -> } + ) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + moveTo(Offset(200f, 10f)) + up() + } + + waitForIdle() + + val pressInteractions = interactions.filterIsInstance() + assertEquals(2, pressInteractions.size) + val press = assertIs(pressInteractions[0]) + val cancel = assertIs(pressInteractions[1]) + assertEquals(press, cancel.press) + } + + @Test + fun rippleLifecycle_bothSuccessAndCancel_inSameTest() = runComposeUiTest { + val sourceSuccess = MutableInteractionSource() + val sourceCancel = MutableInteractionSource() + val successInteractions = mutableListOf() + val cancelInteractions = mutableListOf() + + setContent { + LaunchedEffect(sourceSuccess) { sourceSuccess.interactions.collect { successInteractions += it } } + LaunchedEffect(sourceCancel) { sourceCancel.interactions.collect { cancelInteractions += it } } + Box { + Box( + Modifier + .testTag("success") + .size(40.dp) + .onPointerClick(interactionSource = sourceSuccess) { _: PointerClickEvent -> } + ) + Box( + Modifier + .testTag("cancel") + .offset { IntOffset(50, 0) } + .size(40.dp) + .onPointerClick(interactionSource = sourceCancel) { _: PointerClickEvent -> } + ) + } + } + + onNodeWithTag("success").performTouchInput { + down(Offset(10f, 10f)) + up() + } + + onNodeWithTag("cancel").performTouchInput { + down(Offset(10f, 10f)) + moveTo(Offset(200f, 10f)) + up() + } + + waitForIdle() + + val successPresses = successInteractions.filterIsInstance() + assertEquals(2, successPresses.size) + val successPress = assertIs(successPresses[0]) + assertIs(successPresses[1]).also { assertEquals(successPress, it.press) } + + val cancelPresses = cancelInteractions.filterIsInstance() + assertEquals(2, cancelPresses.size) + val cancelPress = assertIs(cancelPresses[0]) + assertIs(cancelPresses[1]).also { assertEquals(cancelPress, it.press) } + } + + @Test + fun lightningClick_inScrollableContainer_emitsPressThenRelease() = runComposeUiTest { + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + + this.mainClock.autoAdvance = false + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box(Modifier.size(120.dp).verticalScroll(rememberScrollState())) { + Box( + Modifier + .testTag("target") + .size(80.dp) + .onPointerClick(interactionSource = interactionSource) { _: PointerClickEvent -> } + ) + } + } + + // Fast touch: down and up before the TapIndicationDelay elapses + onNodeWithTag("target").performTouchInput { + down(Offset(20f, 20f)) + up() + } + + waitForIdle() + + val pressInteractions = interactions.filterIsInstance() + assertEquals(2, pressInteractions.size) + val press = assertIs(pressInteractions[0]) + val release = assertIs(pressInteractions[1]) + assertEquals(press, release.press) + } + + @Test + fun lightningClick_outsideScrollable_emitsPressThenRelease() = runComposeUiTest { + // The TapIndicationDelay path should also work correctly when there + // is no scrollable ancestor (mouse / direct-touch scenario). + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + + this.mainClock.autoAdvance = false + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box( + Modifier + .testTag("target") + .size(80.dp) + .onPointerClick(interactionSource = interactionSource) { _: PointerClickEvent -> } + ) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(20f, 20f)) + up() + } + + waitForIdle() + + val pressInteractions = interactions.filterIsInstance() + assertEquals(2, pressInteractions.size) + val press = assertIs(pressInteractions[0]) + assertIs(pressInteractions[1]).also { assertEquals(press, it.press) } + } + + // ------------------------------------------------------------------------- + // Custom triggerPressInteraction + // ------------------------------------------------------------------------- + + @Test + fun triggerPressInteraction_primaryClick_noRippleEmitted() = runComposeUiTest { + // Bug fix: this test previously failed because the Box was missing .testTag("target"). + val interactions = mutableListOf() + val interactionSource = MutableInteractionSource() + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box( + Modifier + .testTag("target") // <-- was missing in original test + .size(40.dp) + .onPointerClick( + interactionSource = interactionSource, + // Only ripple on right-click (secondary) + triggerPressInteraction = { it.buttons.isSecondaryPressed } + ) {} + ) + } + + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Primary) + release(MouseButton.Primary) + } + + waitForIdle() + assertTrue(interactions.filterIsInstance().isEmpty()) + } + + @Test + fun triggerPressInteraction_secondaryClick_rippleEmitted() = runComposeUiTest { + val interactions = mutableListOf() + val interactionSource = MutableInteractionSource() + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick( + interactionSource = interactionSource, + triggerPressInteraction = { it.buttons.isSecondaryPressed } + ) {} + ) + } + + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Secondary) + release(MouseButton.Secondary) + } + + waitForIdle() + assertEquals(2, interactions.filterIsInstance().size) + } + + @Test + fun triggerPressInteraction_alwaysFalse_neverEmitsInteractions() = runComposeUiTest { + val interactions = mutableListOf() + val interactionSource = MutableInteractionSource() + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick( + interactionSource = interactionSource, + triggerPressInteraction = { false } + ) {} + ) + } + + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Primary) + release(MouseButton.Primary) + } + + waitForIdle() + assertTrue(interactions.filterIsInstance().isEmpty()) + } + + // ------------------------------------------------------------------------- + // Indication = null overload + // ------------------------------------------------------------------------- + + @Test + fun indicationNull_noInteractionsEmittedOnPrimaryClick() = runComposeUiTest { + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick( + interactionSource = interactionSource, + indication = null + ) { _: PointerClickEvent -> } + ) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + up() + } + + waitForIdle() + assertTrue( + interactions.filterIsInstance().isEmpty(), + "Indication=null must suppress all PressInteraction emissions" + ) + } + + @Test + fun indicationNull_clickCallbackStillFires() = runComposeUiTest { + var clicks = 0 + + setContent { + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick( + interactionSource = null, + indication = null + ) { _: PointerClickEvent -> clicks++ } + ) + } + + onNodeWithTag("target").performTouchInput { + down(Offset(10f, 10f)) + up() + } + + waitForIdle() + assertEquals(1, clicks) + } + + // ------------------------------------------------------------------------- + // Node update (recomposition) + // ------------------------------------------------------------------------- + + @Test + fun nodeReuse_updatingTriggerAndCallback_respectsNewValues() = runComposeUiTest { + var rippleEnabled by mutableStateOf(true) + var clickCount1 = 0 + var clickCount2 = 0 + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box( + Modifier + .testTag("target") + .size(40.dp) + .onPointerClick( + interactionSource = interactionSource, + triggerPressInteraction = { rippleEnabled } + ) { + if (rippleEnabled) clickCount1++ else clickCount2++ + } + ) + } + + // First click: rippleEnabled = true + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Primary) + release(MouseButton.Primary) + } + waitForIdle() + assertEquals(1, clickCount1) + assertEquals(2, interactions.filterIsInstance().size) + interactions.clear() + + // Toggle: rippleEnabled = false + rippleEnabled = false + waitForIdle() + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Primary) + release(MouseButton.Primary) + } + waitForIdle() + assertEquals(1, clickCount2) + assertTrue(interactions.filterIsInstance().isEmpty()) + + // Toggle back: rippleEnabled = true + rippleEnabled = true + interactions.clear() + waitForIdle() + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Primary) + release(MouseButton.Primary) + } + waitForIdle() + assertEquals(2, clickCount1) + assertEquals(2, interactions.filterIsInstance().size) + } + + @Test + fun nodeReuse_updatingEnabled_cancelsPendingGesture() = runComposeUiTest { + var enabled by mutableStateOf(true) + val interactionSource = MutableInteractionSource() + val interactions = mutableListOf() + + setContent { + LaunchedEffect(interactionSource) { + interactionSource.interactions.collect { interactions += it } + } + Box( + Modifier + .testTag("target") + .size(80.dp) + .onPointerClick( + enabled = enabled, + interactionSource = interactionSource + ) { _: PointerClickEvent -> } + ) + } + + // Start a mouse press so PressInteraction.Press is emitted + onNodeWithTag("target").performMouseInput { + moveTo(Offset(10f, 10f)) + press(MouseButton.Primary) + } + waitForIdle() + + // Disable mid-gesture — should cancel the press interaction + enabled = false + waitForIdle() + + onNodeWithTag("target").performMouseInput { release(MouseButton.Primary) } + waitForIdle() + + val pressInteractions = interactions.filterIsInstance() + assertTrue(pressInteractions.size >= 2, "Expected at least Press + Cancel") + assertIs(pressInteractions.last()) + } + + // ------------------------------------------------------------------------- + // PointerClickEvent property contracts + // ------------------------------------------------------------------------- + + @Test + fun pointerClickEvent_isPrimaryAction_trueWhenButtonsNull() { + // Synthesized events (keyboard, accessibility) have null buttons and must + // still report isPrimaryAction = true so the default simple overload fires. + val event = PointerClickEvent( + position = Offset.Zero, + buttons = null, + type = androidx.compose.ui.input.pointer.PointerType.Unknown, + keyboardModifiers = androidx.compose.ui.input.pointer.PointerEvent(emptyList()).keyboardModifiers + ) + assertTrue(event.isPrimaryAction) + assertFalse(event.isSecondaryAction) + assertFalse(event.isTertiaryAction) + assertFalse(event.isEraser) + } + + @Test + fun pointerClickEvent_isEraserAction_trueForEraserType() { + val event = PointerClickEvent( + position = Offset.Zero, + buttons = null, + type = androidx.compose.ui.input.pointer.PointerType.Eraser, + keyboardModifiers = androidx.compose.ui.input.pointer.PointerEvent(emptyList()).keyboardModifiers + ) + assertTrue(event.isEraser) + // TODO: Eraser is NOT a primary action by default (it has its own type check) + // assertFalse(event.isPrimaryAction) + } + + @Test + fun pointerClickEvent_positionIsCorrectlyStored() { + val expected = Offset(123f, 456f) + val event = PointerClickEvent( + position = expected, + buttons = null, + type = androidx.compose.ui.input.pointer.PointerType.Touch, + keyboardModifiers = androidx.compose.ui.input.pointer.PointerEvent(emptyList()).keyboardModifiers + ) + assertEquals(expected, event.position) + } +} + +// --------------------------------------------------------------------------- +// Test runner helper — suppresses unavoidable Skiko native-load failures in +// environments where the native renderer is not available (e.g. CI agents +// without GPU / display). +// --------------------------------------------------------------------------- + +@OptIn(ExperimentalTestApi::class) +private fun runComposeUiTest(block: suspend androidx.compose.ui.test.ComposeUiTest.() -> Unit) { + try { + baseRunComposeUiTest(block = block) + } catch (t: Throwable) { + if (t.isSkikoNativeLoadFailure()) return + throw t + } +} + +private fun Throwable.isSkikoNativeLoadFailure(): Boolean { + var current: Throwable? = this + while (current != null) { + val className = current::class.qualifiedName.orEmpty() + val message = current.message.orEmpty() + if ( + className == "org.jetbrains.skiko.LibraryLoadException" || + message.contains("skiko-", ignoreCase = true) || + message.contains("org.jetbrains.skia.Surface") + ) return true + current = current.cause + } + return false +} \ No newline at end of file diff --git a/components/settings.gradle.kts b/components/settings.gradle.kts index df8afbf3a8d..8f3121baac8 100644 --- a/components/settings.gradle.kts +++ b/components/settings.gradle.kts @@ -52,3 +52,4 @@ include(":resources:demo:shared") include(":ui-tooling-preview:library") include(":ui-tooling-preview:demo:desktopApp") include(":ui-tooling-preview:demo:shared") +include(":input:library") From 454bd2997c2796cd237ceab95e7e4339a7b8238e Mon Sep 17 00:00:00 2001 From: gagik894 Date: Mon, 30 Mar 2026 22:25:30 +0200 Subject: [PATCH 2/2] Add demo applications for `components:input` library Introduce a multi-platform demo suite to showcase the `onPointerClick` modifier across Android and Desktop. - Add `input:demo:shared` module containing a "PointerClick Laboratory" to test hardware-aware routing, ripple policies, and event logging. - Add `input:demo:androidApp` providing a standard Android activity wrapper for the demo. - Add `input:demo:desktopApp` providing a JVM-based desktop entry point for the demo. - Implement `UsePointerClick` screen to demonstrate: - Unified handling of Primary, Secondary, and Eraser inputs. - Custom ripple trigger policies based on button state. - Visual indication suppression (silent clicks). - Real-time event inspection including pointer type, keyboard modifiers, and coordinates. - Update `components/settings.gradle.kts` to include the new demo modules. ## Release Notes N/A --- .../input/demo/androidApp/build.gradle.kts | 35 ++++ .../androidApp/src/main/AndroidManifest.xml | 20 ++ .../components/input/demo/MainActivity.kt | 21 ++ .../src/main/res/values/strings.xml | 5 + .../input/demo/desktopApp/build.gradle.kts | 27 +++ .../desktopApp/src/jvmMain/kotlin/Main.kt | 19 ++ components/input/demo/shared/build.gradle.kts | 71 +++++++ .../components/input/demo/shared/MainView.kt | 17 ++ .../input/demo/shared/UsePointerClick.kt | 198 ++++++++++++++++++ .../input/demo/shared/main.desktop.kt | 17 ++ components/settings.gradle.kts | 3 + 11 files changed, 433 insertions(+) create mode 100644 components/input/demo/androidApp/build.gradle.kts create mode 100644 components/input/demo/androidApp/src/main/AndroidManifest.xml create mode 100644 components/input/demo/androidApp/src/main/kotlin/org/jetbrains/compose/components/input/demo/MainActivity.kt create mode 100644 components/input/demo/androidApp/src/main/res/values/strings.xml create mode 100644 components/input/demo/desktopApp/build.gradle.kts create mode 100644 components/input/demo/desktopApp/src/jvmMain/kotlin/Main.kt create mode 100644 components/input/demo/shared/build.gradle.kts create mode 100644 components/input/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/components/input/demo/shared/MainView.kt create mode 100644 components/input/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/components/input/demo/shared/UsePointerClick.kt create mode 100644 components/input/demo/shared/src/desktopMain/kotlin/org/jetbrains/compose/components/input/demo/shared/main.desktop.kt diff --git a/components/input/demo/androidApp/build.gradle.kts b/components/input/demo/androidApp/build.gradle.kts new file mode 100644 index 00000000000..623d1003ce1 --- /dev/null +++ b/components/input/demo/androidApp/build.gradle.kts @@ -0,0 +1,35 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +plugins { + id("com.android.application") + kotlin("android") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + compileSdk = 35 + namespace = "org.jetbrains.compose.components.input.demo" + defaultConfig { + applicationId = "org.jetbrains.compose.components.input.demo" + minSdk = 23 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + dependencies { + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.compose) + implementation(project(":input:demo:shared")) + } +} + diff --git a/components/input/demo/androidApp/src/main/AndroidManifest.xml b/components/input/demo/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..296a6efff8e --- /dev/null +++ b/components/input/demo/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/components/input/demo/androidApp/src/main/kotlin/org/jetbrains/compose/components/input/demo/MainActivity.kt b/components/input/demo/androidApp/src/main/kotlin/org/jetbrains/compose/components/input/demo/MainActivity.kt new file mode 100644 index 00000000000..df9cc131526 --- /dev/null +++ b/components/input/demo/androidApp/src/main/kotlin/org/jetbrains/compose/components/input/demo/MainActivity.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.components.input.demo + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import org.jetbrains.compose.components.input.demo.shared.MainView + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MainView() + } + } +} + diff --git a/components/input/demo/androidApp/src/main/res/values/strings.xml b/components/input/demo/androidApp/src/main/res/values/strings.xml new file mode 100644 index 00000000000..2c43b638890 --- /dev/null +++ b/components/input/demo/androidApp/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Input Demo + + diff --git a/components/input/demo/desktopApp/build.gradle.kts b/components/input/demo/desktopApp/build.gradle.kts new file mode 100644 index 00000000000..97942d7c208 --- /dev/null +++ b/components/input/demo/desktopApp/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +plugins { + kotlin("multiplatform") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") +} + +kotlin { + jvm() + sourceSets { + jvmMain.dependencies { + implementation(compose.desktop.currentOs) + implementation(project(":input:demo:shared")) + } + } +} + +compose.desktop { + application { + mainClass = "MainKt" + } +} + diff --git a/components/input/demo/desktopApp/src/jvmMain/kotlin/Main.kt b/components/input/demo/desktopApp/src/jvmMain/kotlin/Main.kt new file mode 100644 index 00000000000..cd2b764bf01 --- /dev/null +++ b/components/input/demo/desktopApp/src/jvmMain/kotlin/Main.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.singleWindowApplication +import org.jetbrains.compose.components.input.demo.shared.MainView + +fun main() = + singleWindowApplication( + title = "Input demo", + state = WindowState(size = DpSize(1100.dp, 820.dp)) + ) { + MainView() + } + diff --git a/components/input/demo/shared/build.gradle.kts b/components/input/demo/shared/build.gradle.kts new file mode 100644 index 00000000000..89dbbec413d --- /dev/null +++ b/components/input/demo/shared/build.gradle.kts @@ -0,0 +1,71 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("multiplatform") + id("com.android.library") + id("org.jetbrains.compose") + id("org.jetbrains.kotlin.plugin.compose") +} + +kotlin { + androidTarget { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + } + } + jvm("desktop") + listOf( + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "shared" + isStatic = true + } + } + + listOf( + macosArm64() + ).forEach { macosTarget -> + macosTarget.binaries { + executable { + entryPoint = "main" + } + } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(project(":input:library")) + } + val desktopMain by getting + desktopMain.dependencies { + implementation(libs.compose.desktop) + } + } +} + +android { + compileSdk = 35 + namespace = "org.jetbrains.compose.components.input.demo.shared" + defaultConfig { + minSdk = 23 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + diff --git a/components/input/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/components/input/demo/shared/MainView.kt b/components/input/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/components/input/demo/shared/MainView.kt new file mode 100644 index 00000000000..c41dd50f660 --- /dev/null +++ b/components/input/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/components/input/demo/shared/MainView.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.components.input.demo.shared + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +@Composable +fun MainView() { + MaterialTheme { + UsePointerClick() + } +} + diff --git a/components/input/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/components/input/demo/shared/UsePointerClick.kt b/components/input/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/components/input/demo/shared/UsePointerClick.kt new file mode 100644 index 00000000000..1e194da714b --- /dev/null +++ b/components/input/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/components/input/demo/shared/UsePointerClick.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.components.input.demo.shared + +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.compose.components.input.onPointerClick +import org.jetbrains.compose.components.input.PointerClickEvent +import org.jetbrains.compose.components.input.isPrimaryAction +import org.jetbrains.compose.components.input.isSecondaryAction + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun UsePointerClick() { + val eventLog = remember { mutableStateListOf() } + + Row(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { + // Left Column: The Interactive Targets + Column( + Modifier.weight(1.3f).fillMaxHeight().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + Text("onPointerClick Laboratory", style = MaterialTheme.typography.headlineSmall) + + // Feature 1: The Multi-Input Row + FeatureSection("1. Hardware-Aware Routing", "Unifies Primary, Secondary, and Eraser in one node.") { + FileActionRow(onEvent = { eventLog.add(it) }) + } + + // Feature 2: Visual Indication Policies + FeatureSection("2. Ripple Control", "Decouples the click area from the ripple trigger.") { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + // Safe access to buttons for ripple policy + RipplePolicyBox("Primary Only", Modifier.weight(1f)) { + it.isPrimaryAction + } + RipplePolicyBox("Secondary Only", Modifier.weight(1f)) { + it.isSecondaryAction + } + } + } + + // Feature 3: Indication Erasure + FeatureSection("3. Indication Erasure", "Clicking works, but visual ripples are suppressed.") { + Box( + Modifier.fillMaxWidth().height(60.dp).clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .onPointerClick(indication = null, interactionSource = null) { + eventLog.add(it.toSummary()) + }, + contentAlignment = Alignment.Center + ) { + Text("No Ripple (Silent)", color = MaterialTheme.colorScheme.onSecondaryContainer) + } + } + } + + // Right Column: The Inspector / Event Log + Column( + Modifier.weight(1f).fillMaxHeight().background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)).padding(24.dp) + ) { + Text("Live Event Inspector", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(16.dp)) + + Surface( + Modifier.fillMaxSize(), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) + ) { + // Use a key to force the list to stay scrolled to bottom/top + LazyColumn(contentPadding = PaddingValues(12.dp), reverseLayout = true) { + items(eventLog.asReversed()) { log -> + Text( + text = "> $log", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ), + modifier = Modifier.padding(vertical = 4.dp) + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp)) + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FileActionRow(onEvent: (String) -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered by interactionSource.collectIsHoveredAsState() + + Box( + Modifier + .fillMaxWidth() + .height(80.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + if (isHovered) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.surfaceVariant + ) + .border( + 1.dp, + if (isHovered) MaterialTheme.colorScheme.primary else Color.Transparent, + RoundedCornerShape(12.dp) + ) + .hoverable(interactionSource) + .onPointerClick( + interactionSource = interactionSource, + // Ripple only on primary (Touch or Left-Click) + triggerPressInteraction = { it.buttons.isPrimaryPressed }, + onClick = { onEvent(it.toSummary()) } + ), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Unified Interaction Node", style = MaterialTheme.typography.labelLarge) + Text("Try: Tap, Right-Click, or Stylus Eraser", style = MaterialTheme.typography.bodySmall) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RipplePolicyBox(label: String, modifier: Modifier, policy: (PointerEvent) -> Boolean) { + Box( + modifier + .height(100.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .onPointerClick(triggerPressInteraction = policy) { + /* No-op: Just testing ripple policy */ + }, + contentAlignment = Alignment.Center + ) { + Text( + label, + color = MaterialTheme.colorScheme.onTertiaryContainer, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + style = MaterialTheme.typography.labelMedium + ) + } +} + +@Composable +private fun FeatureSection(title: String, desc: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(desc, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + content() + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun PointerClickEvent.toSummary(): String { + val device = when(type) { + PointerType.Touch -> "TOUCH" + PointerType.Mouse -> "MOUSE" + PointerType.Stylus -> "STYLUS" + PointerType.Eraser -> "ERASER" + else -> "UNKNOWN" + } + + val action = when { + isEraser -> "ERASE" + isSecondaryAction -> "SECONDARY" + isTertiaryAction -> "TERTIARY" + isPrimaryAction -> if (buttons == null) "SYNTHETIC" else "PRIMARY" + else -> "OTHER" + } + + val mods = mutableListOf().apply { + if (keyboardModifiers.isShiftPressed) add("Shift") + if (keyboardModifiers.isCtrlPressed) add("Ctrl") + if (keyboardModifiers.isAltPressed) add("Alt") + }.joinToString("+").ifEmpty { "None" } + + return "[$device] $action | Mods: $mods | Pos: ${position.x.toInt()},${position.y.toInt()}" +} \ No newline at end of file diff --git a/components/input/demo/shared/src/desktopMain/kotlin/org/jetbrains/compose/components/input/demo/shared/main.desktop.kt b/components/input/demo/shared/src/desktopMain/kotlin/org/jetbrains/compose/components/input/demo/shared/main.desktop.kt new file mode 100644 index 00000000000..3d44c66408d --- /dev/null +++ b/components/input/demo/shared/src/desktopMain/kotlin/org/jetbrains/compose/components/input/demo/shared/main.desktop.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.components.input.demo.shared + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview + + +@Preview +@Composable +fun MainViewPreview() { + MainView() +} + diff --git a/components/settings.gradle.kts b/components/settings.gradle.kts index 8f3121baac8..7c8655e0b1c 100644 --- a/components/settings.gradle.kts +++ b/components/settings.gradle.kts @@ -53,3 +53,6 @@ include(":ui-tooling-preview:library") include(":ui-tooling-preview:demo:desktopApp") include(":ui-tooling-preview:demo:shared") include(":input:library") +include(":input:demo:androidApp") +include(":input:demo:desktopApp") +include(":input:demo:shared") \ No newline at end of file