Bug 994472 - Use text spans to implement autocompletion; r=wesj

This commit is contained in:
Jim Chen 2014-05-20 12:39:50 -04:00
parent a24944a5fb
commit d479138380

View File

@ -11,15 +11,20 @@ import org.mozilla.gecko.toolbar.BrowserToolbar.OnDismissListener;
import org.mozilla.gecko.toolbar.BrowserToolbar.OnFilterListener;
import org.mozilla.gecko.CustomEditText;
import org.mozilla.gecko.CustomEditText.OnKeyPreImeListener;
import org.mozilla.gecko.InputMethods;
import org.mozilla.gecko.util.GamepadUtils;
import org.mozilla.gecko.util.StringUtils;
import android.content.Context;
import android.graphics.Color;
import android.graphics.Rect;
import android.text.Editable;
import android.text.NoCopySpan;
import android.text.Selection;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.style.ForegroundColorSpan;
import android.util.AttributeSet;
import android.util.Log;
import android.view.KeyEvent;
@ -36,6 +41,7 @@ public class ToolbarEditText extends CustomEditText
implements AutocompleteHandler {
private static final String LOGTAG = "GeckoToolbarEditText";
private static final NoCopySpan AUTOCOMPLETE_SPAN = new NoCopySpan.Concrete();
private final Context mContext;
@ -45,9 +51,12 @@ public class ToolbarEditText extends CustomEditText
// The previous autocomplete result returned to us
private String mAutoCompleteResult = "";
// The user typed part of the autocomplete result
private String mAutoCompletePrefix = null;
// Length of the user-typed portion of the result
private int mAutoCompletePrefixLength;
// If text change is due to us setting autocomplete
private boolean mSettingAutoComplete;
// Spans used for marking the autocomplete text
private Object[] mAutoCompleteSpans;
public ToolbarEditText(Context context, AttributeSet attrs) {
super(context, attrs);
@ -70,6 +79,7 @@ public class ToolbarEditText extends CustomEditText
public void onAttachedToWindow() {
setOnKeyListener(new KeyListener());
setOnKeyPreImeListener(new KeyPreImeListener());
setOnSelectionChangedListener(new SelectionChangeListener());
addTextChangedListener(new TextChangeListener());
}
@ -82,8 +92,9 @@ public class ToolbarEditText extends CustomEditText
return;
}
InputMethodManager imm = (InputMethodManager)
mContext.getSystemService(Context.INPUT_METHOD_SERVICE);
removeAutocomplete(getText());
final InputMethodManager imm = InputMethods.getInputMethodManager(mContext);
try {
imm.restartInput(this);
imm.hideSoftInputFromWindow(getWindowToken(), 0);
@ -93,33 +104,202 @@ public class ToolbarEditText extends CustomEditText
}
}
// Return early if we're backspacing through the string, or
// have no autocomplete results
@Override
public final void onAutocomplete(final String result) {
if (!isEnabled()) {
/**
* Mark the start of autocomplete changes so our text change
* listener does not react to changes in autocomplete text
*/
private void beginSettingAutocomplete() {
beginBatchEdit();
mSettingAutoComplete = true;
}
/**
* Mark the end of autocomplete changes
*/
private void endSettingAutocomplete() {
mSettingAutoComplete = false;
endBatchEdit();
}
/**
* Reset autocomplete states to their initial values
*/
private void resetAutocompleteState() {
final int textColor = getCurrentTextColor();
mAutoCompleteSpans = new Object[] {
// Span to mark the autocomplete text
AUTOCOMPLETE_SPAN,
// Span to change the autocomplete text color
new ForegroundColorSpan(Color.argb(
0x80, Color.red(textColor), Color.green(textColor), Color.blue(textColor)))
};
mAutoCompleteResult = "";
mAutoCompletePrefixLength = 0;
}
/**
* Get the portion of text that is not marked as autocomplete text.
*
* @param text Current text content that may include autocomplete text
*/
private static String getNonAutocompleteText(final Editable text) {
final int start = text.getSpanStart(AUTOCOMPLETE_SPAN);
if (start < 0) {
// No autocomplete text; return the whole string.
return text.toString();
}
// Only return the portion that's not autocomplete text
return TextUtils.substring(text, 0, start);
}
/**
* Remove any autocomplete text
*
* @param text Current text content that may include autocomplete text
*/
private void removeAutocomplete(final Editable text) {
final int start = text.getSpanStart(AUTOCOMPLETE_SPAN);
if (start < 0) {
// No autocomplete text
return;
}
final String text = getText().toString();
beginSettingAutocomplete();
if (result == null) {
// When we call delete() here, the autocomplete spans we set are removed as well.
text.delete(start, text.length());
// Keep mAutoCompletePrefixLength the same because the prefix has not changed.
// Clear mAutoCompleteResult to make sure we get fresh autocomplete text next time.
mAutoCompleteResult = "";
endSettingAutocomplete();
}
/**
* Convert any autocomplete text to regular text
*
* @param text Current text content that may include autocomplete text
*/
private void commitAutocomplete(final Editable text) {
beginSettingAutocomplete();
// Remove all spans here to convert from autocomplete text to regular text
for (final Object span : mAutoCompleteSpans) {
text.removeSpan(span);
}
// Keep mAutoCompleteResult the same because the result has not changed.
// Reset mAutoCompletePrefixLength because the prefix now includes the autocomplete text.
mAutoCompletePrefixLength = text.length();
endSettingAutocomplete();
// Filter on the new text
if (mFilterListener != null) {
mFilterListener.onFilter(text.toString(), null);
}
}
/**
* Add autocomplete text based on the result URI.
*
* @param result Result URI to be turned into autocomplete text
*/
@Override
public final void onAutocomplete(final String result) {
if (!isEnabled() || result == null) {
mAutoCompleteResult = "";
return;
}
if (!result.startsWith(text) || text.equals(result)) {
return;
}
final Editable text = getText();
final int textLength = text.length();
final int resultLength = result.length();
final int autoCompleteStart = text.getSpanStart(AUTOCOMPLETE_SPAN);
mAutoCompleteResult = result;
getText().append(result.substring(text.length()));
setSelection(text.length(), result.length());
}
private void resetAutocompleteState() {
mAutoCompleteResult = "";
mAutoCompletePrefix = null;
if (autoCompleteStart > -1) {
// Autocomplete text already exists; we should replace existing autocomplete text.
// If the result and the current text don't have the same prefixes,
// the result is stale and we should wait for the another result to come in.
if (!TextUtils.regionMatches(result, 0, text, 0, autoCompleteStart)) {
return;
}
beginSettingAutocomplete();
// Replace the existing autocomplete text with new one.
// replace() preserves the autocomplete spans that we set before.
text.replace(autoCompleteStart, textLength, result, autoCompleteStart, resultLength);
endSettingAutocomplete();
} else {
// No autocomplete text yet; we should add autocomplete text
// If the result prefix doesn't match the current text,
// the result is stale and we should wait for the another result to come in.
if (resultLength <= textLength ||
!TextUtils.regionMatches(result, 0, text, 0, textLength)) {
return;
}
final Object[] spans = text.getSpans(textLength, textLength, Object.class);
final int[] spanStarts = new int[spans.length];
final int[] spanEnds = new int[spans.length];
final int[] spanFlags = new int[spans.length];
// Save selection/composing span bounds so we can restore them later.
for (int i = 0; i < spans.length; i++) {
final Object span = spans[i];
final int spanFlag = text.getSpanFlags(span);
// We don't care about spans that are not selection or composing spans.
// For those spans, spanFlag[i] will be 0 and we don't restore them.
if ((spanFlag & Spanned.SPAN_COMPOSING) == 0 &&
(span != Selection.SELECTION_START) &&
(span != Selection.SELECTION_END)) {
continue;
}
spanStarts[i] = text.getSpanStart(span);
spanEnds[i] = text.getSpanEnd(span);
spanFlags[i] = spanFlag;
}
beginSettingAutocomplete();
// First add trailing text.
text.append(result, textLength, resultLength);
// Mark added text as autocomplete text.
for (final Object span : mAutoCompleteSpans) {
text.setSpan(span, textLength, resultLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
// Make sure the autocomplete text is visible. If the autocomplete text is too
// long, it would appear the cursor will be scrolled out of view. However, this
// is not the case in practice, because EditText still makes sure the cursor is
// still in view.
bringPointIntoView(resultLength);
// Restore selection/composing spans.
for (int i = 0; i < spans.length; i++) {
final int spanFlag = spanFlags[i];
if (spanFlag == 0) {
// Skip if the span was ignored before.
continue;
}
text.setSpan(spans[i], spanStarts[i], spanEnds[i], spanFlag);
}
endSettingAutocomplete();
}
}
private static boolean hasCompositionString(Editable content) {
@ -137,44 +317,64 @@ public class ToolbarEditText extends CustomEditText
return false;
}
private class TextChangeListener implements TextWatcher {
private class SelectionChangeListener implements OnSelectionChangedListener {
@Override
public void afterTextChanged(final Editable s) {
if (!isEnabled()) {
public void onSelectionChanged(final int selStart, final int selEnd) {
// The user has repositioned the cursor somewhere. We need to adjust
// the autocomplete text depending on where the new cursor is.
final Editable text = getText();
final int start = text.getSpanStart(AUTOCOMPLETE_SPAN);
if (start < 0 || (start == selStart && start == selEnd)) {
// Do not commit autocomplete text if there is no autocomplete text
// or if selection is still at start of autocomplete text
return;
}
final String text = s.toString();
if (selStart <= start && selEnd <= start) {
// The cursor is in user-typed text; remove any autocomplete text.
removeAutocomplete(text);
} else {
// The cursor is in the autocomplete text; commit it so it becomes regular text.
commitAutocomplete(text);
}
}
}
boolean useHandler = false;
boolean reuseAutocomplete = false;
if (!hasCompositionString(s) && !StringUtils.isSearchQuery(text, false)) {
useHandler = true;
// If you're hitting backspace (the string is getting smaller
// or is unchanged), don't autocomplete.
if (mAutoCompletePrefix != null && (mAutoCompletePrefix.length() >= text.length())) {
useHandler = false;
} else if (mAutoCompleteResult != null && mAutoCompleteResult.startsWith(text)) {
// If this text already matches our autocomplete text, autocomplete likely
// won't change. Just reuse the old autocomplete value.
useHandler = false;
reuseAutocomplete = true;
}
private class TextChangeListener implements TextWatcher {
@Override
public void afterTextChanged(final Editable editable) {
if (!isEnabled() || mSettingAutoComplete) {
return;
}
// If this is the autocomplete text being set, don't run the filter.
if (TextUtils.isEmpty(mAutoCompleteResult) || !mAutoCompleteResult.equals(text)) {
if (mFilterListener != null) {
mFilterListener.onFilter(text, useHandler ? ToolbarEditText.this : null);
}
final String text = getNonAutocompleteText(editable);
final int textLength = text.length();
boolean doAutocomplete = true;
mAutoCompletePrefix = text;
if (StringUtils.isSearchQuery(text, false)) {
doAutocomplete = false;
} else if (mAutoCompletePrefixLength > textLength) {
// If you're hitting backspace (the string is getting smaller), don't autocomplete
doAutocomplete = false;
}
if (reuseAutocomplete) {
onAutocomplete(mAutoCompleteResult);
}
mAutoCompletePrefixLength = textLength;
if (doAutocomplete && mAutoCompleteResult.startsWith(text)) {
// If this text already matches our autocomplete text, autocomplete likely
// won't change. Just reuse the old autocomplete value.
onAutocomplete(mAutoCompleteResult);
doAutocomplete = false;
} else {
// Otherwise, remove the old autocomplete text
// until any new autocomplete text gets added.
removeAutocomplete(editable);
}
if (mFilterListener != null) {
mFilterListener.onFilter(text, doAutocomplete ? ToolbarEditText.this : null);
}
}