Bug 257061: adding a counter of found matches to the find in page bar. r=Unfocused
authorMike de Boer <mdeboer@mozilla.com>
Thu, 01 May 2014 13:01:14 +0200
changeset 181663 3c60a233a26dc1a5d9d33f72e9dc458ab0d5dbd7
parent 181575 4cc15c7ea4d83112bc53987251b0f3e6f300d5e6
child 181664 a58e5fcc198babe49fc3b886ce158100df93b20e
push id272
push userpvanderbeken@mozilla.com
push dateMon, 05 May 2014 16:31:18 +0000
reviewersUnfocused
bugs257061
milestone32.0a1
Bug 257061: adding a counter of found matches to the find in page bar. r=Unfocused
modules/libpref/src/init/all.js
toolkit/components/typeaheadfind/nsITypeAheadFind.idl
toolkit/components/typeaheadfind/nsTypeAheadFind.cpp
toolkit/components/typeaheadfind/nsTypeAheadFind.h
toolkit/content/tests/chrome/findbar_window.xul
toolkit/content/tests/chrome/test_findbar.xul
toolkit/content/widgets/findbar.xml
toolkit/locales/en-US/chrome/global/findbar.properties
toolkit/modules/Finder.jsm
toolkit/themes/linux/global/findBar.css
toolkit/themes/osx/global/findBar.css
toolkit/themes/windows/global/findBar.css
--- a/modules/libpref/src/init/all.js
+++ b/modules/libpref/src/init/all.js
@@ -519,16 +519,18 @@ pref("accessibility.typeaheadfind.autost
 pref("accessibility.typeaheadfind.casesensitive", 0);
 pref("accessibility.typeaheadfind.linksonly", true);
 pref("accessibility.typeaheadfind.startlinksonly", false);
 pref("accessibility.typeaheadfind.timeout", 4000);
 pref("accessibility.typeaheadfind.enabletimeout", true);
 pref("accessibility.typeaheadfind.soundURL", "beep");
 pref("accessibility.typeaheadfind.enablesound", true);
 pref("accessibility.typeaheadfind.prefillwithselection", true);
+pref("accessibility.typeaheadfind.matchesCountTimeout", 250);
+pref("accessibility.typeaheadfind.matchesCountLimit", 100);
 
 // use Mac OS X Appearance panel text smoothing setting when rendering text, disabled by default
 pref("gfx.use_text_smoothing_setting", false);
 
 // loading and rendering of framesets and iframes
 pref("browser.frames.enabled", true);
 
 // Number of characters to consider emphasizing for rich autocomplete results
--- a/toolkit/components/typeaheadfind/nsITypeAheadFind.idl
+++ b/toolkit/components/typeaheadfind/nsITypeAheadFind.idl
@@ -12,17 +12,17 @@
 
 /******************************** Declarations *******************************/
 
 interface nsIDocShell;
 
 
 /****************************** nsTypeAheadFind ******************************/
 
-[scriptable, uuid(0749a445-19d3-4eb9-9d66-78eca8c6f604)]
+[scriptable, uuid(f4411c5b-761b-498c-8050-dcfc8311f69e)]
 interface nsITypeAheadFind : nsISupports
 {
   /****************************** Initializer ******************************/
 
   /* Necessary initialization that can't happen in the constructor, either
    * because function calls here may fail, or because the docShell is
    * required. */
   void init(in nsIDocShell aDocShell);
@@ -32,32 +32,37 @@ interface nsITypeAheadFind : nsISupports
 
   /* Find aSearchString in page.  If aLinksOnly is true, only search the page's
    * hyperlinks for the string. */
   unsigned short find(in AString aSearchString, in boolean aLinksOnly);
 
   /* Find another match in the page. */
   unsigned short findAgain(in boolean findBackwards, in boolean aLinksOnly);
 
+  /* Return the range of the most recent match. */
+  nsIDOMRange getFoundRange();
+
 
   /**************************** Helper functions ***************************/
 
   /* Change searched docShell.  This happens when e.g. we use the same
    * nsITypeAheadFind object to search different tabs. */
   void setDocShell(in nsIDocShell aDocShell);
 
   /* Change the look of the the "found match" selection to aToggle, and repaint
    * the selection. */
   void setSelectionModeAndRepaint(in short toggle);
 
   /* Collapse the "found match" selection to its start.  Because not all
    * matches are owned by the same selection controller, this doesn't
    * necessarily happen automatically. */
   void collapseSelection();
 
+  /* Check if a range is visible */
+  boolean isRangeVisible(in nsIDOMRange aRange, in boolean aMustBeInViewPort);
 
   /******************************* Attributes ******************************/
 
   readonly attribute AString searchString;
                                         // Most recent search string
   attribute boolean caseSensitive;      // Searches are case sensitive
   readonly attribute nsIDOMElement foundLink;
                                         // Most recent elem found, if a link
--- a/toolkit/components/typeaheadfind/nsTypeAheadFind.cpp
+++ b/toolkit/components/typeaheadfind/nsTypeAheadFind.cpp
@@ -61,17 +61,17 @@ NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(
 NS_INTERFACE_MAP_END
 
 NS_IMPL_CYCLE_COLLECTING_ADDREF(nsTypeAheadFind)
 NS_IMPL_CYCLE_COLLECTING_RELEASE(nsTypeAheadFind)
 
 NS_IMPL_CYCLE_COLLECTION(nsTypeAheadFind, mFoundLink, mFoundEditable,
                          mCurrentWindow, mStartFindRange, mSearchRange,
                          mStartPointRange, mEndPointRange, mSoundInterface,
-                         mFind)
+                         mFind, mFoundRange)
 
 static NS_DEFINE_CID(kFrameTraversalCID, NS_FRAMETRAVERSAL_CID);
 
 #define NS_FIND_CONTRACTID "@mozilla.org/embedcomp/rangefind;1"
 
 nsTypeAheadFind::nsTypeAheadFind():
   mStartLinksOnlyPref(false),
   mCaretBrowsingOn(false),
@@ -171,16 +171,17 @@ nsTypeAheadFind::SetDocShell(nsIDocShell
 
   mStartFindRange = nullptr;
   mStartPointRange = nullptr;
   mSearchRange = nullptr;
   mEndPointRange = nullptr;
 
   mFoundLink = nullptr;
   mFoundEditable = nullptr;
+  mFoundRange = nullptr;
   mCurrentWindow = nullptr;
 
   mSelectionController = nullptr;
 
   mFind = nullptr;
 
   return NS_OK;
 }
@@ -270,16 +271,17 @@ nsTypeAheadFind::PlayNotFoundSound()
 nsresult
 nsTypeAheadFind::FindItNow(nsIPresShell *aPresShell, bool aIsLinksOnly,
                            bool aIsFirstVisiblePreferred, bool aFindPrev,
                            uint16_t* aResult)
 {
   *aResult = FIND_NOTFOUND;
   mFoundLink = nullptr;
   mFoundEditable = nullptr;
+  mFoundRange = nullptr;
   mCurrentWindow = nullptr;
   nsCOMPtr<nsIPresShell> startingPresShell (GetPresShell());
   if (!startingPresShell) {    
     nsCOMPtr<nsIDocShell> ds = do_QueryReferent(mDocShell);
     NS_ENSURE_TRUE(ds, NS_ERROR_FAILURE);
 
     startingPresShell = ds->GetPresShell();
     mPresShell = do_GetWeakReference(startingPresShell);    
@@ -432,16 +434,18 @@ nsTypeAheadFind::FindItNow(nsIPresShell 
             // Start at the end of returnRange
             returnRange->CloneRange(getter_AddRefs(mStartPointRange));
             mStartPointRange->Collapse(false);
           }
         }
         continue;
       }
 
+      mFoundRange = returnRange;
+
       // ------ Success! -------
       // Hide old selection (new one may be on a different controller)
       if (selection) {
         selection->CollapseToStart();
         SetSelectionModeAndRepaint(nsISelectionController::SELECTION_ON);
       }
 
       // Make sure new document is selected
@@ -1086,16 +1090,54 @@ nsTypeAheadFind::GetSelection(nsIPresShe
     frame->GetSelectionController(presContext, aSelCon);
     if (*aSelCon) {
       (*aSelCon)->GetSelection(nsISelectionController::SELECTION_NORMAL,
                                aDOMSel);
     }
   }
 }
 
+NS_IMETHODIMP
+nsTypeAheadFind::GetFoundRange(nsIDOMRange** aFoundRange)
+{
+  NS_ENSURE_ARG_POINTER(aFoundRange);
+  if (mFoundRange == nullptr) {
+    *aFoundRange = nullptr;
+    return NS_OK;
+  }
+
+  mFoundRange->CloneRange(aFoundRange);
+  return NS_OK;
+}
+
+NS_IMETHODIMP
+nsTypeAheadFind::IsRangeVisible(nsIDOMRange *aRange,
+                                bool aMustBeInViewPort,
+                                bool *aResult)
+{
+  // Jump through hoops to extract the docShell from the range.
+  nsCOMPtr<nsIDOMNode> node;
+  aRange->GetStartContainer(getter_AddRefs(node));
+  nsCOMPtr<nsIDOMDocument> document;
+  node->GetOwnerDocument(getter_AddRefs(document));
+  nsCOMPtr<nsIDOMWindow> window;
+  document->GetDefaultView(getter_AddRefs(window));
+  nsCOMPtr<nsIWebNavigation> navNav (do_GetInterface(window));
+  nsCOMPtr<nsIDocShell> docShell (do_GetInterface(navNav));
+
+  // Set up the arguments needed to check if a range is visible.
+  nsCOMPtr<nsIPresShell> presShell (docShell->GetPresShell());
+  nsRefPtr<nsPresContext> presContext = presShell->GetPresContext();
+  nsCOMPtr<nsIDOMRange> startPointRange = new nsRange(presShell->GetDocument());
+  *aResult = IsRangeVisible(presShell, presContext, aRange,
+                            aMustBeInViewPort, false,
+                            getter_AddRefs(startPointRange),
+                            nullptr);
+  return NS_OK;
+}
 
 bool
 nsTypeAheadFind::IsRangeVisible(nsIPresShell *aPresShell,
                                 nsPresContext *aPresContext,
                                 nsIDOMRange *aRange, bool aMustBeInViewPort,
                                 bool aGetTopVisibleLeaf,
                                 nsIDOMRange **aFirstVisibleRange,
                                 bool *aUsesIndependentSelection)
--- a/toolkit/components/typeaheadfind/nsTypeAheadFind.h
+++ b/toolkit/components/typeaheadfind/nsTypeAheadFind.h
@@ -75,16 +75,17 @@ protected:
   nsCString mNotFoundSoundURL;
 
   // PRBools are used instead of PRPackedBools because the address of the
   // boolean variable is getting passed into a method.
   bool mStartLinksOnlyPref;
   bool mCaretBrowsingOn;
   nsCOMPtr<nsIDOMElement> mFoundLink;     // Most recent elem found, if a link
   nsCOMPtr<nsIDOMElement> mFoundEditable; // Most recent elem found, if editable
+  nsCOMPtr<nsIDOMRange> mFoundRange;      // Most recent range found
   nsCOMPtr<nsIDOMWindow> mCurrentWindow;
   // mLastFindLength is the character length of the last find string.  It is used for
   // disabling the "not found" sound when using backspace or delete
   uint32_t mLastFindLength;
 
   // Sound is played asynchronously on some platforms.
   // If we destroy mSoundInterface before sound has played, it won't play
   nsCOMPtr<nsISound> mSoundInterface;
--- a/toolkit/content/tests/chrome/findbar_window.xul
+++ b/toolkit/content/tests/chrome/findbar_window.xul
@@ -96,18 +96,27 @@
       ok(gFindBar.hidden, "Failed to close findbar after testQuickFindText");
       testFindWithHighlight();
       gFindBar.close();
       ok(gFindBar.hidden, "Failed to close findbar after testFindWithHighlight");
       testFindbarSelection();
       testDrop();
       testQuickFindLink();
       if (gHasFindClipboard)
-        testStatusText();
-      testQuickFindClose();
+        testStatusText(afterStatusText);
+      else
+        afterStatusText();
+
+      function afterStatusText() {
+        testFindCountUI(function() {
+          gFindBar.close();
+          ok(gFindBar.hidden, "Failed to close findbar after testFindCountUI");
+          testQuickFindClose();
+        });
+      }
     }
 
     function testFindbarSelection() {
       function checkFindbarState(aTestName, aExpSelection) {
         document.getElementById("cmd_find").doCommand();
         ok(!gFindBar.hidden, "testFindbarSelection: failed to open findbar: " + aTestName);
         ok(document.commandDispatcher.focusedElement == gFindBar._findField.inputField,
            "testFindbarSelection: find field is not focused: " + aTestName);
@@ -158,19 +167,20 @@
         ok(gFindBar.hidden,
            "_isClosedCallback: Failed to auto-close quick find bar after " +
            gFindBar._quickFindTimeoutLength + "ms");
         finish();
       };
       setTimeout(_isClosedCallback, gFindBar._quickFindTimeoutLength + 100);
     }
 
-    function testStatusText() {
+    function testStatusText(aCallback) {
       var _delayedCheckStatusText = function() {
         ok(gStatusText == SAMPLE_URL, "testStatusText: Failed to set status text of found link");
+        aCallback();
       };
       setTimeout(_delayedCheckStatusText, 100);
     }
 
     function enterStringIntoFindField(aString) {
       for (var i=0; i < aString.length; i++) {
         var event = document.createEvent("KeyEvents");
         event.initKeyEvent("keypress", true, true, null, false, false,
@@ -379,16 +389,110 @@
          "testQuickFindText: find field is not focused");
 
       enterStringIntoFindField(SEARCH_TEXT);
       ok(gBrowser.contentWindow.getSelection() == SEARCH_TEXT,
          "testQuickFindText: failed to find '" + SEARCH_TEXT + "'");
       testClipboardSearchString(SEARCH_TEXT);
     }
 
+    // Perform an async function in serial on each of the list items.
+    function asyncForEach(list, async, callback) {
+      let i = 0;
+      let len = list.length;
+
+      if (!len)
+        return callback();
+
+      async(list[i], function handler() {
+          i++;
+          if (i < len) {
+            async(list[i], handler, i);
+          } else {
+            callback();
+          }
+      }, i);
+    }
+
+    function testFindCountUI(callback) {
+      clearFocus();
+      document.getElementById("cmd_find").doCommand();
+
+      ok(!gFindBar.hidden, "testFindCountUI: failed to open findbar");
+      ok(document.commandDispatcher.focusedElement == gFindBar._findField.inputField,
+         "testFindCountUI: find field is not focused");
+
+      let matchCase = gFindBar.getElement("find-case-sensitive");
+      if (matchCase.checked)
+        matchCase.click();
+
+      let foundMatches = gFindBar._foundMatches;
+      let tests = [{
+        text: "t",
+        current: 5,
+        total: 10,
+      }, {
+        text: "te",
+        current: 3,
+        total: 5,
+      }, {
+        text: "tes",
+        current: 1,
+        total: 2,
+      }, {
+        text: "texxx",
+        current: 0,
+        total: 0
+      }];
+      let regex = /([\d]*)\sof\s([\d]*)/;
+      let timeout = gFindBar._matchesCountTimeoutLength + 20;
+
+      function assertMatches(aTest, aMatches) {
+        window.opener.wrappedJSObject.SimpleTest.is(aTest.current, aMatches[1],
+          "Currently highlighted match should be at " + aTest.current);
+        window.opener.wrappedJSObject.SimpleTest.is(aTest.total, aMatches[2],
+          "Total amount of matches should be " + aTest.total);
+      }
+
+      function testString(aTest, aNext) {
+        gFindBar.clear();
+        enterStringIntoFindField(aTest.text);
+
+        setTimeout(function() {
+          let matches = foundMatches.value.match(regex);
+          if (!aTest.total) {
+            ok(!matches, "No message should be shown when 0 matches are expected");
+            aNext();
+          } else {
+            assertMatches(aTest, matches);
+            let cycleTests = [];
+            let cycles = aTest.total;
+            while (--cycles) {
+              aTest.current++;
+              if (aTest.current > aTest.total)
+                aTest.current = 1;
+              cycleTests.push({
+                current: aTest.current,
+                total: aTest.total
+              });
+            }
+            asyncForEach(cycleTests, function(aCycleTest, aNextCycle) {
+              gFindBar.onFindAgainCommand();
+              setTimeout(function() {
+                assertMatches(aCycleTest, foundMatches.value.match(regex));
+                aNextCycle();
+              }, timeout);
+            }, aNext);
+          }
+        }, timeout);
+      }
+
+      asyncForEach(tests, testString, callback);
+    }
+
     function testClipboardSearchString(aExpected) {
       if (!gHasFindClipboard)
         return;
 
       if (!aExpected)
         aExpected = "";
       var searchStr = gFindBar.browser.finder.clipboardSearchString;
       ok(searchStr.toLowerCase() == aExpected.toLowerCase(),
--- a/toolkit/content/tests/chrome/test_findbar.xul
+++ b/toolkit/content/tests/chrome/test_findbar.xul
@@ -1,37 +1,40 @@
 <?xml version="1.0"?>
 <?xml-stylesheet href="chrome://global/skin" type="text/css"?>
 <?xml-stylesheet 
   href="chrome://mochikit/content/tests/SimpleTest/test.css"
   type="text/css"?>
 <!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=257061
 https://bugzilla.mozilla.org/show_bug.cgi?id=288254
 -->
-<window title="Mozilla Bug 288254"
+<window title="Mozilla Bug 257061 and Bug 288254"
   xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
 
   <script type="application/javascript" 
           src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>      
 
 <body  xmlns="http://www.w3.org/1999/xhtml">
-<a target="_blank" 
+<a target="_blank"
+   href="https://bugzilla.mozilla.org/show_bug.cgi?id=257061">Mozilla Bug 257061</a>
+<a target="_blank"
    href="https://bugzilla.mozilla.org/show_bug.cgi?id=288254">Mozilla Bug 288254</a>
 <p id="display"></p>
 <div id="content" style="display: none">
   
 </div>
 <pre id="test">
 </pre>
 </body>
 
 <script class="testbody" type="application/javascript">
 <![CDATA[
 
-/** Test for Bug 288254 **/
+/** Test for Bug 257061 and Bug 288254 **/
 SimpleTest.waitForExplicitFinish();
 window.open("findbar_window.xul", "findbartest", 
             "chrome,width=600,height=600");
 
 ]]>
 </script>
 
 </window>
--- a/toolkit/content/widgets/findbar.xml
+++ b/toolkit/content/widgets/findbar.xml
@@ -186,16 +186,17 @@
                          class="findbar-case-sensitive tabbable"
                          label="&caseSensitive.label;"
                          accesskey="&caseSensitive.accesskey;"
                          tooltiptext="&caseSensitive.tooltiptext;"
                          oncommand="_setCaseSensitivity(this.checked);"
                          type="checkbox"
                          xbl:inherits="accesskey=matchcaseaccesskey"/>
       <xul:label anonid="match-case-status" class="findbar-find-fast"/>
+      <xul:label anonid="found-matches" class="findbar-find-fast found-matches" hidden="true"/>
       <xul:image anonid="find-status-icon" class="findbar-find-fast find-status-icon"/>
       <xul:description anonid="find-status"
                        control="findbar-textbox"
                        class="findbar-find-fast findbar-find-status">
       <!-- Do not use value, first child is used because it provides a11y with text change events -->
       </xul:description>
     </xul:hbox>
     <xul:toolbarbutton anonid="find-closebutton"
@@ -313,29 +314,34 @@
         }
       })]]></field>
 
       <field name="_destroyed">false</field>
 
       <constructor><![CDATA[
         // These elements are accessed frequently and are therefore cached
         this._findField = this.getElement("findbar-textbox");
+        this._foundMatches = this.getElement("found-matches");
         this._findStatusIcon = this.getElement("find-status-icon");
         this._findStatusDesc = this.getElement("find-status");
 
         this._foundURL = null;
 
         let prefsvc =
           Components.classes["@mozilla.org/preferences-service;1"]
                     .getService(Components.interfaces.nsIPrefBranch);
 
         this._quickFindTimeoutLength =
           prefsvc.getIntPref("accessibility.typeaheadfind.timeout");
         this._flashFindBar =
           prefsvc.getIntPref("accessibility.typeaheadfind.flashBar");
+        this._matchesCountTimeoutLength =
+          prefsvc.getIntPref("accessibility.typeaheadfind.matchesCountTimeout");
+        this._matchesCountLimit =
+          prefsvc.getIntPref("accessibility.typeaheadfind.matchesCountLimit");
 
         prefsvc.addObserver("accessibility.typeaheadfind",
                             this._observer, false);
         prefsvc.addObserver("accessibility.typeaheadfind.linksonly",
                             this._observer, false);
         prefsvc.addObserver("accessibility.typeaheadfind.casesensitive",
                             this._observer, false);
 
@@ -402,16 +408,20 @@
           if (this._highlightTimeout) {
             clearTimeout(this._highlightTimeout);
             this._highlightTimeout = null;
           }
           if (this._findResetTimeout) {
             clearTimeout(this._findResetTimeout);
             this._findResetTimeout = null;
           }
+          if (this._updateMatchesCountTimeout) {
+            clearTimeout(this._updateMatchesCountTimeout);
+            this._updateMatchesCountTimeout = null;
+          }
         ]]></body>
       </method>
 
       <method name="_setFindCloseTimeout">
         <body><![CDATA[
           if (this._quickFindTimeout)
             clearTimeout(this._quickFindTimeout);
 
@@ -425,16 +435,64 @@
           this._quickFindTimeout = setTimeout(() => {
              if (this._findMode != this.FIND_NORMAL)
                this.close();
              this._quickFindTimeout = null;
            }, this._quickFindTimeoutLength);
         ]]></body>
       </method>
 
+      <field name="_pluralForm">null</field>
+      <property name="pluralForm">
+        <getter><![CDATA[
+          if (!this._pluralForm) {
+            this._pluralForm = Components.utils.import(
+                               "resource://gre/modules/PluralForm.jsm", {}).PluralForm;
+          }
+          return this._pluralForm;
+        ]]></getter>
+      </property>
+
+      <method name="_updateMatchesCountWorker">
+        <parameter name="aRes"/>
+        <body><![CDATA[
+          let word = this._findField.value;
+          if (aRes == this.nsITypeAheadFind.FIND_NOTFOUND || !word) {
+            this._foundMatches.hidden = true;
+            this._foundMatches.value = "";
+          } else {
+            let matchesCount = this.browser.finder.requestMatchesCount(
+              word, this._matchesCountLimit, this._findMode == this.FIND_LINKS);
+            window.clearTimeout(this._updateMatchesCountTimeout);
+            this._updateMatchesCountTimeout = null;
+          }
+        ]]></body>
+      </method>
+
+      <!--
+        - Updates the search match count after each find operation on a new string.
+        - @param aRes
+        -        the result of the find operation
+        -->
+      <method name="_updateMatchesCount">
+        <parameter name="aRes"/>
+        <body><![CDATA[
+          if (this._matchesCountLimit == 0 || !this._dispatchFindEvent("matchescount"))
+            return;
+
+          if (this._updateMatchesCountTimeout) {
+            window.clearTimeout(this._updateMatchesCountTimeout);
+            this._updateMatchesCountTimeout = null;
+          }
+          this._updateMatchesCountTimeout =
+            window.setTimeout(() => this._updateMatchesCountWorker(aRes),
+                              this._matchesCountTimeoutLength);
+        ]]></body>
+      </method>
+
       <!--
         - Turns highlight on or off.
         - @param aHighlight (boolean)
         -        Whether to turn the highlight on or off
         -->
       <method name="toggleHighlight">
         <parameter name="aHighlight"/>
         <body><![CDATA[
@@ -443,16 +501,19 @@
 
           let word = this._findField.value;
           // Bug 429723. Don't attempt to highlight ""
           if (aHighlight && !word)
             return;
 
           this.browser._lastSearchHighlight = aHighlight;
           this.browser.finder.highlight(aHighlight, word);
+
+          // Update the matches count
+          this._updateMatchesCount(this.nsITypeAheadFind.FIND_FOUND);
         ]]></body>
       </method>
 
       <!--
         - Updates the case-sensitivity mode of the findbar and its UI.
         - @param [optional] aString
         -        The string for which case sensitivity might be turned on.
         -        This only used when case-sensitivity is in auto mode,
@@ -498,36 +559,46 @@
           // Just set the pref; our observer will change the find bar behavior
           prefsvc.setIntPref("accessibility.typeaheadfind.casesensitive",
                              aCaseSensitive ? 1 : 0);
 
           this._dispatchFindEvent("casesensitivitychange");
         ]]></body>
       </method>
 
+      <field name="_strBundle">null</field>
+      <property name="strBundle">
+        <getter><![CDATA[
+          if (!this._strBundle) {
+            this._strBundle =
+              Components.classes["@mozilla.org/intl/stringbundle;1"]
+                        .getService(Components.interfaces.nsIStringBundleService)
+                        .createBundle("chrome://global/locale/findbar.properties");
+          }
+          return this._strBundle;
+        ]]></getter>
+      </property>
+
       <!--
         - Opens and displays the find bar.
         -
         - @param aMode
         -        the find mode to be used, which is either FIND_NORMAL,
         -        FIND_TYPEAHEAD or FIND_LINKS. If not passed, the last
         -        find mode if any or FIND_NORMAL.
         - @returns true if the find bar wasn't previously open, false otherwise.
         -->
       <method name="open">
         <parameter name="aMode"/>
         <body><![CDATA[
           if (aMode != undefined)
             this._findMode = aMode;
 
           if (!this._notFoundStr) {
-            let stringsBundle =
-              Components.classes["@mozilla.org/intl/stringbundle;1"]
-                        .getService(Components.interfaces.nsIStringBundleService)
-                        .createBundle("chrome://global/locale/findbar.properties");
+            var stringsBundle = this.strBundle;
             this._notFoundStr = stringsBundle.GetStringFromName("NotFound");
             this._wrappedToTopStr =
               stringsBundle.GetStringFromName("WrappedToTop");
             this._wrappedToBottomStr =
               stringsBundle.GetStringFromName("WrappedToBottom");
             this._normalFindStr =
               stringsBundle.GetStringFromName("NormalFind");
             this._fastFindStr =
@@ -948,16 +1019,17 @@
               break;
             case this.nsITypeAheadFind.FIND_FOUND:
             default:
               this._findStatusIcon.removeAttribute("status");
               this._findStatusDesc.textContent = "";
               this._findField.removeAttribute("status");
               break;
           }
+          this._updateMatchesCount(res);
         ]]></body>
       </method>
 
       <method name="updateControlState">
         <parameter name="aResult"/>
         <parameter name="aFindPrevious"/>
         <body><![CDATA[
           this._updateStatusUI(aResult, aFindPrevious);
@@ -1165,16 +1237,46 @@
             this._findFailedString = null;
 
           if (this._findMode != this.FIND_NORMAL)
             this._setFindCloseTimeout();
         ]]></body>
       </method>
 
       <!--
+        - This handles all the result changes for matches counts.
+        - @param aResult
+        -   Result Object, containing the total amount of matches and a vector
+        -   of the current result.
+        -->
+      <method name="onMatchesCountResult">
+        <parameter name="aResult"/>
+        <body><![CDATA[
+          if (aResult.total !== 0) {
+            if (aResult.total == -1) {
+              this._foundMatches.value = this.pluralForm.get(
+                this._matchesCountLimit,
+                this.strBundle.GetStringFromName("FoundTooManyMatches")
+              ).replace("#1", this._matchesCountLimit);
+            } else {
+              this._foundMatches.value = this.pluralForm.get(
+                aResult.total,
+                this.strBundle.GetStringFromName("FoundMatches")
+              ).replace("#1", aResult.current)
+               .replace("#2", aResult.total);
+            }
+            this._foundMatches.hidden = false;
+          } else {
+            this._foundMatches.hidden = true;
+            this._foundMatches.value = "";
+          }
+        ]]></body>
+      </method>
+
+      <!--
         - This handler may cancel a request to focus content by returning |false|
         - explicitly.
         -->
       <method name="shouldFocusContent">
         <body><![CDATA[
           const fm = Components.classes["@mozilla.org/focus-manager;1"]
                                .getService(Components.interfaces.nsIFocusManager);
           if (fm.focusedWindow != window)
--- a/toolkit/locales/en-US/chrome/global/findbar.properties
+++ b/toolkit/locales/en-US/chrome/global/findbar.properties
@@ -5,11 +5,17 @@
 # strings used by the Find bar, split from browser.properties
 NotFound=Phrase not found
 WrappedToTop=Reached end of page, continued from top
 WrappedToBottom=Reached top of page, continued from bottom
 NormalFind=Find in page
 FastFind=Quick find
 FastFindLinks=Quick find (links only)
 CaseSensitive=(Case sensitive)
-FoundMatchCount=%S match
-FoundMatchesCount=%S matches
-FoundTooManyMatches=More than %S matches
+# LOCALIZATION NOTE (FoundMatches): Semicolon-separated list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 is currently selected match and #2 the total amount of matches.
+FoundMatches=#1 of #2 match;#1 of #2 matches
+# LOCALIZATION NOTE (FoundTooManyMatches): Semicolon-separated list of plural
+# forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 is the total amount of matches allowed before counting stops.
+FoundTooManyMatches=More than #1 match;More than #1 matches
--- a/toolkit/modules/Finder.jsm
+++ b/toolkit/modules/Finder.jsm
@@ -69,17 +69,19 @@ Finder.prototype = {
       findBackwards: aFindBackwards,
       linkURL: linkURL,
       rect: this._getResultRect(),
       searchString: this._searchString,
       storeResult: aStoreResult
     };
 
     for (let l of this._listeners) {
-      l.onFindResult(data);
+      try {
+        l.onFindResult(data);
+      } catch (ex) {}
     }
   },
 
   get searchString() {
     if (!this._searchString && this._fastFind.searchString)
       this._searchString = this._fastFind.searchString;
     return this._searchString;
   },
@@ -189,18 +191,20 @@ Finder.prototype = {
   removeSelection: function() {
     this._fastFind.collapseSelection();
     this.enableSelection();
   },
 
   focusContent: function() {
     // Allow Finder listeners to cancel focusing the content.
     for (let l of this._listeners) {
-      if (!l.shouldFocusContent())
-        return;
+      try {
+        if (!l.shouldFocusContent())
+          return;
+      } catch (ex) {}
     }
 
     let fastFind = this._fastFind;
     const fm = Cc["@mozilla.org/focus-manager;1"].getService(Ci.nsIFocusManager);
     try {
       // Try to find the best possible match that should receive focus and
       // block scrolling on focus since find already scrolls. Further
       // scrolling is due to user action, so don't override this.
@@ -250,16 +254,190 @@ Finder.prototype = {
         controller.scrollLine(false);
         break;
       case Ci.nsIDOMKeyEvent.DOM_VK_DOWN:
         controller.scrollLine(true);
         break;
     }
   },
 
+  requestMatchesCount: function(aWord, aMatchLimit, aLinksOnly) {
+    let window = this._getWindow();
+    let result = this._countMatchesInWindow(aWord, aMatchLimit, aLinksOnly, window);
+
+    // Count matches in (i)frames AFTER searching through the main window.
+    for (let frame of result._framesToCount) {
+      // We've reached our limit; no need to do more work.
+      if (result.total == -1 || result.total == aMatchLimit)
+        break;
+      this._countMatchesInWindow(aWord, aMatchLimit, aLinksOnly, frame, result);
+    }
+
+    // The `_currentFound` and `_framesToCount` properties are only used for
+    // internal bookkeeping between recursive calls.
+    delete result._currentFound;
+    delete result._framesToCount;
+
+    for (let l of this._listeners) {
+      try {
+        l.onMatchesCountResult(result);
+      } catch (ex) {}
+    }
+  },
+
+  /**
+   * Counts the number of matches for the searched word in the passed window's
+   * content.
+   * @param aWord
+   *        the word to search for.
+   * @param aMatchLimit
+   *        the maximum number of matches shown (for speed reasons).
+   * @param aLinksOnly
+   *        whether we should only search through links.
+   * @param aWindow
+   *        the window to search in. Passing undefined will search the
+   *        current content window. Optional.
+   * @param aStats
+   *        the Object that is returned by this function. It may be passed as an
+   *        argument here in the case of a recursive call.
+   * @returns an object stating the number of matches and a vector for the current match.
+   */
+  _countMatchesInWindow: function(aWord, aMatchLimit, aLinksOnly, aWindow = null, aStats = null) {
+    aWindow = aWindow || this._getWindow();
+    aStats = aStats || {
+      total: 0,
+      current: 0,
+      _framesToCount: new Set(),
+      _currentFound: false
+    };
+
+    // If we already reached our max, there's no need to do more work!
+    if (aStats.total == -1 || aStats.total == aMatchLimit) {
+      aStats.total = -1;
+      return aStats;
+    }
+
+    this._collectFrames(aWindow, aStats);
+
+    let foundRange = this._fastFind.getFoundRange();
+
+    this._findIterator(aWord, aWindow, aRange => {
+      if (!aLinksOnly || this._rangeStartsInLink(aRange)) {
+        ++aStats.total;
+        if (!aStats._currentFound) {
+          ++aStats.current;
+          aStats._currentFound = (foundRange &&
+            aRange.startContainer == foundRange.startContainer &&
+            aRange.startOffset == foundRange.startOffset &&
+            aRange.endContainer == foundRange.endContainer &&
+            aRange.endOffset == foundRange.endOffset);
+        }
+      }
+      if (aStats.total == aMatchLimit) {
+        aStats.total = -1;
+        return false;
+      }
+    });
+
+    return aStats;
+  },
+
+  /**
+   * Basic wrapper around nsIFind that provides invoking a callback `aOnFind`
+   * each time an occurence of `aWord` string is found.
+   *
+   * @param aWord
+   *        the word to search for.
+   * @param aWindow
+   *        the window to search in.
+   * @param aOnFind
+   *        the Function to invoke when a word is found. if Boolean `false` is
+   *        returned, the find operation will be stopped and the Function will
+   *        not be invoked again.
+   */
+  _findIterator: function(aWord, aWindow, aOnFind) {
+    let doc = aWindow.document;
+    let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ?
+               doc.body : doc.documentElement;
+
+    let searchRange = doc.createRange();
+    searchRange.selectNodeContents(body);
+
+    let startPt = searchRange.cloneRange();
+    startPt.collapse(true);
+
+    let endPt = searchRange.cloneRange();
+    endPt.collapse(false);
+
+    let retRange = null;
+
+    let finder = Cc["@mozilla.org/embedcomp/rangefind;1"]
+                   .createInstance()
+                   .QueryInterface(Ci.nsIFind);
+    finder.caseSensitive = this._fastFind.caseSensitive;
+
+    while ((retRange = finder.Find(aWord, searchRange, startPt, endPt))) {
+      if (aOnFind(retRange) === false)
+        break;
+      startPt = retRange.cloneRange();
+      startPt.collapse(false);
+    }
+  },
+
+  /**
+   * Helper method for `_countMatchesInWindow` that recursively collects all
+   * visible (i)frames inside a window.
+   *
+   * @param aWindow
+   *        the window to extract the (i)frames from.
+   * @param aStats
+   *        Object that contains a Set called '_framesToCount'
+   */
+  _collectFrames: function(aWindow, aStats) {
+    if (!aWindow.frames || !aWindow.frames.length)
+      return;
+    // Casting `aWindow.frames` to an Iterator doesn't work, so we're stuck with
+    // a plain, old for-loop.
+    for (let i = 0, l = aWindow.frames.length; i < l; ++i) {
+      let frame = aWindow.frames[i];
+      // Don't count matches in hidden frames.
+      let frameEl = frame && frame.frameElement;
+      if (!frameEl)
+        continue;
+      // Construct a range around the frame element to check its visiblity.
+      let range = aWindow.document.createRange();
+      range.setStart(frameEl, 0);
+      range.setEnd(frameEl, 0);
+      if (!this._fastFind.isRangeVisible(range, this._getDocShell(range), true))
+        continue;
+      // All good, so add it to the set to count later.
+      if (!aStats._framesToCount.has(frame))
+        aStats._framesToCount.add(frame);
+      this._collectFrames(frame, aStats);
+    }
+  },
+
+  /**
+   * Helper method to extract the docShell reference from a Window or Range object.
+   *
+   * @param aWindowOrRange
+   *        Window object to query. May also be a Range, from which the owner
+   *        window will be queried.
+   * @returns nsIDocShell
+   */
+  _getDocShell: function(aWindowOrRange) {
+    let window = aWindowOrRange;
+    // Ranges may also be passed in, so fetch its window.
+    if (aWindowOrRange instanceof Ci.nsIDOMRange)
+      window = aWindowOrRange.startContainer.ownerDocument.defaultView;
+    return window.QueryInterface(Ci.nsIInterfaceRequestor)
+                 .getInterface(Ci.nsIWebNavigation)
+                 .QueryInterface(Ci.nsIDocShell);
+  },
+
   _getWindow: function () {
     return this._docShell.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow);
   },
 
   /**
    * Get the bounding selection rect in CSS px relative to the origin of the
    * top-level content document.
    */
@@ -349,44 +527,21 @@ Finder.prototype = {
     let controller = this._getSelectionController(win);
     let doc = win.document;
     if (!controller || !doc || !doc.documentElement) {
       // Without the selection controller,
       // we are unable to (un)highlight any matches
       return found;
     }
 
-    let body = (doc instanceof Ci.nsIDOMHTMLDocument && doc.body) ?
-               doc.body : doc.documentElement;
-
     if (aHighlight) {
-      let searchRange = doc.createRange();
-      searchRange.selectNodeContents(body);
-
-      let startPt = searchRange.cloneRange();
-      startPt.collapse(true);
-
-      let endPt = searchRange.cloneRange();
-      endPt.collapse(false);
-
-      let retRange = null;
-      let finder = Cc["@mozilla.org/embedcomp/rangefind;1"]
-                     .createInstance()
-                     .QueryInterface(Ci.nsIFind);
-
-      finder.caseSensitive = this._fastFind.caseSensitive;
-
-      while ((retRange = finder.Find(aWord, searchRange,
-                                     startPt, endPt))) {
-        this._highlightRange(retRange, controller);
-        startPt = retRange.cloneRange();
-        startPt.collapse(false);
-
+      this._findIterator(aWord, win, aRange => {
+        this._highlightRange(aRange, controller);
         found = true;
-      }
+      });
     } else {
       // First, attempt to remove highlighting from main document
       let sel = controller.getSelection(Ci.nsISelectionController.SELECTION_FIND);
       sel.removeAllRanges();
 
       // Next, check our editor cache, for editors belonging to this
       // document
       if (this._editors) {
@@ -573,16 +728,51 @@ Finder.prototype = {
     }
 
     if (foundContainingRange)
       return range;
 
     return null;
   },
 
+  /**
+   * Determines whether a range is inside a link.
+   * @param aRange
+   *        the range to check
+   * @returns true if the range starts in a link
+   */
+  _rangeStartsInLink: function(aRange) {
+    let isInsideLink = false;
+    let node = aRange.startContainer;
+
+    if (node.nodeType == node.ELEMENT_NODE) {
+      if (node.hasChildNodes) {
+        let childNode = node.item(aRange.startOffset);
+        if (childNode)
+          node = childNode;
+      }
+    }
+
+    const XLink_NS = "http://www.w3.org/1999/xlink";
+    do {
+      if (node instanceof HTMLAnchorElement) {
+        isInsideLink = node.hasAttribute("href");
+        break;
+      } else if (typeof node.hasAttributeNS == "function" &&
+                 node.hasAttributeNS(XLink_NS, "href")) {
+        isInsideLink = (node.getAttributeNS(XLink_NS, "type") == "simple");
+        break;
+      }
+
+      node = node.parentNode;
+    } while (node);
+
+    return isInsideLink;
+  },
+
   // Start of nsIWebProgressListener implementation.
 
   onLocationChange: function(aWebProgress, aRequest, aLocation, aFlags) {
     if (!aWebProgress.isTopLevel)
       return;
 
     // Avoid leaking if we change the page.
     this._previousLink = null;
--- a/toolkit/themes/linux/global/findBar.css
+++ b/toolkit/themes/linux/global/findBar.css
@@ -145,17 +145,18 @@ findbar[hidden] {
   -moz-border-end-width: 1px;
 }
 
 .findbar-highlight,
 .findbar-case-sensitive {
   -moz-margin-start: 5px;
 }
 
-.findbar-find-status {
+.findbar-find-status,
+.findbar-matches {
   color: GrayText;
   margin: 0 !important;
   -moz-margin-start: 12px !important;
 }
 
 .find-status-icon[status="pending"] {
   list-style-image: url("chrome://global/skin/icons/loading_16.png");
 }
--- a/toolkit/themes/osx/global/findBar.css
+++ b/toolkit/themes/osx/global/findBar.css
@@ -205,17 +205,18 @@ label.findbar-find-fast:-moz-lwtheme,
   display: none;
 }
 
 .find-status-icon[status="pending"] {
   display: block;
   list-style-image: url("chrome://global/skin/icons/loading_16.png");
 }
 
-.findbar-find-status {
+.findbar-find-status,
+.found-matches {
   color: rgba(0,0,0,.5);
   margin: 0 !important;
   -moz-margin-start: 12px !important;
   text-shadow: 0 1px rgba(255,255,255,.4);
 }
 
 /* Highlight and Case Sensitive toggles */
 
--- a/toolkit/themes/windows/global/findBar.css
+++ b/toolkit/themes/windows/global/findBar.css
@@ -138,17 +138,18 @@ findbar[hidden] {
   -moz-margin-start: 5px;
 }
 
 .findbar-highlight > .toolbarbutton-icon,
 .findbar-case-sensitive > .toolbarbutton-icon {
   display: none;
 }
 
-.findbar-find-status {
+.findbar-find-status,
+.found-matches {
   color: GrayText;
   margin: 0 !important;
   -moz-margin-start: 12px !important;
 }
 
 .find-status-icon[status="pending"] {
   list-style-image: url("chrome://global/skin/icons/loading_16.png");
 }