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()
+ }
+ }
+}