From 222e682161d1366611664c7becdb830ca59abbfe Mon Sep 17 00:00:00 2001 From: czq Date: Mon, 5 Dec 2022 15:56:01 +0800 Subject: [PATCH] Make TextAppearanceInfo support spannable text Extract the text appearance from the character before cursor when its TextPaint is overided by CharaterStyle spans. Also move the assignment code from Editor to TextAppearanceInfo#createFromTextView for easier testing. Bug: 261378534 Test: atest android.view.inputmethod.TextAppearanceInfoTest Change-Id: I9365767762bce5137f454260e342e833d3f35e55 --- .../view/inputmethod/TextAppearanceInfo.java | 67 +++++ core/java/android/widget/Editor.java | 40 +-- .../inputmethod/TextAppearanceInfoTest.java | 281 ++++++++++++++++++ 3 files changed, 349 insertions(+), 39 deletions(-) create mode 100644 core/tests/coretests/src/android/view/inputmethod/TextAppearanceInfoTest.java diff --git a/core/java/android/view/inputmethod/TextAppearanceInfo.java b/core/java/android/view/inputmethod/TextAppearanceInfo.java index 500c41cd1fed8..05717dd5d9307 100644 --- a/core/java/android/view/inputmethod/TextAppearanceInfo.java +++ b/core/java/android/view/inputmethod/TextAppearanceInfo.java @@ -31,7 +31,10 @@ import android.inputmethodservice.InputMethodService; import android.os.LocaleList; import android.os.Parcel; import android.os.Parcelable; +import android.text.Spanned; +import android.text.TextPaint; import android.text.method.TransformationMethod; +import android.text.style.CharacterStyle; import android.widget.TextView; import java.util.Objects; @@ -182,6 +185,70 @@ public final class TextAppearanceInfo implements Parcelable { mLinkTextColor = builder.mLinkTextColor; } + /** + * Creates a new instance of {@link TextAppearanceInfo} by extracting text appearance from the + * character before cursor in the target {@link TextView}. + * @param textView the target {@link TextView}. + * @return the new instance of {@link TextAppearanceInfo}. + * @hide + */ + @NonNull + public static TextAppearanceInfo createFromTextView(@NonNull TextView textView) { + final int selectionStart = textView.getSelectionStart(); + final CharSequence text = textView.getText(); + TextPaint textPaint = new TextPaint(); + textPaint.set(textView.getPaint()); // Copy from textView + if (text instanceof Spanned && text.length() > 0 && selectionStart > 0) { + // Extract the CharacterStyle spans that changes text appearance in the character before + // cursor. + Spanned spannedText = (Spanned) text; + int lastCh = selectionStart - 1; + CharacterStyle[] spans = spannedText.getSpans(lastCh, lastCh, CharacterStyle.class); + if (spans != null) { + for (CharacterStyle span: spans) { + // Exclude spans that end at lastCh + if (spannedText.getSpanStart(span) <= lastCh + && lastCh < spannedText.getSpanEnd(span)) { + span.updateDrawState(textPaint); // Override the TextPaint + } + } + } + } + Typeface typeface = textPaint.getTypeface(); + String systemFontFamilyName = null; + int textWeight = FontStyle.FONT_WEIGHT_UNSPECIFIED; + int textStyle = Typeface.NORMAL; + if (typeface != null) { + systemFontFamilyName = typeface.getSystemFontFamilyName(); + textWeight = typeface.getWeight(); + textStyle = typeface.getStyle(); + } + TextAppearanceInfo.Builder builder = new TextAppearanceInfo.Builder(); + builder.setTextSize(textPaint.getTextSize()) + .setTextLocales(textPaint.getTextLocales()) + .setSystemFontFamilyName(systemFontFamilyName) + .setTextFontWeight(textWeight) + .setTextStyle(textStyle) + .setShadowDx(textPaint.getShadowLayerDx()) + .setShadowDy(textPaint.getShadowLayerDy()) + .setShadowRadius(textPaint.getShadowLayerRadius()) + .setShadowColor(textPaint.getShadowLayerColor()) + .setElegantTextHeight(textPaint.isElegantTextHeight()) + .setLetterSpacing(textPaint.getLetterSpacing()) + .setFontFeatureSettings(textPaint.getFontFeatureSettings()) + .setFontVariationSettings(textPaint.getFontVariationSettings()) + .setTextScaleX(textPaint.getTextScaleX()) + .setTextColor(textPaint.getColor()) + .setLinkTextColor(textPaint.linkColor) + .setAllCaps(textView.isAllCaps()) + .setFallbackLineSpacing(textView.isFallbackLineSpacing()) + .setLineBreakStyle(textView.getLineBreakStyle()) + .setLineBreakWordStyle(textView.getLineBreakWordStyle()) + .setHighlightTextColor(textView.getHighlightColor()) + .setHintTextColor(textView.getCurrentHintTextColor()); + return builder.build(); + } + @Override public int describeContents() { return 0; diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java index 0a3ea8a9f356e..e643080ebdd96 100644 --- a/core/java/android/widget/Editor.java +++ b/core/java/android/widget/Editor.java @@ -21,7 +21,6 @@ import static android.widget.TextView.ACCESSIBILITY_ACTION_SMART_START_ID; import android.R; import android.animation.ValueAnimator; -import android.annotation.ColorInt; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; @@ -38,7 +37,6 @@ import android.content.UndoOperation; import android.content.UndoOwner; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; -import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; @@ -51,10 +49,8 @@ import android.graphics.RecordingCanvas; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.RenderNode; -import android.graphics.Typeface; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; -import android.graphics.fonts.FontStyle; import android.os.Build; import android.os.Bundle; import android.os.LocaleList; @@ -4774,41 +4770,7 @@ public class Editor { } if (includeTextAppearance) { - Typeface typeface = mTextView.getPaint().getTypeface(); - String systemFontFamilyName = null; - int textFontWeight = FontStyle.FONT_WEIGHT_UNSPECIFIED; - if (typeface != null) { - systemFontFamilyName = typeface.getSystemFontFamilyName(); - textFontWeight = typeface.getWeight(); - } - ColorStateList linkTextColors = mTextView.getLinkTextColors(); - @ColorInt int linkTextColor = linkTextColors != null - ? linkTextColors.getDefaultColor() : 0; - - TextAppearanceInfo.Builder appearanceBuilder = new TextAppearanceInfo.Builder(); - appearanceBuilder.setTextSize(mTextView.getTextSize()) - .setTextLocales(mTextView.getTextLocales()) - .setSystemFontFamilyName(systemFontFamilyName) - .setTextFontWeight(textFontWeight) - .setTextStyle(mTextView.getTypefaceStyle()) - .setAllCaps(mTextView.isAllCaps()) - .setShadowDx(mTextView.getShadowDx()) - .setShadowDy(mTextView.getShadowDy()) - .setShadowRadius(mTextView.getShadowRadius()) - .setShadowColor(mTextView.getShadowColor()) - .setElegantTextHeight(mTextView.isElegantTextHeight()) - .setFallbackLineSpacing(mTextView.isFallbackLineSpacing()) - .setLetterSpacing(mTextView.getLetterSpacing()) - .setFontFeatureSettings(mTextView.getFontFeatureSettings()) - .setFontVariationSettings(mTextView.getFontVariationSettings()) - .setLineBreakStyle(mTextView.getLineBreakStyle()) - .setLineBreakWordStyle(mTextView.getLineBreakWordStyle()) - .setTextScaleX(mTextView.getTextScaleX()) - .setHighlightTextColor(mTextView.getHighlightColor()) - .setTextColor(mTextView.getCurrentTextColor()) - .setHintTextColor(mTextView.getCurrentHintTextColor()) - .setLinkTextColor(linkTextColor); - builder.setTextAppearanceInfo(appearanceBuilder.build()); + builder.setTextAppearanceInfo(TextAppearanceInfo.createFromTextView(mTextView)); } imm.updateCursorAnchorInfo(mTextView, builder.build()); diff --git a/core/tests/coretests/src/android/view/inputmethod/TextAppearanceInfoTest.java b/core/tests/coretests/src/android/view/inputmethod/TextAppearanceInfoTest.java new file mode 100644 index 0000000000000..f93cd18a8521e --- /dev/null +++ b/core/tests/coretests/src/android/view/inputmethod/TextAppearanceInfoTest.java @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2022 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 android.view.inputmethod; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import android.annotation.Nullable; +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.fonts.FontStyle; +import android.graphics.text.LineBreakConfig; +import android.os.LocaleList; +import android.os.Parcel; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.ScaleXSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.SmallTest; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@SmallTest +@RunWith(AndroidJUnit4.class) +public class TextAppearanceInfoTest { + private static final float EPSILON = 0.0000001f; + private static final String TEST_TEXT = "Happy birthday!"; + private static final float TEXT_SIZE = 16.5f; + private static final LocaleList TEXT_LOCALES = LocaleList.forLanguageTags("en,ja"); + private static final String FONT_FAMILY_NAME = "sans-serif"; + private static final int TEXT_WEIGHT = FontStyle.FONT_WEIGHT_MEDIUM; + private static final int TEXT_STYLE = Typeface.ITALIC; + private static final boolean ALL_CAPS = true; + private static final float SHADOW_DX = 2.0f; + private static final float SHADOW_DY = 2.0f; + private static final float SHADOW_RADIUS = 2.0f; + private static final int SHADOW_COLOR = Color.GRAY; + private static final boolean ELEGANT_TEXT_HEIGHT = true; + private static final boolean FALLBACK_LINE_SPACING = true; + private static final float LETTER_SPACING = 5.0f; + private static final String FONT_FEATURE_SETTINGS = "smcp"; + private static final String FONT_VARIATION_SETTINGS = "'wdth' 1.0"; + private static final int LINE_BREAK_STYLE = LineBreakConfig.LINE_BREAK_STYLE_LOOSE; + private static final int LINE_BREAK_WORD_STYLE = LineBreakConfig.LINE_BREAK_WORD_STYLE_PHRASE; + private static final float TEXT_SCALEX = 1.5f; + private static final int HIGHLIGHT_TEXT_COLOR = Color.YELLOW; + private static final int TEXT_COLOR = Color.RED; + private static final int HINT_TEXT_COLOR = Color.GREEN; + private static final int LINK_TEXT_COLOR = Color.BLUE; + + private final Context mContext = InstrumentationRegistry.getInstrumentation().getContext(); + private final EditText mEditText = new EditText(mContext); + private final SpannableStringBuilder mSpannableText = new SpannableStringBuilder(TEST_TEXT); + private Canvas mCanvas; + + @Before + public void setUp() { + mEditText.setText(mSpannableText); + mEditText.getPaint().setTextSize(TEXT_SIZE); + mEditText.setTextLocales(TEXT_LOCALES); + Typeface family = Typeface.create(FONT_FAMILY_NAME, Typeface.NORMAL); + mEditText.setTypeface( + Typeface.create(family, TEXT_WEIGHT, (TEXT_STYLE & Typeface.ITALIC) != 0)); + mEditText.setAllCaps(ALL_CAPS); + mEditText.setShadowLayer(SHADOW_RADIUS, SHADOW_DX, SHADOW_DY, SHADOW_COLOR); + mEditText.setElegantTextHeight(ELEGANT_TEXT_HEIGHT); + mEditText.setFallbackLineSpacing(FALLBACK_LINE_SPACING); + mEditText.setLetterSpacing(LETTER_SPACING); + mEditText.setFontFeatureSettings(FONT_FEATURE_SETTINGS); + mEditText.setFontVariationSettings(FONT_VARIATION_SETTINGS); + mEditText.setLineBreakStyle(LINE_BREAK_STYLE); + mEditText.setLineBreakWordStyle(LINE_BREAK_WORD_STYLE); + mEditText.setTextScaleX(TEXT_SCALEX); + mEditText.setHighlightColor(HIGHLIGHT_TEXT_COLOR); + mEditText.setTextColor(TEXT_COLOR); + mEditText.setHintTextColor(HINT_TEXT_COLOR); + mEditText.setLinkTextColor(LINK_TEXT_COLOR); + ViewGroup.LayoutParams params = + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + mEditText.setLayoutParams(params); + mEditText.measure(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); + Bitmap bitmap = + Bitmap.createBitmap( + Math.max(1, mEditText.getMeasuredWidth()), + Math.max(1, mEditText.getMeasuredHeight()), + Bitmap.Config.ARGB_8888); + mEditText.layout(0, 0, mEditText.getMeasuredWidth(), mEditText.getMeasuredHeight()); + mCanvas = new Canvas(bitmap); + mEditText.draw(mCanvas); + } + + @Test + public void testCreateFromTextView_noSpan() { + assertTextAppearanceInfoContentsEqual(TextAppearanceInfo.createFromTextView(mEditText)); + } + + @Test + public void testCreateFromTextView_withSpan1() { + AbsoluteSizeSpan sizeSpan = new AbsoluteSizeSpan(30); + mSpannableText.setSpan(sizeSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + ForegroundColorSpan colorSpan = new ForegroundColorSpan(Color.CYAN); + mSpannableText.setSpan(colorSpan, 1, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + TypefaceSpan typefaceSpan = new TypefaceSpan("cursive"); + mSpannableText.setSpan(typefaceSpan, 2, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + mEditText.setText(mSpannableText); + + // |Happy birthday! + mEditText.setSelection(0); + TextAppearanceInfo info1 = TextAppearanceInfo.createFromTextView(mEditText); + assertEquals(info1.getTextSize(), TEXT_SIZE, EPSILON); + assertEquals(info1.getTextColor(), TEXT_COLOR); + assertEquals(info1.getSystemFontFamilyName(), FONT_FAMILY_NAME); + + // H|appy birthday! + mEditText.setSelection(1); + TextAppearanceInfo info2 = TextAppearanceInfo.createFromTextView(mEditText); + assertEquals(info2.getTextSize(), 30f, EPSILON); + assertEquals(info2.getTextColor(), TEXT_COLOR); + assertEquals(info2.getSystemFontFamilyName(), FONT_FAMILY_NAME); + + // Ha|ppy birthday! + mEditText.setSelection(2); + TextAppearanceInfo info3 = TextAppearanceInfo.createFromTextView(mEditText); + assertEquals(info3.getTextSize(), 30f, EPSILON); + assertEquals(info3.getTextColor(), Color.CYAN); + assertEquals(info3.getSystemFontFamilyName(), FONT_FAMILY_NAME); + + // Ha[ppy birthday!] + mEditText.setSelection(2, mSpannableText.length()); + TextAppearanceInfo info4 = TextAppearanceInfo.createFromTextView(mEditText); + assertEquals(info4.getTextSize(), 30f, EPSILON); + assertEquals(info4.getTextColor(), Color.CYAN); + assertEquals(info4.getSystemFontFamilyName(), FONT_FAMILY_NAME); + + // Happy| birthday! + mEditText.setSelection(5); + TextAppearanceInfo info5 = TextAppearanceInfo.createFromTextView(mEditText); + assertEquals(info5.getTextSize(), 30f, EPSILON); + assertEquals(info5.getTextColor(), Color.CYAN); + assertEquals(info5.getSystemFontFamilyName(), "cursive"); + } + + @Test + public void testCreateFromTextView_withSpan2() { + // aab| + SpannableStringBuilder spannableText = new SpannableStringBuilder("aab"); + + AbsoluteSizeSpan sizeSpan = new AbsoluteSizeSpan(30); + spannableText.setSpan(sizeSpan, 0, 3, Spanned.SPAN_INCLUSIVE_INCLUSIVE); + + ForegroundColorSpan colorSpan = new ForegroundColorSpan(Color.CYAN); + spannableText.setSpan(colorSpan, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + StyleSpan styleSpan = new StyleSpan(Typeface.BOLD); + spannableText.setSpan(styleSpan, 1, 2, Spanned.SPAN_EXCLUSIVE_INCLUSIVE); + + TypefaceSpan typefaceSpan = new TypefaceSpan("cursive"); + spannableText.setSpan(typefaceSpan, 3, 3, Spanned.SPAN_INCLUSIVE_INCLUSIVE); + + ScaleXSpan scaleXSpan = new ScaleXSpan(2.0f); + spannableText.setSpan(scaleXSpan, 3, 3, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + mEditText.setText(spannableText); + mEditText.setSelection(3); + TextAppearanceInfo info = TextAppearanceInfo.createFromTextView(mEditText); + + // The character before cursor 'b' should only have an AbsoluteSizeSpan. + assertEquals(info.getTextSize(), 30f, EPSILON); + assertEquals(info.getTextColor(), TEXT_COLOR); + assertEquals(info.getTextStyle(), TEXT_STYLE); + assertEquals(info.getSystemFontFamilyName(), FONT_FAMILY_NAME); + assertEquals(info.getTextScaleX(), TEXT_SCALEX, EPSILON); + } + + @Test + public void testCreateFromTextView_contradictorySpans() { + // Set multiple contradictory spans + AbsoluteSizeSpan sizeSpan1 = new AbsoluteSizeSpan(30); + CustomForegroundColorSpan colorSpan1 = new CustomForegroundColorSpan(Color.BLUE); + AbsoluteSizeSpan sizeSpan2 = new AbsoluteSizeSpan(10); + CustomForegroundColorSpan colorSpan2 = new CustomForegroundColorSpan(Color.GREEN); + + mSpannableText.setSpan(sizeSpan1, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + mSpannableText.setSpan(colorSpan1, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + mSpannableText.setSpan(sizeSpan2, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + mSpannableText.setSpan(colorSpan2, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + mEditText.setText(mSpannableText); + mEditText.draw(mCanvas); + mEditText.setSelection(3); + // Get a copy of the real TextPaint after setting the last span + TextPaint realTextPaint = colorSpan2.lastTextPaint; + assertNotNull(realTextPaint); + TextAppearanceInfo info1 = TextAppearanceInfo.createFromTextView(mEditText); + // Verify the real TextPaint equals the last span of multiple contradictory spans + assertEquals(info1.getTextSize(), 10f, EPSILON); + assertEquals(info1.getTextSize(), realTextPaint.getTextSize(), EPSILON); + assertEquals(info1.getTextColor(), Color.GREEN); + assertEquals(info1.getTextColor(), realTextPaint.getColor()); + assertEquals(info1.getSystemFontFamilyName(), FONT_FAMILY_NAME); + } + + private void assertTextAppearanceInfoContentsEqual(TextAppearanceInfo textAppearanceInfo) { + assertEquals(textAppearanceInfo.getTextSize(), TEXT_SIZE, EPSILON); + assertEquals(textAppearanceInfo.getTextLocales(), TEXT_LOCALES); + assertEquals(textAppearanceInfo.getSystemFontFamilyName(), FONT_FAMILY_NAME); + assertEquals(textAppearanceInfo.getTextFontWeight(), TEXT_WEIGHT); + assertEquals(textAppearanceInfo.getTextStyle(), TEXT_STYLE); + assertEquals(textAppearanceInfo.isAllCaps(), ALL_CAPS); + assertEquals(textAppearanceInfo.getShadowRadius(), SHADOW_RADIUS, EPSILON); + assertEquals(textAppearanceInfo.getShadowDx(), SHADOW_DX, EPSILON); + assertEquals(textAppearanceInfo.getShadowDy(), SHADOW_DY, EPSILON); + assertEquals(textAppearanceInfo.getShadowColor(), SHADOW_COLOR); + assertEquals(textAppearanceInfo.isElegantTextHeight(), ELEGANT_TEXT_HEIGHT); + assertEquals(textAppearanceInfo.isFallbackLineSpacing(), FALLBACK_LINE_SPACING); + assertEquals(textAppearanceInfo.getLetterSpacing(), LETTER_SPACING, EPSILON); + assertEquals(textAppearanceInfo.getFontFeatureSettings(), FONT_FEATURE_SETTINGS); + assertEquals(textAppearanceInfo.getFontVariationSettings(), FONT_VARIATION_SETTINGS); + assertEquals(textAppearanceInfo.getLineBreakStyle(), LINE_BREAK_STYLE); + assertEquals(textAppearanceInfo.getLineBreakWordStyle(), LINE_BREAK_WORD_STYLE); + assertEquals(textAppearanceInfo.getTextScaleX(), TEXT_SCALEX, EPSILON); + assertEquals(textAppearanceInfo.getTextColor(), TEXT_COLOR); + assertEquals(textAppearanceInfo.getHighlightTextColor(), HIGHLIGHT_TEXT_COLOR); + assertEquals(textAppearanceInfo.getHintTextColor(), HINT_TEXT_COLOR); + assertEquals(textAppearanceInfo.getLinkTextColor(), LINK_TEXT_COLOR); + } + + static class CustomForegroundColorSpan extends ForegroundColorSpan { + @Nullable public TextPaint lastTextPaint = null; + + CustomForegroundColorSpan(int color) { + super(color); + } + + CustomForegroundColorSpan(@NonNull Parcel src) { + super(src); + } + + @Override + public void updateDrawState(@NonNull TextPaint tp) { + super.updateDrawState(tp); + // Copy the real TextPaint + TextPaint tpCopy = new TextPaint(); + tpCopy.set(tp); + lastTextPaint = tpCopy; + } + } +}