summaryrefslogtreecommitdiffstats
path: root/src/com/android/browser/autocomplete/SuggestedTextController.java
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/browser/autocomplete/SuggestedTextController.java')
-rw-r--r--src/com/android/browser/autocomplete/SuggestedTextController.java508
1 files changed, 508 insertions, 0 deletions
diff --git a/src/com/android/browser/autocomplete/SuggestedTextController.java b/src/com/android/browser/autocomplete/SuggestedTextController.java
new file mode 100644
index 00000000..e9b74cea
--- /dev/null
+++ b/src/com/android/browser/autocomplete/SuggestedTextController.java
@@ -0,0 +1,508 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.browser.autocomplete;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.Editable;
+import android.text.Selection;
+import android.text.SpanWatcher;
+import android.text.Spannable;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.Log;
+import android.view.View;
+import android.widget.EditText;
+
+import java.util.ArrayList;
+
+import junit.framework.Assert;
+
+
+/**
+ * The query editor can show a suggestion, grayed out following the query that the user has
+ * entered so far. As the user types new characters, these should replace the grayed suggestion
+ * text. This class manages this logic, displaying the suggestion when the user entered text is a
+ * prefix of it, and hiding it otherwise.
+ *
+ * Note, the text in the text view will contain the entire suggestion, not just what the user
+ * entered. Instead of retrieving the text from the text view, {@link #getUserText()} should be
+ * called on this class.
+ */
+public class SuggestedTextController {
+ private static final boolean DBG = false;
+ private static final String TAG = "Browser.SuggestedTextController";
+
+ private final BufferTextWatcher mBufferTextWatcher = new BufferTextWatcher();
+ private final BufferSpanWatcher mBufferSpanWatcher = new BufferSpanWatcher();
+ private final ArrayList<TextChangeWatcher> mTextWatchers;
+ private final TextOwner mTextOwner;
+ private final StringBuffer mUserEntered;
+ private final SuggestedSpan mSuggested;
+ private String mSuggestedText;
+ private TextChangeAttributes mCurrentTextChange;
+ /**
+ * While this is non-null, any changes made to the cursor position or selection are ignored. Is
+ * stored the selection state at the moment when selection change processing was disabled.
+ */
+ private BufferSelection mTextSelectionBeforeIgnoringChanges;
+
+ public SuggestedTextController(final EditText textView, int color) {
+ this(new TextOwner() {
+ @Override
+ public Editable getText() {
+ return textView.getText();
+ }
+ @Override
+ public void addTextChangedListener(TextWatcher watcher) {
+ textView.addTextChangedListener(watcher);
+ }
+ @Override
+ public void removeTextChangedListener(TextWatcher watcher) {
+ textView.removeTextChangedListener(watcher);
+ }
+ @Override
+ public void setText(String text) {
+ textView.setText(text);
+ }
+ }, color);
+ }
+
+ private void initialize(String userText, int selStart, int selEnd, String suggested) {
+ Editable text = mTextOwner.getText();
+
+ if (userText == null) userText = "";
+ String allText = userText;
+ int suggestedStart = allText.length();
+ if (suggested != null && userText != null) {
+ if (suggested.startsWith(userText.toLowerCase())) {
+ allText = suggested;
+ }
+ }
+
+ // allText is at this point either "userText" (not null) or
+ // "suggested" if thats not null and starts with userText.
+ text.replace(0, text.length(), allText);
+ Selection.setSelection(text, selStart, selEnd);
+ mUserEntered.replace(0, mUserEntered.length(), userText);
+ mSuggestedText = suggested;
+ if (suggestedStart < text.length()) {
+ text.setSpan(mSuggested, suggestedStart, text.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ } else {
+ text.removeSpan(mSuggested);
+ }
+ text.setSpan(mBufferSpanWatcher, 0, text.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ mTextOwner.addTextChangedListener(mBufferTextWatcher);
+ if (DBG) checkInvariant(text);
+ }
+
+ private void assertNotIgnoringSelectionChanges() {
+ if (mTextSelectionBeforeIgnoringChanges != null) {
+ throw new IllegalStateException(
+ "Illegal operation while cursor movement processing suspended");
+ }
+ }
+
+ public Parcelable saveInstanceState(Parcelable superState) {
+ assertNotIgnoringSelectionChanges();
+ SavedState ss = new SavedState(superState);
+ Editable buffer = mTextOwner.getText();
+ ss.mUserText = getUserText();
+ ss.mSuggestedText = mSuggestedText;
+ ss.mSelStart = Selection.getSelectionStart(buffer);
+ ss.mSelEnd = Selection.getSelectionEnd(buffer);
+ return ss;
+ }
+
+ public Parcelable restoreInstanceState(Parcelable state) {
+ assertNotIgnoringSelectionChanges();
+ if (!(state instanceof SavedState)) return state;
+ SavedState ss = (SavedState) state;
+ if (DBG) {
+ Log.d(TAG, "restoreInstanceState t='" + ss.mUserText + "' suggestion='" +
+ ss.mSuggestedText + " sel=" + ss.mSelStart + ".." + ss.mSelEnd);
+ }
+ // remove our listeners so we don't get notifications while re-initialising
+ mTextOwner.getText().removeSpan(mBufferSpanWatcher);
+ mTextOwner.removeTextChangedListener(mBufferTextWatcher);
+ // and initialise will re-add the watchers
+ initialize(ss.mUserText, ss.mSelStart, ss.mSelEnd, ss.mSuggestedText);
+ notifyUserEnteredChanged();
+ return ss.getSuperState();
+ }
+
+ /**
+ * Temporarily stop processing cursor movements and selection changes. While cursor movements
+ * are being ignored, the text in the buffer must NOT be changed; doing so will result in an
+ * {@link IllegalStateException} being thrown.
+ *
+ * To stop ignoring cursor movements, call
+ * {@link #resumeCursorMovementHandlingAndApplyChanges()}.
+ */
+ public void suspendCursorMovementHandling() {
+ assertNotIgnoringSelectionChanges();
+ Editable buffer = mTextOwner.getText();
+ mTextSelectionBeforeIgnoringChanges = new BufferSelection(buffer);
+ }
+
+ /**
+ * Start responding to cursor movements and selection changes again. If the cursor or selection
+ * moved while it was being ignored, these changes will be processed now.
+ */
+ public void resumeCursorMovementHandlingAndApplyChanges() {
+ Editable buffer = mTextOwner.getText();
+ BufferSelection oldSelection = mTextSelectionBeforeIgnoringChanges;
+ mTextSelectionBeforeIgnoringChanges = null;
+ BufferSelection newSelection = new BufferSelection(buffer);
+ if (oldSelection.mStart != newSelection.mStart) {
+ mBufferSpanWatcher.onSpanChanged(buffer, Selection.SELECTION_START,
+ oldSelection.mStart, oldSelection.mStart,
+ newSelection.mStart, newSelection.mStart);
+ }
+ if (oldSelection.mEnd != newSelection.mEnd) {
+ mBufferSpanWatcher.onSpanChanged(buffer, Selection.SELECTION_END,
+ oldSelection.mEnd, oldSelection.mEnd,
+ newSelection.mEnd, newSelection.mEnd);
+ }
+ }
+
+ /**
+ * Sets the current suggested text. A portion of this will be added to the user entered text if
+ * that is a prefix of the suggestion.
+ */
+ public void setSuggestedText(String text) {
+ assertNotIgnoringSelectionChanges();
+ if (!TextUtils.equals(text, mSuggestedText)) {
+ if (DBG) Log.d(TAG, "setSuggestedText(" + text + ")");
+ mSuggestedText = text;
+ if (mCurrentTextChange == null) {
+ mCurrentTextChange = new TextChangeAttributes(0, 0, 0);
+ Editable buffer = mTextOwner.getText();
+ handleTextChanged(buffer);
+ }
+ }
+ }
+
+ /**
+ * Gets the portion of displayed text that is not suggested.
+ */
+ public String getUserText() {
+ assertNotIgnoringSelectionChanges();
+ return mUserEntered.toString();
+ }
+
+ /**
+ * Sets the given text as if it has been entered by the user.
+ */
+ public void setText(String text) {
+ assertNotIgnoringSelectionChanges();
+ if (text == null) text = "";
+ Editable buffer = mTextOwner.getText();
+ buffer.removeSpan(mSuggested);
+ // this will cause a handleTextChanged call
+ buffer.replace(0, text.length(), text);
+ }
+
+ public void addUserTextChangeWatcher(TextChangeWatcher watcher) {
+ mTextWatchers.add(watcher);
+ }
+
+ private void handleTextChanged(Editable newText) {
+ // When we make changes to the buffer from within this function, it results in recursive
+ // calls to beforeTextChanges(), afterTextChanged(). We want to ignore the changes we're
+ // making ourself:
+ if (mCurrentTextChange.isHandled()) return;
+ mCurrentTextChange.setHandled();
+ final int pos = mCurrentTextChange.mPos;
+ final int countBefore = mCurrentTextChange.mCountBefore;
+ final int countAfter = mCurrentTextChange.mCountAfter;
+ final int cursorPos = Selection.getSelectionEnd(newText);
+ if (DBG) {
+ Log.d(TAG, "pos=" + pos +"; countBefore=" + countBefore + "; countAfter=" +
+ countAfter + "; cursor=" + cursorPos);
+ }
+ mUserEntered.replace(pos, pos + countBefore,
+ newText.subSequence(pos, pos + countAfter).toString());
+ if (DBG) Log.d(TAG, "User entered: '" + mUserEntered + "' all='" + newText + "'");
+ final int userLen = mUserEntered.length();
+ boolean haveSuggested = newText.getSpanStart(mSuggested) != -1;
+ if (mSuggestedText != null &&
+ mSuggestedText.startsWith(mUserEntered.toString().toLowerCase())) {
+ if (haveSuggested) {
+ if (!mSuggestedText.equalsIgnoreCase(newText.toString())) {
+ if (countAfter > countBefore) {
+ // net insertion
+ int len = countAfter - countBefore;
+ newText.delete(pos + len, pos + len + len);
+ } else {
+ // net deletion
+ newText.replace(userLen, newText.length(),
+ mSuggestedText.substring(userLen));
+ if (countBefore == 0) {
+ // no change to the text - likely just suggested change
+ Selection.setSelection(newText, cursorPos);
+ }
+ }
+ }
+ } else {
+ // no current suggested text - add it
+ newText.insert(userLen, mSuggestedText.substring(userLen));
+ // keep the cursor at the end of the user entered text, if that where it was
+ // before.
+ if (cursorPos == userLen) {
+ Selection.setSelection(newText, userLen);
+ }
+ }
+ if (userLen == newText.length()) {
+ newText.removeSpan(mSuggested);
+ } else {
+ newText.setSpan(mSuggested, userLen, newText.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ } else {
+ if (newText.getSpanStart(mSuggested) != -1) {
+ newText.removeSpan(mSuggested);
+ newText.delete(mUserEntered.length(), newText.length());
+ }
+ }
+ if (DBG) checkInvariant(newText);
+ mCurrentTextChange = null;
+ if (countBefore > 0 || countAfter > 0) {
+ notifyUserEnteredChanged();
+ }
+ }
+
+ private void notifyUserEnteredChanged() {
+ for (TextChangeWatcher watcher : mTextWatchers) {
+ watcher.onTextChanged(mUserEntered.toString());
+ }
+ }
+
+ /**
+ * Basic interface for being notified of changes to some text.
+ */
+ public interface TextChangeWatcher {
+ void onTextChanged(String newText);
+ }
+
+ /**
+ * Interface class to wrap required methods from {@link EditText}, or some other class used
+ * to test without needing an @{link EditText}.
+ */
+ public interface TextOwner {
+ Editable getText();
+ void addTextChangedListener(TextWatcher watcher);
+ void removeTextChangedListener(TextWatcher watcher);
+ void setText(String text);
+ }
+
+ /**
+ * This class stores the parameters passed to {@link BufferTextWatcher#beforeTextChanged},
+ * together with a flag indicating if this invocation has been dealt with yet. We need this
+ * information, together with the parameters passed to
+ * {@link BufferTextWatcher#afterTextChanged}, to restore our internal state when the buffer is
+ * edited.
+ *
+ * Since the changes we make from within {@link BufferTextWatcher#afterTextChanged} also trigger
+ * further recursive calls to {@link BufferTextWatcher#beforeTextChanged} and
+ * {@link BufferTextWatcher#afterTextChanged}, this class helps detect these recursive calls so
+ * they can be ignored.
+ */
+ private static class TextChangeAttributes {
+ public final int mPos;
+ public final int mCountAfter;
+ public final int mCountBefore;
+ private boolean mHandled;
+
+ public TextChangeAttributes(int pos, int countAfter, int countBefore) {
+ mPos = pos;
+ mCountAfter = countAfter;
+ mCountBefore = countBefore;
+ }
+
+ public void setHandled() {
+ mHandled = true;
+ }
+
+ public boolean isHandled() {
+ return mHandled;
+ }
+ }
+
+ /**
+ * Encapsulates the state of the text selection (and cursor) within a text buffer.
+ */
+ private static class BufferSelection {
+ final int mStart;
+ final int mEnd;
+ public BufferSelection(CharSequence text) {
+ mStart = Selection.getSelectionStart(text);
+ mEnd = Selection.getSelectionEnd(text);
+ }
+ @Override
+ public boolean equals(Object other) {
+ if (!(other instanceof BufferSelection)) return super.equals(other);
+ BufferSelection otherSel = (BufferSelection) other;
+ return this.mStart == otherSel.mStart && this.mEnd == otherSel.mEnd;
+ }
+ }
+
+ private class BufferTextWatcher implements TextWatcher {
+ @Override
+ public void afterTextChanged(Editable newText) {
+ if (DBG) {
+ Log.d(TAG, "afterTextChanged('" + newText + "')");
+ }
+ assertNotIgnoringSelectionChanges();
+ handleTextChanged(newText);
+ }
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ assertNotIgnoringSelectionChanges();
+ if (mCurrentTextChange == null) {
+ mCurrentTextChange = new TextChangeAttributes(start, after, count);
+ }
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+ }
+
+ private class BufferSpanWatcher implements SpanWatcher {
+ @Override
+ public void onSpanAdded(Spannable text, Object what, int start, int end) {
+ }
+
+ @Override
+ public void onSpanChanged(
+ Spannable text, Object what, int ostart, int oend, int nstart, int nend) {
+ if (mCurrentTextChange != null) return;
+ if (mTextSelectionBeforeIgnoringChanges != null) return;
+ if (what == Selection.SELECTION_END) {
+ if (DBG) Log.d(TAG, "cursor move to " + nend);
+ if (nend > mUserEntered.length()) {
+ mUserEntered.replace(0, mUserEntered.length(), text.toString());
+ text.removeSpan(mSuggested);
+ }
+ if (DBG) checkInvariant(text);
+ }
+ }
+
+ @Override
+ public void onSpanRemoved(Spannable text, Object what, int start, int end) {
+ }
+ }
+
+ public static class SavedState extends View.BaseSavedState {
+ String mUserText;
+ String mSuggestedText;
+ int mSelStart;
+ int mSelEnd;
+
+ public SavedState(Parcelable superState) {
+ super(superState);
+ }
+
+ @Override
+ public void writeToParcel(Parcel out, int flags) {
+ super.writeToParcel(out, flags);
+ out.writeString(mUserText);
+ out.writeString(mSuggestedText);
+ out.writeInt(mSelStart);
+ out.writeInt(mSelEnd);
+ }
+
+ @SuppressWarnings("hiding")
+ public static final Parcelable.Creator<SavedState> CREATOR
+ = new Parcelable.Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+
+ private SavedState(Parcel in) {
+ super(in);
+ mUserText = in.readString();
+ mSuggestedText = in.readString();
+ mSelStart = in.readInt();
+ mSelEnd = in.readInt();
+ }
+ }
+
+ /*
+ * The remaining functions are used for testing purposes only.
+ * -----------------------------------------------------------
+ */
+
+ /**
+ * Verify that the internal state of this class is consistent.
+ */
+ @VisibleForTesting
+ void checkInvariant(final Spannable s) {
+ int suggestedStart = s.getSpanStart(mSuggested);
+ int suggestedEnd = s.getSpanEnd(mSuggested);
+ int cursorPos = Selection.getSelectionEnd(s);
+ if (suggestedStart == -1 || suggestedEnd == -1) {
+ suggestedStart = suggestedEnd = s.length();
+ }
+ String userEntered = getUserText();
+ Log.d(TAG, "checkInvariant all='" + s + "' (len " + s.length() + ") sug="
+ + suggestedStart + ".." + suggestedEnd + " cursor=" + cursorPos +
+ " ue='" + userEntered + "' (len " + userEntered.length() + ")");
+ int suggestedLen = suggestedEnd - suggestedStart;
+ Assert.assertEquals("Sum of user and suggested text lengths doesn't match total length",
+ s.length(), userEntered.length() + suggestedLen);
+ Assert.assertEquals("End of user entered text doesn't match start of suggested",
+ suggestedStart, userEntered.length());
+ Assert.assertTrue("user entered text does not match start of buffer",
+ userEntered.toString().equalsIgnoreCase(
+ s.subSequence(0, suggestedStart).toString()));
+ if (mSuggestedText != null && suggestedStart < s.length()) {
+ Assert.assertTrue("User entered is not a prefix of suggested",
+ mSuggestedText.startsWith(userEntered.toString().toLowerCase()));
+ Assert.assertTrue("Suggested text does not match buffer contents",
+ mSuggestedText.equalsIgnoreCase(s.toString().toLowerCase()));
+ }
+ if (mSuggestedText == null) {
+ Assert.assertEquals("Non-zero suggention length with null suggestion", 0, suggestedLen);
+ } else {
+ Assert.assertTrue("Suggestion text longer than suggestion (" + mSuggestedText.length() +
+ ">" + suggestedLen + ")", suggestedLen <= mSuggestedText.length());
+ }
+ Assert.assertTrue("Cursor within suggested part", cursorPos <= suggestedStart);
+ }
+
+ @VisibleForTesting
+ SuggestedTextController(TextOwner textOwner, int color) {
+ mUserEntered = new StringBuffer();
+ mSuggested = new SuggestedSpan(color);
+ mTextOwner = textOwner;
+ mTextWatchers = new ArrayList<TextChangeWatcher>();
+ initialize(null, 0, 0, null);
+ }
+}