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