Fixed Context Menu inconsistent behavior in diff screen

Also fixed empty context menu being shown when the list of items is empty (it required  the user to click once to close this invisible popup and make other actions work properly)
This commit is contained in:
Abdelilah El Aissaoui 2024-05-27 02:47:35 +02:00
parent 62c9f11b1b
commit f2df70124b
No known key found for this signature in database
GPG Key ID: 7587FC860F594869
3 changed files with 68 additions and 61 deletions

View File

@ -2,7 +2,6 @@ package com.jetpackduba.gitnuro.ui.context_menu
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextContextMenu import androidx.compose.foundation.text.TextContextMenu
import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.material.Icon import androidx.compose.material.Icon
@ -24,7 +23,9 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.input.InputMode import androidx.compose.ui.input.InputMode
import androidx.compose.ui.input.InputModeManager import androidx.compose.ui.input.InputModeManager
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.isSecondary import androidx.compose.ui.input.pointer.isSecondary
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@ -51,8 +52,8 @@ private var lastCheck: Long = 0
private const val MIN_TIME_BETWEEN_POPUPS_IN_MS = 20 private const val MIN_TIME_BETWEEN_POPUPS_IN_MS = 20
@Composable @Composable
fun ContextMenu(items: () -> List<ContextMenuElement>, function: @Composable () -> Unit) { fun ContextMenu(enabled: Boolean = true,items: () -> List<ContextMenuElement>, function: @Composable () -> Unit) {
Box(modifier = Modifier.contextMenu(items), propagateMinConstraints = true) { Box(modifier = Modifier.contextMenu(enabled, items), propagateMinConstraints = true) {
function() function()
} }
} }
@ -64,21 +65,23 @@ fun DropdownMenu(items: () -> List<ContextMenuElement>, function: @Composable ()
} }
} }
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
private fun Modifier.contextMenu(items: () -> List<ContextMenuElement>): Modifier { private fun Modifier.contextMenu(enabled: Boolean, items: () -> List<ContextMenuElement>): Modifier {
val (lastMouseEventState, setLastMouseEventState) = remember { mutableStateOf<MouseEvent?>(null) } val (contentMenuData, setContentMenuData) = remember { mutableStateOf<ContextMenuData?>(null) }
val modifier = this.pointerInput(Unit) { val modifier = this.pointerInput(enabled) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
val lastMouseEvent = awaitFirstDownEvent() val lastMouseEvent = awaitFirstDownEvent()
val mouseEvent = lastMouseEvent.awtEventOrNull val mouseEvent = lastMouseEvent.awtEventOrNull
if (mouseEvent != null) { if (mouseEvent != null && enabled) {
if (lastMouseEvent.button.isSecondary) { if (lastMouseEvent.button.isSecondary) {
lastMouseEvent.changes.forEach { it.consume() } lastMouseEvent.changes.forEach {
it.consume()
}
val currentCheck = System.currentTimeMillis() val currentCheck = System.currentTimeMillis()
if (lastCheck != 0L && currentCheck - lastCheck < MIN_TIME_BETWEEN_POPUPS_IN_MS) { if (lastCheck != 0L && currentCheck - lastCheck < MIN_TIME_BETWEEN_POPUPS_IN_MS) {
@ -86,7 +89,7 @@ private fun Modifier.contextMenu(items: () -> List<ContextMenuElement>): Modifie
} else { } else {
lastCheck = currentCheck lastCheck = currentCheck
setLastMouseEventState(mouseEvent) setContentMenuData(ContextMenuData(items(), mouseEvent))
} }
} }
} }
@ -94,13 +97,13 @@ private fun Modifier.contextMenu(items: () -> List<ContextMenuElement>): Modifie
} }
} }
if (lastMouseEventState != null) { if (contentMenuData != null && contentMenuData.items.isNotEmpty()) {
DisableSelection { DisableSelection {
showPopup( showPopup(
lastMouseEventState.x, contentMenuData.mouseEvent.x,
lastMouseEventState.y, contentMenuData.mouseEvent.y,
items(), contentMenuData.items,
onDismissRequest = { setLastMouseEventState(null) } onDismissRequest = { setContentMenuData(null) }
) )
} }
} }
@ -108,6 +111,11 @@ private fun Modifier.contextMenu(items: () -> List<ContextMenuElement>): Modifie
return modifier return modifier
} }
class ContextMenuData(
val items: List<ContextMenuElement>,
val mouseEvent: MouseEvent,
)
@Composable @Composable
private fun Modifier.dropdownMenu(items: () -> List<ContextMenuElement>): Modifier { private fun Modifier.dropdownMenu(items: () -> List<ContextMenuElement>): Modifier {
val (isClicked, setIsClicked) = remember { mutableStateOf(false) } val (isClicked, setIsClicked) = remember { mutableStateOf(false) }
@ -237,6 +245,7 @@ fun TextEntry(contextTextEntry: ContextMenuElement.ContextTextEntry, onDismissRe
onDismissRequest() onDismissRequest()
contextTextEntry.onClick() contextTextEntry.onClick()
} }
.pointerHoverIcon(PointerIcon.Default)
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {

View File

@ -6,8 +6,15 @@ import androidx.compose.foundation.text.TextContextMenu
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
/**
* This TextContextMenu will update the parent composable via @param onIsTextSelected when the text selection has changed.
*
* An example is the Diff screen, where lines can show different context menus depending on if text is selected or not.
* If nothing is selected, the default TextContentMenu should not be displayed and the parent composable can decide to
* show a context menu.
*/
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
class CustomTextContextMenu(val onIsTextSelected: (AnnotatedString) -> Unit) : TextContextMenu { class SelectionAwareTextContextMenu(val onIsTextSelected: (AnnotatedString) -> Unit) : TextContextMenu {
@Composable @Composable
override fun Area( override fun Area(
textManager: TextContextMenu.TextManager, textManager: TextContextMenu.TextManager,
@ -21,6 +28,26 @@ class CustomTextContextMenu(val onIsTextSelected: (AnnotatedString) -> Unit) : T
println("Selected text check failed " + ex.message) println("Selected text check failed " + ex.message)
} }
content() val emptyTextManager = object : TextContextMenu.TextManager {
override val copy: (() -> Unit)?
get() = null
override val cut: (() -> Unit)?
get() = null
override val paste: (() -> Unit)?
get() = null
override val selectAll: (() -> Unit)?
get() = null
override val selectedText: AnnotatedString
get() = AnnotatedString("")
}
val textManagerToUse = if (textManager.selectedText.isNotEmpty()) {
textManager
} else {
emptyTextManager
}
AppPopupMenu().Area(textManagerToUse, state, content)
} }
} }

View File

@ -28,10 +28,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalLocalization
import androidx.compose.ui.platform.PlatformLocalization
import androidx.compose.ui.res.loadImageBitmap import androidx.compose.ui.res.loadImageBitmap
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@ -60,7 +56,7 @@ import com.jetpackduba.gitnuro.ui.components.SecondaryButton
import com.jetpackduba.gitnuro.ui.components.tooltip.DelayedTooltip import com.jetpackduba.gitnuro.ui.components.tooltip.DelayedTooltip
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu import com.jetpackduba.gitnuro.ui.context_menu.ContextMenu
import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement import com.jetpackduba.gitnuro.ui.context_menu.ContextMenuElement
import com.jetpackduba.gitnuro.ui.context_menu.CustomTextContextMenu import com.jetpackduba.gitnuro.ui.context_menu.SelectionAwareTextContextMenu
import com.jetpackduba.gitnuro.viewmodels.DiffViewModel import com.jetpackduba.gitnuro.viewmodels.DiffViewModel
import com.jetpackduba.gitnuro.viewmodels.TextDiffType import com.jetpackduba.gitnuro.viewmodels.TextDiffType
import com.jetpackduba.gitnuro.viewmodels.ViewDiffResult import com.jetpackduba.gitnuro.viewmodels.ViewDiffResult
@ -442,11 +438,9 @@ fun HunkUnifiedTextDiff(
) { ) {
val hunks = diffResult.hunks val hunks = diffResult.hunks
var selectedText by remember { mutableStateOf(AnnotatedString("")) } var selectedText by remember { mutableStateOf(AnnotatedString("")) }
val localClipboardManager = LocalClipboardManager.current
val localization = LocalLocalization.current
CompositionLocalProvider( CompositionLocalProvider(
LocalTextContextMenu provides CustomTextContextMenu { LocalTextContextMenu provides SelectionAwareTextContextMenu {
selectedText = it selectedText = it
} }
) { ) {
@ -477,8 +471,6 @@ fun HunkUnifiedTextDiff(
items(hunk.lines) { line -> items(hunk.lines) { line ->
DiffContextMenu( DiffContextMenu(
selectedText = selectedText, selectedText = selectedText,
localization = localization,
localClipboardManager = localClipboardManager,
diffEntryType = diffEntryType, diffEntryType = diffEntryType,
onDiscardLine = { onDiscardLine(diffResult.diffEntry, hunk, line) }, onDiscardLine = { onDiscardLine(diffResult.diffEntry, hunk, line) },
line = line, line = line,
@ -525,7 +517,7 @@ fun HunkSplitTextDiff(
var selectedText by remember { mutableStateOf(AnnotatedString("")) } var selectedText by remember { mutableStateOf(AnnotatedString("")) }
CompositionLocalProvider( CompositionLocalProvider(
LocalTextContextMenu provides CustomTextContextMenu { LocalTextContextMenu provides SelectionAwareTextContextMenu {
selectedText = it selectedText = it
} }
) { ) {
@ -663,10 +655,6 @@ fun SplitDiffLineSide(
var pressedAndMoved by remember(line) { mutableStateOf(Pair(false, false)) } var pressedAndMoved by remember(line) { mutableStateOf(Pair(false, false)) }
var movesCount by remember(line) { mutableStateOf(0) } var movesCount by remember(line) { mutableStateOf(0) }
val localClipboardManager = LocalClipboardManager.current
val localization = LocalLocalization.current
Box( Box(
modifier = modifier modifier = modifier
.onPointerEvent(PointerEventType.Press) { .onPointerEvent(PointerEventType.Press) {
@ -703,8 +691,6 @@ fun SplitDiffLineSide(
) { ) {
DiffContextMenu( DiffContextMenu(
selectedText, selectedText,
localization,
localClipboardManager,
line, line,
diffEntryType, diffEntryType,
onDiscardLine = { onDiscardLine(line) }, onDiscardLine = { onDiscardLine(line) },
@ -725,45 +711,30 @@ fun SplitDiffLineSide(
@Composable @Composable
fun DiffContextMenu( fun DiffContextMenu(
selectedText: AnnotatedString, selectedText: AnnotatedString,
localization: PlatformLocalization,
localClipboardManager: ClipboardManager,
line: Line, line: Line,
diffEntryType: DiffEntryType, diffEntryType: DiffEntryType,
onDiscardLine: () -> Unit, onDiscardLine: () -> Unit,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
ContextMenu( ContextMenu(
enabled = selectedText.isEmpty(),
items = { items = {
val isTextSelected = selectedText.isNotEmpty() if (
line.lineType != LineType.CONTEXT &&
if (isTextSelected) { diffEntryType is DiffEntryType.UnstagedDiff &&
diffEntryType.statusType == StatusType.MODIFIED
) {
listOf( listOf(
ContextMenuElement.ContextTextEntry( ContextMenuElement.ContextTextEntry(
label = localization.copy, label = "Discard line",
icon = { painterResource(AppIcons.COPY) }, icon = { painterResource(AppIcons.UNDO) },
onClick = { onClick = {
localClipboardManager.setText(selectedText) onDiscardLine()
} }
) )
) )
} else { } else
if ( emptyList()
line.lineType != LineType.CONTEXT &&
diffEntryType is DiffEntryType.UnstagedDiff &&
diffEntryType.statusType == StatusType.MODIFIED
) {
listOf(
ContextMenuElement.ContextTextEntry(
label = "Discard line",
icon = { painterResource(AppIcons.UNDO) },
onClick = {
onDiscardLine()
}
)
)
} else
emptyList()
}
}, },
) { ) {
content() content()
@ -869,7 +840,7 @@ private fun DiffHeader(
.weight(1f, true) .weight(1f, true)
) { ) {
SelectionContainer { SelectionContainer {
Row() { Row {
if (dirPath.isNotEmpty()) { if (dirPath.isNotEmpty()) {
Text( Text(
text = dirPath.removeSuffix("/"), text = dirPath.removeSuffix("/"),