From 309731b4229aea931ca954eb5d8a5e7acf45494f Mon Sep 17 00:00:00 2001 From: Kirill Grouchnikov Date: Sun, 29 Jan 2023 14:56:40 -0500 Subject: [PATCH] Improving usability of command popup menus For a command button in a menu, its secondary (cascading) popup should be shown on mouse rollover and not on press, For such a cascading popup menu, the relevant parts of the cascade should be hidden when the user moves the mouse anywhere outside the cascade path bounds Increase initial delay to show rich tooltip to 750ms For #65 --- .../aurora/common/AuroraPopupManager.kt | 9 +- .../aurora/component/AuroraCommandButton.kt | 334 ++++++++++++++++-- .../aurora/component/AuroraRichTooltip.kt | 2 +- 3 files changed, 305 insertions(+), 40 deletions(-) diff --git a/common/src/desktopMain/kotlin/org/pushingpixels/aurora/common/AuroraPopupManager.kt b/common/src/desktopMain/kotlin/org/pushingpixels/aurora/common/AuroraPopupManager.kt index 5b65eef17..4f3ce27e9 100644 --- a/common/src/desktopMain/kotlin/org/pushingpixels/aurora/common/AuroraPopupManager.kt +++ b/common/src/desktopMain/kotlin/org/pushingpixels/aurora/common/AuroraPopupManager.kt @@ -133,9 +133,16 @@ object AuroraPopupManager { pointInOriginator: Offset ): Boolean { val match = shownPath.reversed().find { - (it.popup == originator) && + (it.originatorPopup == originator) && (it.popupTriggerAreaInOriginatorWindow.contains(pointInOriginator)) } return match != null } + + fun dump() { + println("Popups") + for (link in shownPath) { + println("\tOriginator ${link.originatorPopup.javaClass.simpleName}@${link.originatorPopup.hashCode()}") + } + } } \ No newline at end of file diff --git a/component/src/desktopMain/kotlin/org/pushingpixels/aurora/component/AuroraCommandButton.kt b/component/src/desktopMain/kotlin/org/pushingpixels/aurora/component/AuroraCommandButton.kt index c944b046a..6094ec90d 100644 --- a/component/src/desktopMain/kotlin/org/pushingpixels/aurora/component/AuroraCommandButton.kt +++ b/component/src/desktopMain/kotlin/org/pushingpixels/aurora/component/AuroraCommandButton.kt @@ -62,7 +62,6 @@ import org.pushingpixels.aurora.component.utils.* import org.pushingpixels.aurora.theming.* import org.pushingpixels.aurora.theming.utils.MutableColorScheme import java.awt.event.KeyEvent -import javax.swing.JPopupMenu import kotlin.math.max import kotlin.math.roundToInt @@ -78,7 +77,7 @@ private class CommandButtonDrawingCache( private fun Modifier.commandButtonActionHoverable( interactionSource: MutableInteractionSource, enabled: Boolean = true, - onClickState: State<() -> Unit>, + onActivateActionState: State<() -> Unit>, presentationModel: BaseCommandButtonPresentationModel ): Modifier = composed( inspectorInfo = debugInspectorInfo { @@ -102,12 +101,12 @@ private fun Modifier.commandButtonActionHoverable( clickJob = scope.launch { delay(presentationModel.autoRepeatInitialInterval) while (isActive) { - onClickState.value.invoke() + onActivateActionState.value.invoke() delay(presentationModel.autoRepeatSubsequentInterval) } } } else { - onClickState.value.invoke() + onActivateActionState.value.invoke() } } } @@ -161,12 +160,90 @@ private fun Modifier.commandButtonActionHoverable( } } -internal suspend fun PressGestureScope.auroraHandlePressInteraction( +private fun Modifier.commandButtonPopupHoverable( + interactionSource: MutableInteractionSource, + enabled: Boolean = true, + onActivatePopupState: State<() -> Unit>, + onDeactivatePopup: State<() -> Unit>, + presentationModel: BaseCommandButtonPresentationModel +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "hoverable" + properties["interactionSource"] = interactionSource + properties["enabled"] = enabled + } +) { + var hoverInteraction by remember { mutableStateOf(null) } + + suspend fun emitEnter() { + if (hoverInteraction == null) { + val interaction = HoverInteraction.Enter() + interactionSource.emit(interaction) + hoverInteraction = interaction + + if (presentationModel.isMenu) { + onActivatePopupState.value.invoke() + } + } + } + + suspend fun emitExit() { + hoverInteraction?.let { oldValue -> + val interaction = HoverInteraction.Exit(oldValue) + interactionSource.emit(interaction) + hoverInteraction = null + + if (presentationModel.isMenu) { + onDeactivatePopup.value.invoke() + } + } + } + + fun tryEmitExit() { + hoverInteraction?.let { oldValue -> + val interaction = HoverInteraction.Exit(oldValue) + interactionSource.tryEmit(interaction) + hoverInteraction = null + } + } + + DisposableEffect(interactionSource) { + onDispose { tryEmitExit() } + } + LaunchedEffect(enabled) { + if (!enabled) { + emitExit() + } + } + + if (enabled) { + Modifier + .pointerInput(interactionSource) { + coroutineScope { + val currentContext = currentCoroutineContext() + val outerScope = this + awaitPointerEventScope { + while (currentContext.isActive) { + val event = awaitPointerEvent() + when (event.type) { + PointerEventType.Enter -> outerScope.launch { emitEnter() } + PointerEventType.Exit -> outerScope.launch { emitExit() } + } + } + } + } + } + } else { + Modifier + } +} + +internal suspend fun PressGestureScope.auroraHandleActionPressInteraction( pressPoint: Offset, interactionSource: MutableInteractionSource, pressedInteraction: MutableState, - onClickState: State<() -> Unit>, - invokeOnClickOnPress: Boolean, + onActivateActionState: State<() -> Unit>, + invokeOnActivateActionOnPress: Boolean, presentationModel: BaseCommandButtonPresentationModel, scope: CoroutineScope, clickJob: MutableState @@ -177,18 +254,18 @@ internal suspend fun PressGestureScope.auroraHandlePressInteraction( val pressInteraction = PressInteraction.Press(pressPoint) interactionSource.emit(pressInteraction) pressedInteraction.value = pressInteraction - if (invokeOnClickOnPress) { + if (invokeOnActivateActionOnPress) { if (presentationModel.autoRepeatAction) { clickJob.value?.cancel() clickJob.value = scope.launch { delay(presentationModel.autoRepeatInitialInterval) while (isActive) { - onClickState.value.invoke() + onActivateActionState.value.invoke() delay(presentationModel.autoRepeatSubsequentInterval) } } } else { - onClickState.value.invoke() + onActivateActionState.value.invoke() } } } @@ -220,11 +297,58 @@ internal suspend fun PressGestureScope.auroraHandlePressInteraction( } } -private fun Modifier.commandButtonActionClickable( +internal suspend fun PressGestureScope.auroraHandlePopupPressInteraction( + pressPoint: Offset, + interactionSource: MutableInteractionSource, + pressedInteraction: MutableState, + onActivatePopupState: State<() -> Unit>, + presentationModel: BaseCommandButtonPresentationModel, + scope: CoroutineScope, + clickJob: MutableState +) { + coroutineScope { + val delayJob = launch { + delay(0L) + val pressInteraction = PressInteraction.Press(pressPoint) + interactionSource.emit(pressInteraction) + pressedInteraction.value = pressInteraction + if (!presentationModel.isMenu) { + onActivatePopupState.value.invoke() + } + } + val success = tryAwaitRelease() + if (delayJob.isActive) { + delayJob.cancelAndJoin() + // The press released successfully, before the timeout duration - emit the press + // interaction instantly. No else branch - if the press was cancelled before the + // timeout, we don't want to emit a press interaction. + if (success) { + val pressInteraction = PressInteraction.Press(pressPoint) + val releaseInteraction = PressInteraction.Release(pressInteraction) + interactionSource.emit(pressInteraction) + interactionSource.emit(releaseInteraction) + clickJob.value?.cancel() + } + } else { + pressedInteraction.value?.let { pressInteraction -> + val endInteraction = if (success) { + PressInteraction.Release(pressInteraction) + } else { + PressInteraction.Cancel(pressInteraction) + } + interactionSource.emit(endInteraction) + clickJob.value?.cancel() + } + } + pressedInteraction.value = null + } +} + +private fun Modifier.commandButtonActionModifier( interactionSource: MutableInteractionSource, enabled: Boolean = true, presentationModel: BaseCommandButtonPresentationModel, - onClick: () -> Unit + onActivateAction: () -> Unit ) = composed( factory = { // Start building the chain. First the semantics role @@ -234,14 +358,14 @@ private fun Modifier.commandButtonActionClickable( // Then treating "Enter" key up event to fire the action result = result.then(onKeyEvent { if (enabled && (it.type == KeyEventType.KeyUp) && (it.key.nativeKeyCode == KeyEvent.VK_ENTER)) { - onClick() + onActivateAction() true } else { false } }) - val onClickState = rememberUpdatedState(onClick) + val onActivateActionState = rememberUpdatedState(onActivateAction) val pressedInteraction = remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val clickJob: MutableState = mutableStateOf(null) @@ -255,7 +379,7 @@ private fun Modifier.commandButtonActionClickable( Modifier.commandButtonActionHoverable( interactionSource, enabled, - onClickState, + onActivateActionState, presentationModel ) ) @@ -266,9 +390,9 @@ private fun Modifier.commandButtonActionClickable( detectTapAndPress( onPress = { offset -> if (enabled) { - auroraHandlePressInteraction( + auroraHandleActionPressInteraction( offset, interactionSource, pressedInteraction, - onClickState, false, presentationModel, + onActivateActionState, false, presentationModel, scope, clickJob ) } @@ -294,9 +418,9 @@ private fun Modifier.commandButtonActionClickable( detectTapAndPress( onPress = { offset -> if (enabled) { - auroraHandlePressInteraction( + auroraHandleActionPressInteraction( offset, interactionSource, pressedInteraction, - onClickState, + onActivateActionState, presentationModel.actionFireTrigger == ActionFireTrigger.OnPressed, presentationModel, scope, @@ -306,7 +430,110 @@ private fun Modifier.commandButtonActionClickable( }, onTap = { if (enabled && (presentationModel.actionFireTrigger == ActionFireTrigger.OnPressReleased)) { - onClickState.value.invoke() + onActivateActionState.value.invoke() + } + } + ) + }) + } + result + }, + inspectorInfo = debugInspectorInfo { + name = "clickable" + properties["enabled"] = enabled + properties["onClickLabel"] = null + properties["role"] = Role.Button + properties["onClick"] = onActivateAction + properties["indication"] = null + properties["interactionSource"] = interactionSource + } +) + +private fun Modifier.commandButtonPopupModifier( + interactionSource: MutableInteractionSource, + enabled: Boolean = true, + presentationModel: BaseCommandButtonPresentationModel, + onActivatePopup: () -> Unit, + onDeactivatePopup: () -> Unit +) = composed( + factory = { + // Start building the chain. First the semantics role + var result = this.semantics(mergeDescendants = true) { + // TODO - use Role.DropdownList after upgrading to Compose that has it + this.role = Role.Button + } + // Then treating "Enter" key up event to fire the popup + result = result.then(onKeyEvent { + if (enabled && (it.type == KeyEventType.KeyUp) && (it.key.nativeKeyCode == KeyEvent.VK_ENTER)) { + onActivatePopup() + true + } else { + false + } + }) + + val onActivatePopupState = rememberUpdatedState(onActivatePopup) + val onDeactivatePopupState = rememberUpdatedState(onDeactivatePopup) + val pressedInteraction = remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val clickJob: MutableState = mutableStateOf(null) + + // Now for the mouse interaction part + if (presentationModel.isMenu) { + // Activate popup on rollover in menu buttons + + // Start with the hover + result = result.then( + Modifier.commandButtonPopupHoverable( + interactionSource, + enabled, + onActivatePopupState, + onDeactivatePopupState, + presentationModel + ) + ) + + // And add press detector, but without invoking onClick in onPress or onTap, + // since we are invoking onClick on PointerEventType.Enter + result = result.then(Modifier.pointerInput(interactionSource, enabled) { + detectTapAndPress( + onPress = { offset -> + if (enabled) { + auroraHandlePopupPressInteraction( + offset, interactionSource, pressedInteraction, + onActivatePopupState, presentationModel, + scope, clickJob + ) + } + }, + onTap = {} + ) + }) + } else { + // Otherwise track hover state + result = result.hoverable(enabled = enabled, interactionSource = interactionSource) + + // And finally add our custom tap-and-press detector + DisposableEffect(interactionSource) { + onDispose { + pressedInteraction.value?.let { oldValue -> + val interaction = PressInteraction.Cancel(oldValue) + interactionSource.tryEmit(interaction) + pressedInteraction.value = null + } + } + } + result = result.then(Modifier.pointerInput(interactionSource, enabled) { + detectTapAndPress( + onPress = { offset -> + if (enabled) { + auroraHandlePopupPressInteraction( + offset, interactionSource, pressedInteraction, + onActivatePopupState, + presentationModel, + scope, + clickJob + ) } } ) @@ -319,7 +546,7 @@ private fun Modifier.commandButtonActionClickable( properties["enabled"] = enabled properties["onClickLabel"] = null properties["role"] = Role.Button - properties["onClick"] = onClick + properties["onClick"] = onActivatePopup properties["indication"] = null properties["interactionSource"] = interactionSource } @@ -338,7 +565,7 @@ internal fun ) { - val secondaryContentModel = + val secondaryContentModel = rememberUpdatedState(command.secondaryContentModel as M?) val drawingCache = remember { CommandButtonDrawingCache() } @@ -400,7 +627,7 @@ internal fun HorizontalSeparatorProjection( presentationModel = SeparatorPresentationModel( @@ -1157,9 +1397,27 @@ internal fun {} } } + + SideEffect { + if (actionRollover) { + val isShowingPopupFromHere = AuroraPopupManager.isShowingPopupFrom( + originator = popupOriginator, + pointInOriginator = AuroraOffset( + x = buttonTopLeftOffset.x + popupAreaOffset.x + popupAreaSize.value.width / 2.0f, + y = buttonTopLeftOffset.y + popupAreaOffset.y + popupAreaSize.value.height / 2.0f + ).asOffset(density) + ) + if (!isShowingPopupFromHere) { + // We're not showing a popup that originates from the popup area of this + // command button. Hide all popups that originate from our originator. + AuroraPopupManager.hidePopups(originator = popupOriginator) + } + } + } }) { measurables, constraints -> // Pass the constraints from the parent (which may or may not use fixed width diff --git a/component/src/desktopMain/kotlin/org/pushingpixels/aurora/component/AuroraRichTooltip.kt b/component/src/desktopMain/kotlin/org/pushingpixels/aurora/component/AuroraRichTooltip.kt index e3b4eacfa..3d517e27b 100644 --- a/component/src/desktopMain/kotlin/org/pushingpixels/aurora/component/AuroraRichTooltip.kt +++ b/component/src/desktopMain/kotlin/org/pushingpixels/aurora/component/AuroraRichTooltip.kt @@ -99,7 +99,7 @@ fun Modifier.auroraRichTooltip( fun startShowing() { job?.cancel() job = scope.launch { - delay(500) + delay(750) displayRichTooltipContent( popupOriginator = popupOriginator, layoutDirection = layoutDirection,