Bidi support for ListBox

Review at http://gwt-code-reviews.appspot.com/1137801

Review by: aharon@google.com

git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@9347 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/tools/api-checker/config/gwt21_22userApi.conf b/tools/api-checker/config/gwt21_22userApi.conf
index e700609..a15c6b7 100644
--- a/tools/api-checker/config/gwt21_22userApi.conf
+++ b/tools/api-checker/config/gwt21_22userApi.conf
@@ -273,3 +273,7 @@
 com.google.gwt.user.cellview.client.Header::onBrowserEvent(Lcom/google/gwt/dom/client/Element;Lcom/google/gwt/dom/client/NativeEvent;) MISSING
 com.google.gwt.user.cellview.client.Header::render(Lcom/google/gwt/safehtml/shared/SafeHtmlBuilder;) MISSING
 
+# These are supposedly ambiguous with other overloads on null values.
+com.google.gwt.user.client.ui.ListBox::addItem(Ljava/lang/String;Ljava/lang/String;) OVERLOADED_METHOD_CALL
+com.google.gwt.user.client.ui.ListBox::insertItem(Ljava/lang/String;Ljava/lang/String;I) OVERLOADED_METHOD_CALL
+
diff --git a/user/src/com/google/gwt/user/client/ui/ListBox.java b/user/src/com/google/gwt/user/client/ui/ListBox.java
index 4715f94..371e71c 100644
--- a/user/src/com/google/gwt/user/client/ui/ListBox.java
+++ b/user/src/com/google/gwt/user/client/ui/ListBox.java
@@ -23,6 +23,13 @@
 import com.google.gwt.event.dom.client.ChangeHandler;
 import com.google.gwt.event.dom.client.HasChangeHandlers;
 import com.google.gwt.event.shared.HandlerRegistration;
+import com.google.gwt.i18n.client.HasDirection.Direction;
+import com.google.gwt.i18n.shared.BidiFormatter;
+import com.google.gwt.i18n.shared.DirectionEstimator;
+import com.google.gwt.i18n.shared.HasDirectionEstimator;
+import com.google.gwt.i18n.shared.WordCountDirectionEstimator;
+
+import java.util.ArrayList;
 
 /**
  * A widget that presents a list of choices to the user, either as a list box or
@@ -41,7 +48,14 @@
  * <h3>Example</h3>
  * {@example com.google.gwt.examples.ListBoxExample}
  * </p>
- * 
+ *
+ * <p>
+ * <h3>Built-in Bidi Text Support</h3>
+ * This widget is capable of automatically adjusting its direction according to
+ * its content. This feature is controlled by {@link #setDirectionEstimator},
+ * and is off by default.
+ * </p>
+ *
  * <h3>Use in UiBinder Templates</h3>
  * <p>
  * The items of a ListBox element are laid out in &lt;g:item> elements.
@@ -67,7 +81,10 @@
  */
 @SuppressWarnings("deprecation")
 public class ListBox extends FocusWidget implements SourcesChangeEvents,
-    HasChangeHandlers, HasName {
+    HasChangeHandlers, HasName, HasDirectionEstimator {
+
+  public static final DirectionEstimator DEFAULT_DIRECTION_ESTIMATOR =
+    WordCountDirectionEstimator.get();
 
   private static final int INSERT_AT_END = -1;
 
@@ -94,6 +111,9 @@
     return listBox;
   }
 
+  private DirectionEstimator estimator;
+  private ArrayList<String> itemTexts = new ArrayList<String>();
+
   /**
    * Creates an empty list box in single selection mode.
    */
@@ -149,6 +169,21 @@
   }
 
   /**
+   * Adds an item to the list box, specifying its direction. This method has the
+   * same effect as
+   * 
+   * <pre>
+   * addItem(item, dir, item)
+   * </pre>
+   * 
+   * @param item the text of the item to be added
+   * @param dir the item's direction
+   */
+  public void addItem(String item, Direction dir) {
+    insertItem(item, dir, INSERT_AT_END);
+  }
+
+  /**
    * Adds an item to the list box, specifying an initial value for the item.
    * 
    * @param item the text of the item to be added
@@ -160,12 +195,29 @@
   }
 
   /**
+   * Adds an item to the list box, specifying its direction and an initial value
+   * for the item.
+   * 
+   * @param item the text of the item to be added
+   * @param dir the item's direction
+   * @param value the item's value, to be submitted if it is part of a
+   *          {@link FormPanel}; cannot be <code>null</code>
+   */
+  public void addItem(String item, Direction dir, String value) {
+    insertItem(item, dir, value, INSERT_AT_END);
+  }
+
+  /**
    * Removes all items from the list box.
    */
   public void clear() {
     getSelectElement().clear();
   }
 
+  public DirectionEstimator getDirectionEstimator() {
+    return estimator;
+  }
+
   /**
    * Gets the number of items present in the list box.
    * 
@@ -184,7 +236,7 @@
    */
   public String getItemText(int index) {
     checkIndex(index);
-    return getSelectElement().getOptions().getItem(index).getText();
+    return itemTexts.get(index);
   }
 
   public String getName() {
@@ -239,9 +291,28 @@
   }
 
   /**
+   * Inserts an item into the list box, specifying its direction. Has the same
+   * effect as
+   * 
+   * <pre>
+   * insertItem(item, dir, item, index)
+   * </pre>
+   * 
+   * @param item the text of the item to be inserted
+   * @param dir the item's direction
+   * @param index the index at which to insert it
+   */
+  public void insertItem(String item, Direction dir, int index) {
+    insertItem(item, dir, item, index);
+  }
+
+  /**
    * Inserts an item into the list box, specifying an initial value for the
-   * item. If the index is less than zero, or greater than or equal to the
-   * length of the list, then the item will be appended to the end of the list.
+   * item. Has the same effect as
+   *
+   * <pre>
+   * insertItem(item, null, value, index)
+   * </pre>
    * 
    * @param item the text of the item to be inserted
    * @param value the item's value, to be submitted if it is part of a
@@ -249,12 +320,35 @@
    * @param index the index at which to insert it
    */
   public void insertItem(String item, String value, int index) {
+    insertItem(item, null, value, index);
+  }
+
+  /**
+   * Inserts an item into the list box, specifying its direction and an initial
+   * value for the item. If the index is less than zero, or greater than or
+   * equal to the length of the list, then the item will be appended to the end
+   * of the list.
+   * 
+   * @param item the text of the item to be inserted
+   * @param dir the item's direction. If {@code null}, the item is displayed in
+   *          the widget's overall direction, or, if a direction estimator has
+   *          been set, in the item's estimated direction.
+   * @param value the item's value, to be submitted if it is part of a
+   *          {@link FormPanel}.
+   * @param index the index at which to insert it
+   */
+  public void insertItem(String item, Direction dir, String value, int index) {
     SelectElement select = getSelectElement();
     OptionElement option = Document.get().createOptionElement();
-    option.setText(item);
+    option.setText(unicodeWrapIfNeeded(item, dir));
     option.setValue(value);
 
-    if ((index == -1) || (index == select.getLength())) {
+    int itemCount = select.getLength();
+    if (index < 0 || index > itemCount) {
+      index = itemCount;
+    }
+    itemTexts.add(index, item);
+    if (index == itemCount) {
       select.add(option, null);
     } else {
       OptionElement before = select.getOptions().getItem(index);
@@ -294,13 +388,31 @@
 
   /**
    * Removes the item at the specified index.
-   * 
+   *
    * @param index the index of the item to be removed
    * @throws IndexOutOfBoundsException if the index is out of range
    */
   public void removeItem(int index) {
     checkIndex(index);
     getSelectElement().remove(index);
+    itemTexts.remove(index);
+  }
+
+  /**
+   * {@inheritDoc}
+   * See note at
+   * {@link #setDirectionEstimator(com.google.gwt.i18n.shared.DirectionEstimator)}
+   */
+  public void setDirectionEstimator(boolean enabled) {
+    setDirectionEstimator(enabled ? DEFAULT_DIRECTION_ESTIMATOR : null);
+  }
+
+  /**
+   * {@inheritDoc}
+   * Note: this does not affect the direction of already-existing content.
+   */
+  public void setDirectionEstimator(DirectionEstimator directionEstimator) {
+    estimator = directionEstimator;
   }
 
   /**
@@ -328,11 +440,25 @@
    * @throws IndexOutOfBoundsException if the index is out of range
    */
   public void setItemText(int index, String text) {
+    setItemText(index, text, null);
+  }
+
+  /**
+   * Sets the text associated with the item at a given index.
+   * 
+   * @param index the index of the item to be set
+   * @param text the item's new text
+   * @param dir the item's direction.
+   * @throws IndexOutOfBoundsException if the index is out of range
+   */
+  public void setItemText(int index, String text, Direction dir) {
     checkIndex(index);
     if (text == null) {
       throw new NullPointerException("Cannot set an option to have null text");
     }
-    getSelectElement().getOptions().getItem(index).setText(text);
+    getSelectElement().getOptions().getItem(index).setText(unicodeWrapIfNeeded(
+        text, dir));
+    itemTexts.set(index, text);
   }
 
   /**
@@ -424,4 +550,13 @@
   private SelectElement getSelectElement() {
     return getElement().cast();
   }
+
+  private String unicodeWrapIfNeeded(String text, Direction dir) {
+    if (dir == null && estimator != null) {
+      dir = estimator.estimateDirection(text);
+    }
+    return dir == null ? text :
+        BidiFormatter.getInstanceForCurrentLocale().unicodeWrapWithKnownDir(dir,
+            text, false /* isHtml */, false /* dirReset */);
+  }
 }
diff --git a/user/test/com/google/gwt/user/client/ui/ListBoxTest.java b/user/test/com/google/gwt/user/client/ui/ListBoxTest.java
index eb06f9e..0168129 100644
--- a/user/test/com/google/gwt/user/client/ui/ListBoxTest.java
+++ b/user/test/com/google/gwt/user/client/ui/ListBoxTest.java
@@ -15,6 +15,9 @@
  */
 package com.google.gwt.user.client.ui;
 
+import com.google.gwt.dom.client.SelectElement;
+import com.google.gwt.i18n.client.HasDirection.Direction;
+import com.google.gwt.i18n.shared.BidiFormatter;
 import com.google.gwt.junit.client.GWTTestCase;
 import com.google.gwt.user.client.Command;
 import com.google.gwt.user.client.DeferredCommand;
@@ -24,6 +27,12 @@
  */
 public class ListBoxTest extends GWTTestCase {
 
+  private final String RTL_TEXT = "\u05e0 \u05e0\u05e0\u05e0\u05e0\u05e0" +
+      "\u05e0\u05e0\u05e0 \u05e0\u05e0\u05e0\u05e0\u05e0 \u05e0\u05e0\u05e0" +
+      "\u05e0\u05e0\u05e0 \u05e0\u05e0\u05e0 \u05e0\u05e0\u05e0";
+  private final String LTR_TEXT = "The quick brown fox jumps over the" +
+      "lazy dog. The lazy dog seems quite amused.";
+
   @Override
   public String getModuleName() {
     return "com.google.gwt.user.DebugTest";
@@ -52,9 +61,9 @@
     delayTestFinish(5000);
     DeferredCommand.addCommand(new Command() {
       public void execute() {
-        UIObjectTest.assertDebugIdContents("myList-item0", "option0");   
-        UIObjectTest.assertDebugIdContents("myList-item1", "option1");   
-        UIObjectTest.assertDebugIdContents("myList-item2", "option2");   
+        UIObjectTest.assertDebugIdContents("myList-item0", "option0");
+        UIObjectTest.assertDebugIdContents("myList-item1", "option1");
+        UIObjectTest.assertDebugIdContents("myList-item2", "option2");
         UIObjectTest.assertDebugIdContents("myList-item3", "option3");
         finishTest();
       }
@@ -107,6 +116,46 @@
       assertEquals("b", lb.getItemText(1));
       assertEquals("c", lb.getItemText(2));
     }
+
+    // Insert items of different directions
+    {
+      // Explicit direction, no direction estimation
+      ListBox lb = new ListBox();
+      lb.insertItem(RTL_TEXT, Direction.RTL, 0);
+      assertEquals(RTL_TEXT, lb.getItemText(0));
+      assertOptionText(BidiFormatter.getInstanceForCurrentLocale().unicodeWrap(
+          RTL_TEXT, false /* isHtml */, false /* dirReset */), lb, 0);
+      lb.insertItem(LTR_TEXT, Direction.LTR, 0);
+      assertEquals(LTR_TEXT, lb.getItemText(0));
+      assertOptionText(BidiFormatter.getInstanceForCurrentLocale().unicodeWrap(
+          LTR_TEXT, false /* isHtml */, false /* dirReset */), lb, 0);
+      lb.clear();
+
+      // Direction estimation
+      lb.setDirectionEstimator(true);
+      lb.addItem(RTL_TEXT);
+      assertEquals(RTL_TEXT, lb.getItemText(0));
+      assertOptionText(BidiFormatter.getInstanceForCurrentLocale().unicodeWrap(
+          RTL_TEXT, false /* isHtml */, false /* dirReset */), lb, 0);
+      lb.addItem(LTR_TEXT);
+      assertEquals(LTR_TEXT, lb.getItemText(1));
+      assertOptionText(BidiFormatter.getInstanceForCurrentLocale().unicodeWrap(
+          LTR_TEXT, false /* isHtml */, false /* dirReset */), lb, 1);
+
+      // Explicit direction which is opposite to the estimated direction
+      lb.insertItem(RTL_TEXT, Direction.LTR, 0);
+      assertEquals(RTL_TEXT, lb.getItemText(0));
+      assertOptionText(
+          BidiFormatter.getInstanceForCurrentLocale().unicodeWrapWithKnownDir(
+          Direction.LTR, RTL_TEXT, false /* isHtml */, false /* dirReset */),
+          lb, 0);
+      lb.insertItem(LTR_TEXT, Direction.RTL, 1);
+      assertEquals(LTR_TEXT, lb.getItemText(1));
+      assertOptionText(
+          BidiFormatter.getInstanceForCurrentLocale().unicodeWrapWithKnownDir(
+          Direction.RTL, LTR_TEXT, false /* isHtml */, false /* dirReset */),
+          lb, 1);
+    }
   }
 
   public void testRemove() {
@@ -214,6 +263,46 @@
     assertEquals("bc", box.getItemText(1));
     box.setItemText(0, "");
     assertEquals("", box.getItemText(0));
+
+    // Text of different directions
+    {
+      ListBox lb = new ListBox();
+      // Explicit direction, no direction estimation
+      lb.insertItem(RTL_TEXT, Direction.RTL, 0);
+      assertEquals(RTL_TEXT, lb.getItemText(0));
+      assertOptionText(BidiFormatter.getInstanceForCurrentLocale().unicodeWrap(
+          RTL_TEXT, false /* isHtml */, false /* dirReset */), lb, 0);
+      lb.insertItem(LTR_TEXT, Direction.LTR, 0);
+      assertEquals(LTR_TEXT, lb.getItemText(0));
+      assertOptionText(BidiFormatter.getInstanceForCurrentLocale().unicodeWrap(
+          LTR_TEXT, false /* isHtml */, false /* dirReset */), lb, 0);
+
+      // Direction estimation
+      lb.setDirectionEstimator(true);
+      lb.setItemText(0, RTL_TEXT);
+      assertEquals(RTL_TEXT, lb.getItemText(0));
+      assertOptionText(BidiFormatter.getInstanceForCurrentLocale().unicodeWrap(
+          RTL_TEXT, false /* isHtml */, false /* dirReset */), lb, 0);
+      lb.setItemText(0, LTR_TEXT);
+      assertEquals(LTR_TEXT, lb.getItemText(0));
+      assertOptionText(BidiFormatter.getInstanceForCurrentLocale().unicodeWrap(
+          LTR_TEXT, false /* isHtml */, false /* dirReset */), lb, 0);
+
+      // Explicit direction which is opposite to the estimated direction
+      lb.setItemText(0, LTR_TEXT, Direction.RTL);
+      assertEquals(LTR_TEXT, lb.getItemText(0));
+      assertOptionText(
+          BidiFormatter.getInstanceForCurrentLocale().unicodeWrapWithKnownDir(
+          Direction.RTL, LTR_TEXT, false /* isHtml */, false /* dirReset */),
+          lb, 0);
+      lb.setItemText(0, RTL_TEXT, Direction.LTR);
+      assertEquals(RTL_TEXT, lb.getItemText(0));
+      assertOptionText(
+          BidiFormatter.getInstanceForCurrentLocale().unicodeWrapWithKnownDir(
+          Direction.LTR, RTL_TEXT, false /* isHtml */, false /* dirReset */),
+          lb, 0);
+    }
+
     try {
       box.setItemText(0, null);
       fail("Should have thrown Null Pointer");
@@ -262,4 +351,9 @@
       assertEquals("item text", box.getItemText(1));
     }
   }
+
+  private void assertOptionText(String expected, ListBox listBox, int index) {
+    SelectElement select = listBox.getElement().cast();
+    assertEquals(expected, select.getOptions().getItem(index).getText());
+  }
 }