Bug 394516 - Figure out a remaining-time rounding scheme for minutes -> hours/days. r=sdwilsh, r=l10n@mozilla.com (Pike), b-ff3=beltzner
authoredward.lee@engineering.uiuc.edu
Tue, 22 Jan 2008 18:18:24 -0800
changeset 10559 5a9643ef098e8980ced9b531eb5a77e361d11717
parent 10558 3b8f70662805a80749638259352d9308937a1db6
child 10560 51957bb95f637afcfe0b1858002e8901b4270b13
push id1
push userbsmedberg@mozilla.com
push dateThu, 20 Mar 2008 16:49:24 +0000
treeherdermozilla-central@61007906a1f8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssdwilsh, l10n
bugs394516
milestone1.9b3pre
Bug 394516 - Figure out a remaining-time rounding scheme for minutes -> hours/days. r=sdwilsh, r=l10n@mozilla.com (Pike), b-ff3=beltzner
intl/locale/src/Makefile.in
intl/locale/src/PluralForm.jsm
intl/locale/tests/unit/test_pluralForm_english.js
toolkit/locales/en-US/chrome/global/intl.properties
toolkit/locales/en-US/chrome/mozapps/downloads/downloads.properties
toolkit/mozapps/downloads/src/DownloadUtils.jsm
--- a/intl/locale/src/Makefile.in
+++ b/intl/locale/src/Makefile.in
@@ -76,16 +76,20 @@ EXPORTS		= \
 		nsCollation.h \
 		$(NULL)
 
 EXPORT_RESOURCE = \
 		$(srcdir)/langGroups.properties \
 		$(srcdir)/language.properties \
 		$(NULL)
 
+EXTRA_JS_MODULES = \
+  PluralForm.jsm \
+  $(NULL)
+
 # we don't want the shared lib, but we want to force the creation of a static lib.
 FORCE_STATIC_LIB = 1
 
 include $(topsrcdir)/config/rules.mk
 
 libs::
 	$(INSTALL) $(EXPORT_RESOURCE) $(DIST)/bin/res
 
new file mode 100644
--- /dev/null
+++ b/intl/locale/src/PluralForm.jsm
@@ -0,0 +1,135 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ *   Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Plural Form l10n Code.
+ *
+ * The Initial Developer of the Original Code is
+ * Edward Lee <edward.lee@engineering.uiuc.edu>.
+ * Portions created by the Initial Developer are Copyright (C) 2008
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+EXPORTED_SYMBOLS = [ "PluralForm" ];
+
+/**
+ * This module provides the PluralForm object which contains a method to figure
+ * out which plural form of a word to use for a given number based on the
+ * current localization.
+ *
+ * List of methods:
+ *
+ * string pluralForm
+ * get(int aNum, string aWords)
+ */
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+
+const kIntlProperties = "chrome://global/locale/intl.properties";
+
+// These are the available plural functions that give the appropriate index
+// based on the plural rule number specified
+let gFunctions = [
+  function(n) 0,
+  function(n) n!=1?1:0,
+  function(n) n>1?1:0,
+  function(n) n%10==1&&n%100!=11?1:n!=0?2:0,
+  function(n) n==1?0:n==2?1:2,
+  function(n) n==1?0:n==0||n%100>0&&n%100<20?1:2,
+  function(n) n%10==1&&n%100!=11?0:n%10>=2&&(n%100<10||n%100>=20)?2:1,
+  function(n) n%10==1&&n%100!=11?0:n%10>=2&&n%10<=4&&(n%100<10||n%100>=20)?1:2,
+  function(n) n==1?0:n>=2&&n<=4?1:2,
+  function(n) n==1?0:n%10>=2&&n%10<=4&&(n%100<10||n%100>=20)?1:2,
+  function(n) n%100==1?0:n%100==2?1:n%100==3||n%100==4?2:3
+];
+
+let PluralForm = {
+  /**
+   * Get the correct plural form of a word based on the number
+   *
+   * @param aNum
+   *        The number to decide which plural form to use
+   * @param aWords
+   *        A semi-colon (;) separated string of words to pick the plural form
+   * @return The appropriate plural form of the word
+   */
+  get: (function initGetPluralForm()
+  {
+    // initGetPluralForm gets called right away when this module is loaded and
+    // creates getPluralForm function. The function it creates is based on the
+    // value of pluralRule specified in the intl stringbundle.
+    // See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+
+    // Get the plural rule number from the intl stringbundle
+    let ruleNum = Number(Cc["@mozilla.org/intl/stringbundle;1"].
+      getService(Ci.nsIStringBundleService).createBundle(kIntlProperties).
+      GetStringFromName("pluralRule"));
+
+    // Default to "all plural" if the value is out of bounds or invalid
+    if (ruleNum < 0 || ruleNum >= gFunctions.length || isNaN(ruleNum)) {
+      log(["Invalid rule number: ", ruleNum, " -- defaulting to 0"]);
+      ruleNum = 0;
+    }
+
+    // Return a function that gets the right plural form
+    let pluralFunc = gFunctions[ruleNum];
+    return function(aNum, aWords) {
+      // Figure out which index to use for the semi-colon separated words
+      let index = pluralFunc(aNum ? Number(aNum) : 0);
+      let words = aWords ? aWords.split(/;/) : [""];
+
+      let ret = words[index];
+
+      // Check for array out of bounds or empty strings
+      if ((ret == undefined) || (ret == "")) {
+        // Display a message in the error console
+        log(["Index #", index, " of '", aWords, "' for value ", aNum,
+            " is invalid -- plural rule #", ruleNum]);
+
+        // Default to the first entry (which might be empty, but not undefined)
+        ret = words[0];
+      }
+
+      return ret;
+    };
+  })(),
+};
+
+/**
+ * Private helper function to log errors to the error console and command line
+ *
+ * @param aMsg
+ *        Error message to log or an array of strings to concat
+ */
+function log(aMsg)
+{
+  let msg = "PluralForm.jsm: " + (aMsg.join ? aMsg.join("") : aMsg);
+  Cc["@mozilla.org/consoleservice;1"].getService(Ci.nsIConsoleService).
+    logStringMessage(msg);
+  dump(msg + "\n");
+}
new file mode 100644
--- /dev/null
+++ b/intl/locale/tests/unit/test_pluralForm_english.js
@@ -0,0 +1,55 @@
+/* ***** BEGIN LICENSE BLOCK *****
+ *   Version: MPL 1.1/GPL 2.0/LGPL 2.1
+ *
+ * The contents of this file are subject to the Mozilla Public License Version
+ * 1.1 (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.mozilla.org/MPL/
+ *
+ * Software distributed under the License is distributed on an "AS IS" basis,
+ * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+ * for the specific language governing rights and limitations under the
+ * License.
+ *
+ * The Original Code is Plural Form l10n Test Code.
+ *
+ * The Initial Developer of the Original Code is
+ * Edward Lee <edward.lee@engineering.uiuc.edu>.
+ * Portions created by the Initial Developer are Copyright (C) 2008
+ * the Initial Developer. All Rights Reserved.
+ *
+ * Contributor(s):
+ *
+ * Alternatively, the contents of this file may be used under the terms of
+ * either the GNU General Public License Version 2 or later (the "GPL"), or
+ * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+ * in which case the provisions of the GPL or the LGPL are applicable instead
+ * of those above. If you wish to allow use of your version of this file only
+ * under the terms of either the GPL or the LGPL, and not to allow others to
+ * use your version of this file under the terms of the MPL, indicate your
+ * decision by deleting the provisions above and replace them with the notice
+ * and other provisions required by the GPL or the LGPL. If you do not delete
+ * the provisions above, a recipient may use your version of this file under
+ * the terms of any one of the MPL, the GPL or the LGPL.
+ *
+ * ***** END LICENSE BLOCK ***** */
+
+/**
+ * This unit test makes sure the plural form for the default language (by
+ * development), English, is working for the PluralForm javascript module.
+ */
+
+Components.utils.import("resource://gre/modules/PluralForm.jsm");
+
+function run_test()
+{
+  // Make sure for good inputs, things work as expected
+  for (var num = 0; num <= 1000; num++)
+    do_check_eq(num == 1 ? "word" : "words", PluralForm.get(num, "word;words"));
+
+  // Not having enough plural forms defaults to the first form
+  do_check_eq("word", PluralForm.get(2, "word"));
+
+  // Empty forms defaults to the first form
+  do_check_eq("word", PluralForm.get(2, "word;"));
+}
--- a/toolkit/locales/en-US/chrome/global/intl.properties
+++ b/toolkit/locales/en-US/chrome/global/intl.properties
@@ -1,16 +1,23 @@
 # all.js
 #
 # Localization Note: Cases of charset names must be preserved. If you're 
 # adding charsets to your localized version, please refer to 
 # intl/uconv/src/charsetalias.properties file for the list of canonical 
 # charset names and use canonical names exactly as listed there.
 # Also note that "UTF-8" should always be included in intl.charsetmenu.browser.static
 general.useragent.locale=en-US
+
+# LOCALIZATION NOTE (pluralRule): Pick the appropriate plural rule for your
+# language. This will determine how many plural forms of a word you will need
+# to provide and in what order.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+pluralRule=1
+
 # Localization Note: font.language.group controls the initial setting of the
 # language drop-down in the fonts pref panel. Set it to the value of one of the
 # menuitems in the "selectLangs" menulist in
 # browser/components/preferences/fonts.xul
 font.language.group=x-western
 # Localization Note: Add the code for your language at the front of this entry,
 # leaving "en-us, en" for fallback. It's recommended to use the same form, e.g.
 # "ja-jp, ja, en-us, en"
--- a/toolkit/locales/en-US/chrome/mozapps/downloads/downloads.properties
+++ b/toolkit/locales/en-US/chrome/mozapps/downloads/downloads.properties
@@ -1,8 +1,15 @@
+# LOCALIZATION NOTE (seconds, minutes, hours, days): Semi-colon list of plural
+# forms. See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+seconds=second;seconds
+minutes=minute;minutes
+hours=hour;hours
+days=day;days
+
 # LOCALIZATION NOTE (paused): — is the "em dash" (long dash)
 paused=Paused —  #1
 downloading=Downloading
 notStarted=Not Started
 failed=Failed
 finished=Finished
 canceled=Canceled
 
@@ -38,24 +45,27 @@ gigabyte=GB
 
 # LOCALIZATION NOTE (transferSameUnits, transferDiffUnits, transferNoTotal):
 # #1 progress number; #2 progress unit; #3 total number; #4 total unit
 # examples: 1.1 of 333 MB; 11.1 MB of 3.3 GB; 111 KB
 transferSameUnits=#1 of #3 #4
 transferDiffUnits=#1 #2 of #3 #4
 transferNoTotal=#1 #2
 
-# LOCALIZATION NOTE (timeMinutesLeft): number of minutes left (greater than 1)
-# LOCALIZATION NOTE (timeSecondsLeft): number of seconds left (greater than 3)
-# 3 min -> 2 min -> 60 secs -> 59 secs -> … -> 5 secs -> 4 secs -> few secs
-# examples: 11 minutes left; 11 seconds left;
-timeMinutesLeft=#1 minutes left
-timeSecondsLeft=#1 seconds left
-timeFewSeconds=A few seconds left
-timeUnknown=Unknown time left
+# LOCALIZATION NOTE (timePair): #1 time number; #2 time unit
+# example: 1 minute; 11 hours
+timePair=#1 #2
+# LOCALIZATION NOTE (timeLeftSingle): #1 time left
+# example: 1 minute remaining; 11 hours remaining
+timeLeftSingle=#1 remaining
+# LOCALIZATION NOTE (timeLeftDouble): #1 time left; #2 time left sub units
+# example: 11 hours, 2 minutes remaining; 1 day, 22 hours remaining
+timeLeftDouble=#1, #2 remaining
+timeFewSeconds=A few seconds remaining
+timeUnknown=Unknown time remaining
 
 # LOCALIZATION NOTE (doneStatus): — is the "em dash" (long dash)
 # #1 download size for FINISHED or download state; #2 host (e.g., eTLD + 1, IP)
 # #2 can also be doneScheme or doneFileScheme for special URIs like file:
 # examples: 1.1 MB — website2.com; Canceled — 222.net
 doneStatus=#1 — #2
 # LOCALIZATION NOTE (doneSize): #1 size number; #2 size unit
 doneSize=#1 #2
--- a/toolkit/mozapps/downloads/src/DownloadUtils.jsm
+++ b/toolkit/mozapps/downloads/src/DownloadUtils.jsm
@@ -53,38 +53,46 @@ EXPORTED_SYMBOLS = [ "DownloadUtils" ];
  * [string timeLeft, double newLast]
  * getTimeLeft(double aSeconds, [optional] double aLastSec)
  *
  * [string displayHost, string fullHost]
  * getURIHost(string aURIString)
  *
  * [double convertedBytes, string units]
  * convertByteUnits(int aBytes)
+ *
+ * [int time, string units, int subTime, string subUnits]
+ * convertTimeUnits(double aSecs)
  */
 
 const Cc = Components.classes;
 const Ci = Components.interfaces;
+const Cu = Components.utils
+Cu.import("resource://gre/modules/PluralForm.jsm");
 
 const kDownloadProperties =
   "chrome://mozapps/locale/downloads/downloads.properties";
 
 // These strings will be converted to the corresponding ones from the string
 // bundle on load
 let gStr = {
   statusFormat: "statusFormat2",
   transferSameUnits: "transferSameUnits",
   transferDiffUnits: "transferDiffUnits",
   transferNoTotal: "transferNoTotal",
-  timeMinutesLeft: "timeMinutesLeft",
-  timeSecondsLeft: "timeSecondsLeft",
+  timePair: "timePair",
+  timeLeftSingle: "timeLeftSingle",
+  timeLeftDouble: "timeLeftDouble",
   timeFewSeconds: "timeFewSeconds",
   timeUnknown: "timeUnknown",
   doneScheme: "doneScheme",
   doneFileScheme: "doneFileScheme",
   units: ["bytes", "kilobyte", "megabyte", "gigabyte"],
+  // Update timeSize in convertTimeUnits if changing the length of this array
+  timeUnits: ["seconds", "minutes", "hours", "days"],
 };
 
 // Convert strings to those in the string bundle
 let (getStr = Cc["@mozilla.org/intl/stringbundle;1"].
               getService(Ci.nsIStringBundleService).
               createBundle(kDownloadProperties).
               GetStringFromName) {
   for (let [name, value] in Iterator(gStr)) {
@@ -117,17 +125,17 @@ let DownloadUtils = {
       aMaxBytes = -1;
     if (isNil(aSpeed))
       aSpeed = -1;
     if (isNil(aLastSec))
       aLastSec = Infinity;
 
     // Calculate the time remaining if we have valid values
     let seconds = (aSpeed > 0) && (aMaxBytes > 0) ?
-      Math.ceil((aMaxBytes - aCurrBytes) / aSpeed) : -1;
+      (aMaxBytes - aCurrBytes) / aSpeed : -1;
 
     // Update the bytes transferred and bytes total
     let (transfer = DownloadUtils.getTransferTotal(aCurrBytes, aMaxBytes)) {
       // Insert 1 is the download progress
       status = replaceInsert(gStr.statusFormat, 1, transfer);
     }
 
     // Update the download rate
@@ -181,52 +189,74 @@ let DownloadUtils = {
     transfer = replaceInsert(transfer, 4, totalUnits);
 
     return transfer;
   },
 
   /**
    * Generate a "time left" string given an estimate on the time left and the
    * last time. The extra time is used to give a better estimate on the time to
-   * show.
+   * show. Both the time values are doubles instead of integers to help get
+   * sub-second accuracy for current and future estimates.
    *
    * @param aSeconds
    *        Current estimate on number of seconds left for the download
    * @param [optional] aLastSec
    *        Last time remaining in seconds or Infinity for unknown
    * @return A pair: [time left text, new value of "last seconds"]
    */
   getTimeLeft: function(aSeconds, aLastSec)
   {
     if (isNil(aLastSec))
       aLastSec = Infinity;
 
     if (aSeconds < 0)
       return [gStr.timeUnknown, aLastSec];
 
-    // Reuse the last seconds if the new one is only slighty longer
-    // This avoids jittering seconds, e.g., 41 40 38 40 -> 41 40 38 38
-    // However, large changes are shown, e.g., 41 38 49 -> 41 38 49
-    let (diff = aSeconds - aLastSec) {
-      if (diff > 0 && diff <= 10)
-        aSeconds = aLastSec;
+    // Apply smoothing only if the new time isn't a huge change -- e.g., if the
+    // new time is more than half the previous time; this is useful for
+    // downloads that start/resume slowly
+    if (aSeconds > aLastSec / 2) {
+      // Apply hysteresis to favor downward over upward swings
+      // 30% of down and 10% of up (exponential smoothing)
+      let (diff = aSeconds - aLastSec) {
+        aSeconds = aLastSec + (diff < 0 ? .3 : .1) * diff;
+      }
+
+      // If the new time is similar, reuse something close to the last seconds,
+      // but subtract a little to provide forward progress
+      let diff = aSeconds - aLastSec;
+      let diffPct = diff / aLastSec * 100;
+      if (Math.abs(diff) < 5 || Math.abs(diffPct) < 5)
+        aSeconds = aLastSec - (diff < 0 ? .4 : .2);
     }
 
     // Decide what text to show for the time
     let timeLeft;
     if (aSeconds < 4) {
       // Be friendly in the last few seconds
       timeLeft = gStr.timeFewSeconds;
-    } else if (aSeconds <= 60) {
-      // Show 2 digit seconds starting at 60
-      timeLeft = replaceInsert(gStr.timeSecondsLeft, 1, aSeconds);
     } else {
-      // Show minutes
-      timeLeft = replaceInsert(gStr.timeMinutesLeft, 1,
-                               Math.ceil(aSeconds / 60));
+      // Convert the seconds into its two largest units to display
+      let [time1, unit1, time2, unit2] =
+        DownloadUtils.convertTimeUnits(aSeconds);
+
+      let pair1 = replaceInsert(gStr.timePair, 1, time1);
+      pair1 = replaceInsert(pair1, 2, unit1);
+      let pair2 = replaceInsert(gStr.timePair, 1, time2);
+      pair2 = replaceInsert(pair2, 2, unit2);
+
+      // Only show minutes for under 1 hour or the second pair is 0
+      if (aSeconds < 3600 || time2 == 0) {
+        timeLeft = replaceInsert(gStr.timeLeftSingle, 1, pair1);
+      } else {
+        // We've got 2 pairs of times to display
+        timeLeft = replaceInsert(gStr.timeLeftDouble, 1, pair1);
+        timeLeft = replaceInsert(timeLeft, 2, pair2);
+      }
     }
 
     return [timeLeft, aSeconds];
   },
 
   /**
    * Get the appropriate display host string for a URI string depending on if
    * the URI has an eTLD + 1, is an IP address, a local file, or other protocol
@@ -310,19 +340,87 @@ let DownloadUtils = {
     }
 
     // Get rid of insignificant bits by truncating to 1 or 0 decimal points
     // 0 -> 0; 1.2 -> 1.2; 12.3 -> 12.3; 123.4 -> 123; 234.5 -> 235
     aBytes = aBytes.toFixed((aBytes > 0) && (aBytes < 100) ? 1 : 0);
 
     return [aBytes, gStr.units[unitIndex]];
   },
+
+  /**
+   * Converts a number of seconds to the two largest units. Time values are
+   * whole numbers, and units have the correct plural/singular form.
+   *
+   * @param aSecs
+   *        Seconds to convert into the appropriate 2 units
+   * @return 4-item array [first value, its unit, second value, its unit]
+   */
+  convertTimeUnits: function(aSecs)
+  {
+    // These are the maximum values for seconds, minutes, hours corresponding
+    // with gStr.timeUnits without the last item
+    let timeSize = [60, 60, 24];
+
+    let time = aSecs;
+    let scale = 1;
+    let unitIndex = 0;
+
+    // Keep converting to the next unit while we have units left and the
+    // current one isn't the largest unit possible
+    while ((unitIndex < timeSize.length) && (time >= timeSize[unitIndex])) {
+      time /= timeSize[unitIndex];
+      scale *= timeSize[unitIndex];
+      unitIndex++;
+    }
+
+    let value = convertTimeUnitsValue(time);
+    let units = convertTimeUnitsUnits(value, unitIndex);
+
+    let extra = aSecs - value * scale;
+    let nextIndex = unitIndex - 1;
+
+    // Convert the extra time to the next largest unit
+    for (let index = 0; index < nextIndex; index++)
+      extra /= timeSize[index];
+
+    let value2 = convertTimeUnitsValue(extra);
+    let units2 = convertTimeUnitsUnits(value2, nextIndex);
+
+    return [value, units, value2, units2];
+  },
 };
 
 /**
+ * Private helper for convertTimeUnits that gets the display value of a time
+ *
+ * @param aTime
+ *        Time value for display
+ * @return An integer value for the time rounded down
+ */
+function convertTimeUnitsValue(aTime)
+{
+  return Math.floor(aTime);
+}
+
+/**
+ * Private helper for convertTimeUnits that gets the display units of a time
+ *
+ * @param aTime
+ *        Time value for display
+ * @param aIndex
+ *        Index into gStr.timeUnits for the appropriate unit
+ * @return The appropriate plural form of the unit for the time
+ */
+function convertTimeUnitsUnits(aTime, aIndex)
+{
+  return PluralForm.get(aTime, gStr.timeUnits[aIndex]);
+}
+
+/**
  * Private helper function to replace a placeholder string with a real string
  *
  * @param aText
  *        Source text containing placeholder (e.g., #1)
  * @param aIndex
  *        Index number of placeholder to replace
  * @param aValue
  *        New string to put in place of placeholder