Skip to content

Commit c3a7e38

Browse files
authored
Disable, rather than remove items from the (old) desktop text context menus (#2617)
1 parent 4797bfb commit c3a7e38

File tree

7 files changed

+258
-116
lines changed

7 files changed

+258
-116
lines changed

compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/swingexample/Main.jvm.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,15 +265,17 @@ fun CustomTextMenuProvider(content: @Composable () -> Unit) {
265265

266266
private fun AnnotatedString.crop() = if (length <= 5) toString() else "${take(5)}..."
267267

268+
@OptIn(ExperimentalFoundationApi::class)
268269
private fun swingItem(
269270
label: String,
270271
color: java.awt.Color,
271272
key: Int,
272-
onClick: () -> Unit
273+
menuItemAction: TextContextMenu.Action
273274
) = JMenuItem(label).apply {
274275
icon = circleIcon(color)
275276
accelerator = KeyStroke.getKeyStroke(key, if (hostOs.isMacOS) META_DOWN_MASK else CTRL_DOWN_MASK)
276-
addActionListener { onClick() }
277+
isEnabled = menuItemAction.enabled
278+
addActionListener { menuItemAction.execute() }
277279
}
278280

279281
private fun circleIcon(color: java.awt.Color) = object : Icon {

compose/foundation/foundation/api/desktop/foundation.api

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ public final class androidx/compose/foundation/ContextMenuData {
8484
public class androidx/compose/foundation/ContextMenuItem {
8585
public static final field $stable I
8686
public fun <init> (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V
87+
public fun <init> (Ljava/lang/String;ZLkotlin/jvm/functions/Function0;)V
88+
public final fun contextMenuItemBaseClassEnabled ()Z
8789
public fun equals (Ljava/lang/Object;)Z
8890
public final fun getLabel ()Ljava/lang/String;
8991
public final fun getOnClick ()Lkotlin/jvm/functions/Function0;
@@ -137,7 +139,8 @@ public final class androidx/compose/foundation/DarkThemeKt {
137139

138140
public final class androidx/compose/foundation/DefaultContextMenuRepresentation : androidx/compose/foundation/ContextMenuRepresentation {
139141
public static final field $stable I
140-
public synthetic fun <init> (JJJLkotlin/jvm/internal/DefaultConstructorMarker;)V
142+
public synthetic fun <init> (JJJJILkotlin/jvm/internal/DefaultConstructorMarker;)V
143+
public synthetic fun <init> (JJJJLkotlin/jvm/internal/DefaultConstructorMarker;)V
141144
public fun Representation (Landroidx/compose/foundation/ContextMenuState;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V
142145
}
143146

compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.desktop.kt

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItemWith
2121
import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession
2222
import androidx.compose.runtime.Composable
2323
import androidx.compose.runtime.DisposableEffect
24+
import androidx.compose.runtime.SideEffect
2425
import androidx.compose.runtime.derivedStateOf
2526
import androidx.compose.runtime.getValue
2627
import androidx.compose.runtime.remember
@@ -65,42 +66,48 @@ val DarkDefaultContextMenuRepresentation = DefaultContextMenuRepresentation(
6566
class DefaultContextMenuRepresentation(
6667
private val backgroundColor: Color,
6768
private val textColor: Color,
68-
private val itemHoverColor: Color
69+
private val itemHoverColor: Color,
70+
private val disabledTextColor: Color = textColor.copy(alpha = 0.38f),
6971
) : ContextMenuRepresentation {
7072
@OptIn(ExperimentalComposeUiApi::class)
7173
@Composable
7274
override fun Representation(state: ContextMenuState, items: () -> List<ContextMenuItem>) {
7375
val status = state.status
74-
if (status is ContextMenuState.Status.Open) {
75-
val session = remember(state) {
76-
object : TextContextMenuSession {
77-
override fun close() {
78-
state.status = ContextMenuState.Status.Closed
79-
}
76+
if (status !is ContextMenuState.Status.Open) return
77+
78+
val session = remember(state) {
79+
object : TextContextMenuSession {
80+
override fun close() {
81+
state.status = ContextMenuState.Status.Closed
8082
}
8183
}
82-
val components by remember {
83-
derivedStateOf {
84-
items().map {
85-
TextContextMenuItemWithComposableLeadingIcon(
86-
key = it,
87-
label = it.label,
88-
enabled = true,
89-
onClick = {
90-
session.close()
91-
it.onClick()
92-
}
93-
)
94-
}
84+
}
85+
val components by remember {
86+
derivedStateOf {
87+
items().map {
88+
TextContextMenuItemWithComposableLeadingIcon(
89+
key = it,
90+
label = it.label,
91+
enabled = it.enabled,
92+
onClick = {
93+
session.close()
94+
it.onClick()
95+
}
96+
)
9597
}
9698
}
97-
val colors = remember(backgroundColor, textColor, itemHoverColor) {
99+
}
100+
101+
if (components.isEmpty()) {
102+
SideEffect { session.close() }
103+
} else {
104+
val colors = remember(backgroundColor, textColor, itemHoverColor, disabledTextColor) {
98105
ContextMenuColors(
99106
backgroundColor = backgroundColor,
100107
textColor = textColor,
101-
iconColor = Color.Unspecified,
102-
disabledTextColor = Color.Unspecified,
103-
disabledIconColor = Color.Unspecified,
108+
iconColor = textColor,
109+
disabledTextColor = disabledTextColor,
110+
disabledIconColor = disabledTextColor,
104111
hoverColor = itemHoverColor,
105112
)
106113
}

compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/ContextMenuProvider.desktop.kt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,32 +186,41 @@ private suspend fun AwaitPointerEventScope.awaitEventFirstDown(): PointerEvent {
186186
* Individual element of context menu.
187187
*
188188
* @param label The text to be displayed as a context menu item.
189+
* @param enabled Whether the item is enabled.
189190
* @param onClick The action to be executed after click on the item.
190191
*/
191192
open class ContextMenuItem(
192193
val label: String,
194+
enabled: Boolean,
193195
val onClick: () -> Unit
194196
) {
197+
constructor(label: String, onClick: () -> Unit) : this(label, true, onClick)
198+
199+
// Avoid breaking backwards compatibility with subclasses that already have an `enabled` field
200+
private val _enabled = enabled
201+
val enabled: Boolean
202+
@JvmName("contextMenuItemBaseClassEnabled") get() = _enabled
203+
195204
override fun equals(other: Any?): Boolean {
196205
if (this === other) return true
197-
if (other == null || this::class != other::class) return false
198-
199-
other as ContextMenuItem
206+
if (other !is ContextMenuItem) return false
200207

201208
if (label != other.label) return false
209+
if (enabled != other.enabled) return false
202210
if (onClick != other.onClick) return false
203211

204212
return true
205213
}
206214

207215
override fun hashCode(): Int {
208216
var result = label.hashCode()
217+
result = 31 * result + enabled.hashCode()
209218
result = 31 * result + onClick.hashCode()
210219
return result
211220
}
212221

213222
override fun toString(): String {
214-
return "ContextMenuItem(label='$label')"
223+
return "ContextMenuItem(label='$label', enabled=$enabled)"
215224
}
216225
}
217226

compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/text/ContextMenu.desktop.kt

Lines changed: 95 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -137,45 +137,41 @@ private val TextFieldSelectionManager.textManager: TextManager get() = object :
137137

138138
val isPassword get() = visualTransformation is PasswordVisualTransformation
139139

140-
override val cut: (() -> Unit)? get() =
141-
if (!value.selection.collapsed && editable && !isPassword) {
142-
{
140+
override val cut: TextContextMenu.Action get() =
141+
TextContextMenu.Action(
142+
enabled = !value.selection.collapsed && editable && !isPassword,
143+
execute = {
143144
cut()
144145
focusRequester?.requestFocus()
145146
}
146-
} else {
147-
null
148-
}
147+
)
149148

150-
override val copy: (() -> Unit)? get() =
151-
if (!value.selection.collapsed && !isPassword) {
152-
{
149+
override val copy: TextContextMenu.Action get() =
150+
TextContextMenu.Action(
151+
enabled = !value.selection.collapsed && !isPassword,
152+
execute = {
153153
copy(false)
154154
focusRequester?.requestFocus()
155155
}
156-
} else {
157-
null
158-
}
156+
)
159157

160-
override val paste: (() -> Unit)? get() =
161-
if (editable && clipboard?.nativeClipboardHasText() == true) {
162-
{
158+
override val paste: TextContextMenu.Action get() =
159+
TextContextMenu.Action(
160+
enabled = editable && clipboard?.nativeClipboardHasText() == true,
161+
execute = {
163162
paste()
164163
focusRequester?.requestFocus()
165164
}
166-
} else {
167-
null
168-
}
165+
)
169166

170-
override val selectAll: (() -> Unit)? get() =
171-
if (value.selection.length != value.text.length) {
172-
{
167+
override val selectAll: TextContextMenu.Action get() =
168+
TextContextMenu.Action(
169+
enabled = value.selection.length != value.text.length,
170+
execute = {
173171
selectAll()
174172
focusRequester?.requestFocus()
175173
}
176-
} else {
177-
null
178-
}
174+
)
179175

180176
override fun selectWordAtPositionIfNotAlreadySelected(offset: Offset) {
181177
this@textManager.selectWordAtPositionIfNotAlreadySelected(offset)
@@ -198,20 +194,32 @@ private fun TextFieldSelectionState.textManager(coroutineScope: CoroutineScope):
198194

199195
private fun pasteImpl() = launchUndispatched { paste() }
200196

201-
override val cut: (() -> Unit)?
202-
get() = if (canShowCutMenuItem()) ::cutImpl else null
197+
override val cut: TextContextMenu.Action get() =
198+
TextContextMenu.Action(
199+
enabled = canShowCutMenuItem(),
200+
execute = ::cutImpl
201+
)
203202

204-
override val copy: (() -> Unit)?
205-
get() = if (canShowCopyMenuItem()) ::copyImpl else null
203+
override val copy: TextContextMenu.Action get() =
204+
TextContextMenu.Action(
205+
enabled = canShowCopyMenuItem(),
206+
execute = ::copyImpl
207+
)
206208

207-
override val paste: (() -> Unit)?
208-
get() {
209-
launchUndispatched { updateClipboardEntry() }
210-
return if (canShowPasteMenuItem()) ::pasteImpl else null
211-
}
209+
override val paste: TextContextMenu.Action get() =
210+
TextContextMenu.Action(
211+
enabled = run {
212+
launchUndispatched { updateClipboardEntry() }
213+
canShowPasteMenuItem()
214+
},
215+
execute = ::pasteImpl
216+
)
212217

213-
override val selectAll: (() -> Unit)?
214-
get() = if (canShowSelectAllMenuItem()) ::selectAll else null
218+
override val selectAll: TextContextMenu.Action get() =
219+
TextContextMenu.Action(
220+
enabled = canShowSelectAllMenuItem(),
221+
execute = ::selectAll
222+
)
215223

216224
override fun selectWordAtPositionIfNotAlreadySelected(offset: Offset) {
217225
if (!textLayoutState.isPositionOnText(offset)) return
@@ -236,7 +244,11 @@ private fun TextFieldSelectionState.textManager(coroutineScope: CoroutineScope):
236244
private val SelectionManager.textManager: TextManager get() = object : TextManager {
237245
override val selectedText get() = getSelectedText() ?: AnnotatedString("")
238246
override val cut = null
239-
override val copy = { copy() }
247+
override val copy get() =
248+
TextContextMenu.Action(
249+
enabled = isNonEmptySelection(),
250+
execute = { copy()}
251+
)
240252
override val paste = null
241253
override val selectAll = null
242254
override fun selectWordAtPositionIfNotAlreadySelected(offset: Offset) {
@@ -283,22 +295,22 @@ interface TextContextMenu {
283295
/**
284296
* Action for cutting the selected text to the clipboard. Null if there is no text to cut.
285297
*/
286-
val cut: (() -> Unit)?
298+
val cut: Action?
287299

288300
/**
289301
* Action for copy the selected text to the clipboard. Null if there is no text to copy.
290302
*/
291-
val copy: (() -> Unit)?
303+
val copy: Action?
292304

293305
/**
294306
* Action for pasting text from the clipboard. Null if there is no text in the clipboard.
295307
*/
296-
val paste: (() -> Unit)?
308+
val paste: Action?
297309

298310
/**
299311
* Action for selecting the whole text. Null if the text is already selected.
300312
*/
301-
val selectAll: (() -> Unit)?
313+
val selectAll: Action?
302314

303315
/**
304316
* Selects the word at the given [offset], unless the current selection already encompasses
@@ -307,40 +319,56 @@ interface TextContextMenu {
307319
fun selectWordAtPositionIfNotAlreadySelected(offset: Offset)
308320
}
309321

322+
@ExperimentalFoundationApi
323+
class Action(val enabled: Boolean, val execute: () -> Unit)
324+
310325
companion object {
311326
/**
312327
* [TextContextMenu] that is used by default in Compose.
313328
*/
314329
@ExperimentalFoundationApi
315-
val Default = object : TextContextMenu {
316-
@Composable
317-
override fun Area(textManager: TextManager, state: ContextMenuState, content: @Composable () -> Unit) {
318-
val localization = LocalLocalization.current
319-
val items = {
320-
listOfNotNull(
321-
textManager.cut?.let {
322-
ContextMenuItem(localization.cut, it)
323-
},
324-
textManager.copy?.let {
325-
ContextMenuItem(localization.copy, it)
326-
},
327-
textManager.paste?.let {
328-
ContextMenuItem(localization.paste, it)
329-
},
330-
textManager.selectAll?.let {
331-
ContextMenuItem(localization.selectAll, it)
332-
},
333-
)
334-
}
335-
336-
TextContextMenuArea(
337-
textManager = textManager,
338-
items = items,
339-
state = state,
340-
content = content
341-
)
330+
val Default: TextContextMenu = BasicTextContextMenu(showDisabledItems = true)
331+
332+
/**
333+
* [TextContextMenu] that doesn't show any disabled items.
334+
*/
335+
@ExperimentalFoundationApi
336+
val HideDisabledMenuItems: TextContextMenu = BasicTextContextMenu(showDisabledItems = false)
337+
}
338+
}
339+
340+
/**
341+
* Basic implementation of [TextContextMenu].
342+
*/
343+
private class BasicTextContextMenu(
344+
val showDisabledItems: Boolean
345+
) : TextContextMenu {
346+
@Composable
347+
override fun Area(
348+
textManager: TextManager,
349+
state: ContextMenuState,
350+
content: @Composable () -> Unit
351+
) {
352+
val localization = LocalLocalization.current
353+
val items = {
354+
listOf(
355+
localization.cut to textManager.cut,
356+
localization.copy to textManager.copy,
357+
localization.paste to textManager.paste,
358+
localization.selectAll to textManager.selectAll,
359+
).mapNotNull { (localization, action) ->
360+
if (action == null) return@mapNotNull null
361+
if (!showDisabledItems && !action.enabled) return@mapNotNull null
362+
ContextMenuItem(localization, action.enabled, action.execute)
342363
}
343364
}
365+
366+
TextContextMenuArea(
367+
textManager = textManager,
368+
items = items,
369+
state = state,
370+
content = content
371+
)
344372
}
345373
}
346374

0 commit comments

Comments
 (0)