Bug 1746081 - Use listbox rather than tree in FilterListDialog. r=frg
authorIan Neal <iann_cvs@blueyonder.co.uk>
Tue, 14 Dec 2021 22:07:39 +0000
changeset 34701 aa09e7678bc9dae96f638c0132cca190f0050754
parent 34700 8452dd347a64ef569d62d29052ffae83f94e4902
child 34702 c53af82ae9c56cfd80b53db5984eb6fb29554d67
push id19534
push userfrgrahl@gmx.net
push dateSat, 08 Jan 2022 23:07:22 +0000
treeherdercomm-central@acf27c44e7a0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersfrg
bugs1746081
Bug 1746081 - Use listbox rather than tree in FilterListDialog. r=frg
suite/mailnews/content/FilterListDialog.js
suite/mailnews/content/FilterListDialog.xul
suite/themes/classic/mac/messenger/filterDialog.css
suite/themes/classic/messenger/filterDialog.css
suite/themes/modern/messenger/filterDialog.css
--- a/suite/mailnews/content/FilterListDialog.js
+++ b/suite/mailnews/content/FilterListDialog.js
@@ -14,17 +14,18 @@ var gTopButton;
 var gUpButton;
 var gDownButton;
 var gBottomButton;
 var gRunFiltersFolderPrefix;
 var gRunFiltersFolder;
 var gRunFiltersButton;
 var gFilterBundle;
 var gFilterListMsgWindow = null;
-var gFilterTree;
+var gFilterListbox;
+var gCurrentFilterList;
 var gStatusBar;
 var gStatusText;
 var gServerMenu;
 
 var msgMoveMotion = {
   Up     : 0,
   Down   : 1,
   Top    : 2,
@@ -58,116 +59,47 @@ var gStatusFeedback = {
   showProgress: function(percentage)
   {
   },
   closeWindow: function()
   {
   }
 };
 
-var gFilterTreeView = {
-  mTree: null,
-  get tree() {
-    return this.mTree;
-  },
-  mFilterList: null,
-  get filterList() {
-    return this.mFilterList;
-  },
-  set filterList(val) {
-    if (this.mTree)
-      this.mTree.beginUpdateBatch();
-    if (this.selection) {
-      this.selection.clearSelection();
-      this.selection.currentIndex = -1;
-    }
-    this.mFilterList = val;
-    if (this.mTree) {
-      this.mTree.scrollToRow(0);
-      this.mTree.endUpdateBatch();
-    }
-  },
-  /* nsITreeView methods */
-  get rowCount() {
-    return this.mFilterList ? this.mFilterList.filterCount : 0;
-  },
-  selection: null,
-  getRowProperties: function getRowProperties(row) {
-    return this.mFilterList.getFilterAt(row).enabled ? "Enabled-true" : "";
-  },
-  getCellProperties: function getCellProperties(row, col) {
-    return this.mFilterList.getFilterAt(row).enabled ? "Enabled-true" : "";
-  },
-  getColumnProperties: function getColumnProperties(col) { return ""; },
-  isContainer: function isContainer(index) { return false; },
-  isContainerOpen: function isContainerOpen(index) { return false; },
-  isContainerEmpty: function isContainerEmpty(index) { return false; },
-  isSeparator: function isSeparator(index) { return false; },
-  isSorted: function isSorted() { return false; },
-  canDrop: function canDrop(index, orientation) { return false; },
-  drop: function drop(index, orientation) {},
-  getParentIndex: function getParentIndex(index) { return -1; },
-  hasNextSibling: function hasNextSibling(rowIndex, afterIndex) { return false; },
-  getLevel: function getLevel(index) { return 0; },
-  getImageSrc: function getImageSrc(row, col) { return null; },
-  getProgressMode: function getProgressMode(row, col) { return 0; },
-  getCellValue: function getCellValue(row, col) { return null; },
-  getCellText: function getCellText(row, col) {
-    return this.mFilterList.getFilterAt(row).filterName;
-  },
-  setTree: function setTree(tree) {
-    this.mTree = tree;
-  },
-  toggleOpenState: function toggleOpenState(index) {},
-  cycleHeader: function cycleHeader(col) {},
-  selectionChanged: function selectionChanged() {},
-  cycleCell: function cycleCell(row, col) {
-    if (toggleFilter(row))
-      this.mTree.invalidateCell(row, col);
-  },
-  isEditable: function isEditable(row, col) { return false; }, // XXX Fix me!
-  isSelectable: function isSelectable(row, col) { return false; },
-  setCellValue: function setCellValue(row, col, value) {},
-  setCellText: function setCellText(row, col, value) { /* XXX Write me */ },
-}
-
 function onLoad()
 {
     setHelpFileURI("chrome://communicator/locale/help/suitehelp.rdf");
     gFilterListMsgWindow = Cc["@mozilla.org/messenger/msgwindow;1"].createInstance(Ci.nsIMsgWindow);
     gFilterListMsgWindow.domWindow = window;
     gFilterListMsgWindow.rootDocShell.appType = Ci.nsIDocShell.APP_TYPE_MAIL;
     gFilterListMsgWindow.statusFeedback = gStatusFeedback;
 
     gFilterBundle = document.getElementById("bundle_filter");
 
     gServerMenu = document.getElementById("serverMenu");
-    gFilterTree = document.getElementById("filterTree");
+    gFilterListbox = document.getElementById("filterList");
 
     gEditButton = document.getElementById("editButton");
     gDeleteButton = document.getElementById("deleteButton");
     gNewButton = document.getElementById("newButton");
     gCopyToNewButton = document.getElementById("copyToNewButton");
     gTopButton = document.getElementById("reorderTopButton");
     gUpButton = document.getElementById("reorderUpButton");
     gDownButton = document.getElementById("reorderDownButton");
     gBottomButton = document.getElementById("reorderBottomButton");
     gRunFiltersFolderPrefix = document.getElementById("folderPickerPrefix");
     gRunFiltersFolder = document.getElementById("runFiltersFolder");
     gRunFiltersButton = document.getElementById("runFiltersButton");
     gStatusBar = document.getElementById("statusbar-icon");
     gStatusText = document.getElementById("statusText");
 
-    gFilterTree.view = gFilterTreeView;
+    updateButtons();
 
     processWindowArguments(window.arguments[0]);
 
-    // Focus the list.
-    gFilterTree.focus();
-
     Services.obs.addObserver(onFilterClose,
                              "quit-application-requested");
 
     top.controllers.insertControllerAt(0, gFilterController);
 }
 
 /**
  * Processes arguments sent to this dialog when opened or refreshed.
@@ -196,21 +128,23 @@ function processWindowArguments(aArgumen
     if (!firstItem) {
       var server = getServerThatCanHaveFilters();
       if (server)
         firstItem = server.rootFolder;
     }
 
     if (firstItem)
       setFilterFolder(firstItem);
-    else
-      updateButtons();
 
     if (wantedFolder)
       setRunFolder(wantedFolder);
+  } else {
+    // If we didn't change folder still redraw the list
+    // to show potential new filters if we were called for refresh.
+    rebuildFilterList();
   }
 }
 
 /**
  * This is called from OpenOrFocusWindow() if the dialog is already open.
  * New filters could have been created by operations outside the dialog.
  *
  * @param aArguments  An object of arguments having the same format
@@ -234,32 +168,32 @@ function CanRunFiltersAfterTheFact(aServ
  *                  (or a folder for NNTP server).
  */
 function setFilterFolder(msgFolder) {
   if (!msgFolder || msgFolder == gServerMenu._folder)
     return;
 
   // Save the current filters to disk before switching because
   // the dialog may be closed and we'll lose current filters.
-  let filterList = currentFilterList();
-  if (filterList)
-    filterList.saveToDefaultFile();
+  if (gCurrentFilterList)
+    gCurrentFilterList.saveToDefaultFile();
 
   // Setting this attribute should go away in bug 473009.
   gServerMenu._folder = msgFolder;
   // Calling this should go away in bug 802609.
   gServerMenu.menupopup.selectFolder(msgFolder);
 
-  // Calling getFilterList will detect any errors in rules.dat,
-  // backup the file, and alert the user
-  gFilterTreeView.filterList = msgFolder.getEditableFilterList(gFilterListMsgWindow);
+  // Calling getEditableFilterList will detect any errors in
+  // msgFilterRules.dat, backup the file, and alert the user.
+  gCurrentFilterList = msgFolder.getEditableFilterList(gFilterListMsgWindow);
+  rebuildFilterList();
 
   // Select the first item in the list, if there is one.
-  if (gFilterTreeView.rowCount)
-    gFilterTreeView.selection.select(0);
+  if (gFilterListbox.itemCount > 0)
+    gFilterListbox.selectItem(gFilterListbox.getItemAtIndex(0));
 
   // This will get the deferred to account root folder, if server is deferred.
   // We intentionally do this after setting the current server, as we want
   // that to refer to the rootFolder for the actual server, not the
   // deferred-to server, as current server is really a proxy for the
   // server whose filters we are editing. But below here we are managing
   // where the filters will get applied, which is on the deferred-to server.
   msgFolder = msgFolder.server.rootMsgFolder;
@@ -322,63 +256,65 @@ function setFilterFolder(msgFolder) {
 function setRunFolder(aFolder) {
   // Setting this attribute should go away in bug 473009.
   gRunFiltersFolder._folder = aFolder;
   // Calling this should go away in bug 802609.
   gRunFiltersFolder.menupopup.selectFolder(gRunFiltersFolder._folder);
   updateButtons();
 }
 
-function toggleFilter(index)
+/**
+ * Toggle enabled state of a filter, in both the filter properties and the UI.
+ *
+ * @param aFilterItem  an item (row) of the filter list to be toggled
+ */
+function toggleFilter(aFilterItem)
 {
-    var filter = getFilter(index);
-    if (filter.unparseable)
-    {
-      Services.prompt.alert(window, null,
-                            gFilterBundle.getFormattedString("cannotEnableIncompatFilter",
-                            [document.getElementById("bundle_brand").getString("brandShortName")]));
-      return false;
-    }
-    filter.enabled = !filter.enabled;
-    return true;
+  let filter = aFilterItem._filter;
+  if (filter.unparseable && !filter.enabled)
+  {
+    Services.prompt.alert(window, null,
+                          gFilterBundle.getFormattedString("cannotEnableIncompatFilter",
+                          [document.getElementById("bundle_brand").getString("brandShortName")]));
+    return;
+  }
+  filter.enabled = !filter.enabled;
+
+  // Now update the checkbox
+  aFilterItem.childNodes[1].setAttribute("enabled", filter.enabled);
+  // For accessibility set the checked state on listitem
+  aFilterItem.setAttribute("aria-checked", filter.enabled);
 }
 
-function getFilter(index)
-{
-  return gFilterTreeView.filterList.getFilterAt(index);
-}
-
+/**
+ * Returns the currently selected filter. If multiple filters are selected,
+ * returns the first one. If none are selected, returns null.
+ */
 function currentFilter()
 {
-  var currentIndex = gFilterTree.currentIndex;
-  return currentIndex == -1 ? null : getFilter(currentIndex);
-}
-
-function currentFilterList()
-{
-  return gFilterTreeView.filterList;
-}
-
-function onFilterSelect(event)
-{
-    updateButtons();
+  let currentItem = gFilterListbox.selectedItem;
+  return currentItem ? currentItem._filter : null;
 }
 
 function onEditFilter()
 {
   if (gEditButton.disabled)
     return;
 
   var selectedFilter = currentFilter();
-  var curFilterList = currentFilterList();
-  var args = {filter: selectedFilter, filterList: curFilterList};
+  if (!selectedFilter)
+    return;
+
+  let args = {filter: selectedFilter, filterList: gCurrentFilterList};
 
   window.openDialog("chrome://messenger/content/FilterEditor.xul", "FilterEditor", "chrome,modal,titlebar,resizable,centerscreen", args);
 
-  // The focus change will cause a repaint of the row updating any name change
+  if ("refresh" in args && args.refresh) {
+    rebuildFilterList();
+  }
 }
 
 /**
  * Handler function for the 'New...' buttons.
  * Opens the filter dialog for creating a new filter.
  */
 function onNewFilter() {
   calculatePositionAndShowCreateFilterDialog({});
@@ -404,69 +340,88 @@ function onCopyToNewFilter() {
  * and then displays the create dialog.
  *
  * @param args  The object containing the arguments for the dialog,
  *              passed to the filterEditorOnLoad() function.
  *              It will be augmented with the insertion position
  *              and global filters list properties by this function.
  */
 function calculatePositionAndShowCreateFilterDialog(args) {
-  var position = Math.max(gFilterTree.currentIndex, 0);
-  args.filterList = currentFilterList();
+  let selectedFilter = currentFilter();
+  // If no filter is selected use the first position.
+  let position = 0;
+  if (selectedFilter) {
+    // Get the position in the unfiltered list.
+    // - this is where the new filter should be inserted!
+    let filterCount = gCurrentFilterList.filterCount;
+    for (let i = 0; i < filterCount; i++) {
+      if (gCurrentFilterList.getFilterAt(i) == selectedFilter) {
+        position = i;
+        break;
+      }
+    }
+  }
   args.filterPosition = position;
+  args.filterList = gCurrentFilterList;
   args.refresh = false;
 
   window.openDialog("chrome://messenger/content/FilterEditor.xul",
                     "FilterEditor",
                     "chrome,modal,titlebar,resizable,centerscreen", args);
 
   if (args.refresh)
   {
-    gFilterTreeView.tree.rowCountChanged(position, 1);
-    gFilterTree.view.selection.select(position);
-    gFilterTree.treeBoxObject.ensureRowIsVisible(position);
+    rebuildFilterList();
+
+    // Select the new filter, it is at the position of previous selection.
+    gFilterListbox.selectItem(gFilterListbox.getItemAtIndex(position));
+    if (currentFilter() != args.newFilter)
+      Cu.reportError("Filter created at an unexpected position!");
   }
 }
 
 function onDeleteFilter()
 {
   if (gDeleteButton.disabled)
     return;
 
-  var filterList = currentFilterList();
-  if (!filterList)
-    return;
-
-  var sel = gFilterTree.view.selection;
-  var selCount = sel.getRangeCount();
-  if (!selCount)
+  let items = gFilterListbox.selectedItems;
+  if (!items.length)
     return;
 
   let checkValue = {value: false};
   if (Services.prefs.getBoolPref("mailnews.filters.confirm_delete") &&
       Services.prompt.confirmEx(window, null,
                         gFilterBundle.getString("deleteFilterConfirmation"),
                         Services.prompt.STD_YES_NO_BUTTONS,
                         '', '', '',
                         gFilterBundle.getString('dontWarnAboutDeleteCheckbox'),
                         checkValue))
     return;
 
   if (checkValue.value)
     Services.prefs.setBoolPref("mailnews.filters.confirm_delete", false);
 
-  for (var i = selCount - 1; i >= 0; --i) {
-    var start = {}, end = {};
-    sel.getRangeAt(i, start, end);
-    for (var j = end.value; j >= start.value; --j) {
-      var curFilter = getFilter(j);
-      if (curFilter)
-        filterList.removeFilter(curFilter);
-    }
-    gFilterTreeView.tree.rowCountChanged(start.value, start.value - end.value - 1);
+  // Save filter position before the first selected one.
+  let newSelectionIndex = gFilterListbox.selectedIndex - 1;
+
+  // Must reverse the loop, as the items list shrinks when we delete.
+  for (let index = items.length - 1; index >= 0; --index) {
+    let item = items[index];
+    gCurrentFilterList.removeFilter(item._filter);
+    gFilterListbox.removeItemAt(gFilterListbox.getIndexOfItem(item));
+  }
+
+  // Select filter above previously selected if one existed,
+  // otherwise the first one.
+  if (newSelectionIndex == -1 && gFilterListbox.itemCount > 0)
+    newSelectionIndex = 0;
+  if (newSelectionIndex > -1) {
+    gFilterListbox.selectedIndex = newSelectionIndex;
+    updateViewPosition(-1);
   }
 }
 
 /**
  * Move filter one step up in visible list.
  */
 function onUp(event) {
   moveFilter(msgMoveMotion.Up);
@@ -501,57 +456,57 @@ function onBottom(event) {
  *   msgMoveMotion.Up, msgMoveMotion.Down, msgMoveMotion.Top, msgMoveMotion.Bottom
  */
 function moveFilter(motion) {
   // At the moment, do not allow moving groups of filters.
   let selectedFilter = currentFilter();
   if (!selectedFilter)
     return;
 
-  let filterList = currentFilterList();
   let moveFilterNative;
 
   switch (motion) {
     case msgMoveMotion.Top:
-      filterList.removeFilter(selectedFilter);
-      filterList.insertFilterAt(0, selectedFilter);
-      gFilterTree.treeBoxObject.ensureRowIsVisible(0);
-      gFilterTree.view.selection.select(0);
+      if (selectedFilter) {
+        gCurrentFilterList.removeFilter(selectedFilter);
+        gCurrentFilterList.insertFilterAt(0, selectedFilter);
+        rebuildFilterList();
+      }
       return;
     case msgMoveMotion.Bottom:
-      filterList.removeFilter(selectedFilter);
-      filterList.insertFilterAt(filterList.filterCount, selectedFilter);
-      gFilterTree.treeBoxObject.ensureRowIsVisible(filterList.filterCount - 1);
-      gFilterTree.view.selection.select(filterList.filterCount - 1);
+      if (selectedFilter) {
+        gCurrentFilterList.removeFilter(selectedFilter);
+        gCurrentFilterList.insertFilterAt(gCurrentFilterList.filterCount,
+                                          selectedFilter);
+        rebuildFilterList();
+      }
       return;
     case msgMoveMotion.Up:
       moveFilterNative = Ci.nsMsgFilterMotion.up;
       break;
     case msgMoveMotion.Down:
       moveFilterNative = Ci.nsMsgFilterMotion.down;
       break;
   }
 
   moveCurrentFilter(moveFilterNative);
 }
 
 function viewLog()
 {
-  var filterList = currentFilterList();
-  var args = {filterList: filterList};
+  let args = {filterList: gCurrentFilterList};
 
   window.openDialog("chrome://messenger/content/viewLog.xul", "FilterLog", "chrome,modal,titlebar,resizable,centerscreen", args);
 }
 
 function onFilterUnload()
 {
   // make sure to save the filter to disk
-  var filterList = currentFilterList();
-  if (filterList)
-    filterList.saveToDefaultFile();
+  if (gCurrentFilterList)
+    gCurrentFilterList.saveToDefaultFile();
 
   Services.obs.removeObserver(onFilterClose, "quit-application-requested");
   top.controllers.removeController(gFilterController);
 }
 
 function onFilterClose(aCancelQuit, aTopic, aData)
 {
   if (aTopic == "quit-application-requested" &&
@@ -591,58 +546,150 @@ function runSelectedFilters()
   let folder = gRunFiltersFolder._folder ||
                gRunFiltersFolder.selectedItem._folder;
   if (!folder)
     return;
 
   let filterList = MailServices.filters.getTempFilterList(folder);
 
   // make sure the tmp filter list uses the real filter list log stream
-  filterList.loggingEnabled = currentFilterList().loggingEnabled;
-  filterList.logStream = currentFilterList().logStream;
-  var index = 0, sel = gFilterTree.view.selection;
-  for (var i = 0; i < sel.getRangeCount(); i++) {
-    var start = {}, end = {};
-    sel.getRangeAt(i, start, end);
-    for (var j = start.value; j <= end.value; j++) {
-      var curFilter = getFilter(j);
-      if (curFilter)
-        filterList.insertFilterAt(index++, curFilter);
-    }
+  filterList.logStream = gCurrentFilterList.logStream;
+  filterList.loggingEnabled = gCurrentFilterList.loggingEnabled;
+
+  let index = 0;
+  for (let item of gFilterListbox.selectedItems) {
+    filterList.insertFilterAt(index++, item._filter);
   }
 
   MailServices.filters.applyFiltersToFolders(filterList, [folder], gFilterListMsgWindow);
 }
 
 function moveCurrentFilter(motion)
 {
-    var filterList = currentFilterList();
-    var filter = currentFilter();
-    if (!filterList || !filter)
-      return;
+  let filter = currentFilter();
+  if (!filter)
+    return;
 
-    filterList.moveFilter(filter, motion);
-    if (motion == Ci.nsMsgFilterMotion.up)
-      gFilterTree.view.selection.select(gFilterTree.currentIndex - 1);
-    else
-      gFilterTree.view.selection.select(gFilterTree.currentIndex + 1);
-
-    gFilterTree.treeBoxObject.ensureRowIsVisible(gFilterTree.currentIndex);
+  gCurrentFilterList.moveFilter(filter, motion);
+  rebuildFilterList();
 }
 
 /**
+ * Redraws the list of filters. Takes the search box value into account.
+ *
+ * This function should perform very fast even in case of high number of filters.
+ * Therefore there are some optimizations (e.g. listelement.children[] instead of
+ * list.getItemAtIndex()), that favour speed vs. semantical perfection.
+ */
+function rebuildFilterList()
+{
+  // Make a note of which filters were previously selected
+  let selectedNames = [];
+  for (let i = 0; i < gFilterListbox.selectedItems.length; i++)
+    selectedNames.push(gFilterListbox.selectedItems[i]._filter.filterName);
+
+  // Save scroll position so we can try to restore it later.
+  // Doesn't work when the list is rebuilt after search box condition changed.
+  let firstVisibleRowIndex = gFilterListbox.getIndexOfFirstVisibleRow();
+
+  // listbox.xml seems to cache the value of the first selected item in a
+  // range at _selectionStart. The old value though is now obsolete,
+  // since we will recreate all of the elements. We need to clear this,
+  // and one way to do this is with a call to clearSelection. This might be
+  // ugly from an accessibility perspective, since it fires an onSelect event.
+  gFilterListbox.clearSelection();
+
+  let listitem, nameCell, enabledCell, filter;
+  let filterCount = gCurrentFilterList.filterCount;
+  let listitemCount = gFilterListbox.itemCount;
+  let listitemIndex = 0;
+  for (let i = 0; i < filterCount; i++) {
+    filter = gCurrentFilterList.getFilterAt(i);
+
+    if (listitemCount > listitemIndex) {
+      // If there is a free existing listitem, reuse it.
+      // Use .children[] instead of .getItemAtIndex() as it is much faster.
+      listitem = gFilterListbox.children[listitemIndex + 1];
+      nameCell = listitem.childNodes[0];
+      enabledCell = listitem.childNodes[1];
+    }
+    else
+    {
+      // If there are not enough listitems in the list, create a new one.
+      listitem = document.createElement("listitem");
+      listitem.setAttribute("role", "checkbox");
+      nameCell = document.createElement("listcell");
+      enabledCell = document.createElement("listcell");
+      enabledCell.setAttribute("class", "listcell-iconic");
+      listitem.appendChild(nameCell);
+      listitem.appendChild(enabledCell);
+      gFilterListbox.appendChild(listitem);
+      let size = (enabledCell.clientWidth - 28) / 2;
+      enabledCell.style.paddingLeft = size.toString() + "px";
+      // We have to attach this listener to the listitem, even though we only
+      // care about clicks on the enabledCell. However, attaching to that item
+      // doesn't result in any events actually getting received.
+      listitem.addEventListener("click", onFilterClick, true);
+      listitem.addEventListener("dblclick", onFilterDoubleClick, true);
+    }
+    // For accessibility set the label on listitem.
+    listitem.setAttribute("label", filter.filterName);
+    // Set the listitem values to represent the current filter.
+    nameCell.setAttribute("label", filter.filterName);
+    enabledCell.setAttribute("enabled", filter.enabled);
+    listitem.setAttribute("aria-checked", filter.enabled);
+    listitem._filter = filter;
+
+    if (selectedNames.includes(filter.filterName))
+      gFilterListbox.addItemToSelection(listitem);
+
+    listitemIndex++;
+  }
+  // Remove any superfluous listitems, if the number of filters shrunk.
+  for (let i = listitemCount - 1; i >= listitemIndex; i--) {
+    gFilterListbox.lastChild.remove();
+  }
+
+  updateViewPosition(firstVisibleRowIndex);
+
+  gFilterListbox.focus();
+}
+
+function updateViewPosition(firstVisibleRowIndex)
+{
+  if (firstVisibleRowIndex == -1)
+    firstVisibleRowIndex = gFilterListbox.getIndexOfFirstVisibleRow();
+
+  // Restore to the extent possible the scroll position.
+  if (firstVisibleRowIndex && gFilterListbox.itemCount)
+    gFilterListbox.scrollToIndex(Math.min(firstVisibleRowIndex,
+                                          gFilterListbox.itemCount - 1));
+
+  if (gFilterListbox.selectedCount) {
+    // Make sure that at least the first selected item is visible.
+    gFilterListbox.ensureElementIsVisible(gFilterListbox.selectedItems[0]);
+
+    // The current item should be the first selected item, so that keyboard
+    // selection extension can work.
+    gFilterListbox.currentItem = gFilterListbox.selectedItems[0];
+  }
+
+  updateButtons();
+}
+ 
+/**
  * Try to only enable buttons that make sense
  *  - moving filters is currently only enabled for single selection
  *    also movement is restricted by searchBox and current selection position
  *  - edit only for single filters
  *  - delete / run only for one or more selected filters
  */
 function updateButtons()
 {
-    var numFiltersSelected = gFilterTree.view.selection.count;
+    var numFiltersSelected = gFilterListbox.selectedItems.length;
     var oneFilterSelected = (numFiltersSelected == 1);
 
     // "edit" only enabled when one filter selected
     // or if we couldn't parse the filter.
     let disabled = !oneFilterSelected || currentFilter().unparseable;
     gEditButton.disabled = disabled;
 
     // "copy" is the same as "edit".
@@ -655,24 +702,27 @@ function updateButtons()
     // so only disable this UI if no filters are selected
     gRunFiltersFolderPrefix.disabled = !numFiltersSelected;
     gRunFiltersFolder.disabled = !numFiltersSelected;
     gRunFiltersButton.disabled = !numFiltersSelected ||
                                  !gRunFiltersFolder._folder;
 
     // "up" and "top" enabled only if one filter is selected,
     // and it's not the first.
-    disabled = !(oneFilterSelected && gFilterTree.currentIndex > 0);
+    // Don't use gFilterListbox.currentIndex here, it's buggy when we've just
+    // changed the children in the list (via rebuildFilterList)
+    disabled = !(oneFilterSelected &&
+                 gFilterListbox.getSelectedItem(0) != gFilterListbox.getItemAtIndex(0));
     gUpButton.disabled = disabled;
     gTopButton.disabled = disabled;
 
     // "down" and "bottom" enabled only if one filter selected,
     // and it's not the last.
     disabled = !(oneFilterSelected &&
-                 gFilterTree.currentIndex < gFilterTree.view.rowCount - 1);
+                 gFilterListbox.selectedIndex < gFilterListbox.itemCount - 1);
     gDownButton.disabled = disabled;
     gBottomButton.disabled = disabled;
 }
 
 /**
  * Given a selected folder, returns the folder where filters should
  *  be defined (the root folder except for news) if the server can
  *  accept filters.
@@ -714,35 +764,42 @@ function getServerThatCanHaveFilters()
     {
         if (currentServer.canHaveFilters)
             return currentServer;
     }
 
     return null;
 }
 
+function onFilterClick(event)
+{
+  // We only care about button 0 (left click) events.
+  if (event.button != 0)
+    return;
+
+  // Remember, we had to attach the click-listener to the whole listitem, so
+  // now we need to see if the clicked the enable-column
+  let toggle = event.target.childNodes[1];
+  if ((event.clientX < toggle.boxObject.x + toggle.boxObject.width) &&
+      (event.clientX > toggle.boxObject.x)) {
+    toggleFilter(event.target);
+    event.stopPropagation();
+  }
+}
+
 function onFilterDoubleClick(event)
 {
-    // we only care about button 0 (left click) events
-    if (event.button != 0)
-      return;
+  // We only care about button 0 (left click) events.
+  if (event.button != 0)
+    return;
 
-    var cell = gFilterTree.treeBoxObject.getCellAt(event.clientX, event.clientY);
-    if (cell.row == -1 || cell.row > gFilterTree.view.rowCount - 1 || event.originalTarget.localName != "treechildren") {
-      // double clicking on a non valid row should not open the edit filter dialog
-      return;
-    }
-
-    // if the cell is in a "cycler" column (the enabled column)
-    // don't open the edit filter dialog with the selected filter
-    if (!cell.col.cycler)
-      onEditFilter();
+  onEditFilter();
 }
 
-function onFilterTreeKeyPress(aEvent) {
+function onFilterListKeyPress(aEvent) {
   if (aEvent.ctrlKey || aEvent.altKey || aEvent.metaKey || aEvent.shiftKey)
     return;
 
   if (aEvent.keyCode) {
     switch (aEvent.keyCode) {
       case KeyEvent.DOM_VK_INSERT:
         if (!gNewButton.disabled)
           onNewFilter();
@@ -756,24 +813,19 @@ function onFilterTreeKeyPress(aEvent) {
           onEditFilter();
         break;
     }
     return;
   }
 
   switch (aEvent.charCode) {
     case KeyEvent.DOM_VK_SPACE:
-      let rangeCount = gFilterTree.view.selection.getRangeCount();
-      for (let i = 0; i < rangeCount; ++i) {
-        let start = {}, end = {};
-        gFilterTree.view.selection.getRangeAt(i, start, end);
-        for (let k = start.value; k <= end.value; ++k)
-          toggleFilter(k);
+      for (let item of gFilterListbox.selectedItems) {
+        toggleFilter(item);
       }
-      gFilterTree.view.selection.invalidateSelection();
       break;
     default:
   }
 }
 
 function doHelpButton()
 {
   openHelp("mail-filters");
@@ -789,15 +841,15 @@ var gFilterController =
   isCommandEnabled: function(aCommand)
   {
     return aCommand == "cmd_selectAll";
   },
 
   doCommand: function(aCommand)
   {
     if (aCommand == "cmd_selectAll")
-      gFilterTree.view.selection.selectAll();
+      gFilterListbox.selectAll();
   },
 
   onEvent: function(aEvent)
   {
   }
 };
--- a/suite/mailnews/content/FilterListDialog.xul
+++ b/suite/mailnews/content/FilterListDialog.xul
@@ -68,34 +68,33 @@
         <hbox>
           <label id="filterHeader"
                  flex="1"
                  control="filterTree">&filterHeader.label;</label>
        </hbox>
       </row>
 
       <row flex="1">
-        <tree id="filterTree"
-              hidecolumnpicker="true"
-              onselect="onFilterSelect(event);"
-              onkeypress="onFilterTreeKeyPress(event);"
-              ondblclick="onFilterDoubleClick(event);">
+        <vbox>
+          <listbox id="filterList"
+                   flex="1"
+                   seltype="multiple"
+                   onselect="updateButtons();"
+                   onkeypress="onFilterListKeyPress(event);">
+            <listhead>
+              <listheader id="nameColumn"
+                          label="&nameColumn.label;"
+                          flex="1"/>
+              <listheader id="activeColumn"
+                          label="&activeColumn.label;"
+                          minwidth="40px"/>
+            </listhead>
+          </listbox>
+        </vbox>
 
-          <treecols>
-            <treecol id="nameColumn"
-                     flex="1"
-                     label="&nameColumn.label;"
-                     sort="?Name"/>
-            <treecol id="activeColumn"
-                     label="&activeColumn.label;"
-                     cycler="true"/>
-          </treecols>
-
-          <treechildren/>
-        </tree>
         <vbox>
           <button id="newButton"
                   label="&newButton.label;"
                   accesskey="&newButton.accesskey;"
                   oncommand="onNewFilter();"/>
           <button id="copyToNewButton"
                   label="&copyButton.label;"
                   accesskey="&copyButton.accesskey;"
--- a/suite/themes/classic/mac/messenger/filterDialog.css
+++ b/suite/themes/classic/mac/messenger/filterDialog.css
@@ -7,22 +7,26 @@
   ======================================================================= */
 
 @import url("chrome://messenger/skin/messenger.css");
 
 @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
 
 /* ::::: columns :::::: */
 
-treechildren::-moz-tree-image(activeColumn) {
+listcell.listcell-iconic > .listcell-label {
+  display: none;
+}
+
+listcell[enabled="false"] {
   list-style-image: url("chrome://messenger/skin/icons/dot.png");
 }
 
-treechildren::-moz-tree-image(activeColumn, Enabled-true) {
- list-style-image: url("chrome://messenger/skin/icons/check.png");
+listcell[enabled="true"] {
+  list-style-image: url("chrome://messenger/skin/icons/check.png");
 }
 
 .small-button {
   -moz-appearance: none;
   font: icon;
   text-shadow: 0 1px #F2F2F2;
   border: 1px solid #A8A8A8; 
   background: linear-gradient(to top, #ECECEC, #ECECEC 50%, #F9F9F9 0%, #F9F9F9);
--- a/suite/themes/classic/messenger/filterDialog.css
+++ b/suite/themes/classic/messenger/filterDialog.css
@@ -7,22 +7,26 @@
   ======================================================================= */
 
 @import url("chrome://messenger/skin/messenger.css");
 
 @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
 
 /* ::::: columns :::::: */
 
-treechildren::-moz-tree-image(activeColumn) {
+listcell.listcell-iconic > .listcell-label {
+  display: none;
+}
+
+listcell[enabled="false"] {
   list-style-image: url("chrome://messenger/skin/icons/dot.png");
 }
 
-treechildren::-moz-tree-image(activeColumn, Enabled-true) {
- list-style-image: url("chrome://messenger/skin/icons/check.png");
+listcell[enabled="true"] {
+  list-style-image: url("chrome://messenger/skin/icons/check.png");
 }
 
 .small-button {
   min-width: 3em;
   padding: 0px;
   margin: 0px 1px;
 }
 
--- a/suite/themes/modern/messenger/filterDialog.css
+++ b/suite/themes/modern/messenger/filterDialog.css
@@ -7,21 +7,25 @@
   ======================================================================= */
 
 @import url("chrome://messenger/skin/messenger.css");
 
 @namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul");
 
 /* ::::: columns :::::: */
 
-treechildren::-moz-tree-image(activeColumn) {
+listcell.listcell-iconic > .listcell-label {
+  display: none;
+}
+
+listcell[enabled="false"] {
   list-style-image: url("chrome://global/skin/checkbox/cbox.png");
 }
 
-treechildren::-moz-tree-image(activeColumn, Enabled-true) {
+listcell[enabled="true"] {
   list-style-image: url("chrome://global/skin/checkbox/cbox-check.png");
 }
 
 .search-menulist, .search-value-menulist {
   width: 12em;
 }
 
 .search-menulist[unavailable="true"] {