| 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;
|
| + }
|
| +}
|
|
|