Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 66260c60 authored by Seigo Nonaka's avatar Seigo Nonaka
Browse files

Implement Ctrl+B/I/U shorcut

These shortcut works as follows:

- If the selected region is empty, do nothing.
- If the selected region has at least one character that is not styled,
  e.g. bold, apply bold style.
- If the selected region is fully styled, e.g. bold, remove bold style
  from the region.

The default TextView/EditText supports styled editing, but it is used
commonly as plain text fields, e.g. email address in the form, etc.
So, not to style the text in such places, disable styled shortcut by
 default and let developers to enable it when necessary.

Bug: 240628246
Test: TextViewStyleShortcutTest
Change-Id: Id0f1cd85eb198553a852c5c2b628f5d03a2741e1
parent 266bef46
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -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 {
+351 −0
Original line number Diff line number Diff line
/*
 * 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<StyleSpan> 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<StyleSpan> 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<UnderlineSpan> 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 <T> boolean isCovered(@NonNull Spannable spannable, @NonNull List<T> 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;
    }
}
+101 −0
Original line number Diff line number Diff line
@@ -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;
    }
}
+2 −0
Original line number Diff line number Diff line
@@ -4509,6 +4509,8 @@
        </attr>
    </declare-styleable>
    <declare-styleable name="EditText">
        <!-- Enables styling shortcuts, e.g. Ctrl+B for bold. This is off by default.  -->
        <attr name="enableTextStylingShortcuts" format="boolean" />
    </declare-styleable>
    <declare-styleable name="FastScroll">
        <!-- Drawable used for the scroll bar thumb. -->
+6 −0
Original line number Diff line number Diff line
@@ -70,6 +70,12 @@
  <item type="id" name="cut" />
  <item type="id" name="copy" />
  <item type="id" name="paste" />
  <!-- Editor action that makes selected text bold. -->
  <item type="id" name="bold" />
  <!-- Editor action that makes selected text italic. -->
  <item type="id" name="italic" />
  <!-- Editor action that makes selected text underline. -->
  <item type="id" name="underline" />
  <item type="id" name="copyUrl" />
  <item type="id" name="selectTextMode" />
  <item type="id" name="switchInputMethod" />
Loading