diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index c0f7f9167..4c9cbbb04 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1542,6 +1542,20 @@ Camera Photos Attach + Formatting + Bold + Italic + Strikethrough + Code + Link + Bulleted list + Numbered list + Quote + Code block + Add a link + https:// + Enter a single link, like https://flipcash.com + Add Back to attach menu Take photo More camera controls diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 6dfcb12a3..1983e80ac 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -291,6 +291,10 @@ internal class ChatViewModel @AssistedInject constructor( */ val webLinkPreviewsEnabled: StateFlow = featureFlags.observe(FeatureFlag.WebLinkPreviews) + /** Whether the composer offers the formatting tools. Rendering formatted text is not flagged. */ + val composerFormattingEnabled: StateFlow = featureFlags.observe(FeatureFlag.ChatFormattingComposer) + + /** * Whether the transcript may be drawn. Saved link previews load at app start, but a chat opened * straight from a notification can beat them to it, and a card drawn before its saved answer diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index b066dc624..98bbcefa7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -86,6 +86,7 @@ import com.flipcash.shared.common.ui.profile.PrefetchProfileCover internal fun MessengerScreen(viewModel: ChatViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val webLinkPreviewsEnabled by viewModel.webLinkPreviewsEnabled.collectAsStateWithLifecycle() + val composerFormattingEnabled by viewModel.composerFormattingEnabled.collectAsStateWithLifecycle() val previewsReady by viewModel.previewsReady.collectAsStateWithLifecycle() val messages = viewModel.messages.collectAsLazyPagingItems() val mediaProgress = viewModel.mediaSendProgress.collectAsStateWithLifecycle() @@ -444,6 +445,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onAction = chatActionHandler, dispatch = viewModel::dispatchEvent, topBarHeight = barHeight, + formattingEnabled = composerFormattingEnabled, ) } if (behindBackdrop) { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index a4cb4cbde..8d72cce00 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -96,6 +96,7 @@ import com.flipcash.shared.chat.models.ChatActionHandler import com.flipcash.services.models.chat.ChatRuleRequirement import com.getcode.theme.CodeTheme import com.getcode.ui.components.chat.ChatInput +import com.getcode.ui.components.chat.ComposerFormatToolbarHost import com.getcode.ui.components.chat.ChatInputSubmit import com.getcode.ui.core.drawWithGradient import com.getcode.ui.core.measured @@ -133,6 +134,7 @@ internal fun UserControlBottomBar( onAction: ChatActionHandler, dispatch: (ChatViewModel.Event) -> Unit, topBarHeight: Dp = 0.dp, + formattingEnabled: Boolean = false, ) { if (state.isAnonymous) { DeactivatedChatBottomBar() @@ -401,6 +403,8 @@ internal fun UserControlBottomBar( .padding(horizontal = sideInset), ) { if (state.editing != null) cancelEdit() else sendCash() } } + val formatting = rememberComposerFormatting(state.chatInputState) + val markerDimming = rememberMarkerDimming(formattingEnabled) Row( modifier = Modifier .fillMaxWidth() @@ -422,10 +426,18 @@ internal fun UserControlBottomBar( ) } } + ComposerFormatToolbarHost( + enabled = formattingEnabled, + items = { formatting.toolbarItems() }, + ) { ChatInput( modifier = Modifier .testTag("chat_message_input") .hazeBlur(HazeInput.Sources(hazeState), material), + outputTransformation = markerDimming, + stackedLeading = if (formattingEnabled && state.editing == null) { + { FormatToggleButton(formatting) } + } else null, outside = if (state.editing != null) cancelEdit else null, trailingOutside = if (state.editing == null) sendCash else null, focusRequester = focusRequester, @@ -551,6 +563,7 @@ internal fun UserControlBottomBar( } }, ) + } // An edit starts from a long-press, which leaves the keyboard down, so the // composer has to claim focus itself or the pre-filled text sits unreachable. @@ -592,6 +605,18 @@ internal fun UserControlBottomBar( } } } + if (formattingEnabled && canType && formatting.stripOpen && keyboard.visible) { + FormatStrip(formatting, state.chatInputState) + } + if (formatting.linkSheetOpen) { + LinkSheet( + onDismiss = { formatting.linkSheetOpen = false }, + onConfirm = { + formatting.addLink(it) + formatting.linkSheetOpen = false + }, + ) + } } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerFormatControls.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerFormatControls.kt new file mode 100644 index 000000000..5ef283f9e --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerFormatControls.kt @@ -0,0 +1,275 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.DataObject +import androidx.compose.material.icons.rounded.FormatBold +import androidx.compose.material.icons.rounded.FormatItalic +import androidx.compose.material.icons.rounded.FormatListBulleted +import androidx.compose.material.icons.rounded.FormatListNumbered +import androidx.compose.material.icons.rounded.FormatQuote +import androidx.compose.material.icons.rounded.FormatStrikethrough +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.flipcash.core.R as CoreR +import com.flipcash.libs.textformat.ComposerEdit +import com.flipcash.libs.textformat.DraftRanges +import com.flipcash.libs.textformat.InlineFormat +import com.flipcash.libs.textformat.LineFormat +import com.flipcash.libs.textformat.consumedRanges +import com.flipcash.libs.textformat.insertLink +import com.flipcash.libs.textformat.isInlineActive +import com.flipcash.libs.textformat.isLineActive +import com.flipcash.libs.textformat.toggleInline +import com.flipcash.libs.textformat.toggleLine +import com.flipcash.shared.chat.ui.maskableUrlOrNull +import com.flipcash.shared.chat.ui.parseChatText +import com.flipcash.shared.chat.ui.protectedRangesOf +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.FormatAction +import com.getcode.ui.components.chat.ToolbarFormatItem + +private val Ranges = DraftRanges { protectedRangesOf(it) } + +/** + * The composer's formatting state: whether the strip is open, whether the link sheet is, and the + * edits the buttons make to [field]. Every edit goes through the shared helpers in + * `:libs:text-format`, so the parser is the one judge of what a button may do. + */ +@Stable +internal class ComposerFormatting(private val field: TextFieldState) { + var stripOpen by mutableStateOf(false) + var linkSheetOpen by mutableStateOf(false) + + private val text get() = field.text.toString() + private val from get() = field.selection.min + private val to get() = field.selection.max + + fun toggle(format: InlineFormat) { + toggleInline(text, from, to, format, Ranges)?.let(::apply) + } + + fun toggle(format: LineFormat) = apply(toggleLine(text, from, to, format)) + + fun isActive(format: InlineFormat) = isInlineActive(text, from, to, format, Ranges) + + fun isEnabled(format: InlineFormat) = + field.selection.collapsed || isActive(format) || toggleInline(text, from, to, format, Ranges) != null + + fun isActive(format: LineFormat) = isLineActive(text, from, to, format) + + fun addLink(url: String) = apply(insertLink(text, from, to, url)) + + private fun apply(edit: ComposerEdit) { + field.edit { + replace(0, length, edit.text) + selection = TextRange(edit.selectionStart, edit.selectionEnd) + } + } + + /** The items the system text toolbar leads with: none until there is a selection to wrap. */ + fun toolbarItems(): List { + if (field.selection.collapsed) return emptyList() + fun inline(key: String, label: String, format: InlineFormat) = + ToolbarFormatItem(key, label, isEnabled(format)) { toggle(format) } + return listOf( + inline("fmt_bold", "B", InlineFormat.Bold), + inline("fmt_italic", "I", InlineFormat.Italic), + inline("fmt_strike", "S", InlineFormat.Strike), + inline("fmt_code", "", InlineFormat.Code), + ToolbarFormatItem("fmt_link", "Link", true) { linkSheetOpen = true }, + ) + } +} + +@Composable +internal fun rememberComposerFormatting(field: TextFieldState): ComposerFormatting { + val formatting = remember(field) { ComposerFormatting(field) } + // A cleared draft is back to one line, where the toggle is gone, so the strip goes with it. + LaunchedEffect(field.text.isEmpty()) { + if (field.text.isEmpty()) formatting.stripOpen = false + } + return formatting +} + +/** Dims the characters the parser consumed, so the markers show but recede. */ +@Composable +internal fun rememberMarkerDimming(enabled: Boolean): OutputTransformation? { + val dim = CodeTheme.colors.textSecondary + return remember(enabled, dim) { + if (!enabled) null else OutputTransformation { + val raw = asCharSequence().toString() + consumedRanges(raw, parseChatText(raw)).forEach { + addStyle(SpanStyle(color = dim), it.first, it.last + 1) + } + } + } +} + +/** The `Aa` button that opens and closes the strip. */ +@Composable +internal fun FormatToggleButton(formatting: ComposerFormatting) { + val open = formatting.stripOpen + Box( + modifier = Modifier + .testTag("chat_format_toggle") + .size(com.getcode.ui.components.chat.ChatInputDefaults.AccessorySize) + .clip(CircleShape) + .background(Color.White.copy(alpha = if (open) 0.25f else 0.1f), CircleShape) + .clickable { formatting.stripOpen = !formatting.stripOpen }, + contentAlignment = Alignment.Center, + ) { + Text( + text = "Aa", + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) + } +} + +/** The nine buttons, docked under the composer in the keyboard's colour. */ +@Composable +internal fun FormatStrip(formatting: ComposerFormatting, field: TextFieldState) { + // Reads the field so the buttons follow the selection and the text. + val resources = androidx.compose.ui.platform.LocalResources.current + @Suppress("UNUSED_VARIABLE") val tick = field.text to field.selection + fun inline(id: String, icon: androidx.compose.ui.graphics.vector.ImageVector, res: Int, f: InlineFormat) = + FormatAction(id, icon, resources.getString(res), formatting.isActive(f), formatting.isEnabled(f)) { formatting.toggle(f) } + fun line(id: String, icon: androidx.compose.ui.graphics.vector.ImageVector, res: Int, f: LineFormat) = + FormatAction(id, icon, resources.getString(res), formatting.isActive(f), true) { formatting.toggle(f) } + com.getcode.ui.components.chat.ComposerFormatStrip( + inline = listOf( + inline("bold", Icons.Rounded.FormatBold, CoreR.string.description_chatFormatBold, InlineFormat.Bold), + inline("italic", Icons.Rounded.FormatItalic, CoreR.string.description_chatFormatItalic, InlineFormat.Italic), + inline("strike", Icons.Rounded.FormatStrikethrough, CoreR.string.description_chatFormatStrike, InlineFormat.Strike), + inline("code", Icons.Rounded.Code, CoreR.string.description_chatFormatCode, InlineFormat.Code), + FormatAction( + id = "link", + icon = Icons.Rounded.Link, + description = stringResource(CoreR.string.description_chatFormatLink), + selected = false, + enabled = true, + onClick = { formatting.linkSheetOpen = true }, + ), + ), + lines = listOf( + line("bulleted", Icons.Rounded.FormatListBulleted, CoreR.string.description_chatFormatBulleted, LineFormat.Bullet), + line("numbered", Icons.Rounded.FormatListNumbered, CoreR.string.description_chatFormatNumbered, LineFormat.Numbered), + line("quote", Icons.Rounded.FormatQuote, CoreR.string.description_chatFormatQuote, LineFormat.Quote), + line("codeblock", Icons.Rounded.DataObject, CoreR.string.description_chatFormatCodeBlock, LineFormat.CodeBlock), + ), + ) +} + +/** Asks for the URL, and accepts only exactly one detected link. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun LinkSheet(onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var input by remember { mutableStateOf("") } + var rejected by remember { mutableStateOf(false) } + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = CodeTheme.colors.background, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(CoreR.string.title_chatFormatLink), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Box( + Modifier + .fillMaxWidth() + .clip(CodeTheme.shapes.small) + .background(Color.White.copy(alpha = 0.08f)) + .padding(14.dp), + ) { + if (input.isEmpty()) { + Text( + text = stringResource(CoreR.string.hint_chatFormatLink), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + ) + } + BasicTextField( + value = input, + onValueChange = { input = it; rejected = false }, + modifier = Modifier.fillMaxWidth().testTag("chat_format_link_input"), + singleLine = true, + textStyle = CodeTheme.typography.textMedium.copy(color = CodeTheme.colors.textMain), + cursorBrush = SolidColor(CodeTheme.colors.textMain), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + } + if (rejected) { + Text( + text = stringResource(CoreR.string.error_chatFormatLink), + style = CodeTheme.typography.textSmall, + color = com.getcode.theme.TextError, + modifier = Modifier.testTag("chat_format_link_error"), + ) + } + Box( + modifier = Modifier + .testTag("chat_format_link_add") + .fillMaxWidth() + .clip(CodeTheme.shapes.small) + .background(Color.White) + .clickable { + val url = maskableUrlOrNull(input) + if (url == null) rejected = true else onConfirm(url) + } + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(CoreR.string.action_chatFormatLinkAdd), + style = CodeTheme.typography.textMedium, + color = Color.Black, + ) + } + } + } +} diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComposerLinkTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComposerLinkTest.kt new file mode 100644 index 000000000..40e310eff --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/ComposerLinkTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.shared.chat.ui + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** What the composer's link sheet accepts, and the ranges it hands the formatting helpers. */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ComposerLinkTest { + + @Test + fun `exactly one link is accepted`() { + assertEquals("https://flipcash.com", maskableUrlOrNull("https://flipcash.com")) + assertEquals("https://flipcash.com/a?b=1", maskableUrlOrNull(" https://flipcash.com/a?b=1 ")) + } + + @Test + fun `anything that is not exactly one link is rejected`() { + assertNull(maskableUrlOrNull("")) + assertNull(maskableUrlOrNull("hello")) + assertNull(maskableUrlOrNull("see https://flipcash.com")) + assertNull(maskableUrlOrNull("https://a.com https://b.com")) + } + + @Test + fun `links and mentions in a draft are protected`() { + val ranges = protectedRangesOf("hi @jeff see https://flipcash.com") + assertEquals(2, ranges.size) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/TextFormatting.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/TextFormatting.kt index 4a474f8fd..5b3730b99 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/TextFormatting.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/TextFormatting.kt @@ -47,3 +47,32 @@ fun cardLinks(text: String, links: List = detectUrls(text)): List= 2 && text[start - 1] == '(' && text[start - 2] == ']' && text.getOrNull(end) == ')' + +/** + * The links and mentions in [text] as the ranges a marker may not open or close inside. The + * composer hands these to the formatting helpers so a draft is judged by the same detection the + * transcript renders with. + */ +fun protectedRangesOf(text: String): List { + val links = detectUrls(text) + val mentions = detectMentions(text, links) + return buildList { + links.forEach { add(ProtectedRange(it.start, it.end, RangeKind.Link)) } + mentions.forEach { add(ProtectedRange(it.start, it.end, RangeKind.Mention)) } + } +} + +/** + * The URL to write into `[label](url)` for what the user typed in the link sheet, or null when it + * is not exactly one detected link. The parser would leave a mask around anything else literal, so + * the sheet rejects it instead of writing a message that shows brackets. + */ +fun maskableUrlOrNull(input: String): String? { + val typed = input.trim() + if (typed.isEmpty() || typed.any { it.isWhitespace() }) return null + val found = detectUrls(typed).singleOrNull() ?: return null + if (found.start != 0 || found.end != typed.length) return null + val probe = "[x]($typed)" + val masked = parseChatText(probe).displayRanges.any { it.target != null } + return if (masked) typed else null +} diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 169f53eb8..e051aabb3 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -99,6 +99,15 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } + @FeatureFlagMarker + data object ChatFormattingComposer : FeatureFlag { + override val key: String = "chat_formatting_composer" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false + } + companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -119,6 +128,7 @@ val FeatureFlag<*>.title: String FeatureFlag.BackgroundReset -> "Background Reset" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" FeatureFlag.WebLinkPreviews -> "Web Link Previews" + FeatureFlag.ChatFormattingComposer -> "Chat Formatting Tools" } val FeatureFlag<*>.message: String @@ -130,4 +140,5 @@ val FeatureFlag<*>.message: String FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" FeatureFlag.WebLinkPreviews -> "When enabled, a link to a web page in a chat shows the page's title, description and image, fetched from this device" + FeatureFlag.ChatFormattingComposer -> "When enabled, the chat composer offers bold, italic, strikethrough, code, link, list and quote formatting. Formatted messages always display; this only adds the tools to write them" } diff --git a/libs/text-format/src/commonMain/kotlin/com/flipcash/libs/textformat/ComposerFormatting.kt b/libs/text-format/src/commonMain/kotlin/com/flipcash/libs/textformat/ComposerFormatting.kt new file mode 100644 index 000000000..059ca98e8 --- /dev/null +++ b/libs/text-format/src/commonMain/kotlin/com/flipcash/libs/textformat/ComposerFormatting.kt @@ -0,0 +1,277 @@ +package com.flipcash.libs.textformat + +/** The inline styles a composer button wraps a selection in. */ +enum class InlineFormat(val marker: Char, val style: FormatStyle) { + Bold('*', FormatStyle.Bold), + Italic('_', FormatStyle.Italic), + Strike('~', FormatStyle.Strike), + Code('`', FormatStyle.Code), +} + +/** The styles a composer button adds to, or removes from, every line the selection touches. */ +enum class LineFormat { Bullet, Numbered, Quote, CodeBlock } + +/** The draft after a formatting button: [text], and the selection in it. */ +data class ComposerEdit(val text: String, val selectionStart: Int, val selectionEnd: Int) + +/** + * The links and mentions in a draft, as the parser wants them. Detection lives with each app, so + * the composer is handed it rather than doing its own. + */ +fun interface DraftRanges { + fun of(text: String): List + + companion object { + val None = DraftRanges { emptyList() } + } +} + +/** + * The draft with [format]'s markers around the selection, or with them taken off when they are + * already there. Null when the button does nothing: the selection is only whitespace, or the + * markers would not parse (a selection that starts mid-word), which is also when the button shows + * as disabled. The parser is the only judge of that. + * + * With no selection it inserts the pair and puts the cursor between, which arms the style for what + * is typed next. The cursor between a pair that is already there takes it out. + */ +fun toggleInline( + text: String, + selectionStart: Int, + selectionEnd: Int, + format: InlineFormat, + ranges: DraftRanges = DraftRanges.None, +): ComposerEdit? { + var start = minOf(selectionStart, selectionEnd).coerceIn(0, text.length) + var end = maxOf(selectionStart, selectionEnd).coerceIn(0, text.length) + val m = format.marker + + if (start == end) { + if (start >= 1 && start < text.length && text[start - 1] == m && text[start] == m) { + return ComposerEdit(text.removeRange(start - 1, start + 1), start - 1, start - 1) + } + val next = text.substring(0, start) + m + m + text.substring(start) + return ComposerEdit(next, start + 1, start + 1) + } + + while (start < end && text[start].isWhitespace()) start++ + while (end > start && text[end - 1].isWhitespace()) end-- + if (start == end) return null + + removalFor(text, start, end, format, ranges)?.let { return it } + + val wrapped = text.substring(0, start) + m + text.substring(start, end) + m + text.substring(end) + val before = parseTextFormat(text, ranges.of(text)) + val after = parseTextFormat(wrapped, ranges.of(wrapped)) + // The new markers parse when the display text is no longer than it was and still carries the + // style: markers that stayed literal would have made it two characters longer. + val consumed = after.display.length == before.display.length && + after.spans.count { it.style == format.style } >= before.spans.count { it.style == format.style } && + after.spans.any { it.style == format.style } + return if (consumed) ComposerEdit(wrapped, start + 1, end + 1) else null +} + +/** Whether [format] already covers the selection, so a button shows as selected. */ +fun isInlineActive( + text: String, + selectionStart: Int, + selectionEnd: Int, + format: InlineFormat, + ranges: DraftRanges = DraftRanges.None, +): Boolean { + var start = minOf(selectionStart, selectionEnd).coerceIn(0, text.length) + var end = maxOf(selectionStart, selectionEnd).coerceIn(0, text.length) + if (start == end) return false + while (start < end && text[start].isWhitespace()) start++ + while (end > start && text[end - 1].isWhitespace()) end-- + return start < end && removalFor(text, start, end, format, ranges) != null +} + +/** + * The markers around, or at the ends of, the trimmed selection, taken out. Only when the parser + * agrees the text carries [format] at all, so a stray `*` is not mistaken for bold. + */ +private fun removalFor( + text: String, + start: Int, + end: Int, + format: InlineFormat, + ranges: DraftRanges, +): ComposerEdit? { + val m = format.marker + val parsed = parseTextFormat(text, ranges.of(text)) + if (parsed.spans.none { it.style == format.style }) return null + val around = start >= 1 && end < text.length && text[start - 1] == m && text[end] == m + if (around) { + return ComposerEdit(text.removeRange(end, end + 1).removeRange(start - 1, start), start - 1, end - 1) + } + val within = end - start >= 3 && text[start] == m && text[end - 1] == m + if (within) { + return ComposerEdit(text.removeRange(end - 1, end).removeRange(start, start + 1), start, end - 2) + } + return null +} + +private const val QUOTE = "> " +private val LIST_PREFIX = Regex("""^(?:[-*] |\d{1,3}\. )""") +private const val FENCE = "```" + +/** + * [format] added to every line the selection touches, or removed from all of them when each + * already has it. A quote prefix goes first on a line, ahead of a list marker, and a list marker + * replaces the other kind. + */ +fun toggleLine( + text: String, + selectionStart: Int, + selectionEnd: Int, + format: LineFormat, +): ComposerEdit { + val start = minOf(selectionStart, selectionEnd).coerceIn(0, text.length) + val end = maxOf(selectionStart, selectionEnd).coerceIn(0, text.length) + val blockStart = text.lastIndexOf('\n', start - 1).let { if (start == 0) 0 else it + 1 } + val lastChar = if (end > start && text[end - 1] == '\n') end - 1 else end + val blockEnd = text.indexOf('\n', lastChar).let { if (it < 0) text.length else it } + val region = text.substring(blockStart, blockEnd) + + if (format == LineFormat.CodeBlock) { + val lines = region.split('\n') + return if (lines.size >= 3 && lines.first() == FENCE && lines.last() == FENCE) { + val inner = lines.subList(1, lines.size - 1).joinToString("\n") + ComposerEdit( + text.substring(0, blockStart) + inner + text.substring(blockEnd), + blockStart, + blockStart + inner.length, + ) + } else { + val wrapped = "$FENCE\n$region\n$FENCE" + ComposerEdit( + text.substring(0, blockStart) + wrapped + text.substring(blockEnd), + blockStart + FENCE.length + 1, + blockStart + FENCE.length + 1 + region.length, + ) + } + } + + val lines = region.split('\n') + val allHave = lines.filter { it.isNotBlank() }.let { filled -> + filled.isNotEmpty() && filled.all { hasPrefix(it, format) } + } + var number = 0 + val deltas = IntArray(lines.size) + val rewritten = lines.mapIndexed { index, line -> + val out = when { + allHave -> removePrefix(line, format) + line.isBlank() && lines.size > 1 -> line + else -> addPrefix(line, format, ++number) + } + deltas[index] = out.length - line.length + out + } + + // A cursor moves with the prefix added on its own line and every line before it. + fun map(position: Int): Int { + var consumed = blockStart + var shift = 0 + lines.forEachIndexed { index, line -> + shift += deltas[index] + if (position <= consumed + line.length || index == lines.lastIndex) { + val lineStart = blockStart + rewritten.take(index).sumOf { it.length + 1 } + return (position + shift).coerceAtLeast(lineStart) + } + consumed += line.length + 1 + } + return position + } + + val body = rewritten.joinToString("\n") + return ComposerEdit( + text.substring(0, blockStart) + body + text.substring(blockEnd), + map(start), + map(end), + ) +} + +/** Whether every non-blank line the selection touches already has [format]. */ +fun isLineActive(text: String, selectionStart: Int, selectionEnd: Int, format: LineFormat): Boolean { + val start = minOf(selectionStart, selectionEnd).coerceIn(0, text.length) + val end = maxOf(selectionStart, selectionEnd).coerceIn(0, text.length) + val blockStart = text.lastIndexOf('\n', start - 1).let { if (start == 0) 0 else it + 1 } + val lastChar = if (end > start && text[end - 1] == '\n') end - 1 else end + val blockEnd = text.indexOf('\n', lastChar).let { if (it < 0) text.length else it } + val lines = text.substring(blockStart, blockEnd).split('\n') + if (format == LineFormat.CodeBlock) { + return lines.size >= 3 && lines.first() == FENCE && lines.last() == FENCE + } + val filled = lines.filter { it.isNotBlank() } + return filled.isNotEmpty() && filled.all { hasPrefix(it, format) } +} + +private fun splitLine(line: String): Triple { + val quote = if (line.startsWith(QUOTE)) QUOTE else "" + val afterQuote = line.removePrefix(quote) + val list = LIST_PREFIX.find(afterQuote)?.value.orEmpty() + return Triple(quote, list, afterQuote.removePrefix(list)) +} + +private fun hasPrefix(line: String, format: LineFormat): Boolean { + val (quote, list, _) = splitLine(line) + return when (format) { + LineFormat.Quote -> quote.isNotEmpty() + LineFormat.Bullet -> list == "- " || list == "* " + LineFormat.Numbered -> list.isNotEmpty() && list[0].isDigit() + LineFormat.CodeBlock -> false + } +} + +private fun removePrefix(line: String, format: LineFormat): String { + val (quote, list, rest) = splitLine(line) + return when (format) { + LineFormat.Quote -> list + rest + LineFormat.Bullet, LineFormat.Numbered -> quote + rest + LineFormat.CodeBlock -> line + } +} + +private fun addPrefix(line: String, format: LineFormat, number: Int): String { + val (quote, list, rest) = splitLine(line) + return when (format) { + LineFormat.Quote -> QUOTE + list + rest + LineFormat.Bullet -> quote + "- " + rest + LineFormat.Numbered -> "$quote$number. $rest" + LineFormat.CodeBlock -> line + } +} + +/** The draft with a link written at the selection: `[selection](url)`, or [url] alone with none. */ +fun insertLink(text: String, selectionStart: Int, selectionEnd: Int, url: String): ComposerEdit { + val start = minOf(selectionStart, selectionEnd).coerceIn(0, text.length) + val end = maxOf(selectionStart, selectionEnd).coerceIn(0, text.length) + val written = if (start == end) url else "[${text.substring(start, end)}]($url)" + val next = text.substring(0, start) + written + text.substring(end) + return ComposerEdit(next, start + written.length, start + written.length) +} + +/** + * The offsets in [raw] of the characters the parser consumed, so a field can dim the markers it + * shows. [parsed] is `parseTextFormat(raw, ...)`; consumed characters are the ones the display text + * does not account for, matched left to right. + */ +fun consumedRanges(raw: String, parsed: FormattedText): List { + val display = parsed.display + if (display == raw) return emptyList() + val out = mutableListOf() + var j = 0 + var runStart = -1 + for (i in raw.indices) { + if (j < display.length && raw[i] == display[j]) { + if (runStart >= 0) out += runStart until i + runStart = -1 + j++ + } else if (runStart < 0) { + runStart = i + } + } + if (runStart >= 0) out += runStart until raw.length + return out +} diff --git a/libs/text-format/src/commonTest/kotlin/com/flipcash/libs/textformat/ComposerFormattingTest.kt b/libs/text-format/src/commonTest/kotlin/com/flipcash/libs/textformat/ComposerFormattingTest.kt new file mode 100644 index 000000000..85883f1ea --- /dev/null +++ b/libs/text-format/src/commonTest/kotlin/com/flipcash/libs/textformat/ComposerFormattingTest.kt @@ -0,0 +1,136 @@ +package com.flipcash.libs.textformat + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ComposerFormattingTest { + + private fun inline(text: String, from: Int, to: Int, format: InlineFormat) = + toggleInline(text, from, to, format) + + @Test + fun `a selection is wrapped and stays selected`() { + val edit = inline("say hello now", 4, 9, InlineFormat.Bold)!! + assertEquals("say *hello* now", edit.text) + assertEquals(5 to 10, edit.selectionStart to edit.selectionEnd) + } + + @Test + fun `whitespace at the ends of the selection stays outside the markers`() { + assertEquals("say *hello* now", inline("say hello now", 3, 10, InlineFormat.Bold)!!.text) + } + + @Test + fun `no selection inserts the pair and puts the cursor between`() { + val edit = inline("hi ", 3, 3, InlineFormat.Italic)!! + assertEquals("hi __", edit.text) + assertEquals(4, edit.selectionStart) + } + + @Test + fun `the cursor between an empty pair takes it out`() { + val edit = inline("hi __", 4, 4, InlineFormat.Italic)!! + assertEquals("hi ", edit.text) + assertEquals(3, edit.selectionStart) + } + + @Test + fun `a selection that starts mid-word does nothing for bold`() { + assertNull(inline("unbelievable", 2, 12, InlineFormat.Bold)) + } + + @Test + fun `a selection of only whitespace does nothing`() { + assertNull(inline("a b", 1, 4, InlineFormat.Bold)) + } + + @Test + fun `a second tap removes the markers`() { + val bold = inline("say hello now", 4, 9, InlineFormat.Bold)!! + val edit = inline(bold.text, bold.selectionStart, bold.selectionEnd, InlineFormat.Bold)!! + assertEquals("say hello now", edit.text) + assertEquals(4 to 9, edit.selectionStart to edit.selectionEnd) + } + + @Test + fun `a selection that includes the markers also removes them`() { + assertEquals("say hello now", inline("say *hello* now", 4, 11, InlineFormat.Bold)!!.text) + } + + @Test + fun `a button shows as selected only when its style covers the selection`() { + assertTrue(isInlineActive("say *hello* now", 5, 10, InlineFormat.Bold)) + assertFalse(isInlineActive("say *hello* now", 5, 10, InlineFormat.Italic)) + assertFalse(isInlineActive("say hello now", 4, 9, InlineFormat.Bold)) + assertFalse(isInlineActive("a * b *", 3, 4, InlineFormat.Bold)) + } + + @Test + fun `styles nest`() { + val edit = inline("*say hello*", 5, 10, InlineFormat.Italic)!! + assertEquals("*say _hello_*", edit.text) + } + + @Test + fun `a protected range stops a marker inside it`() { + val ranges = DraftRanges { listOf(ProtectedRange(0, it.length, RangeKind.Link)) } + assertNull(toggleInline("example.com/a_b", 12, 15, InlineFormat.Italic, ranges)) + } + + @Test + fun `a line prefix is added to every touched line and removed again`() { + val text = "one\ntwo\nthree" + val added = toggleLine(text, 2, 6, LineFormat.Bullet) + assertEquals("- one\n- two\nthree", added.text) + assertTrue(isLineActive(added.text, added.selectionStart, added.selectionEnd, LineFormat.Bullet)) + + val removed = toggleLine(added.text, added.selectionStart, added.selectionEnd, LineFormat.Bullet) + assertEquals(text, removed.text) + } + + @Test + fun `numbered lines count up and a bullet is replaced`() { + assertEquals("1. a\n2. b", toggleLine("a\nb", 0, 3, LineFormat.Numbered).text) + assertEquals("1. a", toggleLine("- a", 0, 0, LineFormat.Numbered).text) + } + + @Test + fun `a quote goes ahead of a list marker`() { + assertEquals("> - a", toggleLine("- a", 0, 0, LineFormat.Quote).text) + assertEquals("- a", toggleLine("> - a", 0, 0, LineFormat.Quote).text) + } + + @Test + fun `the cursor follows the prefix`() { + val edit = toggleLine("abc", 3, 3, LineFormat.Quote) + assertEquals("> abc", edit.text) + assertEquals(5, edit.selectionStart) + assertEquals(2, toggleLine("", 0, 0, LineFormat.Quote).selectionStart) + } + + @Test + fun `a code block is fenced and unfenced`() { + val fenced = toggleLine("a\nb", 0, 3, LineFormat.CodeBlock) + assertEquals("```\na\nb\n```", fenced.text) + assertEquals("a\nb", toggleLine(fenced.text, 0, fenced.text.length, LineFormat.CodeBlock).text) + } + + @Test + fun `a link wraps the selection or stands alone`() { + assertEquals( + "see [the docs](https://a.com) now", + insertLink("see the docs now", 4, 12, "https://a.com").text, + ) + assertEquals("see https://a.com", insertLink("see ", 4, 4, "https://a.com").text) + } + + @Test + fun `consumed markers are the characters the display text lacks`() { + val raw = "a *b* c" + assertEquals(listOf(2..2, 4..4), consumedRanges(raw, parseTextFormat(raw, emptyList()))) + assertEquals(emptyList(), consumedRanges("plain", parseTextFormat("plain", emptyList()))) + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index 8837bff52..059036ca3 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -348,6 +348,11 @@ fun ChatInput( // A control after the field, outside it (send cash). Makes way while the field holds text. // Null for none. trailingOutside: (@Composable () -> Unit)? = null, + // A control that joins the leading one in the row of controls once the text is stacked over it, + // such as the formatting toggle. Hidden while the field is a single row. Null for none. + stackedLeading: (@Composable () -> Unit)? = null, + // Applied to the text as it is drawn, for styling it without changing what is stored. + outputTransformation: androidx.compose.foundation.text.input.OutputTransformation? = null, ) { val textStyle = CodeTheme.typography.textMedium.copy( fontSize = 16.sp, @@ -501,6 +506,7 @@ fun ChatInput( .focusRequester(focusRequester), state = state, enabled = enabled, + outputTransformation = outputTransformation, textStyle = textStyle, cursorBrush = SolidColor(CodeTheme.colors.textMain), keyboardOptions = KeyboardOptions.Default.copy( @@ -527,11 +533,18 @@ fun ChatInput( }, ) } - if (leading != null) Box( + if (leading != null || stackedLeading != null) Box( Modifier .layoutId(Slot.Leading) .onSizeChanged { leadingWidth = it.width } - ) { leading() } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + leading?.invoke() + if (stacked && stackedLeading != null) { + Box(Modifier.padding(start = metrics.controlSpacing)) { stackedLeading() } + } + } + } Box( modifier = Modifier .layoutId(Slot.Send) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ComposerFormat.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ComposerFormat.kt new file mode 100644 index 000000000..b384216c5 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ComposerFormat.kt @@ -0,0 +1,241 @@ +package com.getcode.ui.components.chat + +import android.graphics.Rect +import android.view.ActionMode +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItem +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSeparator +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession +import androidx.compose.foundation.text.contextmenu.provider.LocalTextContextMenuToolbarProvider +import androidx.compose.foundation.text.contextmenu.provider.TextContextMenuDataProvider +import androidx.compose.foundation.text.contextmenu.provider.TextContextMenuProvider +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import kotlinx.coroutines.channels.Channel + +/** One button in [ComposerFormatStrip]. */ +data class FormatAction( + val id: String, + val icon: ImageVector, + val description: String, + val selected: Boolean, + val enabled: Boolean, + val onClick: () -> Unit, +) + +/** The colour the on-screen keyboard draws in, which the strip docks to and matches. */ +val ComposerFormatStripColor = Color(0xFF1B1B1F) + +/** + * An icon button that can be selected. Selected reads as a filled circle; disabled dims and stops + * taking taps. + */ +@Composable +fun SelectableIconButton( + icon: ImageVector, + contentDescription: String, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(40.dp) + .clip(CircleShape) + .background(if (selected) Color.White.copy(alpha = 0.18f) else Color.Transparent, CircleShape) + .alpha(if (enabled) 1f else 0.3f) + .semantics { + this.contentDescription = contentDescription + this.role = Role.Button + this.selected = selected + } + .clickable(enabled = enabled, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(22.dp), + ) + } +} + +/** + * The formatting strip docked to the top of the keyboard: [inline] styles, a divider, then [lines] + * styles. All of them fit without scrolling on a phone; the row scrolls only as a fallback on a + * narrow window. + */ +@Composable +fun ComposerFormatStrip( + inline: List, + lines: List, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .background(ComposerFormatStripColor) + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 4.dp) + .testTag("composer_format_strip"), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + inline.forEach { a -> + SelectableIconButton(a.icon, a.description, a.selected, a.enabled, a.onClick, Modifier.testTag("format_${a.id}")) + } + Box( + Modifier + .width(1.dp) + .height(20.dp) + .background(Color.White.copy(alpha = 0.2f)), + ) + lines.forEach { a -> + SelectableIconButton(a.icon, a.description, a.selected, a.enabled, a.onClick, Modifier.testTag("format_${a.id}")) + } + } +} + +/** One item the text toolbar shows ahead of the system's own, as a short text label. */ +data class ToolbarFormatItem( + val key: String, + val label: String, + val enabled: Boolean, + val onClick: () -> Unit, +) + +/** + * Puts [items] first in the system text toolbar of everything inside [content]. Icons do not render + * in that toolbar, so the labels are short text (B, I, S, , Link). The items are read each time + * the toolbar opens, so their enabled state follows the selection. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ComposerFormatToolbarHost( + enabled: Boolean, + items: () -> List, + content: @Composable () -> Unit, +) { + if (!enabled) { + content() + return + } + val view = LocalView.current + var coords by remember { mutableStateOf(null) } + val currentItems = rememberUpdatedState(items) + val provider = remember(view) { + FormatActionModeProvider(view, { currentItems.value() }) { coords } + } + CompositionLocalProvider(LocalTextContextMenuToolbarProvider provides provider) { + Box(Modifier.onGloballyPositioned { coords = it }) { content() } + } +} + +@OptIn(ExperimentalFoundationApi::class) +private class FormatActionModeProvider( + private val view: View, + private val items: () -> List, + private val coords: () -> LayoutCoordinates?, +) : TextContextMenuProvider { + override suspend fun showTextContextMenu(dataProvider: TextContextMenuDataProvider) { + val closed = Channel(Channel.CONFLATED) + val session = object : TextContextMenuSession { + override fun close() { + closed.trySend(Unit) + } + } + val callback = object : ActionMode.Callback2() { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + var order = 1 + var group = 1 + items().forEach { f -> + val o = order++ + menu.add(group, o, o, f.label).apply { + setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + isEnabled = f.enabled + setOnMenuItemClickListener { f.onClick(); closed.trySend(Unit); true } + } + } + group++ + dataProvider.data().components.forEach { c -> + when (c) { + is TextContextMenuItem -> { + val o = order++ + menu.add(group, o, o, c.label).apply { + setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) + setOnMenuItemClickListener { + with(c) { session.onClick() } + true + } + } + } + is TextContextMenuSeparator -> group++ + else -> Unit + } + } + return menu.size() > 0 + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false + override fun onActionItemClicked(mode: ActionMode, item: MenuItem) = false + override fun onDestroyActionMode(mode: ActionMode) { + closed.trySend(Unit) + } + + override fun onGetContentRect(mode: ActionMode, v: View?, outRect: Rect) { + val c = coords()?.takeIf { it.isAttached } ?: return + val r: ComposeRect = dataProvider.contentBounds(c).translate(c.positionInRoot()) + outRect.set(r.left.toInt(), r.top.toInt(), r.right.toInt(), r.bottom.toInt()) + } + } + val mode = view.startActionMode(callback, ActionMode.TYPE_FLOATING) ?: return + try { + closed.receive() + } finally { + mode.finish() + } + } +}