diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginHubPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginHubPanel.java index e17482f4ce..aa5dbd5074 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginHubPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginHubPanel.java @@ -87,7 +87,6 @@ import net.runelite.client.ui.components.IconTextField; import net.runelite.client.util.ImageUtil; import net.runelite.client.util.LinkBrowser; import net.runelite.client.util.SwingUtil; -import net.runelite.client.util.Text; import net.runelite.client.util.VerificationException; @Slf4j @@ -115,13 +114,15 @@ class PluginHubPanel extends PluginPanel CONFIGURE_ICON_HOVER = new ImageIcon(ImageUtil.alphaOffset(configureIcon, -100)); } - private class PluginItem extends JPanel + private class PluginItem extends JPanel implements SearchablePlugin { private static final int HEIGHT = 70; private static final int ICON_WIDTH = 48; private static final int BOTTOM_LINE_HEIGHT = 16; private final ExternalPluginManifest manifest; + + @Getter private final List keywords = new ArrayList<>(); @Getter @@ -333,6 +334,12 @@ class PluginHubPanel extends PluginPanel .addComponent(addrm, BOTTOM_LINE_HEIGHT, BOTTOM_LINE_HEIGHT, BOTTOM_LINE_HEIGHT)) .addGap(5))); } + + @Override + public String getSearchableName() + { + return manifest.getDisplayName(); + } } private final PluginListPanel pluginListPanel; @@ -547,22 +554,19 @@ class PluginHubPanel extends PluginPanel Stream stream = plugins.stream(); - String search = searchBar.getText(); - boolean isSearching = search != null && !search.trim().isEmpty(); + String query = searchBar.getText(); + boolean isSearching = query != null && !query.trim().isEmpty(); if (isSearching) { - String[] searchArray = SPACES.split(search.toLowerCase()); - stream = stream - .filter(p -> Text.matchesSearchTerms(searchArray, p.keywords)) - .sorted(Comparator.comparing(p -> p.manifest.getDisplayName())); + PluginSearch.search(plugins, query).forEach(mainPanel::add); } else { - stream = stream - .sorted(Comparator.comparing(PluginItem::isInstalled).thenComparing(p -> p.manifest.getDisplayName())); + stream + .sorted(Comparator.comparing(PluginItem::isInstalled).thenComparing(p -> p.manifest.getDisplayName())) + .forEach(mainPanel::add); } - stream.forEach(mainPanel::add); mainPanel.revalidate(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java index f6c36315a9..c71bcc8788 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java @@ -53,7 +53,7 @@ import net.runelite.client.ui.PluginPanel; import net.runelite.client.util.ImageUtil; import net.runelite.client.util.SwingUtil; -class PluginListItem extends JPanel +class PluginListItem extends JPanel implements SearchablePlugin { private static final ImageIcon CONFIG_ICON; private static final ImageIcon CONFIG_ICON_HOVER; @@ -188,7 +188,14 @@ class PluginListItem extends JPanel } } - boolean isPinned() + @Override + public String getSearchableName() + { + return pluginConfig.getName(); + } + + @Override + public boolean isPinned() { return pinButton.isSelected(); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListPanel.java index feff6d644f..0130688bc8 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListPanel.java @@ -263,33 +263,11 @@ class PluginListPanel extends PluginPanel private void onSearchBarChanged() { final String text = searchBar.getText(); - pluginList.forEach(mainPanel::remove); - - showMatchingPlugins(true, text); - showMatchingPlugins(false, text); - + PluginSearch.search(pluginList, text).forEach(mainPanel::add); revalidate(); } - private void showMatchingPlugins(boolean pinned, String text) - { - if (text.isEmpty()) - { - pluginList.stream().filter(item -> pinned == item.isPinned()).forEach(mainPanel::add); - return; - } - - final String[] searchTerms = text.toLowerCase().split(" "); - pluginList.forEach(listItem -> - { - if (pinned == listItem.isPinned() && Text.matchesSearchTerms(searchTerms, listItem.getKeywords())) - { - mainPanel.add(listItem); - } - }); - } - void openConfigurationPanel(String configGroup) { for (PluginListItem pluginListItem : pluginList) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginSearch.java b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginSearch.java new file mode 100644 index 0000000000..3cd3dad6cc --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginSearch.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2020, Jack Hodkinson + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.config; + +import com.google.common.base.Splitter; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import net.runelite.client.util.Text; +import org.apache.commons.lang3.StringUtils; + +public class PluginSearch +{ + private static final Splitter SPLITTER = Splitter.on(" ").trimResults().omitEmptyStrings(); + + public static List search(Collection searchablePlugins, String query) + { + return searchablePlugins.stream() + .filter(plugin -> Text.matchesSearchTerms(SPLITTER.split(query.toLowerCase()), plugin.getKeywords())) + .sorted(comparator(query)) + .collect(Collectors.toList()); + } + + private static Comparator comparator(String query) + { + if (StringUtils.isBlank(query)) + { + return Comparator.nullsLast(Comparator.comparing(SearchablePlugin::isPinned, Comparator.nullsLast(Comparator.reverseOrder()))) + .thenComparing(SearchablePlugin::getSearchableName, Comparator.nullsLast(Comparator.naturalOrder())); + } + Iterable queryPieces = SPLITTER.split(query.toLowerCase()); + return Comparator.nullsLast(Comparator.comparing((SearchablePlugin sp) -> query.equalsIgnoreCase(sp.getSearchableName()), Comparator.reverseOrder())) + .thenComparing(sp -> + { + if (sp.getSearchableName() == null) + { + return 0L; + } + return stream(SPLITTER.split(sp.getSearchableName())) + .filter(piece -> stream(queryPieces).anyMatch(qp -> containsOrIsContainedBy(piece.toLowerCase(), qp))) + .count(); + }, Comparator.reverseOrder()) + .thenComparing(sp -> + { + if (sp.getKeywords() == null) + { + return 0L; + } + return stream(sp.getKeywords()) + .filter(piece -> stream(queryPieces).anyMatch(qp -> containsOrIsContainedBy(piece.toLowerCase(), qp))) + .count(); + }, Comparator.reverseOrder()) + .thenComparing(SearchablePlugin::isPinned, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(SearchablePlugin::getSearchableName, Comparator.nullsLast(Comparator.naturalOrder())); + } + + private static Stream stream(Iterable iterable) + { + return StreamSupport.stream(iterable.spliterator(), false); + } + + private static boolean containsOrIsContainedBy(String a, String b) + { + return a.contains(b) || b.contains(a); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/config/SearchablePlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/config/SearchablePlugin.java new file mode 100644 index 0000000000..c8656787fa --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/config/SearchablePlugin.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020, Jack Hodkinson + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.config; + +import java.util.List; + +public interface SearchablePlugin +{ + String getSearchableName(); + + List getKeywords(); + + default boolean isPinned() + { + return false; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/util/Text.java b/runelite-client/src/main/java/net/runelite/client/util/Text.java index b98b8dbe8e..3050554a71 100644 --- a/runelite-client/src/main/java/net/runelite/client/util/Text.java +++ b/runelite-client/src/main/java/net/runelite/client/util/Text.java @@ -221,7 +221,7 @@ public class Text * * @return true if all search terms matches at least one keyword, or false if otherwise. */ - public static boolean matchesSearchTerms(String[] searchTerms, final Collection keywords) + public static boolean matchesSearchTerms(Iterable searchTerms, final Collection keywords) { for (String term : searchTerms) { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/config/PluginSearchTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/config/PluginSearchTest.java new file mode 100644 index 0000000000..798bd7e672 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/config/PluginSearchTest.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2020, Jack Hodkinson + * All rights reserved + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.config; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasSize; + +public class PluginSearchTest +{ + private Map plugins; + + @Before + public void setUp() + { + plugins = new HashMap<>(); + plugins.put("Discord", new TestSearchablePlugin("Discord", false, "action", "activity", "external", "integration", "status")); + plugins.put("Emojis", new TestSearchablePlugin("Emojis", true, "replaces", "common", "emoticons")); + plugins.put("Grand Exchange", new TestSearchablePlugin("Grand Exchange", true, "external", "integration", "notifications", "panel", "prices", "trade")); + plugins.put("Status Bars", new TestSearchablePlugin("Status Bars", false, "Draws", "status", "bars")); + } + + @Test + public void emptyQueryReturnsPluginsInAlphabeticalOrderWithPinnedItemsFirst() + { + List results = PluginSearch.search(plugins.values(), " "); + assertThat(results, containsInAnyOrder(plugins.values().toArray(new SearchablePlugin[] {}))); + } + + @Test + public void searchReturnsMatchingPlugins() + { + List results = PluginSearch.search(plugins.values(), "sTATus"); + assertThat(results, hasSize(2)); + assertThat(results, containsInAnyOrder(plugins.get("Discord"), plugins.get("Status Bars"))); + } + + @Test + public void searchOrdersItemsWithMatchesInTitleFirst() + { + List results = PluginSearch.search(plugins.values(), "STATUS"); + assertThat(results.get(0), equalTo(plugins.get("Status Bars"))); + } + + @Test + public void searchOrdersPinnedItemsFirstIfThereAreNoExactMatches() + { + List results = PluginSearch.search(plugins.values(), "integrat"); + assertThat(results, contains(plugins.get("Grand Exchange"), plugins.get("Discord"))); + } + + private static class TestSearchablePlugin implements SearchablePlugin + { + private final String name; + private final boolean pinned; + private final List keywords; + + public TestSearchablePlugin(String name, boolean pinned, String... keywords) + { + this.name = name; + this.pinned = pinned; + this.keywords = Arrays.asList(keywords); + } + + @Override + public String getSearchableName() + { + return name; + } + + @Override + public boolean isPinned() + { + return pinned; + } + + @Override + public List getKeywords() + { + return keywords; + } + } +}