New context menu system

This commit is contained in:
Syer10
2021-08-20 20:13:52 -04:00
parent c22cfea1de
commit ec595baf25
4 changed files with 243 additions and 129 deletions

View File

@@ -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<ContextMenuItem>,
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<MouseEvent?>(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 <T : Function<Boolean>> ActionPropertyKey(
name: String
): SemanticsPropertyKey<AccessibilityAction<T>> {
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
}
}
}
}

View File

@@ -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
}

View File

@@ -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(

View File

@@ -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<Pair<Any, (() -> 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)
}