CursorAnchorInfoUtils.java revision dac49f9f6de83d9a0cb4d9e290340cde5297e0b3
1/*
2 * Copyright (C) 2014 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.inputmethod.latin.utils;
18
19import android.annotation.TargetApi;
20import android.graphics.Matrix;
21import android.graphics.Rect;
22import android.inputmethodservice.ExtractEditText;
23import android.inputmethodservice.InputMethodService;
24import android.os.Build;
25import android.text.Layout;
26import android.text.Spannable;
27import android.view.View;
28import android.view.ViewParent;
29import android.view.inputmethod.CursorAnchorInfo;
30import android.widget.TextView;
31
32import com.android.inputmethod.compat.BuildCompatUtils;
33import com.android.inputmethod.compat.CursorAnchorInfoCompatWrapper;
34
35import javax.annotation.Nonnull;
36import javax.annotation.Nullable;
37
38/**
39 * This class allows input methods to extract {@link CursorAnchorInfo} directly from the given
40 * {@link TextView}. This is useful and even necessary to support full-screen mode where the default
41 * {@link InputMethodService#onUpdateCursorAnchorInfo(CursorAnchorInfo)} event callback must be
42 * ignored because it reports the character locations of the target application rather than
43 * characters on {@link ExtractEditText}.
44 */
45public final class CursorAnchorInfoUtils {
46    private CursorAnchorInfoUtils() {
47        // This helper class is not instantiable.
48    }
49
50    private static boolean isPositionVisible(final View view, final float positionX,
51            final float positionY) {
52        final float[] position = new float[] { positionX, positionY };
53        View currentView = view;
54
55        while (currentView != null) {
56            if (currentView != view) {
57                // Local scroll is already taken into account in positionX/Y
58                position[0] -= currentView.getScrollX();
59                position[1] -= currentView.getScrollY();
60            }
61
62            if (position[0] < 0 || position[1] < 0 ||
63                    position[0] > currentView.getWidth() || position[1] > currentView.getHeight()) {
64                return false;
65            }
66
67            if (!currentView.getMatrix().isIdentity()) {
68                currentView.getMatrix().mapPoints(position);
69            }
70
71            position[0] += currentView.getLeft();
72            position[1] += currentView.getTop();
73
74            final ViewParent parent = currentView.getParent();
75            if (parent instanceof View) {
76                currentView = (View) parent;
77            } else {
78                // We've reached the ViewRoot, stop iterating
79                currentView = null;
80            }
81        }
82
83        // We've been able to walk up the view hierarchy and the position was never clipped
84        return true;
85    }
86
87    /**
88     * Extracts {@link CursorAnchorInfoCompatWrapper} from the given {@link TextView}.
89     * @param textView the target text view from which {@link CursorAnchorInfoCompatWrapper} is to
90     * be extracted.
91     * @return the {@link CursorAnchorInfoCompatWrapper} object based on the current layout.
92     * {@code null} if {@code Build.VERSION.SDK_INT} is 20 or prior or {@link TextView} is not
93     * ready to provide layout information.
94     */
95    @Nullable
96    public static CursorAnchorInfoCompatWrapper extractFromTextView(
97            @Nonnull final TextView textView) {
98        if (Build.VERSION.SDK_INT < BuildCompatUtils.VERSION_CODES_LXX) {
99            return null;
100        }
101        return CursorAnchorInfoCompatWrapper.wrap(extractFromTextViewInternal(textView));
102    }
103
104    /**
105     * Returns {@link CursorAnchorInfo} from the given {@link TextView}.
106     * @param textView the target text view from which {@link CursorAnchorInfo} is to be extracted.
107     * @return the {@link CursorAnchorInfo} object based on the current layout. {@code null} if it
108     * is not feasible.
109     */
110    @TargetApi(BuildCompatUtils.VERSION_CODES_LXX)
111    @Nullable
112    private static CursorAnchorInfo extractFromTextViewInternal(@Nonnull final TextView textView) {
113        final Layout layout = textView.getLayout();
114        if (layout == null) {
115            return null;
116        }
117
118        final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
119
120        final int selectionStart = textView.getSelectionStart();
121        builder.setSelectionRange(selectionStart, textView.getSelectionEnd());
122
123        // Construct transformation matrix from view local coordinates to screen coordinates.
124        final Matrix viewToScreenMatrix = new Matrix(textView.getMatrix());
125        final int[] viewOriginInScreen = new int[2];
126        textView.getLocationOnScreen(viewOriginInScreen);
127        viewToScreenMatrix.postTranslate(viewOriginInScreen[0], viewOriginInScreen[1]);
128        builder.setMatrix(viewToScreenMatrix);
129
130        if (layout.getLineCount() == 0) {
131            return null;
132        }
133        final Rect lineBoundsWithoutOffset = new Rect();
134        final Rect lineBoundsWithOffset = new Rect();
135        layout.getLineBounds(0, lineBoundsWithoutOffset);
136        textView.getLineBounds(0, lineBoundsWithOffset);
137        final float viewportToContentHorizontalOffset = lineBoundsWithOffset.left
138                - lineBoundsWithoutOffset.left - textView.getScrollX();
139        final float viewportToContentVerticalOffset = lineBoundsWithOffset.top
140                - lineBoundsWithoutOffset.top - textView.getScrollY();
141
142        final CharSequence text = textView.getText();
143        if (text instanceof Spannable) {
144            // Here we assume that the composing text is marked as SPAN_COMPOSING flag. This is not
145            // necessarily true, but basically works.
146            int composingTextStart = text.length();
147            int composingTextEnd = 0;
148            final Spannable spannable = (Spannable) text;
149            final Object[] spans = spannable.getSpans(0, text.length(), Object.class);
150            for (Object span : spans) {
151                final int spanFlag = spannable.getSpanFlags(span);
152                if ((spanFlag & Spannable.SPAN_COMPOSING) != 0) {
153                    composingTextStart = Math.min(composingTextStart,
154                            spannable.getSpanStart(span));
155                    composingTextEnd = Math.max(composingTextEnd, spannable.getSpanEnd(span));
156                }
157            }
158
159            final boolean hasComposingText =
160                    (0 <= composingTextStart) && (composingTextStart < composingTextEnd);
161            if (hasComposingText) {
162                final CharSequence composingText = text.subSequence(composingTextStart,
163                        composingTextEnd);
164                builder.setComposingText(composingTextStart, composingText);
165
166                final int minLine = layout.getLineForOffset(composingTextStart);
167                final int maxLine = layout.getLineForOffset(composingTextEnd - 1);
168                for (int line = minLine; line <= maxLine; ++line) {
169                    final int lineStart = layout.getLineStart(line);
170                    final int lineEnd = layout.getLineEnd(line);
171                    final int offsetStart = Math.max(lineStart, composingTextStart);
172                    final int offsetEnd = Math.min(lineEnd, composingTextEnd);
173                    final boolean ltrLine =
174                            layout.getParagraphDirection(line) == Layout.DIR_LEFT_TO_RIGHT;
175                    final float[] widths = new float[offsetEnd - offsetStart];
176                    layout.getPaint().getTextWidths(text, offsetStart, offsetEnd, widths);
177                    final float top = layout.getLineTop(line);
178                    final float bottom = layout.getLineBottom(line);
179                    for (int offset = offsetStart; offset < offsetEnd; ++offset) {
180                        final float charWidth = widths[offset - offsetStart];
181                        final boolean isRtl = layout.isRtlCharAt(offset);
182                        final float primary = layout.getPrimaryHorizontal(offset);
183                        final float secondary = layout.getSecondaryHorizontal(offset);
184                        // TODO: This doesn't work perfectly for text with custom styles and TAB
185                        // chars.
186                        final float left;
187                        final float right;
188                        if (ltrLine) {
189                            if (isRtl) {
190                                left = secondary - charWidth;
191                                right = secondary;
192                            } else {
193                                left = primary;
194                                right = primary + charWidth;
195                            }
196                        } else {
197                            if (!isRtl) {
198                                left = secondary;
199                                right = secondary + charWidth;
200                            } else {
201                                left = primary - charWidth;
202                                right = primary;
203                            }
204                        }
205                        // TODO: Check top-right and bottom-left as well.
206                        final float localLeft = left + viewportToContentHorizontalOffset;
207                        final float localRight = right + viewportToContentHorizontalOffset;
208                        final float localTop = top + viewportToContentVerticalOffset;
209                        final float localBottom = bottom + viewportToContentVerticalOffset;
210                        final boolean isTopLeftVisible = isPositionVisible(textView,
211                                localLeft, localTop);
212                        final boolean isBottomRightVisible =
213                                isPositionVisible(textView, localRight, localBottom);
214                        int characterBoundsFlags = 0;
215                        if (isTopLeftVisible || isBottomRightVisible) {
216                            characterBoundsFlags |= CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION;
217                        }
218                        if (!isTopLeftVisible || !isTopLeftVisible) {
219                            characterBoundsFlags |= CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION;
220                        }
221                        if (isRtl) {
222                            characterBoundsFlags |= CursorAnchorInfo.FLAG_IS_RTL;
223                        }
224                        // Here offset is the index in Java chars.
225                        builder.addCharacterBounds(offset, localLeft, localTop, localRight,
226                                localBottom, characterBoundsFlags);
227                    }
228                }
229            }
230        }
231
232        // Treat selectionStart as the insertion point.
233        if (0 <= selectionStart) {
234            final int offset = selectionStart;
235            final int line = layout.getLineForOffset(offset);
236            final float insertionMarkerX = layout.getPrimaryHorizontal(offset)
237                    + viewportToContentHorizontalOffset;
238            final float insertionMarkerTop = layout.getLineTop(line)
239                    + viewportToContentVerticalOffset;
240            final float insertionMarkerBaseline = layout.getLineBaseline(line)
241                    + viewportToContentVerticalOffset;
242            final float insertionMarkerBottom = layout.getLineBottom(line)
243                    + viewportToContentVerticalOffset;
244            final boolean isTopVisible =
245                    isPositionVisible(textView, insertionMarkerX, insertionMarkerTop);
246            final boolean isBottomVisible =
247                    isPositionVisible(textView, insertionMarkerX, insertionMarkerBottom);
248            int insertionMarkerFlags = 0;
249            if (isTopVisible || isBottomVisible) {
250                insertionMarkerFlags |= CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION;
251            }
252            if (!isTopVisible || !isBottomVisible) {
253                insertionMarkerFlags |= CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION;
254            }
255            if (layout.isRtlCharAt(offset)) {
256                insertionMarkerFlags |= CursorAnchorInfo.FLAG_IS_RTL;
257            }
258            builder.setInsertionMarkerLocation(insertionMarkerX, insertionMarkerTop,
259                    insertionMarkerBaseline, insertionMarkerBottom, insertionMarkerFlags);
260        }
261        return builder.build();
262    }
263}
264