tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit fa5bbfd5adef3cc4f78198c79dc526286387553f
parent bd28c9a0c61bdfa19426e87af91f83ab2ab97837
Author: Mugurell <Mugurell@users.noreply.github.com>
Date:   Wed,  3 Dec 2025 10:32:36 +0000

Bug 1999177 - Support cursor scroll in the composable toolbar r=android-reviewers,moyin

By using the BasicTextField version with TextFieldState instead of the one
with TextFieldValue.

Differential Revision: https://phabricator.services.mozilla.com/D274742

Diffstat:
Mmobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextField.kt | 381++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mmobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextFieldTest.kt | 28++++++++++++++++++++++++++++
2 files changed, 251 insertions(+), 158 deletions(-)

diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextField.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/main/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextField.kt @@ -17,19 +17,29 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions 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.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -52,23 +62,18 @@ import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.toClipEntry -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.OffsetMapping -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.TransformedText -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.sp import androidx.core.graphics.toColorInt import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import mozilla.components.compose.base.theme.AcornTheme import mozilla.components.compose.browser.toolbar.concept.BrowserToolbarTestTags.ADDRESSBAR_SEARCH_BOX @@ -106,50 +111,27 @@ internal fun InlineAutocompleteTextField( onUrlEdit: (BrowserToolbarQuery) -> Unit = {}, onUrlCommitted: (String) -> Unit = {}, ) { - var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } - LaunchedEffect(query) { - if (query != textFieldValue.text) { - textFieldValue = TextFieldValue( - text = query, - selection = when (showQueryAsPreselected) { - true -> TextRange(0, query.length) - false -> TextRange(query.length) - }, - ) - } - } + val textFieldState = rememberTextFieldState( + initialText = query, + initialSelection = when { + showQueryAsPreselected -> TextRange(0, query.length) + else -> TextRange(query.length) + }, + ) + var useSuggestion by remember { mutableStateOf(true) } + // Properties referenced in long lived lambdas + val currentSuggestion by rememberUpdatedState(suggestion) + val currentUseSuggestion by rememberUpdatedState(useSuggestion) val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } - var useSuggestion by remember { mutableStateOf(true) } val suggestionTextColor = MaterialTheme.colorScheme.onSurface val highlightBackgroundColor = Color(TEXT_HIGHLIGHT_COLOR.toColorInt()) - val suggestionVisualTransformation = remember(useSuggestion, suggestion, textFieldValue) { - when (textFieldValue.text.isEmpty() || !useSuggestion) { - true -> VisualTransformation.None - false -> AutocompleteVisualTransformation( - userInput = textFieldValue, - suggestion = suggestion, - textColor = suggestionTextColor, - textBackground = highlightBackgroundColor, - ) - } - } - - val localView = LocalView.current - LaunchedEffect(suggestion) { - suggestion?.text?.let { - @Suppress("DEPRECATION") - localView.announceForAccessibility(it) - } - } var suggestionBounds by remember { mutableStateOf<Rect?>(null) } val deviceLayoutDirection = LocalLayoutDirection.current + val scrollState = rememberScrollState() val context = LocalContext.current val defaultTextToolbar = LocalTextToolbar.current @@ -158,6 +140,32 @@ internal fun InlineAutocompleteTextField( val pasteInterceptorToolbar = remember(defaultTextToolbar, clipboard) { PasteSanitizerTextToolbar(context, defaultTextToolbar, clipboard, coroutineScope) } + DisposableEffect(Unit) { + onDispose { pasteInterceptorToolbar.hide() } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + LaunchedEffect(query) { + if (query != textFieldState.text.toString()) { + textFieldState.edit { + replace(0, length, query) + selection = TextRange(query.length) + } + } + } + + val localView = LocalView.current + LaunchedEffect(suggestion) { + if (useSuggestion) { + suggestion?.text?.let { + @Suppress("DEPRECATION") + localView.announceForAccessibility(it) + } + } + } // Always want the text to be entered left to right. CompositionLocalProvider( @@ -178,38 +186,7 @@ internal fun InlineAutocompleteTextField( }, ) { BasicTextField( - value = textFieldValue, - onValueChange = { newValue -> - // Remove suggestion if cursor placement changed - val onlySelectionChanged = textFieldValue.text == newValue.text && - textFieldValue.composition == newValue.composition && - textFieldValue.annotatedString == newValue.annotatedString - if (onlySelectionChanged) { - useSuggestion = false - textFieldValue = newValue - return@BasicTextField - } - - // Remove suggestion if user pressed backspace and - // only delete query characters for the next backspace after the suggestion was removed. - val originalText = textFieldValue.text - val newText = newValue.text - val isBackspaceHidingSuggestion = originalText.length == newText.length + 1 && - originalText.startsWith(newText) && - (useSuggestion && suggestion?.text?.startsWith(originalText) == true) - if (isBackspaceHidingSuggestion) { - useSuggestion = false - } else { - useSuggestion = true - onUrlEdit( - BrowserToolbarQuery( - previous = originalText, - current = newText, - ), - ) - textFieldValue = newValue - } - }, + state = textFieldState, modifier = modifier .testTag(ADDRESSBAR_SEARCH_BOX) .fillMaxWidth() @@ -227,80 +204,66 @@ internal fun InlineAutocompleteTextField( LayoutDirection.Rtl -> TextAlign.End }, ), + lineLimits = TextFieldLineLimits.SingleLine, + scrollState = scrollState, keyboardOptions = KeyboardOptions( showKeyboardOnFocus = true, keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, autoCorrectEnabled = !usePrivateModeQueries, ), - keyboardActions = KeyboardActions( - onGo = { - keyboardController?.hide() - val currentSuggestion = suggestion?.text - onUrlCommitted( - when (useSuggestion && currentSuggestion?.startsWith(textFieldValue.text) == true) { - true -> currentSuggestion - else -> textFieldValue.text - }, - ) - }, - ), - singleLine = true, - visualTransformation = suggestionVisualTransformation, + onKeyboardAction = { + keyboardController?.hide() + val currentText = textFieldState.text.toString() + val finalUrl = if (useSuggestion && suggestion?.text?.startsWith(currentText) == true) { + suggestion.text + } else { + currentText + } + onUrlCommitted(finalUrl) + }, + inputTransformation = remember(onUrlEdit) { + AutocompleteInputTransformation( + suggestion = { currentSuggestion }, + shouldUseSuggestion = { currentUseSuggestion }, + onSuggestionVisibilityChangeRequest = { useSuggestion = it }, + onUrlEdit = onUrlEdit, + ) + }, + outputTransformation = remember(suggestionTextColor) { + AutocompleteOutputTransformation( + suggestion = { currentSuggestion }, + shouldUseSuggestion = { currentUseSuggestion }, + textColor = suggestionTextColor, + textBackground = highlightBackgroundColor, + ) + }, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), onTextLayout = { layoutResult -> - val currentInput = textFieldValue.text + val currentInput = textFieldState.text suggestionBounds = when (currentInput.isEmpty()) { true -> null false -> try { - layoutResult.getBoundingBox(currentInput.length - 1) + layoutResult()?.getBoundingBox(currentInput.length - 1) } catch (_: IllegalArgumentException) { null } } }, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - decorationBox = { innerTextField -> - Box( - modifier = Modifier - .fillMaxWidth() - // Commit the suggestion when users tap on the outside of the typed in text. - .pointerInput(suggestion, suggestionBounds) { - awaitEachGesture { - val downEvent = awaitFirstDown(requireUnconsumed = false) - val bounds = suggestionBounds - val suggestion = suggestion?.text - if (bounds != null && suggestion != null && - bounds.right < downEvent.position.x - ) { - onUrlEdit( - BrowserToolbarQuery( - previous = textFieldValue.text, - current = suggestion, - ), - ) - textFieldValue = TextFieldValue( - text = suggestion, - selection = TextRange(suggestion.length), - ) - } - } - }, - contentAlignment = when (deviceLayoutDirection) { - LayoutDirection.Ltr -> Alignment.CenterStart - LayoutDirection.Rtl -> Alignment.CenterEnd + decorator = { innerTextField -> + AutocompleteDecorator( + hint = hint, + suggestion = when { + useSuggestion -> currentSuggestion + else -> null }, - ) { - if (textFieldValue.text.isEmpty()) { - Text( - text = hint, - style = TextStyle( - fontSize = TEXT_SIZE.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - innerTextField() - } + onSuggestionVisibilityChangeRequest = { useSuggestion = it }, + suggestionBounds = suggestionBounds, + textFieldState = textFieldState, + onUrlEdit = onUrlEdit, + deviceLayoutDirection = deviceLayoutDirection, + innerTextField = innerTextField, + ) }, ) } @@ -319,44 +282,146 @@ data class BrowserToolbarQuery( ) /** + * Helper for removing the suggestion or delete from the user query when backspace is pressed. + */ +@OptIn(ExperimentalFoundationApi::class) +private class AutocompleteInputTransformation( + private val suggestion: () -> AutocompleteResult?, + private val shouldUseSuggestion: () -> Boolean, + private val onSuggestionVisibilityChangeRequest: (Boolean) -> Unit, + private val onUrlEdit: (BrowserToolbarQuery) -> Unit, +) : InputTransformation { + override fun TextFieldBuffer.transformInput() { + val originalText = originalText.toString() + val newText = asCharSequence().toString() + val suggestion = suggestion()?.text + + val isBackspace = originalText.length > newText.length && originalText.startsWith(newText) + val isSuggestionVisible = shouldUseSuggestion() && + suggestion?.startsWith(originalText) == true && suggestion.length > originalText.length + val isCursorAtQueryEnd = originalSelection.collapsed && originalSelection.end == originalText.length + + if (isBackspace) { + onSuggestionVisibilityChangeRequest(false) + + val isBackspaceHidingSuggestion = isCursorAtQueryEnd && isSuggestionVisible + if (isBackspaceHidingSuggestion) { + // Avoid deleting text, just hide the suggestion. + revertAllChanges() + } else { + // Actually delete text and hide the suggestion. + onUrlEdit(BrowserToolbarQuery(previous = originalText, current = newText)) + } + } else { + if (originalText != newText) { + onSuggestionVisibilityChangeRequest(true) + onUrlEdit(BrowserToolbarQuery(previous = originalText, current = newText)) + } + } + } +} + +/** * Helper for showing the autocomplete suggestion inline with user's input. */ -private class AutocompleteVisualTransformation( - private val userInput: TextFieldValue, - private val suggestion: AutocompleteResult?, +@OptIn(ExperimentalFoundationApi::class) +private class AutocompleteOutputTransformation( + private val suggestion: () -> AutocompleteResult?, + private val shouldUseSuggestion: () -> Boolean, private val textColor: Color, private val textBackground: Color, -) : VisualTransformation { - - override fun filter(text: AnnotatedString): TransformedText { - if (suggestion?.text.isNullOrEmpty() || !suggestion.text.startsWith(userInput.text)) { - return TransformedText(AnnotatedString(userInput.text), OffsetMapping.Identity) - } +) : OutputTransformation { + override fun TextFieldBuffer.transformOutput() { + val userInput = asCharSequence() + val suggestion = suggestion() + if (!shouldUseSuggestion() || + suggestion?.text?.isEmpty() == true || + suggestion?.text?.startsWith(userInput) == false + ) { return } - val transformed = buildAnnotatedString { - append(userInput.text) - append( - AnnotatedString( - suggestion.text.removePrefix(userInput.text), - spanStyle = SpanStyle( - color = textColor, - background = textBackground, - ), + val suffix = suggestion?.text?.removePrefix(userInput) ?: return + if (suffix.isNotEmpty()) { + val originalLength = length + append(suffix) + addStyle( + SpanStyle( + color = textColor, + background = textBackground, ), + originalLength, + length, ) } + } +} - val offsetMapping = object : OffsetMapping { - override fun originalToTransformed(offset: Int): Int { - return offset +/** + * Helper for handling the text shown to the user: + * - show the current query or hint if query is empty. + * - dismisses the suggestion if cursor is placed in query. + * - commits the suggestion if cursor is placed in the suggestion or after it. + */ +@Composable +@Suppress("LongParameterList") +private fun AutocompleteDecorator( + hint: String, + suggestion: AutocompleteResult?, + onSuggestionVisibilityChangeRequest: (Boolean) -> Unit, + suggestionBounds: Rect?, + textFieldState: TextFieldState, + onUrlEdit: (BrowserToolbarQuery) -> Unit, + deviceLayoutDirection: LayoutDirection, + innerTextField: @Composable () -> Unit, +) { + // Stop using the suggestion if cursor is moved manually away from the end. + LaunchedEffect(textFieldState) { + snapshotFlow { textFieldState.selection } + .collectLatest { + if (it.end != textFieldState.text.length) { + onSuggestionVisibilityChangeRequest(false) + } } + } - override fun transformedToOriginal(offset: Int): Int { - return offset.coerceIn(0, userInput.text.length) - } + Box( + modifier = Modifier + .fillMaxWidth() + // Commit the suggestion when users tap on the outside of the typed in text. + .pointerInput(suggestion, suggestionBounds) { + awaitEachGesture { + val downEvent = awaitFirstDown(requireUnconsumed = false) + val suggestionText = suggestion?.text + if (suggestionBounds != null && suggestionText != null && + suggestionBounds.right < downEvent.position.x + ) { + onUrlEdit( + BrowserToolbarQuery( + previous = textFieldState.text.toString(), + current = suggestionText, + ), + ) + textFieldState.edit { + replace(0, length, suggestionText) + selection = TextRange(suggestionText.length) + } + } + } + }, + contentAlignment = when (deviceLayoutDirection) { + LayoutDirection.Ltr -> Alignment.CenterStart + LayoutDirection.Rtl -> Alignment.CenterEnd + }, + ) { + if (textFieldState.text.isEmpty()) { + Text( + text = hint, + style = LocalTextStyle.current.merge( + fontSize = TEXT_SIZE.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) } - - return TransformedText(transformed, offsetMapping) + innerTextField() } } diff --git a/mobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextFieldTest.kt b/mobile/android/android-components/components/compose/browser-toolbar/src/test/java/mozilla/components/compose/browser/toolbar/ui/InlineAutocompleteTextFieldTest.kt @@ -111,6 +111,34 @@ class InlineAutocompleteTextFieldTest { } @Test + fun `GIVEN an autocomplete suggestion is shown WHEN tapping outside the query, THEN commit the autocomplete suggestion`() { + val onUrlEdit: (BrowserToolbarQuery) -> Unit = mock() + val suggestion = AutocompleteResult( + input = "w", + text = "wikipedia.org", + url = "https://wikipedia.org", + source = "test", + totalItems = 1, + ) + + composeTestRule.setContent { + InlineAutocompleteTextField( + query = "w", + hint = "", + suggestion = suggestion, + showQueryAsPreselected = false, + usePrivateModeQueries = false, + onUrlEdit = onUrlEdit, + ) + } + + // Tapping on the very right is to the outside of the current query. + composeTestRule.onNodeWithTag(ADDRESSBAR_SEARCH_BOX).performTouchInput { click(position = centerRight) } + + verify(onUrlEdit).invoke(BrowserToolbarQuery(previous = "w", current = "wikipedia.org")) + } + + @Test fun `GIVEN a query and suggestion are shown WHEN backspace is first pressed THEN only clear the suggestion`() { val onUrlEdit: (BrowserToolbarQuery) -> Unit = mock() val suggestion = AutocompleteResult(