| /* |
| * Copyright 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.compose.foundation.text |
| |
| import androidx.ui.core.Constraints |
| import androidx.ui.core.LayoutCoordinates |
| import androidx.ui.core.LayoutDirection |
| import androidx.ui.geometry.Offset |
| import androidx.ui.geometry.Rect |
| import androidx.ui.graphics.Canvas |
| import androidx.ui.graphics.Color |
| import androidx.ui.input.CommitTextEditOp |
| import androidx.ui.input.EditOperation |
| import androidx.ui.input.EditProcessor |
| import androidx.ui.input.TextFieldValue |
| import androidx.ui.input.FinishComposingTextEditOp |
| import androidx.ui.input.ImeAction |
| import androidx.ui.input.KeyboardType |
| import androidx.ui.input.OffsetMap |
| import androidx.ui.input.SetSelectionEditOp |
| import androidx.ui.input.TextInputService |
| import androidx.ui.input.TransformedText |
| import androidx.ui.input.VisualTransformation |
| import androidx.ui.text.AnnotatedString |
| import androidx.ui.text.InternalTextApi |
| import androidx.ui.text.MultiParagraphIntrinsics |
| import androidx.ui.text.SpanStyle |
| import androidx.ui.text.TextDelegate |
| import androidx.ui.text.TextLayoutResult |
| import androidx.ui.text.TextRange |
| import androidx.ui.text.TextStyle |
| import androidx.ui.text.style.TextDecoration |
| import androidx.ui.unit.Density |
| import androidx.ui.unit.IntSize |
| import com.google.common.truth.Truth.assertThat |
| import com.nhaarman.mockitokotlin2.any |
| import com.nhaarman.mockitokotlin2.argumentCaptor |
| import com.nhaarman.mockitokotlin2.eq |
| import com.nhaarman.mockitokotlin2.mock |
| import com.nhaarman.mockitokotlin2.never |
| import com.nhaarman.mockitokotlin2.times |
| import com.nhaarman.mockitokotlin2.verify |
| import com.nhaarman.mockitokotlin2.whenever |
| import org.junit.Assert.assertEquals |
| import org.junit.Assert.assertTrue |
| import org.junit.Before |
| import org.junit.Test |
| import org.junit.runner.RunWith |
| import org.junit.runners.JUnit4 |
| |
| @OptIn(InternalTextApi::class) |
| @RunWith(JUnit4::class) |
| class TextFieldDelegateTest { |
| |
| private lateinit var canvas: Canvas |
| private lateinit var mDelegate: TextDelegate |
| private lateinit var processor: EditProcessor |
| private lateinit var onValueChange: (TextFieldValue) -> Unit |
| private lateinit var onEditorActionPerformed: (Any) -> Unit |
| private lateinit var textInputService: TextInputService |
| private lateinit var layoutCoordinates: LayoutCoordinates |
| private lateinit var multiParagraphIntrinsics: MultiParagraphIntrinsics |
| private lateinit var textLayoutResult: TextLayoutResult |
| |
| private val layoutDirection = LayoutDirection.Ltr |
| |
| /** |
| * Test implementation of offset map which doubles the offset in transformed text. |
| */ |
| private val skippingOffsetMap = object : OffsetMap { |
| override fun originalToTransformed(offset: Int): Int = offset * 2 |
| override fun transformedToOriginal(offset: Int): Int = offset / 2 |
| } |
| |
| @Before |
| fun setup() { |
| mDelegate = mock() |
| canvas = mock() |
| processor = mock() |
| onValueChange = mock() |
| onEditorActionPerformed = mock() |
| textInputService = mock() |
| layoutCoordinates = mock() |
| multiParagraphIntrinsics = mock() |
| textLayoutResult = mock() |
| } |
| |
| @Test |
| fun test_on_edit_command() { |
| val ops = listOf(CommitTextEditOp("Hello, World", 1)) |
| val dummyEditorState = TextFieldValue(text = "Hello, World", selection = TextRange(1)) |
| |
| whenever(processor.onEditCommands(ops)).thenReturn(dummyEditorState) |
| |
| TextFieldDelegate.onEditCommand(ops, processor, onValueChange) |
| |
| verify(onValueChange, times(1)).invoke(eq( |
| TextFieldValue( |
| text = dummyEditorState.text, |
| selection = dummyEditorState.selection |
| ) |
| )) |
| } |
| |
| @Test |
| fun test_on_release() { |
| val position = Offset(100f, 200f) |
| val offset = 10 |
| val dummyEditorState = TextFieldValue(text = "Hello, World", selection = TextRange(1)) |
| val dummyInputSessionToken = 10 // We are not using this value in this test. Just dummy. |
| |
| whenever(textLayoutResult.getOffsetForPosition(position)).thenReturn(offset) |
| |
| val captor = argumentCaptor<List<EditOperation>>() |
| |
| whenever(processor.onEditCommands(captor.capture())).thenReturn(dummyEditorState) |
| |
| TextFieldDelegate.onRelease( |
| position, |
| textLayoutResult, |
| processor, |
| OffsetMap.identityOffsetMap, |
| onValueChange, |
| textInputService, |
| dummyInputSessionToken, |
| true) |
| |
| assertEquals(1, captor.allValues.size) |
| assertEquals(1, captor.firstValue.size) |
| assertTrue(captor.firstValue[0] is SetSelectionEditOp) |
| verify(onValueChange, times(1)).invoke(eq( |
| TextFieldValue( |
| text = dummyEditorState.text, |
| selection = dummyEditorState.selection |
| ) |
| )) |
| verify(textInputService).showSoftwareKeyboard(eq(dummyInputSessionToken)) |
| } |
| |
| @Test |
| fun test_on_release_do_not_place_cursor_if_focus_is_out() { |
| val position = Offset(100f, 200f) |
| val offset = 10 |
| val dummyInputSessionToken = 10 // We are not using this value in this test. Just dummy. |
| |
| whenever(textLayoutResult.getOffsetForPosition(position)).thenReturn(offset) |
| TextFieldDelegate.onRelease( |
| position, |
| textLayoutResult, |
| processor, |
| OffsetMap.identityOffsetMap, |
| onValueChange, |
| textInputService, |
| dummyInputSessionToken, |
| false) |
| |
| verify(onValueChange, never()).invoke(any()) |
| verify(textInputService).showSoftwareKeyboard(eq(dummyInputSessionToken)) |
| } |
| |
| @Test |
| fun on_focus() { |
| val dummyEditorState = TextFieldValue(text = "Hello, World", selection = TextRange(1)) |
| TextFieldDelegate.onFocus(textInputService, dummyEditorState, processor, |
| KeyboardType.Text, ImeAction.Unspecified, onValueChange, onEditorActionPerformed) |
| verify(textInputService).startInput( |
| eq( |
| TextFieldValue( |
| text = dummyEditorState.text, |
| selection = dummyEditorState.selection |
| ) |
| ), |
| eq(KeyboardType.Text), |
| eq(ImeAction.Unspecified), |
| any(), |
| eq(onEditorActionPerformed) |
| ) |
| } |
| |
| @Test |
| fun on_blur() { |
| val captor = argumentCaptor<List<EditOperation>>() |
| val dummyInputSessionToken = 10 // We are not using this value in this test. Just dummy. |
| |
| whenever(processor.onEditCommands(captor.capture())).thenReturn(TextFieldValue()) |
| |
| TextFieldDelegate.onBlur( |
| textInputService, |
| dummyInputSessionToken, |
| processor, |
| true, |
| onValueChange) |
| |
| assertEquals(1, captor.allValues.size) |
| assertEquals(1, captor.firstValue.size) |
| assertTrue(captor.firstValue[0] is FinishComposingTextEditOp) |
| verify(textInputService).stopInput(eq(dummyInputSessionToken)) |
| verify(textInputService, never()).hideSoftwareKeyboard(any()) |
| } |
| |
| @Test |
| fun on_blur_with_hiding() { |
| val captor = argumentCaptor<List<EditOperation>>() |
| val dummyInputSessionToken = 10 // We are not using this value in this test. Just dummy. |
| |
| whenever(processor.onEditCommands(captor.capture())).thenReturn(TextFieldValue()) |
| |
| TextFieldDelegate.onBlur( |
| textInputService, |
| dummyInputSessionToken, |
| processor, |
| false, // There is no next focused client. Hide the keyboard. |
| onValueChange) |
| |
| assertEquals(1, captor.allValues.size) |
| assertEquals(1, captor.firstValue.size) |
| assertTrue(captor.firstValue[0] is FinishComposingTextEditOp) |
| verify(textInputService).stopInput(eq(dummyInputSessionToken)) |
| verify(textInputService).hideSoftwareKeyboard(eq(dummyInputSessionToken)) |
| } |
| |
| @Test |
| fun notify_focused_rect() { |
| val dummyRect = Rect(0f, 1f, 2f, 3f) |
| whenever(textLayoutResult.getBoundingBox(any())).thenReturn(dummyRect) |
| val dummyPoint = Offset(5f, 6f) |
| whenever(layoutCoordinates.localToRoot(any())).thenReturn(dummyPoint) |
| val dummyEditorState = TextFieldValue(text = "Hello, World", selection = TextRange(1)) |
| val dummyInputSessionToken = 10 // We are not using this value in this test. Just dummy. |
| TextFieldDelegate.notifyFocusedRect( |
| dummyEditorState, |
| mDelegate, |
| textLayoutResult, |
| layoutCoordinates, |
| textInputService, |
| dummyInputSessionToken, |
| true /* hasFocus */, |
| OffsetMap.identityOffsetMap |
| ) |
| verify(textInputService).notifyFocusedRect(eq(dummyInputSessionToken), any()) |
| } |
| |
| @Test |
| fun notify_focused_rect_without_focus() { |
| val dummyEditorState = TextFieldValue(text = "Hello, World", selection = TextRange(1)) |
| val dummyInputSessionToken = 10 // We are not using this value in this test. Just dummy. |
| TextFieldDelegate.notifyFocusedRect( |
| dummyEditorState, |
| mDelegate, |
| textLayoutResult, |
| layoutCoordinates, |
| textInputService, |
| dummyInputSessionToken, |
| false /* hasFocus */, |
| OffsetMap.identityOffsetMap |
| ) |
| verify(textInputService, never()).notifyFocusedRect(any(), any()) |
| } |
| |
| @Test |
| fun notify_rect_tail() { |
| val dummyRect = Rect(0f, 1f, 2f, 3f) |
| whenever(textLayoutResult.getBoundingBox(any())).thenReturn(dummyRect) |
| val dummyPoint = Offset(5f, 6f) |
| whenever(layoutCoordinates.localToRoot(any())).thenReturn(dummyPoint) |
| val dummyEditorState = TextFieldValue(text = "Hello, World", selection = TextRange(12)) |
| val dummyInputSessionToken = 10 // We are not using this value in this test. Just dummy. |
| TextFieldDelegate.notifyFocusedRect( |
| dummyEditorState, |
| mDelegate, |
| textLayoutResult, |
| layoutCoordinates, |
| textInputService, |
| dummyInputSessionToken, |
| true /* hasFocus */, |
| OffsetMap.identityOffsetMap |
| ) |
| verify(textInputService).notifyFocusedRect(eq(dummyInputSessionToken), any()) |
| } |
| |
| @Test |
| fun layout() { |
| val constraints = Constraints( |
| minWidth = 0, |
| maxWidth = 1280, |
| minHeight = 0, |
| maxHeight = 2048 |
| ) |
| |
| val dummyText = AnnotatedString(text = "Hello, World") |
| whenever(mDelegate.text).thenReturn(dummyText) |
| whenever(mDelegate.style).thenReturn(TextStyle()) |
| whenever(mDelegate.density).thenReturn(Density(1.0f)) |
| whenever(mDelegate.resourceLoader).thenReturn(mock()) |
| whenever(mDelegate.layout(any(), any(), eq(null))).thenReturn(textLayoutResult) |
| whenever(textLayoutResult.size).thenReturn(IntSize(1024, 512)) |
| |
| val (width, height, layoutResult) = TextFieldDelegate.layout( |
| mDelegate, |
| constraints, |
| layoutDirection |
| ) |
| assertEquals(1024f, width.toFloat()) |
| assertEquals(512f, height.toFloat()) |
| assertEquals(layoutResult, textLayoutResult) |
| } |
| |
| @Test |
| fun check_notify_rect_uses_offset_map() { |
| val dummyRect = Rect(0f, 1f, 2f, 3f) |
| val dummyPoint = Offset(5f, 6f) |
| val dummyEditorState = TextFieldValue(text = "Hello, World", selection = TextRange(1, 3)) |
| val dummyInputSessionToken = 10 // We are not using this value in this test. Just dummy. |
| whenever(textLayoutResult.getBoundingBox(any())).thenReturn(dummyRect) |
| whenever(layoutCoordinates.localToRoot(any())).thenReturn(dummyPoint) |
| |
| TextFieldDelegate.notifyFocusedRect( |
| dummyEditorState, |
| mDelegate, |
| textLayoutResult, |
| layoutCoordinates, |
| textInputService, |
| dummyInputSessionToken, |
| true /* hasFocus */, |
| skippingOffsetMap |
| ) |
| verify(textLayoutResult).getBoundingBox(6) |
| verify(textInputService).notifyFocusedRect(eq(dummyInputSessionToken), any()) |
| } |
| |
| @Test |
| fun check_on_release_uses_offset_map() { |
| val position = Offset(100f, 200f) |
| val offset = 10 |
| val dummyEditorState = TextFieldValue(text = "Hello, World", selection = TextRange(1)) |
| val dummyInputSessionToken = 10 // We are not using this value in this test. Just dummy. |
| |
| whenever(textLayoutResult.getOffsetForPosition(position)).thenReturn(offset) |
| |
| val captor = argumentCaptor<List<EditOperation>>() |
| |
| whenever(processor.onEditCommands(captor.capture())).thenReturn(dummyEditorState) |
| |
| TextFieldDelegate.onRelease( |
| position, |
| textLayoutResult, |
| processor, |
| skippingOffsetMap, |
| onValueChange, |
| textInputService, |
| dummyInputSessionToken, |
| true) |
| |
| val cursorOffsetInTransformedText = offset / 2 |
| assertEquals(1, captor.allValues.size) |
| assertEquals(1, captor.firstValue.size) |
| assertTrue(captor.firstValue[0] is SetSelectionEditOp) |
| val setSelectionEditOp = captor.firstValue[0] as SetSelectionEditOp |
| assertEquals(cursorOffsetInTransformedText, setSelectionEditOp.start) |
| assertEquals(cursorOffsetInTransformedText, setSelectionEditOp.end) |
| verify(onValueChange, times(1)).invoke(eq( |
| TextFieldValue( |
| text = dummyEditorState.text, |
| selection = dummyEditorState.selection |
| ) |
| )) |
| } |
| |
| @Test |
| fun use_identity_mapping_if_none_visual_transformation() { |
| val (visualText, offsetMap) = |
| VisualTransformation.None.filter(AnnotatedString(text = "Hello, World")) |
| |
| assertEquals("Hello, World", visualText.text) |
| for (i in 0..visualText.text.length) { |
| // Identity mapping returns if no visual filter is provided. |
| assertEquals(i, offsetMap.originalToTransformed(i)) |
| assertEquals(i, offsetMap.transformedToOriginal(i)) |
| } |
| } |
| |
| @Test |
| fun apply_composition_decoration() { |
| val identityOffsetMap = object : OffsetMap { |
| override fun originalToTransformed(offset: Int): Int = offset |
| override fun transformedToOriginal(offset: Int): Int = offset |
| } |
| |
| val input = TransformedText( |
| transformedText = AnnotatedString.Builder().apply { |
| pushStyle(SpanStyle(color = Color.Red)) |
| append("Hello, World") |
| }.toAnnotatedString(), |
| offsetMap = identityOffsetMap |
| ) |
| |
| val result = TextFieldDelegate.applyCompositionDecoration( |
| compositionRange = TextRange(3, 6), |
| transformed = input |
| ) |
| |
| assertThat(result.transformedText.text).isEqualTo(input.transformedText.text) |
| assertThat(result.transformedText.spanStyles.size).isEqualTo(2) |
| assertThat(result.transformedText.spanStyles).contains( |
| AnnotatedString.Range(SpanStyle(textDecoration = TextDecoration.Underline), 3, 6) |
| ) |
| } |
| } |