/* * 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.xbrowser.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; private boolean mSuspended = false; /** * 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 boolean isCursorHandlingSuspended() { return mSuspended; } 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); mSuspended = true; } /** * 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); } mSuspended = false; } /** * 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); } }