diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt b/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt index 841f205d..926e874d 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/components/CombinedMouseClickable.kt @@ -6,41 +6,103 @@ package ca.gosyer.ui.base.components +import androidx.compose.foundation.ExperimentalDesktopApi import androidx.compose.foundation.Indication import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.clickable +import androidx.compose.foundation.MouseClickScope +import androidx.compose.foundation.awaitEventFirstDown import androidx.compose.foundation.gestures.forEachGesture +import androidx.compose.foundation.indication import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.CursorDropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Text +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerButtons import androidx.compose.ui.input.pointer.PointerEvent -import androidx.compose.ui.input.pointer.changedToDown +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.consumeDownChange +import androidx.compose.ui.input.pointer.isOutOfBounds import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.ContextMenuItem import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.semantics.AccessibilityAction import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.IntOffset -import java.awt.event.MouseEvent +import kotlinx.coroutines.coroutineScope -internal suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent { - var event: PointerEvent - do { - event = awaitPointerEvent() - } while ( - !event.changes.all { it.changedToDown() } +@OptIn(ExperimentalDesktopApi::class) +fun Modifier.contextMenuClickable( + items: () -> List, + onClickLabel: String? = null, + onMiddleClickLabel: String? = null, + onRightClickLabel: String? = null, + onClick: MouseClickScope.(IntOffset) -> Unit = {}, + onMiddleClick: MouseClickScope.(IntOffset) -> Unit = {}, + enabled: Boolean = true +) = composed( + inspectorInfo = debugInspectorInfo { + name = "contextMenuClickable" + properties["onClick"] = onClick + properties["onMiddleClick"] = onMiddleClick + properties["enabled"] = enabled + properties["onClickLabel"] = onClickLabel + properties["onMiddleClickLabel"] = onMiddleClickLabel + properties["onRightClickLabel"] = onRightClickLabel + } +) { + var expanded by remember { mutableStateOf(false) } + CursorDropdownMenu( + expanded, + onDismissRequest = { expanded = false } + ) { + items().forEach { item -> + DropdownMenuItem( + onClick = { + expanded = false + item.onClick() + } + ) { + Text(text = item.label) + } + } + } + Modifier.combinedMouseClickable( + onClick = onClick, + onMiddleClick = onMiddleClick, + onClickLabel = onClickLabel, + onMiddleClickLabel = onMiddleClickLabel, + onRightClickLabel = onRightClickLabel, + onRightClick = { expanded = true }, + enabled = enabled ) - return event } +@OptIn(ExperimentalDesktopApi::class) fun Modifier.combinedMouseClickable( rightClickIsContextMenu: Boolean = true, - onClick: (IntOffset) -> Unit = {}, - onMiddleClick: (IntOffset) -> Unit = {}, - onRightClick: (IntOffset) -> Unit = {} + onClick: MouseClickScope.(IntOffset) -> Unit = {}, + onMiddleClick: MouseClickScope.(IntOffset) -> Unit = {}, + onRightClick: MouseClickScope.(IntOffset) -> Unit = {}, + onClickLabel: String? = null, + onMiddleClickLabel: String? = null, + onRightClickLabel: String? = null, + enabled: Boolean = true ) = composed( inspectorInfo = debugInspectorInfo { name = "combinedMouseClickable" @@ -48,6 +110,10 @@ fun Modifier.combinedMouseClickable( properties["onClick"] = onClick properties["onMiddleClick"] = onMiddleClick properties["onRightClick"] = onRightClick + properties["enabled"] = enabled + properties["onClickLabel"] = onClickLabel + properties["onMiddleClickLabel"] = onMiddleClickLabel + properties["onRightClickLabel"] = onRightClickLabel } ) { Modifier.combinedMouseClickable( @@ -56,25 +122,34 @@ fun Modifier.combinedMouseClickable( rightClickIsContextMenu = rightClickIsContextMenu, onClick = onClick, onMiddleClick = onMiddleClick, - onRightClick = onRightClick + onRightClick = onRightClick, + onClickLabel = onClickLabel, + onMiddleClickLabel = onMiddleClickLabel, + onRightClickLabel = onRightClickLabel, + enabled = enabled ) } +@OptIn(ExperimentalDesktopApi::class) fun Modifier.combinedMouseClickable( interactionSource: MutableInteractionSource, indication: Indication?, enabled: Boolean = true, onClickLabel: String? = null, + onMiddleClickLabel: String? = null, + onRightClickLabel: String? = null, role: Role? = null, rightClickIsContextMenu: Boolean = true, - onClick: (IntOffset) -> Unit, - onMiddleClick: (IntOffset) -> Unit, - onRightClick: (IntOffset) -> Unit + onClick: MouseClickScope.(IntOffset) -> Unit, + onMiddleClick: MouseClickScope.(IntOffset) -> Unit, + onRightClick: MouseClickScope.(IntOffset) -> Unit ) = composed( inspectorInfo = debugInspectorInfo { name = "combinedMouseClickable" properties["enabled"] = enabled properties["onClickLabel"] = onClickLabel + properties["onMiddleClickLabel"] = onMiddleClickLabel + properties["onRightClickLabel"] = onRightClickLabel properties["role"] = role properties["rightClickIsContextMenu"] = rightClickIsContextMenu properties["onClick"] = onClick @@ -84,23 +159,136 @@ fun Modifier.combinedMouseClickable( properties["interactionSource"] = interactionSource } ) { - var lastEvent by remember { mutableStateOf(null) } - Modifier - .clickable(interactionSource, indication, enabled, onClickLabel, role) { - val savedLastEvent = lastEvent ?: return@clickable - val offset = savedLastEvent.let { IntOffset(it.xOnScreen, it.yOnScreen) } - when { - rightClickIsContextMenu && savedLastEvent.isPopupTrigger -> onRightClick(offset) - savedLastEvent.button == MouseEvent.BUTTON1 -> onClick(offset) - savedLastEvent.button == MouseEvent.BUTTON2 -> onMiddleClick(offset) - savedLastEvent.button == MouseEvent.BUTTON3 -> onRightClick(offset) - } + val onClickState = rememberUpdatedState(onClick) + val onMiddleClickState = rememberUpdatedState(onMiddleClick) + val onRightClickState = rememberUpdatedState(onRightClick) + val gesture = if (enabled) { + Modifier.pointerInput(Unit) { + detectTapWithContext( + onTap = { down, event -> + val scope = MouseClickScope( + down.buttons, + down.keyboardModifiers + ) + val offset = event.mouseEvent?.let { IntOffset(it.xOnScreen, it.yOnScreen) } + ?: IntOffset.Zero + + when { + rightClickIsContextMenu && event.mouseEvent?.isPopupTrigger == true -> onRightClickState.value(scope, offset) + down.buttons.isTertiaryPressed -> onMiddleClickState.value(scope, offset) + down.buttons.isSecondaryPressed -> onRightClickState.value(scope, offset) + else -> onClickState.value(scope, offset) + } + } + ) } - .pointerInput(interactionSource) { - forEachGesture { - awaitPointerEventScope { - lastEvent = awaitEventFirstDown().mouseEvent + } else { + Modifier + } + Modifier + .genericClickableWithoutGesture( + gestureModifiers = gesture, + enabled = enabled, + onClickLabel = onClickLabel, + role = role, + onMiddleClickLabel = onMiddleClickLabel, + onRightClickLabel = onRightClickLabel, + onMiddleClick = { onMiddleClick(EmptyClickContext, IntOffset.Zero) }, + onRightClick = { onRightClick(EmptyClickContext, IntOffset.Zero) }, + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { onClick(EmptyClickContext, IntOffset.Zero) } + ) +} + +@Composable +@Suppress("ComposableModifierFactory") +private fun Modifier.genericClickableWithoutGesture( + gestureModifiers: Modifier, + interactionSource: MutableInteractionSource, + indication: Indication?, + enabled: Boolean = true, + onClickLabel: String? = null, + onMiddleClickLabel: String? = null, + onMiddleClick: (() -> Unit), + onRightClickLabel: String? = null, + onRightClick: (() -> Unit), + role: Role? = null, + onClick: () -> Unit +): Modifier { + val semanticModifier = Modifier.semantics(mergeDescendants = true) { + if (role != null) { + this.role = role + } + // b/156468846: add long click semantics and double click if needed + onClick(action = { onClick(); true }, label = onClickLabel) + this[DesktopSemanticsActions.onMiddleClick] = AccessibilityAction(onMiddleClickLabel) { onMiddleClick(); true } + this[DesktopSemanticsActions.onRightClick] = AccessibilityAction(onRightClickLabel) { onRightClick(); true } + if (!enabled) { + disabled() + } + } + return this + .then(semanticModifier) + .indication(interactionSource, indication) + .then(gestureModifiers) +} + +object DesktopSemanticsActions { + val onRightClick = ActionPropertyKey<() -> Boolean>("OnRightClick") + val onMiddleClick = ActionPropertyKey<() -> Boolean>("OnMiddleClick") +} + +private fun > ActionPropertyKey( + name: String +): SemanticsPropertyKey> { + return SemanticsPropertyKey( + name = name, + mergePolicy = { parentValue, childValue -> + AccessibilityAction( + parentValue?.label ?: childValue.label, + parentValue?.action ?: childValue.action + ) + } + ) +} + +@ExperimentalDesktopApi +internal val EmptyClickContext = MouseClickScope( + PointerButtons(0), PointerKeyboardModifiers(0) +) + +@ExperimentalDesktopApi +private suspend fun PointerInputScope.detectTapWithContext( + onTap: ((PointerEvent, PointerEvent) -> Unit)? = null +) { + forEachGesture { + coroutineScope { + awaitPointerEventScope { + val down = awaitEventFirstDown().also { + it.changes.forEach { it.consumeDownChange() } + } + + val up = waitForFirstInboundUp() + if (up != null) { + up.changes.forEach { it.consumeDownChange() } + onTap?.invoke(down, up) } } } + } +} + +private suspend fun AwaitPointerEventScope.waitForFirstInboundUp(): PointerEvent? { + while (true) { + val event = awaitPointerEvent() + val change = event.changes[0] + if (change.changedToUp()) { + return if (change.isOutOfBounds(size)) { + null + } else { + event + } + } + } } diff --git a/src/main/kotlin/ca/gosyer/ui/base/components/DropdownIconButton.kt b/src/main/kotlin/ca/gosyer/ui/base/components/DropdownIconButton.kt index 64eb00d5..a06b1105 100644 --- a/src/main/kotlin/ca/gosyer/ui/base/components/DropdownIconButton.kt +++ b/src/main/kotlin/ca/gosyer/ui/base/components/DropdownIconButton.kt @@ -22,6 +22,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.changedToDown import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.DpOffset @@ -63,3 +66,13 @@ fun DropdownIconButton( content = content ) } + +internal suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent { + var event: PointerEvent + do { + event = awaitPointerEvent() + } while ( + !event.changes.all { it.changedToDown() } + ) + return event +} diff --git a/src/main/kotlin/ca/gosyer/ui/manga/ChapterItem.kt b/src/main/kotlin/ca/gosyer/ui/manga/ChapterItem.kt index a4ce4e1e..6ebfef54 100644 --- a/src/main/kotlin/ca/gosyer/ui/manga/ChapterItem.kt +++ b/src/main/kotlin/ca/gosyer/ui/manga/ChapterItem.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ContextMenuItem import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -48,10 +49,8 @@ import androidx.compose.ui.unit.dp import ca.gosyer.data.download.model.DownloadChapter import ca.gosyer.data.download.model.DownloadState import ca.gosyer.ui.base.components.DropdownIconButton -import ca.gosyer.ui.base.components.LocalComposeWindow -import ca.gosyer.ui.base.components.combinedMouseClickable +import ca.gosyer.ui.base.components.contextMenuClickable import ca.gosyer.ui.base.resources.stringResource -import ca.gosyer.util.compose.contextMenu import java.time.Instant @Composable @@ -72,23 +71,16 @@ fun ChapterItem( elevation = 1.dp, shape = RoundedCornerShape(4.dp) ) { - val window = LocalComposeWindow.current BoxWithConstraints( - Modifier.combinedMouseClickable( - onClick = { - onClick(chapter.index) + Modifier.contextMenuClickable( + { + listOf( + ContextMenuItem("Toggle read") { toggleRead(chapter.index) }, + ContextMenuItem("Mark previous as read") { markPreviousAsRead(chapter.index) }, + ContextMenuItem("Toggle bookmarked") { toggleBookmarked(chapter.index) } + ) }, - onRightClick = { - contextMenu( - window, - it - ) { - menuItem("Toggle read") { toggleRead(chapter.index) } - menuItem("Mark previous as read") { markPreviousAsRead(chapter.index) } - separator() - menuItem("Toggle bookmarked") { toggleBookmarked(chapter.index) } - } - } + onClick = { onClick(chapter.index) } ) ) { Row( diff --git a/src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt b/src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt deleted file mode 100644 index e648fc68..00000000 --- a/src/main/kotlin/ca/gosyer/util/compose/ContextMenu.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ - -package ca.gosyer.util.compose - -import androidx.compose.ui.awt.ComposeWindow -import androidx.compose.ui.unit.IntOffset -import ca.gosyer.util.lang.launchUI -import com.github.weisj.darklaf.listener.MouseClickListener -import kotlinx.coroutines.DelicateCoroutinesApi -import java.awt.event.WindowEvent -import java.awt.event.WindowFocusListener -import javax.swing.Icon -import javax.swing.JMenuItem -import javax.swing.JPopupMenu -import javax.swing.JSeparator - -class ContextMenu internal constructor(private val window: ComposeWindow) { - internal val items = mutableListOf Unit)?>>() - - @OptIn(DelicateCoroutinesApi::class) - internal fun popupMenu() = JPopupMenu().apply { - var mouseListener: MouseClickListener? = null - var focusListener: WindowFocusListener? = null - fun close() { - isVisible = false - mouseListener?.let { window.removeMouseListener(it) } - focusListener?.let { window.removeWindowFocusListener(it) } - } - fun (() -> Unit)?.andClose() { - launchUI { - close() - this@andClose?.invoke() - } - } - - mouseListener = MouseClickListener { - launchUI { - close() - } - } - window.addMouseListener(mouseListener) - - focusListener = object : WindowFocusListener { - override fun windowGainedFocus(e: WindowEvent?) {} - override fun windowLostFocus(e: WindowEvent?) { - launchUI { - close() - } - } - } - window.addWindowFocusListener(focusListener) - - items.forEach { (item, block) -> - when (item) { - is JMenuItem -> add(item).apply { - addActionListener { - block.andClose() - } - } - is JSeparator -> add(item) - } - } - } - - fun menuItem(name: String, icon: Icon? = null, builder: JMenuItem.() -> Unit = {}, action: () -> Unit) { - items += JMenuItem(name, icon).apply(builder) to action - } - fun separator() { - items += JSeparator() to null - } -} - -fun contextMenu(window: ComposeWindow, offset: IntOffset, contextMenu: ContextMenu.() -> Unit) { - ContextMenu(window).apply(contextMenu).popupMenu().show(null, offset.x, offset.y) -}