diff --git a/core/api/current.txt b/core/api/current.txt index 96517004b9f2b..b716f5cf2ba3a 100644 --- a/core/api/current.txt +++ b/core/api/current.txt @@ -636,6 +636,7 @@ package android { field public static final int ellipsize = 16842923; // 0x10100ab field public static final int ems = 16843096; // 0x1010158 field public static final int enableOnBackInvokedCallback = 16844396; // 0x101066c + field public static final int enableTextStylingShortcuts; field public static final int enableVrMode = 16844069; // 0x1010525 field public static final int enabled = 16842766; // 0x101000e field public static final int end = 16843996; // 0x10104dc @@ -2113,6 +2114,7 @@ package android { field public static final int addToDictionary = 16908330; // 0x102002a field public static final int autofill = 16908355; // 0x1020043 field public static final int background = 16908288; // 0x1020000 + field public static final int bold; field public static final int button1 = 16908313; // 0x1020019 field public static final int button2 = 16908314; // 0x102001a field public static final int button3 = 16908315; // 0x102001b @@ -2138,6 +2140,7 @@ package android { field public static final int inputExtractAccessories = 16908378; // 0x102005a field public static final int inputExtractAction = 16908377; // 0x1020059 field public static final int inputExtractEditText = 16908325; // 0x1020025 + field public static final int italic; field @Deprecated public static final int keyboardView = 16908326; // 0x1020026 field public static final int list = 16908298; // 0x102000a field public static final int list_container = 16908351; // 0x102003f @@ -2169,6 +2172,7 @@ package android { field public static final int textAssist = 16908353; // 0x1020041 field public static final int title = 16908310; // 0x1020016 field public static final int toggle = 16908311; // 0x1020017 + field public static final int underline; field public static final int undo = 16908338; // 0x1020032 field public static final int widget_frame = 16908312; // 0x1020018 } @@ -55989,9 +55993,11 @@ package android.widget { ctor public EditText(android.content.Context, android.util.AttributeSet, int, int); method public void extendSelection(int); method public android.text.Editable getText(); + method public boolean isStyleShortcutEnabled(); method public void selectAll(); method public void setSelection(int, int); method public void setSelection(int); + method public void setStyleShortcutsEnabled(boolean); } public interface ExpandableListAdapter { diff --git a/core/java/android/text/style/SpanUtils.java b/core/java/android/text/style/SpanUtils.java new file mode 100644 index 0000000000000..6b4bd1a763583 --- /dev/null +++ b/core/java/android/text/style/SpanUtils.java @@ -0,0 +1,351 @@ +/* + * 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.text.style; + +import android.annotation.IntRange; +import android.annotation.NonNull; +import android.graphics.Typeface; +import android.text.Spannable; +import android.text.Spanned; +import android.util.LongArray; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * @hide + */ +public class SpanUtils { + private SpanUtils() {} // Do not instantiate + + /** + * Toggle the bold state of the given range. + * + * If there is at least one character is not bold in the given range, make the entire region to + * be bold. If all characters of the given range is already bolded, this method removes bold + * style from the given selection. + * + * @param spannable a spannable string + * @param min minimum inclusive index of the selection. + * @param max maximum exclusive index of the selection. + * @return true if the selected region is toggled. + */ + public static boolean toggleBold(@NonNull Spannable spannable, + @IntRange(from = 0) int min, @IntRange(from = 0) int max) { + + if (min == max) { + return false; + } + + final StyleSpan[] boldSpans = spannable.getSpans(min, max, StyleSpan.class); + final ArrayList filteredBoldSpans = new ArrayList<>(); + for (StyleSpan span : boldSpans) { + if ((span.getStyle() & Typeface.BOLD) == Typeface.BOLD) { + filteredBoldSpans.add(span); + } + } + + if (!isCovered(spannable, filteredBoldSpans, min, max)) { + // At least one character doesn't have bold style. Making given region bold. + spannable.setSpan( + new StyleSpan(Typeface.BOLD), min, max, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + return true; + } + + // Span covers the entire selection. Removing spans from tha region. + for (int si = 0; si < filteredBoldSpans.size(); ++si) { + final StyleSpan span = filteredBoldSpans.get(si); + final int start = spannable.getSpanStart(span); + final int end = spannable.getSpanEnd(span); + final int flag = spannable.getSpanFlags(span); + + // If BOLD_ITALIC style is attached, need to set ITALIC span to the subtracted range. + final boolean needItalicSpan = (span.getStyle() & Typeface.ITALIC) == Typeface.ITALIC; + + if (start < min) { + if (end > max) { + // selection: ------------|===================|---------------- + // span: <--------------------------------> + // result: <-------> <----> + spannable.setSpan(span, start, min, flag); + spannable.setSpan(new StyleSpan(span.getStyle()), max, end, flag); + if (needItalicSpan) { + spannable.setSpan(new StyleSpan(Typeface.ITALIC), min, max, flag); + } + } else { + // selection: ------------|===================|---------------- + // span: <-----------> + // result: <-------> + spannable.setSpan(span, start, min, flag); + if (needItalicSpan) { + spannable.setSpan(new StyleSpan(Typeface.ITALIC), min, end, flag); + } + } + } else { + if (end > max) { + // selection: ------------|===================|---------------- + // span: <------------------------> + // result: <------------> + spannable.setSpan(span, max, end, flag); + if (needItalicSpan) { + spannable.setSpan(new StyleSpan(Typeface.ITALIC), max, end, flag); + } + } else { + // selection: ------------|===================|---------------- + // span: <-----------> + // result: + spannable.removeSpan(span); + if (needItalicSpan) { + spannable.setSpan(new StyleSpan(Typeface.ITALIC), start, end, flag); + } + } + } + } + return true; + } + + /** + * Toggle the italic state of the given range. + * + * If there is at least one character is not italic in the given range, make the entire region + * to be italic. If all characters of the given range is already italic, this method removes + * italic style from the given selection. + * + * @param spannable a spannable string + * @param min minimum inclusive index of the selection. + * @param max maximum exclusive index of the selection. + * @return true if the selected region is toggled. + */ + public static boolean toggleItalic(@NonNull Spannable spannable, + @IntRange(from = 0) int min, @IntRange(from = 0) int max) { + + if (min == max) { + return false; + } + + final StyleSpan[] boldSpans = spannable.getSpans(min, max, StyleSpan.class); + final ArrayList filteredBoldSpans = new ArrayList<>(); + for (StyleSpan span : boldSpans) { + if ((span.getStyle() & Typeface.ITALIC) == Typeface.ITALIC) { + filteredBoldSpans.add(span); + } + } + + if (!isCovered(spannable, filteredBoldSpans, min, max)) { + // At least one character doesn't have italic style. Making given region italic. + spannable.setSpan( + new StyleSpan(Typeface.ITALIC), min, max, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + return true; + } + + // Span covers the entire selection. Removing spans from tha region. + for (int si = 0; si < filteredBoldSpans.size(); ++si) { + final StyleSpan span = filteredBoldSpans.get(si); + final int start = spannable.getSpanStart(span); + final int end = spannable.getSpanEnd(span); + final int flag = spannable.getSpanFlags(span); + + // If BOLD_ITALIC style is attached, need to set BOLD span to the subtracted range. + final boolean needBoldSpan = (span.getStyle() & Typeface.BOLD) == Typeface.BOLD; + + if (start < min) { + if (end > max) { + // selection: ------------|===================|---------------- + // span: <--------------------------------> + // result: <-------> <----> + spannable.setSpan(span, start, min, flag); + spannable.setSpan(new StyleSpan(span.getStyle()), max, end, flag); + if (needBoldSpan) { + spannable.setSpan(new StyleSpan(Typeface.BOLD), min, max, flag); + } + } else { + // selection: ------------|===================|---------------- + // span: <-----------> + // result: <-------> + spannable.setSpan(span, start, min, flag); + if (needBoldSpan) { + spannable.setSpan(new StyleSpan(Typeface.BOLD), min, end, flag); + } + } + } else { + if (end > max) { + // selection: ------------|===================|---------------- + // span: <------------------------> + // result: <------------> + spannable.setSpan(span, max, end, flag); + if (needBoldSpan) { + spannable.setSpan(new StyleSpan(Typeface.BOLD), max, end, flag); + } + } else { + // selection: ------------|===================|---------------- + // span: <-----------> + // result: + spannable.removeSpan(span); + if (needBoldSpan) { + spannable.setSpan(new StyleSpan(Typeface.BOLD), start, end, flag); + } + } + } + } + return true; + } + + /** + * Toggle the underline state of the given range. + * + * If there is at least one character is not underlined in the given range, make the entire + * region to underlined. If all characters of the given range is already underlined, this + * method removes underline from the given selection. + * + * @param spannable a spannable string + * @param min minimum inclusive index of the selection. + * @param max maximum exclusive index of the selection. + * @return true if the selected region is toggled. + */ + public static boolean toggleUnderline(@NonNull Spannable spannable, + @IntRange(from = 0) int min, @IntRange(from = 0) int max) { + + if (min == max) { + return false; + } + + final List spans = + Arrays.asList(spannable.getSpans(min, max, UnderlineSpan.class)); + + if (!isCovered(spannable, spans, min, max)) { + // At least one character doesn't have underline style. Making given region underline. + spannable.setSpan(new UnderlineSpan(), min, max, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + return true; + } + // Span covers the entire selection. Removing spans from tha region. + for (int si = 0; si < spans.size(); ++si) { + final UnderlineSpan span = spans.get(si); + final int start = spannable.getSpanStart(span); + final int end = spannable.getSpanEnd(span); + final int flag = spannable.getSpanFlags(span); + + if (start < min) { + if (end > max) { + // selection: ------------|===================|---------------- + // span: <--------------------------------> + // result: <-------> <----> + spannable.setSpan(span, start, min, flag); + spannable.setSpan(new UnderlineSpan(), max, end, flag); + } else { + // selection: ------------|===================|---------------- + // span: <-----------> + // result: <-------> + spannable.setSpan(span, start, min, flag); + } + } else { + if (end > max) { + // selection: ------------|===================|---------------- + // span: <------------------------> + // result: <------------> + spannable.setSpan(span, max, end, flag); + } else { + // selection: ------------|===================|---------------- + // span: <-----------> + // result: + spannable.removeSpan(span); + } + } + } + return true; + } + + private static long pack(int from, int to) { + return ((long) from) << 32 | (long) to; + } + + private static int min(long packed) { + return (int) (packed >> 32); + } + + private static int max(long packed) { + return (int) (packed & 0xFFFFFFFFL); + } + + private static boolean hasIntersection(int aMin, int aMax, int bMin, int bMax) { + return aMin < bMax && bMin < aMax; + } + + private static long intersection(int aMin, int aMax, int bMin, int bMax) { + return pack(Math.max(aMin, bMin), Math.min(aMax, bMax)); + } + + private static boolean isCovered(@NonNull Spannable spannable, @NonNull List spans, + @IntRange(from = 0) int min, @IntRange(from = 0) int max) { + + if (min == max) { + return false; + } + + LongArray uncoveredRanges = new LongArray(); + LongArray nextUncoveredRanges = new LongArray(); + + uncoveredRanges.add(pack(min, max)); + + for (int si = 0; si < spans.size(); ++si) { + final T span = spans.get(si); + final int start = spannable.getSpanStart(span); + final int end = spannable.getSpanEnd(span); + + for (int i = 0; i < uncoveredRanges.size(); ++i) { + final long packed = uncoveredRanges.get(i); + final int uncoveredStart = min(packed); + final int uncoveredEnd = max(packed); + + if (!hasIntersection(start, end, uncoveredStart, uncoveredEnd)) { + // This span doesn't affect this uncovered range. Try next span. + nextUncoveredRanges.add(packed); + } else { + // This span has an intersection with uncovered range. Update the uncovered + // range. + long intersectionPack = intersection(start, end, uncoveredStart, uncoveredEnd); + int intersectStart = min(intersectionPack); + int intersectEnd = max(intersectionPack); + + // Uncovered Range : ----------|=======================|------------- + // Intersection : <----------> + // Remaining uncovered ranges: ----------|=====|----------|======|------------- + if (uncoveredStart != intersectStart) { + // There is still uncovered area on the left. + nextUncoveredRanges.add(pack(uncoveredStart, intersectStart)); + } + if (intersectEnd != uncoveredEnd) { + // There is still uncovered area on the right. + nextUncoveredRanges.add(pack(intersectEnd, uncoveredEnd)); + } + } + } + + if (nextUncoveredRanges.size() == 0) { + return true; + } + + // Swap the uncoveredRanges and nextUncoveredRanges and clear the next one. + final LongArray tmp = nextUncoveredRanges; + nextUncoveredRanges = uncoveredRanges; + uncoveredRanges = tmp; + nextUncoveredRanges.clear(); + } + + return false; + } +} diff --git a/core/java/android/widget/EditText.java b/core/java/android/widget/EditText.java index 2c612804d7183..aa2474d7903e2 100644 --- a/core/java/android/widget/EditText.java +++ b/core/java/android/widget/EditText.java @@ -17,13 +17,17 @@ package android.widget; import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; import android.text.Editable; import android.text.Selection; import android.text.Spannable; import android.text.TextUtils; import android.text.method.ArrowKeyMovementMethod; import android.text.method.MovementMethod; +import android.text.style.SpanUtils; import android.util.AttributeSet; +import android.view.KeyEvent; /* * This is supposed to be a *very* thin veneer over TextView. @@ -69,8 +73,18 @@ import android.util.AttributeSet; * See {@link android.R.styleable#EditText EditText Attributes}, * {@link android.R.styleable#TextView TextView Attributes}, * {@link android.R.styleable#View View Attributes} + * + * @attr ref android.R.styleable#EditText_enableTextStylingShortcuts */ public class EditText extends TextView { + + // True if the style shortcut is enabled. + private boolean mStyleShortcutsEnabled = false; + + private static final int ID_BOLD = android.R.id.bold; + private static final int ID_ITALIC = android.R.id.italic; + private static final int ID_UNDERLINE = android.R.id.underline; + public EditText(Context context) { this(context, null); } @@ -85,6 +99,20 @@ public class EditText extends TextView { public EditText(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); + + final Resources.Theme theme = context.getTheme(); + final TypedArray a = theme.obtainStyledAttributes(attrs, + com.android.internal.R.styleable.EditText, defStyleAttr, defStyleRes); + + final int n = a.getIndexCount(); + for (int i = 0; i < n; ++i) { + int attr = a.getIndex(i); + switch (attr) { + case com.android.internal.R.styleable.EditText_enableTextStylingShortcuts: + mStyleShortcutsEnabled = a.getBoolean(attr, false); + break; + } + } } @Override @@ -178,4 +206,77 @@ public class EditText extends TextView { protected boolean supportsAutoSizeText() { return false; } + + @Override + public boolean onKeyShortcut(int keyCode, KeyEvent event) { + if (event.hasModifiers(KeyEvent.META_CTRL_ON)) { + // Handle Ctrl-only shortcuts. + switch (keyCode) { + case KeyEvent.KEYCODE_B: + if (mStyleShortcutsEnabled && hasSelection()) { + return onTextContextMenuItem(ID_BOLD); + } + break; + case KeyEvent.KEYCODE_I: + if (mStyleShortcutsEnabled && hasSelection()) { + return onTextContextMenuItem(ID_ITALIC); + } + break; + case KeyEvent.KEYCODE_U: + if (mStyleShortcutsEnabled && hasSelection()) { + return onTextContextMenuItem(ID_UNDERLINE); + } + break; + } + } + return super.onKeyShortcut(keyCode, event); + } + + @Override + public boolean onTextContextMenuItem(int id) { + // TODO: Move to switch-case once the resource ID is finalized. + if (id == ID_BOLD || id == ID_ITALIC || id == ID_UNDERLINE) { + return performStylingAction(id); + } + return super.onTextContextMenuItem(id); + } + + private boolean performStylingAction(int actionId) { + final int selectionStart = getSelectionStart(); + final int selectionEnd = getSelectionEnd(); + if (selectionStart < 0 || selectionEnd < 0) { + return false; // There is no selection. + } + int min = Math.min(selectionStart, selectionEnd); + int max = Math.max(selectionStart, selectionEnd); + + + Spannable spannable = getText(); + if (actionId == ID_BOLD) { + return SpanUtils.toggleBold(spannable, min, max); + } else if (actionId == ID_ITALIC) { + return SpanUtils.toggleItalic(spannable, min, max); + } else if (actionId == ID_UNDERLINE) { + return SpanUtils.toggleUnderline(spannable, min, max); + } + + return false; + } + + /** + * Enables styls shortcuts, e.g. Ctrl+B for making text bold. + * + * @param enabled true for enabled, false for disabled. + */ + public void setStyleShortcutsEnabled(boolean enabled) { + mStyleShortcutsEnabled = enabled; + } + + /** + * Return true if style shortcut is enabled, otherwise returns false. + * @return true if style shortcut is enabled, otherwise returns false. + */ + public boolean isStyleShortcutEnabled() { + return mStyleShortcutsEnabled; + } } diff --git a/core/res/res/values/attrs.xml b/core/res/res/values/attrs.xml index c7153fcf86091..69c5043645444 100644 --- a/core/res/res/values/attrs.xml +++ b/core/res/res/values/attrs.xml @@ -4509,6 +4509,8 @@ + + diff --git a/core/res/res/values/ids.xml b/core/res/res/values/ids.xml index 082acbe3443a5..4ddbf58757b8a 100644 --- a/core/res/res/values/ids.xml +++ b/core/res/res/values/ids.xml @@ -70,6 +70,12 @@ + + + + + + diff --git a/core/res/res/values/public-staging.xml b/core/res/res/values/public-staging.xml index ad7d03e43d291..89741effb9452 100644 --- a/core/res/res/values/public-staging.xml +++ b/core/res/res/values/public-staging.xml @@ -115,9 +115,13 @@ + + + +