// Copyright 2017 The Chromium Authors
// 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.Rect;
import android.text.SpannableString;
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.content.R;
import org.chromium.ui.UiUtils;
import org.chromium.ui.base.WindowAndroid;
/** Popup window that displays a menu for viewing and applying text replacement suggestions. */
public abstract 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;
protected final TextSuggestionHost mTextSuggestionHost;
private final View mParentView;
private WindowAndroid mWindowAndroid;
private Activity mActivity;
private DisplayMetrics mDisplayMetrics;
private PopupWindow mPopupWindow;
private LinearLayout mContentView;
private String mHighlightedText;
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 windowAndroid The current WindowAndroid instance.
* @param parentView The view used to attach the PopupWindow.
*/
public SuggestionsPopupWindow(
Context context,
TextSuggestionHost textSuggestionHost,
WindowAndroid windowAndroid,
View parentView) {
mContext = context;
mTextSuggestionHost = textSuggestionHost;
mWindowAndroid = windowAndroid;
mParentView = parentView;
createPopupWindow();
initContentView();
mPopupWindow.setContentView(mContentView);
}
/**
* Method to be implemented by subclasses that returns how mnay suggestions are available (some
* of them may not be displayed if there's not enough room in the window).
*/
protected abstract int getSuggestionsCount();
/**
* Method to be implemented by subclasses to return an object representing the suggestion at
* the specified position.
*/
protected abstract Object getSuggestionItem(int position);
/**
* Method to be implemented by subclasses to return a SpannableString representing text,
* possibly with formatting added, to display for the suggestion at the specified position.
*/
protected abstract SpannableString getSuggestionText(int position);
/** Method to be implemented by subclasses to apply the suggestion at the specified position. */
protected abstract void applySuggestion(int position);
/** Hides or shows the "Add to dictionary" button in the suggestion menu footer. */
protected void setAddToDictionaryEnabled(boolean isEnabled) {
mAddToDictionaryButton.setVisibility(isEnabled ? View.VISIBLE : View.GONE);
}
private void createPopupWindow() {
mPopupWindow = new PopupWindow();
mPopupWindow.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT);
mPopupWindow.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT);
// Set the background on the PopupWindow instead of on mContentView (where we set it for
// pre-Lollipop) since the popup will not properly dismiss on pre-Marshmallow unless it
// has a background set.
mPopupWindow.setBackgroundDrawable(
ApiCompatibilityUtils.getDrawable(
mContext.getResources(), R.drawable.floating_popup_background));
mPopupWindow.setElevation(
mContext.getResources()
.getDimensionPixelSize(R.dimen.text_suggestion_popup_elevation));
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);
// 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);
mSuggestionListView.setAdapter(new SuggestionAdapter());
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();
}
/** Used by TextSuggestionHost to update {@link WindowAndroid} to the current one. */
public void updateWindowAndroid(WindowAndroid windowAndroid) {
mWindowAndroid = windowAndroid;
}
private void addToDictionary() {
final Intent intent = new Intent(ACTION_USER_DICTIONARY_INSERT);
String wordToAdd = mHighlightedText;
intent.putExtra(USER_DICTIONARY_EXTRA_WORD, wordToAdd);
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 getSuggestionItem(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);
}
textView.setText(getSuggestionText(position));
return textView;
}
}
private void measureContent() {
// Make the menu wide enough to fit its widest item.
int width =
UiUtils.computeListAdapterContentDimensions(mSuggestionListView.getAdapter(), null)[
0];
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).
*/
protected void show(double caretXPx, double caretYPx, String highlightedText) {
mNumberOfSuggestionsToUse = getSuggestionsCount();
mHighlightedText = highlightedText;
mActivity = mWindowAndroid.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 && !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(caretXPx - width / 2.0f);
int positionY = (int) Math.round(caretYPx);
// 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.onNewWordAddedToDictionary(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;
}
applySuggestion(position);
mDismissedByItemTap = true;
mPopupWindow.dismiss();
}
@Override
public void onDismiss() {
mTextSuggestionHost.onSuggestionMenuClosed(mDismissedByItemTap);
mDismissedByItemTap = false;
}
/**
* @return The popup's content view.
*/
public View getContentViewForTesting() {
return mContentView;
}
}