mirror of
https://github.com/Suwayomi/TachideskJUI.git
synced 2026-01-23 12:04:05 +01:00
New context menu system
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user