The SuggestBox widget and its dependencies.
Review by: ecc
knorton
git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@855 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/user/src/com/google/gwt/user/client/ui/MultiWordSuggestOracle.java b/user/src/com/google/gwt/user/client/ui/MultiWordSuggestOracle.java
new file mode 100644
index 0000000..4a065fa
--- /dev/null
+++ b/user/src/com/google/gwt/user/client/ui/MultiWordSuggestOracle.java
@@ -0,0 +1,382 @@
+/*
+ * Copyright 2007 Google Inc.
+ *
+ * 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.google.gwt.user.client.ui;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * The default {@link com.google.gwt.user.client.ui.SuggestOracle}. The default
+ * oracle returns potential suggestions based on breaking the query into
+ * separate words and looking for matches. It also modifies the returned text to
+ * show which prefix matched the query term. The matching is case insensitive.
+ * All suggestions are sorted before being passed into a response.
+ * <p>
+ * Example Table
+ * </p>
+ * <p>
+ * <table width = "100%" border = "1">
+ * <tr>
+ * <td><b> All Suggestions </b> </td>
+ * <td><b>Query string</b> </td>
+ * <td><b>Matching Suggestions</b></td>
+ * </tr>
+ * <tr>
+ * <td> John Smith, Joe Brown, Jane Doe, Jane Smith, Bob Jones</td>
+ * <td> Jo</td>
+ * <td> John Smith, Joe Brown, Bob Jones</td>
+ * </tr>
+ * <tr>
+ * <td> John Smith, Joe Brown, Jane Doe, Jane Smith, Bob Jones</td>
+ * <td> Smith</td>
+ * <td> John Smith, Jane Smith</td>
+ * </tr>
+ * <tr>
+ * <td> Georgia, New York, California</td>
+ * <td> g</td>
+ * <td> Georgia</td>
+ * </tr>
+ * </table>
+ * </p>
+ */
+public final class MultiWordSuggestOracle extends SuggestOracle {
+
+ /**
+ * Suggestion class for {@link MultiWordSuggestOracle}.
+ */
+ protected static class MultiWordSuggestion implements Suggestion {
+ private final String value;
+ private final String displayString;
+
+ /**
+ * Constructor for <code>MultiWordSuggestion</code>.
+ *
+ * @param value the value
+ * @param displayString the display string
+ */
+ public MultiWordSuggestion(String value, String displayString) {
+ this.value = value;
+ this.displayString = displayString;
+ }
+
+ public String getDisplayString() {
+ return displayString;
+ }
+
+ public Object getValue() {
+ return value;
+ }
+ }
+
+ private static final char WHITESPACE_CHAR = ' ';
+ private static final String WHITESPACE_STRING = " ";
+
+ /**
+ * Regular expression used to collapse all whitespace in a query string.
+ */
+ private static final String NORMALIZE_TO_SINGLE_WHITE_SPACE = "\\s+";
+
+ private static HTML convertMe = new HTML();
+
+ /**
+ * Associates substrings with words.
+ */
+ private final PrefixTree tree = new PrefixTree();
+
+ /**
+ * Associates individual words with candidates.
+ */
+ private HashMap toCandidates = new HashMap();
+
+ /**
+ * Associates candidates with their formatted suggestions.
+ */
+ private HashMap toRealSuggestions = new HashMap();
+
+ /**
+ * The whitespace masks used to prevent matching and replacing of the given
+ * substrings.
+ */
+ private char[] whitespaceChars;
+
+ /**
+ * Constructor for <code>MultiWordSuggestOracle</code>. This uses a space as
+ * the whitespace character.
+ *
+ * @see #MultiWordSuggestOracle(String)
+ */
+ public MultiWordSuggestOracle() {
+ this(" ");
+ }
+
+ /**
+ * Constructor for <code>MultiWordSuggestOracle</code> which takes in a set
+ * of whitespace chars that filter its input.
+ * <p>
+ * Example: If <code>".,"</code> is passed in as whitespace, then the string
+ * "foo.bar" would match the queries "foo", "bar", "foo.bar", "foo...bar", and
+ * "foo, bar". If the empty string is used, then all characters are used in
+ * matching. For example, the query "bar" would match "bar", but not "foo
+ * bar".
+ * </p>
+ *
+ * @param whitespaceChars the characters to treat as word separators
+ */
+ public MultiWordSuggestOracle(String whitespaceChars) {
+ this.whitespaceChars = new char[whitespaceChars.length()];
+ for (int i = 0; i < whitespaceChars.length(); i++) {
+ this.whitespaceChars[i] = whitespaceChars.charAt(i);
+ }
+ }
+
+ /**
+ * Adds a suggestion to the oracle. Each suggestion must be plain text.
+ *
+ * @param suggestion the suggestion
+ */
+ public void add(String suggestion) {
+ String candidate = normalizeSuggestion(suggestion);
+ // candidates --> real suggestions.
+ toRealSuggestions.put(candidate, suggestion);
+
+ // word fragments --> candidates.
+ String[] words = candidate.split(WHITESPACE_STRING);
+ for (int i = 0; i < words.length; i++) {
+ String word = words[i];
+ tree.add(word);
+ HashSet l = (HashSet) toCandidates.get(word);
+ if (l == null) {
+ l = new HashSet();
+ toCandidates.put(word, l);
+ }
+ l.add(candidate);
+ }
+ }
+
+ /**
+ * Adds all suggestions specified. Each suggestion must be plain text.
+ *
+ * @param collection the collection
+ */
+ public void addAll(Collection collection) {
+ Iterator suggestions = collection.iterator();
+ while (suggestions.hasNext()) {
+ add((String) suggestions.next());
+ }
+ }
+
+ public boolean isDisplayStringHTML() {
+ return true;
+ }
+
+ public void requestSuggestions(Request request, Callback callback) {
+ final List suggestions = computeItemsFor(request.getQuery(), request
+ .getLimit());
+ Response response = new Response(suggestions);
+ callback.onSuggestionsReady(request, response);
+ }
+
+ String escapeText(String escapeMe) {
+ convertMe.setText(escapeMe);
+ String escaped = convertMe.getHTML();
+ return escaped;
+ }
+
+ /**
+ * Compute the suggestions that are matches for a given query.
+ *
+ * @param query search string
+ * @param limit limit
+ * @return matching suggestions
+ */
+ private List computeItemsFor(String query, int limit) {
+ query = normalizeSearch(query);
+
+ // Get candidates from search words.
+ List candidates = createCandidatesFromSearch(query, limit);
+
+ // Convert candidates to suggestions.
+ return convertToFormattedSuggestions(query, candidates);
+ }
+
+ /**
+ * Returns real suggestions with the given query in <code>strong</code> html
+ * font.
+ *
+ * @param query query string
+ * @param candidates candidates
+ * @return real suggestions
+ */
+ private List convertToFormattedSuggestions(String query, List candidates) {
+ List suggestions = new ArrayList();
+
+ for (int i = 0; i < candidates.size(); i++) {
+ String candidate = (String) candidates.get(i);
+ int index = 0;
+ int cursor = 0;
+ // Use real suggestion for assembly.
+ String formattedSuggestion = (String) toRealSuggestions.get(candidate);
+
+ // Create strong search string.
+ StringBuffer accum = new StringBuffer();
+
+ while (true) {
+ index = candidate.indexOf(query, index);
+ if (index == -1) {
+ break;
+ }
+ int endIndex = index + query.length();
+ if (index == 0 || (WHITESPACE_CHAR == candidate.charAt(index - 1))) {
+ String part1 = escapeText(formattedSuggestion
+ .substring(cursor, index));
+ String part2 = escapeText(formattedSuggestion.substring(index,
+ endIndex));
+ cursor = endIndex;
+ accum.append(part1).append("<strong>").append(part2).append(
+ "</strong>");
+ }
+ index = endIndex;
+ }
+
+ // Check to make sure the search was found in the string.
+ if (cursor == 0) {
+ continue;
+ }
+
+ // Finish creating the formatted string.
+ String end = escapeText(formattedSuggestion.substring(cursor));
+ accum.append(end);
+ MultiWordSuggestion suggestion = new MultiWordSuggestion(
+ formattedSuggestion, accum.toString());
+ suggestions.add(suggestion);
+ }
+ return suggestions;
+ }
+
+ /**
+ * Find the sorted list of candidates that are matches for the given query.
+ */
+ private List createCandidatesFromSearch(String query, int limit) {
+ ArrayList candidates = new ArrayList();
+
+ if (query.length() == 0) {
+ return candidates;
+ }
+
+ // Find all words to search for.
+ String[] searchWords = query.split(WHITESPACE_STRING);
+ HashSet candidateSet = null;
+ for (int i = 0; i < searchWords.length; i++) {
+ String word = searchWords[i];
+
+ // Eliminate bogus word choices.
+ if (word.length() == 0 || word.matches(WHITESPACE_STRING)) {
+ continue;
+ }
+
+ // Find the set of candidates that are associated with all the
+ // searchWords.
+ HashSet thisWordChoices = createCandidatesFromWord(word);
+ if (candidateSet == null) {
+ candidateSet = thisWordChoices;
+ } else {
+ candidateSet.retainAll(thisWordChoices);
+
+ if (candidateSet.size() < 2) {
+ // If there is only one candidate, on average it is cheaper to
+ // check if that candidate contains our search string than to
+ // continue intersecting suggestion sets.
+ break;
+ }
+ }
+ }
+ if (candidateSet != null) {
+ candidates.addAll(candidateSet);
+ Collections.sort(candidates);
+ // Respect limit for number of choices.
+ for (int i = candidates.size() - 1; i > limit; i--) {
+ candidates.remove(i);
+ }
+ }
+ return candidates;
+ }
+
+ /**
+ * Creates a set of potential candidates that match the given query.
+ *
+ * @param limit number of candidates to return
+ * @param query query string
+ * @return possible candidates
+ */
+ private HashSet createCandidatesFromWord(String query) {
+ HashSet candidateSet = new HashSet();
+ List words = tree.getSuggestions(query, Integer.MAX_VALUE);
+ if (words != null) {
+ // Find all candidates that contain the given word the search is a
+ // subset of.
+ for (int i = 0; i < words.size(); i++) {
+ Collection belongsTo = (Collection) toCandidates.get(words.get(i));
+ if (belongsTo != null) {
+ candidateSet.addAll(belongsTo);
+ }
+ }
+ }
+ return candidateSet;
+ }
+
+ /**
+ * Normalize the search key by making it lower case, removing multiple spaces,
+ * apply whitespace masks, and make it lower case.
+ */
+ private String normalizeSearch(String search) {
+ // Use the same whitespace masks and case normalization for the search
+ // string as was used with the candidate values.
+ search = normalizeSuggestion(search);
+
+ // Remove all excess whitespace from the search string.
+ search = search.replaceAll(NORMALIZE_TO_SINGLE_WHITE_SPACE,
+ WHITESPACE_STRING);
+
+ return search.trim();
+ }
+
+ /**
+ * Takes the formatted suggestion, makes it lower case and blanks out any
+ * existing whitespace for searching.
+ */
+ private String normalizeSuggestion(String formattedSuggestion) {
+ // Formatted suggestions should already have normalized whitespace. So we
+ // can skip that step.
+
+ // Lower case suggestion.
+ formattedSuggestion = formattedSuggestion.toLowerCase();
+
+ // Apply whitespace.
+ if (whitespaceChars != null) {
+ for (int i = 0; i < whitespaceChars.length; i++) {
+ char ignore = whitespaceChars[i];
+ formattedSuggestion = formattedSuggestion.replace(ignore,
+ WHITESPACE_CHAR);
+ }
+ }
+ return formattedSuggestion;
+ }
+}
diff --git a/user/src/com/google/gwt/user/client/ui/SuggestBox.java b/user/src/com/google/gwt/user/client/ui/SuggestBox.java
new file mode 100644
index 0000000..d0e1fe6
--- /dev/null
+++ b/user/src/com/google/gwt/user/client/ui/SuggestBox.java
@@ -0,0 +1,411 @@
+/*
+ * Copyright 2007 Google Inc.
+ *
+ * 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.google.gwt.user.client.ui;
+
+import com.google.gwt.user.client.ui.SuggestOracle.Callback;
+import com.google.gwt.user.client.ui.SuggestOracle.Request;
+import com.google.gwt.user.client.ui.SuggestOracle.Response;
+import com.google.gwt.user.client.ui.impl.ItemPickerDropDownImpl;
+import com.google.gwt.user.client.ui.impl.SuggestPickerImpl;
+
+import java.util.Collection;
+
+/**
+ * A {@link SuggestBox} is a text box or text area which displays a
+ * pre-configured set of selections that match the user's input.
+ *
+ * Each {@link SuggestBox} is associated with a single {@link SuggestOracle}.
+ * The {@link SuggestOracle} is used to provide a set of selections given a
+ * specific query string.
+ *
+ * <p>
+ * By default, the {@link SuggestBox} uses a {@link MultiWordSuggestOracle} as
+ * its oracle. Below we show how a {@link MultiWordSuggestOracle} can be
+ * configured:
+ * </p>
+ *
+ * <pre>
+ * MultiWordSuggestOracle oracle = new MultiWordSuggestOracle();
+ * oracle.add("Cat");
+ * oracle.add("Dog");
+ * oracle.add("Horse");
+ * oracle.add("Canary");
+ *
+ * SuggestBox box = new SuggestBox(oracle);
+ * </pre>
+ *
+ * Using the example above, if the user types "C" into the text widget, the
+ * oracle will configure the suggestions with the "Cat" and "Canary"
+ * suggestions. Specifically, whenever the user types a key into the text
+ * widget, the value is submitted to the <code>MultiWordSuggestOracle</code>.
+ *
+ * <p>
+ * <img class='gallery' src='SuggestBox.png'/>
+ * </p>
+ *
+ * <h3>CSS Style Rules</h3>
+ * <ul class='css'>
+ * <li>.gwt-SuggestBox { the suggest box itself }</li>
+ * <li>.gwt-SuggestBoxPopup { the suggestion popup }</li>
+ * <li>.gwt-SuggestBoxPopup .item { an unselected suggestion }</li>
+ * <li>.gwt-SuggestBoxPopup .item-selected { a selected suggestion }</li>
+ * </ul>
+ *
+ * @see SuggestOracle, MultiWordSuggestOracle, TextBoxBase
+ */
+public final class SuggestBox extends Composite implements HasText, HasFocus,
+ SourcesClickEvents, SourcesFocusEvents, SourcesChangeEvents,
+ SourcesKeyboardEvents {
+
+ private static final String STYLENAME_DEFAULT = "gwt-SuggestBox";
+
+ private int limit = 20;
+ private int selectStart;
+ private int selectEnd;
+ private SuggestOracle oracle;
+ private char[] separators;
+ private String currentValue;
+ private final PopupPanel popup;
+ private final SuggestPickerImpl picker;
+ private final TextBoxBase box;
+ private DelegatingClickListenerCollection clickListeners;
+ private DelegatingChangeListenerCollection changeListeners;
+ private DelegatingFocusListenerCollection focusListeners;
+ private DelegatingKeyboardListenerCollection keyboardListeners;
+ private String separatorPadding = "";
+
+ private final Callback callBack = new Callback() {
+ public void onSuggestionsReady(Request request, Response response) {
+ showSuggestions(response.getSuggestions());
+ }
+ };
+
+ /**
+ * Constructor for {@link SuggestBox}. Creates a
+ * {@link MultiWordSuggestOracle} and {@link TextBox} to use with this
+ * {@link SuggestBox}.
+ */
+ public SuggestBox() {
+ this(new MultiWordSuggestOracle());
+ }
+
+ /**
+ * Constructor for {@link SuggestBox}. Creates a {@link TextBox} to use with
+ * this {@link SuggestBox}.
+ *
+ * @param oracle the oracle for this <code>SuggestBox</code>
+ */
+ public SuggestBox(SuggestOracle oracle) {
+ this(oracle, new TextBox());
+ }
+
+ /**
+ * Constructor for {@link SuggestBox}. The text box will be removed from it's
+ * current location and wrapped by the {@link SuggestBox}.
+ *
+ * @param oracle supplies suggestions based upon the current contents of the
+ * text widget
+ * @param box the text widget
+ */
+ public SuggestBox(SuggestOracle oracle, TextBoxBase box) {
+ this.box = box;
+ initWidget(box);
+ this.picker = new SuggestPickerImpl(oracle.isDisplayStringHTML());
+ this.popup = new ItemPickerDropDownImpl(this, picker);
+ addPopupChangeListener();
+ addKeyboardSupport();
+ setOracle(oracle);
+ setStyleName(STYLENAME_DEFAULT);
+ }
+
+ public final void addChangeListener(ChangeListener listener) {
+ if (changeListeners == null) {
+ changeListeners = new DelegatingChangeListenerCollection(this, box);
+ }
+ changeListeners.add(listener);
+ }
+
+ public final void addClickListener(ClickListener listener) {
+ if (clickListeners == null) {
+ clickListeners = new DelegatingClickListenerCollection(this, box);
+ }
+ clickListeners.add(listener);
+ }
+
+ public final void addFocusListener(FocusListener listener) {
+ if (focusListeners == null) {
+ focusListeners = new DelegatingFocusListenerCollection(this, box);
+ }
+ focusListeners.add(listener);
+ }
+
+ public final void addKeyboardListener(KeyboardListener listener) {
+ if (keyboardListeners == null) {
+ keyboardListeners = new DelegatingKeyboardListenerCollection(this, box);
+ }
+ keyboardListeners.add(listener);
+ }
+
+ /**
+ * Gets the limit for the number of suggestions that should be displayed for
+ * this box. It is up to the current {@link SuggestOracle} to enforce this
+ * limit.
+ *
+ * @return the limit for the number of suggestions
+ */
+ public final int getLimit() {
+ return limit;
+ }
+
+ /**
+ * Gets the suggest box's {@link com.google.gwt.user.client.ui.SuggestOracle}.
+ *
+ * @return the {@link SuggestOracle}
+ */
+ public final SuggestOracle getSuggestOracle() {
+ return oracle;
+ }
+
+ public final int getTabIndex() {
+ return box.getTabIndex();
+ }
+
+ public final String getText() {
+ return box.getText();
+ }
+
+ public final void removeChangeListener(ChangeListener listener) {
+ if (clickListeners != null) {
+ clickListeners.remove(listener);
+ }
+ }
+
+ public final void removeClickListener(ClickListener listener) {
+ if (clickListeners != null) {
+ clickListeners.remove(listener);
+ }
+ }
+
+ public final void removeFocusListener(FocusListener listener) {
+ if (focusListeners != null) {
+ focusListeners.remove(listener);
+ }
+ }
+
+ public final void removeKeyboardListener(KeyboardListener listener) {
+ if (keyboardListeners != null) {
+ keyboardListeners.remove(listener);
+ }
+ }
+
+ public final void setAccessKey(char key) {
+ box.setAccessKey(key);
+ }
+
+ public final void setFocus(boolean focused) {
+ box.setFocus(focused);
+ }
+
+ /**
+ * Sets the limit to the number of suggestions the oracle should provide. It
+ * is up to the oracle to enforce this limit.
+ *
+ * @param limit the limit to the number of suggestions provided
+ */
+ public final void setLimit(int limit) {
+ this.limit = limit;
+ }
+
+ public final void setTabIndex(int index) {
+ box.setTabIndex(index);
+ }
+
+ public final void setText(String text) {
+ box.setText(text);
+ }
+
+ /**
+ * Show the given collection of suggestions.
+ *
+ * @param suggestions suggestions to show
+ */
+ private void showSuggestions(Collection suggestions) {
+ if (suggestions.size() > 0) {
+ picker.setItems(suggestions);
+ popup.show();
+ } else {
+ popup.hide();
+ }
+ }
+
+ private void addKeyboardSupport() {
+ box.addKeyboardListener(new KeyboardListenerAdapter() {
+ private boolean pendingCancel;
+
+ public void onKeyDown(Widget sender, char keyCode, int modifiers) {
+ pendingCancel = picker.delegateKeyDown(keyCode);
+ }
+
+ public void onKeyPress(Widget sender, char keyCode, int modifiers) {
+ if (pendingCancel) {
+ // IE does not allow cancel key on key down, so we have delayed the
+ // cancellation of the key until the associated key press.
+ box.cancelKey();
+ pendingCancel = false;
+ } else if (popup.isAttached()) {
+ if (separators != null && isSeparator(keyCode)) {
+ // onKeyDown/onKeyUps's keyCode for ',' comes back '1/4', so unlike
+ // navigation, we use key press events to determine when the user
+ // wants to simulate clicking on the popup.
+ picker.commitSelection();
+
+ // The separator will be added after the popup is activated, so the
+ // popup will have already added a new separator. Therefore, the
+ // original separator should not be added as well.
+ box.cancelKey();
+ }
+ }
+ }
+
+ public void onKeyUp(Widget sender, char keyCode, int modifiers) {
+ // After every user key input, refresh the popup's suggestions.
+ refreshSuggestions();
+ }
+
+ /**
+ * In the presence of separators, returns the active search selection.
+ */
+ private String getActiveSelection(String text) {
+ selectEnd = box.getCursorPos();
+
+ // Find the last instance of a separator.
+ selectStart = -1;
+ for (int i = 0; i < separators.length; i++) {
+ selectStart = Math.max(
+ text.lastIndexOf(separators[i], selectEnd - 1), selectStart);
+ }
+ ++selectStart;
+
+ return text.substring(selectStart, selectEnd).trim();
+ }
+
+ private void refreshSuggestions() {
+ // Get the raw text.
+ String text = box.getText();
+ if (text.equals(currentValue)) {
+ return;
+ } else {
+ currentValue = text;
+ }
+
+ // Find selection to replace.
+ String selection;
+ if (separators == null) {
+ selection = text;
+ } else {
+ selection = getActiveSelection(text);
+ }
+ // If we have no text, let's not show the suggestions.
+ if (selection.length() == 0) {
+ popup.hide();
+ } else {
+ showSuggestions(selection);
+ }
+ }
+ });
+ }
+
+ /**
+ * Adds a standard popup listener to the suggest box's popup.
+ */
+ private void addPopupChangeListener() {
+ picker.addChangeListener(new ChangeListener() {
+ public void onChange(Widget sender) {
+ if (separators != null) {
+ onChangeWithSeparators();
+ } else {
+ currentValue = picker.getSelectedValue().toString();
+ box.setText(currentValue);
+ }
+ if (changeListeners != null) {
+ changeListeners.fireChange(SuggestBox.this);
+ }
+ }
+
+ private void onChangeWithSeparators() {
+ String newValue = (String) picker.getSelectedValue();
+
+ StringBuffer accum = new StringBuffer();
+ String text = box.getText();
+
+ // Add all text up to the selection start.
+ accum.append(text.substring(0, selectStart));
+
+ // Add one space if not at start.
+ if (selectStart > 0) {
+ accum.append(separatorPadding);
+ }
+ // Add the new value.
+ accum.append(newValue);
+
+ // Find correct cursor position.
+ int savedCursorPos = accum.length();
+
+ // Add all text after the selection end
+ String ender = text.substring(selectEnd).trim();
+ if (ender.length() == 0 || !isSeparator(ender.charAt(0))) {
+ // Add a separator if the first char of the ender is not already a
+ // separator.
+ accum.append(separators[0]).append(separatorPadding);
+ savedCursorPos = accum.length();
+ }
+ accum.append(ender);
+
+ // Set the text and cursor pos to correct location.
+ String replacement = accum.toString();
+ currentValue = replacement.trim();
+ box.setText(replacement);
+ box.setCursorPos(savedCursorPos);
+ }
+ });
+ }
+
+ /**
+ * Convenience method for identifying if a character is a separator.
+ */
+ private boolean isSeparator(char candidate) {
+ // An int map would be very handy right here...
+ for (int i = 0; i < separators.length; i++) {
+ if (candidate == separators[i]) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Sets the suggestion oracle used to create suggestions.
+ *
+ * @param oracle the oracle
+ */
+ private void setOracle(SuggestOracle oracle) {
+ this.oracle = oracle;
+ }
+
+ private void showSuggestions(String query) {
+ oracle.requestSuggestions(new Request(query, limit), callBack);
+ }
+}
diff --git a/user/src/com/google/gwt/user/client/ui/SuggestOracle.java b/user/src/com/google/gwt/user/client/ui/SuggestOracle.java
new file mode 100644
index 0000000..db62d1f
--- /dev/null
+++ b/user/src/com/google/gwt/user/client/ui/SuggestOracle.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2007 Google Inc.
+ *
+ * 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.google.gwt.user.client.ui;
+
+import com.google.gwt.user.client.rpc.IsSerializable;
+
+import java.util.Collection;
+
+/**
+ * A {@link com.google.gwt.user.client.ui.SuggestOracle} can be used to create
+ * suggestions associated with a specific query string. It is currently used by
+ * {@link SuggestBox}.
+ *
+ * @see SuggestBox
+ */
+public abstract class SuggestOracle {
+
+ /**
+ * Constructor for {@link com.google.gwt.user.client.ui.SuggestOracle}.
+ */
+ public SuggestOracle() {
+ }
+
+ /**
+ * Should {@link Suggestion} display strings be treated as HTML? If true, this
+ * all suggestions' display strings will be interpreted as HTML, otherwise as
+ * text.
+ *
+ * @return by default, returns false
+ */
+ public boolean isDisplayStringHTML() {
+ return false;
+ }
+
+ /**
+ * Generate a {@link Response} based on a specific {@link Request}. After the
+ * {@link Response} is created, it is passed into
+ * {@link Callback#onSuggestionsReady(com.google.gwt.user.client.ui.SuggestOracle.Request, com.google.gwt.user.client.ui.SuggestOracle.Response)}.
+ *
+ * @param request the request
+ * @param callback the callback to use for the response
+ */
+ public abstract void requestSuggestions(Request request, Callback callback);
+
+ /**
+ * Callback for {@link com.google.gwt.user.client.ui.SuggestOracle}. Every
+ * {@link Request} should be associated with a callback that should be called
+ * after a {@link Response} is generated.
+ */
+ public interface Callback {
+ /**
+ * Consume the suggestions created by a
+ * {@link com.google.gwt.user.client.ui.SuggestOracle} in response to a
+ * {@link Request}.
+ *
+ * @param request the request
+ * @param response the response
+ */
+ public void onSuggestionsReady(Request request, Response response);
+ }
+
+ /**
+ * A {@link com.google.gwt.user.client.ui.SuggestOracle} request.
+ */
+ public static class Request implements IsSerializable {
+ private int limit = 20;
+ private String query;
+
+ /**
+ * Constructor for {@link Request}.
+ */
+ public Request() {
+ }
+
+ /**
+ * Constructor for {@link Request}.
+ *
+ * @param query the query string
+ */
+ public Request(String query) {
+ setQuery(query);
+ }
+
+ /**
+ * Constructor for {@link Request}.
+ *
+ * @param query the query string
+ * @param limit limit on the number of suggestions that should be created
+ * for this query
+ */
+ public Request(String query, int limit) {
+ setQuery(query);
+ setLimit(limit);
+ }
+
+ /**
+ * Gets the limit on the number of suggestions that should be created.
+ *
+ * @return the limit
+ */
+ public int getLimit() {
+ return limit;
+ }
+
+ /**
+ * Gets the query string.
+ *
+ * @return the query string
+ */
+ public String getQuery() {
+ return query;
+ }
+
+ /**
+ * Sets the limit on the number of suggestions that should be created.
+ *
+ * @param limit the limit
+ */
+ public void setLimit(int limit) {
+ this.limit = limit;
+ }
+
+ /**
+ * Sets the query string used for this request.
+ *
+ * @param query the query string
+ */
+ public void setQuery(String query) {
+ this.query = query;
+ }
+ }
+
+ /**
+ * {@link com.google.gwt.user.client.ui.SuggestOracle} response.
+ */
+ public static class Response implements IsSerializable {
+
+ /**
+ * @gwt.typeArgs <com.google.gwt.user.client.ui.SuggestOracle.Suggestion>
+ */
+ private Collection suggestions;
+
+ /**
+ * Constructor for {@link Response}.
+ */
+ public Response() {
+ }
+
+ /**
+ * Constructor for {@link Response}.
+ *
+ * @param suggestions each element of suggestions must implement the
+ * {@link Suggestion} interface
+ */
+ public Response(Collection suggestions) {
+ setSuggestions(suggestions);
+ }
+
+ /**
+ * Gets the collection of suggestions. Each suggestion must implement the
+ * {@link Suggestion} interface.
+ *
+ * @return the collection of suggestions
+ */
+ public Collection getSuggestions() {
+ return this.suggestions;
+ }
+
+ /**
+ * Sets the suggestions for this response. Each suggestion must implement
+ * the {@link Suggestion} interface.
+ *
+ * @param suggestions the suggestions
+ */
+ public void setSuggestions(Collection suggestions) {
+ this.suggestions = suggestions;
+ }
+ }
+
+ /**
+ * Suggestion supplied by the
+ * {@link com.google.gwt.user.client.ui.SuggestOracle}. Each suggestion has a
+ * value and a display string. The interpretation of the display string
+ * depends upon the value of its oracle's {@link SuggestOracle#isDisplayStringHTML()}.
+ *
+ */
+ public interface Suggestion {
+ /**
+ * Gets the display string associated with this suggestion. The
+ * interpretation of the display string depends upon the value of
+ * its oracle's {@link SuggestOracle#isDisplayStringHTML()}.
+ *
+ * @return the display string
+ */
+ String getDisplayString();
+
+ /**
+ * Get the value associated with this suggestion.
+ *
+ * @return the value
+ */
+ Object getValue();
+ }
+}
diff --git a/user/src/com/google/gwt/user/client/ui/impl/AbstractItemPickerImpl.java b/user/src/com/google/gwt/user/client/ui/impl/AbstractItemPickerImpl.java
index 82ae5aa..73ae298 100644
--- a/user/src/com/google/gwt/user/client/ui/impl/AbstractItemPickerImpl.java
+++ b/user/src/com/google/gwt/user/client/ui/impl/AbstractItemPickerImpl.java
@@ -76,7 +76,7 @@
}
}
- private static final String STYLENAME_SELECTED_ITEM = "selected";
+ private static final String STYLENAME_SELECTED_ITEM = "item-selected";
private static final String STYLENAME_ITEM = "item";
final Element body;
diff --git a/user/src/com/google/gwt/user/client/ui/impl/ItemPickerDropDownImpl.java b/user/src/com/google/gwt/user/client/ui/impl/ItemPickerDropDownImpl.java
new file mode 100644
index 0000000..cccfe29
--- /dev/null
+++ b/user/src/com/google/gwt/user/client/ui/impl/ItemPickerDropDownImpl.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2007 Google Inc.
+ *
+ * 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.google.gwt.user.client.ui.impl;
+
+import com.google.gwt.user.client.Window;
+import com.google.gwt.user.client.ui.ChangeListener;
+import com.google.gwt.user.client.ui.HasFocus;
+import com.google.gwt.user.client.ui.PopupPanel;
+import com.google.gwt.user.client.ui.UIObject;
+import com.google.gwt.user.client.ui.Widget;
+
+/**
+ * Suggestion picker drop-down, used in the implementation of
+ * {@link com.google.gwt.user.client.ui.SuggestBox}.
+ */
+public class ItemPickerDropDownImpl extends PopupPanel {
+ private final AbstractItemPickerImpl picker;
+ private final HasFocus owner;
+
+ public ItemPickerDropDownImpl(final HasFocus owner, AbstractItemPickerImpl picker) {
+ super(true);
+ setWidget(picker);
+ this.picker = picker;
+ this.owner = owner;
+
+ picker.addChangeListener(new ChangeListener() {
+ public void onChange(Widget sender) {
+ hide();
+ }
+ });
+ }
+
+ /**
+ * Shows the popup, by default <code>show</code> selects the first item and
+ * displays itself under it's owner.
+ */
+ public void show() {
+ showBelow((UIObject) owner);
+ }
+
+ /**
+ * Shows the popup below the given UI object. By default, first item is
+ * selected in the item picker.
+ * <p>
+ * Note, if the popup would not be visible on the browser, than the popup's
+ * position may be adjusted.
+ * </p>
+ *
+ * @param showBelow the <code>UIObject</code> beneath which the popup should
+ * be shown
+ */
+ public void showBelow(UIObject showBelow) {
+ // A drop down with 0 items should never show itself.
+ if (picker.getItemCount() == 0) {
+ hide();
+ return;
+ }
+
+ // Initialize the picker to the first element.
+ picker.setSelectedIndex(0);
+
+ // Show must be called first, as otherwise getOffsetWidth is not correct. As
+ // the adjustment is very fast, the user experience is not effected by this
+ // call.
+ super.show();
+
+ // Calculate left.
+ int left = showBelow.getAbsoluteLeft();
+ int windowRight = Window.getClientWidth() + Window.getScrollLeft();
+ int overshootLeft = Math.max(0, (left + getOffsetWidth()) - windowRight);
+ left = left - overshootLeft;
+
+ // Calculate top.
+ int top = showBelow.getAbsoluteTop() + showBelow.getOffsetHeight();
+ int windowBottom = Window.getScrollTop() + Window.getClientHeight();
+ int overshootTop = Math.max(0, (top + getOffsetHeight()) - windowBottom);
+ top = top - overshootTop;
+
+ // Set the popup position.
+ setPopupPosition(left, top);
+ super.show();
+ }
+}
diff --git a/user/src/com/google/gwt/user/client/ui/impl/SuggestPickerImpl.java b/user/src/com/google/gwt/user/client/ui/impl/SuggestPickerImpl.java
new file mode 100644
index 0000000..ccf936f
--- /dev/null
+++ b/user/src/com/google/gwt/user/client/ui/impl/SuggestPickerImpl.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2007 Google Inc.
+ *
+ * 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.google.gwt.user.client.ui.impl;
+
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.Element;
+import com.google.gwt.user.client.ui.KeyboardListener;
+import com.google.gwt.user.client.ui.UIObject;
+import com.google.gwt.user.client.ui.SuggestOracle.Suggestion;
+
+import java.util.Collection;
+import java.util.Iterator;
+
+/**
+ * Suggestion picker, used in the implementation of
+ * {@link com.google.gwt.user.client.ui.SuggestBox}.
+ */
+public class SuggestPickerImpl extends AbstractItemPickerImpl {
+
+ /**
+ * Default style for the picker.
+ */
+ private static final String STYLENAME_DEFAULT = "gwt-SuggestBoxPopup";
+ private final boolean asHTML;
+ private int startInvisible = Integer.MAX_VALUE;
+
+ /**
+ * Constructor for <code>SuggestPickerImpl</code>.
+ *
+ * @param asHTML flag used to indicate how to treat {@link Suggestion} display
+ * strings
+ */
+ public SuggestPickerImpl(boolean asHTML) {
+ this.asHTML = asHTML;
+ setStyleName(STYLENAME_DEFAULT);
+ }
+
+ public boolean delegateKeyDown(char keyCode) {
+ if (isAttached()) {
+ switch (keyCode) {
+ case KeyboardListener.KEY_DOWN:
+ shiftSelection(1);
+ return true;
+ case KeyboardListener.KEY_UP:
+ shiftSelection(-1);
+ return true;
+ case KeyboardListener.KEY_ENTER:
+ commitSelection();
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public int getItemCount() {
+ if (startInvisible == Integer.MAX_VALUE) {
+ return 0;
+ } else {
+ return startInvisible;
+ }
+ }
+
+ /**
+ * Sets the suggestions associated with this picker.
+ *
+ * @param suggestions suggestions for this picker
+ */
+ public final void setItems(Collection suggestions) {
+ setItems(suggestions.iterator());
+ }
+
+ protected native Element getRow(Element elem, int row)/*-{
+ return elem.rows[row];
+ }-*/;
+
+ void shiftSelection(int shift) {
+ int newSelect = getSelectedIndex() + shift;
+ if (newSelect >= super.getItemCount() || newSelect < 0
+ || newSelect >= startInvisible) {
+ return;
+ }
+ setSelection(getItem(newSelect));
+ }
+
+ /**
+ * Ensures the existence of the given item and returns it.
+ *
+ * @param itemIndex item index to ensure
+ * @return associated item
+ */
+ private Item ensureItem(int itemIndex) {
+ for (int i = super.getItemCount(); i <= itemIndex; i++) {
+ Item item = new Item(i);
+ addItem(item, true);
+ }
+ return getItem(itemIndex);
+ }
+
+ /**
+ * Sets the suggestions associated with this picker.
+ */
+ private final void setItems(Iterator suggestions) {
+ int itemCount = 0;
+
+ // Ensure all needed items exist and set each item's html to the given
+ // suggestion.
+ while (suggestions.hasNext()) {
+ Item item = ensureItem(itemCount);
+ Suggestion suggestion = (Suggestion) suggestions.next();
+ String display = suggestion.getDisplayString();
+ if (asHTML) {
+ DOM.setInnerHTML(item.getElement(), display);
+ } else {
+ DOM.setInnerText(item.getElement(), display);
+ }
+ item.setValue(suggestion.getValue());
+ ++itemCount;
+ }
+
+ if (itemCount == 0) {
+ throw new IllegalStateException(
+ "Must set at least one item in a SuggestPicker");
+ }
+
+ // Render visible all needed cells.
+ int min = Math.min(itemCount, super.getItemCount());
+ for (int i = startInvisible; i < min; i++) {
+ setVisible(i, true);
+ }
+
+ // Render invisible all useless cells.
+ startInvisible = itemCount;
+ for (int i = itemCount; i < super.getItemCount(); i++) {
+ setVisible(i, false);
+ }
+ }
+
+ /**
+ * Sets whether the given item is visible.
+ *
+ * @param itemIndex item index
+ * @param visible visible boolean
+ */
+ private void setVisible(int itemIndex, boolean visible) {
+ UIObject.setVisible(getRow(body, itemIndex), visible);
+ }
+}