Index: content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java |
diff --git a/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java b/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java |
new file mode 100644 |
index 0000000000000000000000000000000000000000..19d8d9cfa29d6bde0b4e8970f808fe56112ca761 |
--- /dev/null |
+++ b/content/public/android/java/src/org/chromium/content/browser/input/SuggestionsPopupWindow.java |
@@ -0,0 +1,380 @@ |
+// Copyright 2017 The Chromium Authors. All rights reserved. |
+// Use of this source code is governed by a BSD-style license that can be |
+// found in the LICENSE file. |
+ |
+package org.chromium.content.browser.input; |
+ |
+import android.app.Activity; |
+import android.content.Context; |
+import android.content.Intent; |
+import android.graphics.Color; |
+import android.graphics.Rect; |
+import android.graphics.drawable.ColorDrawable; |
+import android.os.Build; |
+import android.util.DisplayMetrics; |
+import android.view.Gravity; |
+import android.view.LayoutInflater; |
+import android.view.View; |
+import android.view.ViewGroup; |
+import android.widget.AdapterView; |
+import android.widget.AdapterView.OnItemClickListener; |
+import android.widget.BaseAdapter; |
+import android.widget.LinearLayout; |
+import android.widget.ListView; |
+import android.widget.PopupWindow; |
+import android.widget.PopupWindow.OnDismissListener; |
+import android.widget.TextView; |
+ |
+import org.chromium.base.ApiCompatibilityUtils; |
+import org.chromium.base.VisibleForTesting; |
+import org.chromium.content.R; |
+import org.chromium.content.browser.WindowAndroidProvider; |
+import org.chromium.ui.UiUtils; |
+ |
+/** |
+ * Popup window that displays a menu for viewing and applying text replacement suggestions. |
+ */ |
+public class SuggestionsPopupWindow |
+ implements OnItemClickListener, OnDismissListener, View.OnClickListener { |
+ private static final String ACTION_USER_DICTIONARY_INSERT = |
+ "com.android.settings.USER_DICTIONARY_INSERT"; |
+ private static final String USER_DICTIONARY_EXTRA_WORD = "word"; |
+ |
+ private final Context mContext; |
+ private final TextSuggestionHost mTextSuggestionHost; |
+ private final View mParentView; |
+ private final WindowAndroidProvider mWindowAndroidProvider; |
+ |
+ private Activity mActivity; |
+ private DisplayMetrics mDisplayMetrics; |
+ private PopupWindow mPopupWindow; |
+ private LinearLayout mContentView; |
+ |
+ private SuggestionAdapter mSuggestionsAdapter; |
+ private String mHighlightedText; |
+ private String[] mSpellCheckSuggestions = new String[0]; |
+ private int mNumberOfSuggestionsToUse; |
+ private TextView mAddToDictionaryButton; |
+ private TextView mDeleteButton; |
+ private ListView mSuggestionListView; |
+ private LinearLayout mListFooter; |
+ private View mDivider; |
+ private int mPopupVerticalMargin; |
+ |
+ private boolean mDismissedByItemTap; |
+ |
+ /** |
+ * @param context Android context to use. |
+ * @param textSuggestionHost TextSuggestionHost instance (used to communicate with Blink). |
+ * @param parentView The view used to attach the PopupWindow. |
+ * @param windowAndroidProvider A WindowAndroidProvider instance used to get the window size. |
+ */ |
+ public SuggestionsPopupWindow(Context context, TextSuggestionHost textSuggestionHost, |
+ View parentView, WindowAndroidProvider windowAndroidProvider) { |
+ mContext = context; |
+ mTextSuggestionHost = textSuggestionHost; |
+ mParentView = parentView; |
+ mWindowAndroidProvider = windowAndroidProvider; |
+ |
+ createPopupWindow(); |
+ initContentView(); |
+ |
+ mPopupWindow.setContentView(mContentView); |
+ } |
+ |
+ private void createPopupWindow() { |
+ mPopupWindow = new PopupWindow(); |
+ mPopupWindow.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); |
+ mPopupWindow.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); |
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { |
+ // On Lollipop and later, we use elevation to create a drop shadow effect. |
+ // On pre-Lollipop, we use a background image instead (in the layout file). |
+ mPopupWindow.setElevation(mContext.getResources().getDimensionPixelSize( |
+ R.dimen.text_suggestion_popup_elevation)); |
+ } else { |
+ // The PopupWindow does not properly dismiss on Jelly Bean without the following line. |
+ mPopupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); |
+ } |
+ mPopupWindow.setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); |
+ mPopupWindow.setFocusable(true); |
+ mPopupWindow.setClippingEnabled(false); |
+ mPopupWindow.setOnDismissListener(this); |
+ } |
+ |
+ private void initContentView() { |
+ final LayoutInflater inflater = |
+ (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); |
+ mContentView = |
+ (LinearLayout) inflater.inflate(R.layout.text_edit_suggestion_container, null); |
+ |
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { |
+ mContentView.setBackground(ApiCompatibilityUtils.getDrawable( |
+ mContext.getResources(), R.drawable.floating_popup_background_light)); |
+ } else { |
+ mContentView.setBackground(ApiCompatibilityUtils.getDrawable( |
+ mContext.getResources(), R.drawable.dropdown_popup_background)); |
+ } |
+ |
+ // mPopupVerticalMargin is the minimum amount of space we want to have between the popup |
+ // and the top or bottom of the window. |
+ mPopupVerticalMargin = mContext.getResources().getDimensionPixelSize( |
+ R.dimen.text_suggestion_popup_vertical_margin); |
+ |
+ mSuggestionListView = (ListView) mContentView.findViewById(R.id.suggestionContainer); |
+ // android:divider="@null" in the XML file crashes on Android N and O |
+ // when running as a WebView (b/38346876). |
+ mSuggestionListView.setDivider(null); |
+ |
+ mListFooter = |
+ (LinearLayout) inflater.inflate(R.layout.text_edit_suggestion_list_footer, null); |
+ mSuggestionListView.addFooterView(mListFooter, null, false); |
+ |
+ mSuggestionsAdapter = new SuggestionAdapter(); |
+ mSuggestionListView.setAdapter(mSuggestionsAdapter); |
+ mSuggestionListView.setOnItemClickListener(this); |
+ |
+ mDivider = mContentView.findViewById(R.id.divider); |
+ |
+ mAddToDictionaryButton = (TextView) mContentView.findViewById(R.id.addToDictionaryButton); |
+ mAddToDictionaryButton.setOnClickListener(this); |
+ |
+ mDeleteButton = (TextView) mContentView.findViewById(R.id.deleteButton); |
+ mDeleteButton.setOnClickListener(this); |
+ } |
+ |
+ /** |
+ * Dismisses the text suggestion menu (called by TextSuggestionHost when certain events occur, |
+ * for example device rotation). |
+ */ |
+ public void dismiss() { |
+ mPopupWindow.dismiss(); |
+ } |
+ |
+ /** |
+ * Used by TextSuggestionHost to determine if the text suggestion menu is currently visible. |
+ */ |
+ public boolean isShowing() { |
+ return mPopupWindow.isShowing(); |
+ } |
+ |
+ private void addToDictionary() { |
+ final Intent intent = new Intent(ACTION_USER_DICTIONARY_INSERT); |
+ intent.putExtra(USER_DICTIONARY_EXTRA_WORD, mHighlightedText); |
+ intent.setFlags(intent.getFlags() | Intent.FLAG_ACTIVITY_NEW_TASK); |
+ mContext.startActivity(intent); |
+ } |
+ |
+ private class SuggestionAdapter extends BaseAdapter { |
+ private LayoutInflater mInflater = |
+ (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); |
+ |
+ @Override |
+ public int getCount() { |
+ return mNumberOfSuggestionsToUse; |
+ } |
+ |
+ @Override |
+ public Object getItem(int position) { |
+ return mSpellCheckSuggestions[position]; |
+ } |
+ |
+ @Override |
+ public long getItemId(int position) { |
+ return position; |
+ } |
+ |
+ @Override |
+ public View getView(int position, View convertView, ViewGroup parent) { |
+ TextView textView = (TextView) convertView; |
+ if (textView == null) { |
+ textView = (TextView) mInflater.inflate( |
+ R.layout.text_edit_suggestion_item, parent, false); |
+ } |
+ final String suggestion = mSpellCheckSuggestions[position]; |
+ textView.setText(suggestion); |
+ return textView; |
+ } |
+ } |
+ |
+ private void measureContent() { |
+ // Make the menu wide enough to fit its widest item. |
+ int width = UiUtils.computeMaxWidthOfListAdapterItems(mSuggestionListView.getAdapter()); |
+ width += mContentView.getPaddingLeft() + mContentView.getPaddingRight(); |
+ |
+ final int verticalMeasure = View.MeasureSpec.makeMeasureSpec( |
+ mDisplayMetrics.heightPixels, View.MeasureSpec.AT_MOST); |
+ mContentView.measure( |
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), verticalMeasure); |
+ mPopupWindow.setWidth(width); |
+ } |
+ |
+ private void updateDividerVisibility() { |
+ // If we don't have any spell check suggestions, "Add to dictionary" will be the first menu |
+ // item, and we shouldn't show a divider above it. |
+ if (mNumberOfSuggestionsToUse == 0) { |
+ mDivider.setVisibility(View.GONE); |
+ } else { |
+ mDivider.setVisibility(View.VISIBLE); |
+ } |
+ } |
+ |
+ /** |
+ * Called by TextSuggestionHost to tell this class what text is currently highlighted (so it can |
+ * be added to the dictionary if requested). |
+ */ |
+ public void setHighlightedText(String text) { |
+ mHighlightedText = text; |
+ } |
+ |
+ /** |
+ * Called by TextSuggestionHost to set the list of spell check suggestions to show in the |
+ * suggestion menu. |
+ */ |
+ public void setSpellCheckSuggestions(String[] suggestions) { |
+ mSpellCheckSuggestions = suggestions.clone(); |
+ mNumberOfSuggestionsToUse = mSpellCheckSuggestions.length; |
+ } |
+ |
+ /** |
+ * Shows the text suggestion menu at the specified coordinates (relative to the viewport). |
+ */ |
+ public void show(double caretX, double caretY) { |
+ mSuggestionsAdapter.notifyDataSetChanged(); |
+ |
+ mActivity = mWindowAndroidProvider.getWindowAndroid().getActivity().get(); |
+ // Note: the Activity can be null here if we're in a WebView that was created without |
+ // using an Activity. So all code in this class should handle this case. |
+ if (mActivity != null) { |
+ mDisplayMetrics = mActivity.getResources().getDisplayMetrics(); |
+ } else { |
+ // Getting the DisplayMetrics from the passed-in context doesn't handle multi-window |
+ // mode as well, but it's good enough for the "improperly-created WebView" case |
+ mDisplayMetrics = mContext.getResources().getDisplayMetrics(); |
+ } |
+ |
+ // In single-window mode, we need to get the status bar height to make sure we don't try to |
+ // draw on top of it (we can't draw on top in older versions of Android). |
+ // In multi-window mode, as of Android N, the behavior is as follows: |
+ // |
+ // Portrait mode, top window: the window height does not include the height of the status |
+ // bar, but drawing at Y position 0 starts at the top of the status bar. |
+ // |
+ // Portrait mode, bottom window: the window height does not include the height of the status |
+ // bar, and the status bar isn't touching the window, so we can't draw on it regardless. |
+ // |
+ // Landscape mode: the window height includes the whole height of the keyboard |
+ // (Google-internal b/63405914), so we are unable to handle this case properly. |
+ // |
+ // For our purposes, we don't worry about if we're drawing over the status bar in |
+ // multi-window mode, but we need to make sure we don't do it in single-window mode (in case |
+ // we're on an old version of Android). |
+ int statusBarHeight = 0; |
+ if (mActivity != null |
+ && (Build.VERSION.SDK_INT < Build.VERSION_CODES.N |
+ || !mActivity.isInMultiWindowMode())) { |
+ Rect rect = new Rect(); |
+ mActivity.getWindow().getDecorView().getWindowVisibleDisplayFrame(rect); |
+ statusBarHeight = rect.top; |
+ } |
+ |
+ // We determine the maximum number of suggestions we can show by taking the available |
+ // height in the window, subtracting the height of the list footer (divider, add to |
+ // dictionary button, delete button), and dividing by the height of a suggestion item. |
+ mListFooter.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), |
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); |
+ |
+ final int verticalSpaceAvailableForSuggestions = mDisplayMetrics.heightPixels |
+ - statusBarHeight - mListFooter.getMeasuredHeight() - 2 * mPopupVerticalMargin |
+ - mContentView.getPaddingTop() - mContentView.getPaddingBottom(); |
+ final int itemHeight = mContext.getResources().getDimensionPixelSize( |
+ R.dimen.text_edit_suggestion_item_layout_height); |
+ final int maxItemsToShow = verticalSpaceAvailableForSuggestions > 0 |
+ ? verticalSpaceAvailableForSuggestions / itemHeight |
+ : 0; |
+ |
+ mNumberOfSuggestionsToUse = Math.min(mNumberOfSuggestionsToUse, maxItemsToShow); |
+ // If we're not showing any suggestions, hide the divider before "Add to dictionary" and |
+ // "Delete". |
+ updateDividerVisibility(); |
+ measureContent(); |
+ |
+ final int width = mContentView.getMeasuredWidth(); |
+ final int height = mContentView.getMeasuredHeight(); |
+ |
+ // Horizontally center the menu on the caret location, and vertically position the menu |
+ // under the caret. |
+ int positionX = (int) Math.round(caretX - width / 2.0f); |
+ int positionY = (int) Math.round(caretY); |
+ |
+ // We get the insertion point coords relative to the viewport. |
+ // We need to render the popup relative to the window. |
+ final int[] positionInWindow = new int[2]; |
+ mParentView.getLocationInWindow(positionInWindow); |
+ |
+ positionX += positionInWindow[0]; |
+ positionY += positionInWindow[1]; |
+ |
+ // Subtract off the container's top padding to get the proper alignment with the caret. |
+ // Note: there is no explicit padding set. On Android L and later, we use elevation to draw |
+ // a drop shadow and there is no top padding. On pre-L, we instead use a background image, |
+ // which results in some implicit padding getting added that we need to account for. |
+ positionY -= mContentView.getPaddingTop(); |
+ |
+ // Horizontal clipping: if part of the menu (except the shadow) would fall off the left |
+ // or right edge of the screen, shift the menu to keep it on-screen. |
+ final int menuAtRightEdgeOfWindowPositionX = |
+ mDisplayMetrics.widthPixels - width + mContentView.getPaddingRight(); |
+ positionX = Math.min(menuAtRightEdgeOfWindowPositionX, positionX); |
+ positionX = Math.max(-mContentView.getPaddingLeft(), positionX); |
+ |
+ // Vertical clipping: if part of the menu or its bottom margin would fall off the bottom of |
+ // the screen, shift it up to keep it on-screen. |
+ positionY = Math.min(positionY, |
+ mDisplayMetrics.heightPixels - height - mContentView.getPaddingTop() |
+ - mPopupVerticalMargin); |
+ |
+ mPopupWindow.showAtLocation(mParentView, Gravity.NO_GRAVITY, positionX, positionY); |
+ } |
+ |
+ @Override |
+ public void onClick(View v) { |
+ if (v == mAddToDictionaryButton) { |
+ addToDictionary(); |
+ mTextSuggestionHost.newWordAddedToDictionary(mHighlightedText); |
+ mDismissedByItemTap = true; |
+ mPopupWindow.dismiss(); |
+ } else if (v == mDeleteButton) { |
+ mTextSuggestionHost.deleteActiveSuggestionRange(); |
+ mDismissedByItemTap = true; |
+ mPopupWindow.dismiss(); |
+ } |
+ } |
+ |
+ @Override |
+ public void onItemClick(AdapterView<?> parent, View view, int position, long id) { |
+ // Ignore taps somewhere in the list footer (divider, "Add to dictionary", "Delete") that |
+ // don't get handled by a button. |
+ if (position >= mNumberOfSuggestionsToUse) { |
+ return; |
+ } |
+ |
+ String suggestion = mSpellCheckSuggestions[position]; |
+ mTextSuggestionHost.applySpellCheckSuggestion(suggestion); |
+ mDismissedByItemTap = true; |
+ mPopupWindow.dismiss(); |
+ } |
+ |
+ @Override |
+ public void onDismiss() { |
+ mTextSuggestionHost.suggestionMenuClosed(mDismissedByItemTap); |
+ mDismissedByItemTap = false; |
+ } |
+ |
+ /** |
+ * @return The popup's content view. |
+ */ |
+ @VisibleForTesting |
+ public View getContentViewForTesting() { |
+ return mContentView; |
+ } |
+} |