Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeRight
import androidx.lifecycle.Lifecycle
import androidx.test.espresso.Espresso
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

Expand Down Expand Up @@ -79,12 +79,13 @@ class NavigationTest {
// Chewie, we're home
assertConversationIsDisplayed()

// The profiles were popped off the back stack, so pressing back leaves the app
val activity = composeTestRule.activity
// The profiles were popped off the back stack, so pressing back leaves the app. API 31+
// moves a root launcher activity to the background instead of finishing it, so check
// that the activity stopped rather than that it is finishing.
val scenario = composeTestRule.activityRule.scenario
Espresso.pressBackUnconditionally()
composeTestRule.runOnIdle {
assertTrue(activity.isFinishing)
}
// Stopping waits on the launcher, which can take a few seconds on slow CI emulators.
composeTestRule.waitUntil(timeoutMillis = 5_000) { !scenario.state.isAtLeast(Lifecycle.State.STARTED) }
}

private fun assertConversationIsDisplayed() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,25 @@
package com.example.compose.jetchat

import androidx.activity.ComponentActivity
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.text.AnnotatedString
import androidx.test.espresso.Espresso
import com.example.compose.jetchat.conversation.ConversationContent
import com.example.compose.jetchat.conversation.KeyboardShownKey
Expand Down Expand Up @@ -133,6 +138,39 @@ class UserInputTest {
findSendButton().assertIsEnabled()
}

@Test
fun markdownMarkers_hiddenOnScreenButKeptInText() {
findTextInputField().performTextInput("*Hello*")

findTextInputField().assertTextEquals("Hello")
assertRawText("*Hello*")
}

@Test
fun typingAfterHiddenMarker_keepsTheMarker() {
findTextInputField().performTextInput("*Hello*")
// Display offset 5 is right after "Hello"; it maps onto the hidden closing '*', as a tap does.
// Move away first: setting the selection it already has is a no-op.
findTextInputField().performSemanticsAction(SemanticsActions.SetSelection) { it(0, 0, false) }
findTextInputField().performSemanticsAction(SemanticsActions.SetSelection) { it(5, 5, false) }
findTextInputField().performTextInput(" world")

assertRawText("*Hello* world")
}

@Test
fun typingBetweenTouchingTokens_keepsBothMarkers() {
findTextInputField().performTextInput("*a*_b_")
// Display offset 1 (between "a" and "b") maps onto the hidden '*' and '_' together.
findTextInputField().performSemanticsAction(SemanticsActions.SetSelection) { it(1, 1, false) }
findTextInputField().performTextInput("x")

assertRawText("*a*x_b_")
}

private fun assertRawText(text: String) = findTextInputField()
.assert(SemanticsMatcher.expectValue(SemanticsProperties.InputText, AnnotatedString(text)))

private fun clickOnTextField() = composeTestRule
.onNodeWithContentDescription(activity.getString(R.string.textfield_desc))
.performClick()
Expand All @@ -157,7 +195,7 @@ class UserInputTest {
private fun findTextInputField(): SemanticsNodeInteraction {
return composeTestRule.onNode(
hasSetTextAction() and
hasAnyAncestor(hasContentDescription(activity.getString(R.string.textfield_desc))),
hasContentDescription(activity.getString(R.string.textfield_desc)),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.then
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
Expand Down Expand Up @@ -92,6 +93,8 @@ fun rememberFormattedTextState(initialText: String = ""): FormattedTextState = r

/**
* A rich-text [BasicTextField] wrapper that accepts a [FormattedTextState].
*
* @param inputTransformation runs after the [state]'s own style-override transformation.
*/
@Composable
fun FormattedTextField(
Expand All @@ -103,11 +106,13 @@ fun FormattedTextField(
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
cursorBrush: Brush = SolidColor(Color.Black),
outputTransformation: OutputTransformation? = null,
inputTransformation: InputTransformation? = null,
) {
BasicTextField(
state = state.textFieldState,
modifier = modifier,
inputTransformation = state.inputTransformation,
inputTransformation = inputTransformation?.let { state.inputTransformation.then(it) }
?: state.inputTransformation,
textStyle = textStyle,
keyboardOptions = keyboardOptions,
onKeyboardAction = onKeyboardAction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.verticalScroll
Expand Down Expand Up @@ -111,6 +112,7 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
Expand Down Expand Up @@ -823,17 +825,29 @@ private fun BoxScope.UserInputTextField(
val codeBackground = MaterialTheme.colorScheme.surface
val markdownStyling = remember(codeBackground) {
val codeStyle = SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)
// Same syntax as the message bubbles (MessageFormatter). Markers stay visible while typing.
// Same syntax as the message bubbles (MessageFormatter). Markers are hidden on screen only,
// so the sent text keeps them and the bubble formats it the same way.
OutputTransformation {
symbolPattern.findAll(asCharSequence()).forEach { match ->
val text = asCharSequence().toString()
var removed = 0
symbolPattern.findAll(text).forEach { match ->
val style = when (match.value.first()) {
'@', '*' -> InlineStyle.Bold.spanStyle
'_' -> InlineStyle.Italic.spanStyle
'~' -> InlineStyle.Strikethrough.spanStyle
'`' -> codeStyle
else -> null // URLs
} ?: return@forEach
val start = match.range.first - removed
val end = match.range.last + 1 - removed
if (match.hidesMarkers()) {
replace(end - 1, end, "")
replace(start, start + 1, "")
addStyle(style, start, end - 2)
removed += 2
} else {
addStyle(style, start, end)
}
style?.let { addStyle(it, match.range.first, match.range.last + 1) }
}
}
}
Expand Down Expand Up @@ -861,6 +875,7 @@ private fun BoxScope.UserInputTextField(
color = MaterialTheme.colorScheme.primary,
),
outputTransformation = markdownStyling,
inputTransformation = SkipHiddenMarkers,
)

if (formattedTextState.isEmpty && !focusState) {
Expand All @@ -873,6 +888,31 @@ private fun BoxScope.UserInputTextField(
}
}

/** `*bold*`, `_italic_`, `~strike~` and `` `code` `` hide their markers in the input field. */
private fun MatchResult.hidesMarkers() = value.first() in "*_~`"

/**
* A tap next to hidden markers maps to a selection of just those markers (2 when tokens touch, as
* in `*a*_b_`), so the next keystroke would overwrite them. Collapse it to between the tokens:
* after closing markers, before opening ones.
*/
private val SkipHiddenMarkers = InputTransformation {
if (changes.changeCount != 0 || selection.collapsed) return@InputTransformation
val opening = mutableSetOf<Int>()
val closing = mutableSetOf<Int>()
symbolPattern.findAll(asCharSequence().toString()).forEach { match ->
if (match.hidesMarkers()) {
opening += match.range.first
closing += match.range.last
}
}
if ((selection.min until selection.max).all { it in opening || it in closing }) {
var cursor = selection.min
while (cursor in closing) cursor++
selection = TextRange(cursor)
}
}

@Composable
fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) {
var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) }
Expand Down
Loading