diff --git a/runelite-api/src/main/java/net/runelite/api/ScriptID.java b/runelite-api/src/main/java/net/runelite/api/ScriptID.java index 494489fadc..1b6e93e33c 100644 --- a/runelite-api/src/main/java/net/runelite/api/ScriptID.java +++ b/runelite-api/src/main/java/net/runelite/api/ScriptID.java @@ -222,6 +222,12 @@ public final class ScriptID @ScriptArguments(integer = 15) public static final int GE_OFFERS_SETUP_BUILD = 779; + /** + * Builds the grand exchange item search widget + */ + @ScriptArguments(integer = 2) + public static final int GE_ITEM_SEARCH = 752; + /** * Builds the quest list inside the quest tab that shows each quest's progress */ diff --git a/runelite-api/src/main/java/net/runelite/api/widgets/WidgetID.java b/runelite-api/src/main/java/net/runelite/api/widgets/WidgetID.java index d8d8a93f4d..19a5945f67 100644 --- a/runelite-api/src/main/java/net/runelite/api/widgets/WidgetID.java +++ b/runelite-api/src/main/java/net/runelite/api/widgets/WidgetID.java @@ -466,6 +466,7 @@ public class WidgetID static final int CONTAINER = 40; static final int TITLE = 44; static final int FULL_INPUT = 45; + static final int GE_SEARCH_RESULTS = 53; static final int MESSAGES = 55; static final int TRANSPARENT_BACKGROUND_LINES = 56; static final int INPUT = 57; diff --git a/runelite-api/src/main/java/net/runelite/api/widgets/WidgetInfo.java b/runelite-api/src/main/java/net/runelite/api/widgets/WidgetInfo.java index 754a93766d..81eb08dd49 100644 --- a/runelite-api/src/main/java/net/runelite/api/widgets/WidgetInfo.java +++ b/runelite-api/src/main/java/net/runelite/api/widgets/WidgetInfo.java @@ -351,6 +351,7 @@ public enum WidgetInfo CHATBOX_BUTTONS(WidgetID.CHATBOX_GROUP_ID, WidgetID.Chatbox.BUTTONS), CHATBOX_TITLE(WidgetID.CHATBOX_GROUP_ID, WidgetID.Chatbox.TITLE), CHATBOX_FULL_INPUT(WidgetID.CHATBOX_GROUP_ID, WidgetID.Chatbox.FULL_INPUT), + CHATBOX_GE_SEARCH_RESULTS(WidgetID.CHATBOX_GROUP_ID, WidgetID.Chatbox.GE_SEARCH_RESULTS), CHATBOX_CONTAINER(WidgetID.CHATBOX_GROUP_ID, WidgetID.Chatbox.CONTAINER), CHATBOX_REPORT_TEXT(WidgetID.CHATBOX_GROUP_ID, WidgetID.Chatbox.REPORT_TEXT), CHATBOX_INPUT(WidgetID.CHATBOX_GROUP_ID, WidgetID.Chatbox.INPUT), diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangeConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangeConfig.java index b1eb1e212c..272c1a7254 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangeConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangeConfig.java @@ -96,4 +96,26 @@ public interface GrandExchangeConfig extends Config { return false; } + + @ConfigItem( + position = 7, + keyName = "highlightSearchMatch", + name = "Highlight Search Match", + description = "Highlights the search match with an underline" + ) + default boolean highlightSearchMatch() + { + return true; + } + + @ConfigItem( + position = 8, + keyName = "geSearchMode", + name = "Search Mode", + description = "The search mode to use for the GE" + ) + default GrandExchangeSearchMode geSearchMode() + { + return GrandExchangeSearchMode.DEFAULT; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangePlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangePlugin.java index 8dbe31bda5..f3629054bc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangePlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangePlugin.java @@ -2,6 +2,7 @@ * Copyright (c) 2019, Adam * Copyright (c) 2017, Robbie * Copyright (c) 2018, SomeoneWithAnInternetConnection + * Copyright (c) 2020, Dennis * All rights reserved. * * Redistribution and use in source and binary forms, with or without @@ -27,13 +28,23 @@ package net.runelite.client.plugins.grandexchange; +import com.google.common.primitives.Shorts; import com.google.common.reflect.TypeToken; import com.google.gson.Gson; import com.google.inject.Provides; +import java.awt.Color; import java.awt.image.BufferedImage; import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.ScheduledExecutorService; +import java.util.function.ToIntFunction; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import javax.inject.Inject; import javax.swing.SwingUtilities; import lombok.AccessLevel; @@ -49,10 +60,12 @@ import net.runelite.api.ItemComposition; import net.runelite.api.MenuAction; import net.runelite.api.MenuEntry; import net.runelite.api.ScriptID; +import net.runelite.api.VarClientStr; import net.runelite.api.events.ChatMessage; import net.runelite.api.events.FocusChanged; import net.runelite.api.events.GameStateChanged; import net.runelite.api.events.GrandExchangeOfferChanged; +import net.runelite.api.events.GrandExchangeSearched; import net.runelite.api.events.MenuEntryAdded; import net.runelite.api.events.ScriptCallbackEvent; import net.runelite.api.events.ScriptPostFired; @@ -75,6 +88,7 @@ import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.ui.ClientToolbar; import net.runelite.client.ui.NavigationButton; +import net.runelite.client.util.ColorUtil; import net.runelite.client.util.ImageUtil; import net.runelite.client.util.QuantityFormatter; import net.runelite.client.util.Text; @@ -83,6 +97,7 @@ import net.runelite.http.api.ge.GrandExchangeTrade; import net.runelite.http.api.item.ItemStats; import net.runelite.http.api.osbuddy.OSBGrandExchangeClient; import net.runelite.http.api.osbuddy.OSBGrandExchangeResult; +import org.apache.commons.text.similarity.FuzzyScore; @PluginDescriptor( name = "Grand Exchange", @@ -105,6 +120,12 @@ public class GrandExchangePlugin extends Plugin static final String SEARCH_GRAND_EXCHANGE = "Search Grand Exchange"; + private static final int MAX_RESULT_COUNT = 250; + + private static final FuzzyScore FUZZY = new FuzzyScore(Locale.ENGLISH); + + private static final Color FUZZY_HIGHLIGHT_COLOR = new Color(0x800000); + @Getter(AccessLevel.PACKAGE) private NavigationButton button; @@ -156,6 +177,48 @@ public class GrandExchangePlugin extends Plugin private GrandExchangeClient grandExchangeClient; + private boolean wasFuzzySearch; + + /** + * Logic from {@link org.apache.commons.text.similarity.FuzzyScore} + */ + private static List findFuzzyIndices(String term, String query) + { + List indices = new ArrayList<>(); + + // fuzzy logic is case insensitive. We normalize the Strings to lower + // case right from the start. Turning characters to lower case + // via Character.toLowerCase(char) is unfortunately insufficient + // as it does not accept a locale. + final String termLowerCase = term.toLowerCase(); + final String queryLowerCase = query.toLowerCase(); + + // the position in the term which will be scanned next for potential + // query character matches + int termIndex = 0; + + for (int queryIndex = 0; queryIndex < queryLowerCase.length(); queryIndex++) + { + final char queryChar = queryLowerCase.charAt(queryIndex); + + for (; termIndex < termLowerCase.length(); termIndex++) + { + final char termChar = termLowerCase.charAt(termIndex); + + if (queryChar == termChar) + { + indices.add(termIndex); + + // we can leave the nested loop. Every character in the + // query can match at most one character in the term. + break; + } + } + } + + return indices; + } + private SavedOffer getOffer(int slot) { String offer = configManager.getConfiguration("geoffer." + client.getUsername().toLowerCase(), Integer.toString(slot)); @@ -441,11 +504,131 @@ public class GrandExchangePlugin extends Plugin public void onScriptPostFired(ScriptPostFired event) { // GE offers setup init - if (event.getScriptId() != ScriptID.GE_OFFERS_SETUP_BUILD) + if (event.getScriptId() == ScriptID.GE_OFFERS_SETUP_BUILD) + { + rebuildGeText(); + } + else if (event.getScriptId() == ScriptID.GE_ITEM_SEARCH && config.highlightSearchMatch()) + { + highlightSearchMatches(); + } + } + + private void highlightSearchMatches() + { + if (!wasFuzzySearch) { return; } - rebuildGeText(); + String input = client.getVar(VarClientStr.INPUT_TEXT); + + String underlineTag = ""; + + Widget results = client.getWidget(WidgetInfo.CHATBOX_GE_SEARCH_RESULTS); + Widget[] children = results.getDynamicChildren(); + int resultCount = children.length / 3; + + for (int i = 0; i < resultCount; i++) + { + Widget itemNameWidget = children[i * 3 + 1]; + String itemName = itemNameWidget.getText(); + + List indices; + String otherName = itemName.replace('-', ' '); + if (!itemName.contains("-") || FUZZY.fuzzyScore(itemName, input) >= FUZZY.fuzzyScore(otherName, input)) + { + indices = findFuzzyIndices(itemName, input); + } + else + { + indices = findFuzzyIndices(otherName, input); + } + Collections.reverse(indices); + + StringBuilder newItemName = new StringBuilder(itemName); + for (int index : indices) + { + if (wasFuzzySearch && (itemName.charAt(index) == ' ' || itemName.charAt(index) == '-')) + { + continue; + } + newItemName.insert(index + 1, ""); + newItemName.insert(index, underlineTag); + } + + itemNameWidget.setText(newItemName.toString()); + } + } + + @Subscribe + public void onGrandExchangeSearched(GrandExchangeSearched event) + { + wasFuzzySearch = false; + + GrandExchangeSearchMode searchMode = config.geSearchMode(); + final String input = client.getVar(VarClientStr.INPUT_TEXT); + if (searchMode == GrandExchangeSearchMode.DEFAULT || input.isEmpty()) + { + return; + } + + event.consume(); + + client.setGeSearchResultIndex(0); + + int resultCount = 0; + if (searchMode == GrandExchangeSearchMode.FUZZY_FALLBACK) + { + List ids = IntStream.range(0, client.getItemCount()) + .mapToObj(itemManager::getItemComposition) + .filter(item -> item.isTradeable() && item.getNote() == -1 + && item.getName().toLowerCase().contains(input)) + .limit(MAX_RESULT_COUNT + 1) + .sorted(Comparator.comparing(ItemComposition::getName)) + .map(ItemComposition::getId) + .collect(Collectors.toList()); + if (ids.size() > MAX_RESULT_COUNT) + { + client.setGeSearchResultCount(-1); + client.setGeSearchResultIds(null); + } + else + { + resultCount = ids.size(); + client.setGeSearchResultCount(resultCount); + client.setGeSearchResultIds(Shorts.toArray(ids)); + } + } + + if (resultCount == 0) + { + // We do this so that for example the items "Anti-venom ..." are still at the top + // when searching "anti venom" + ToIntFunction getScore = item -> + { + int score = FUZZY.fuzzyScore(item.getName(), input); + if (item.getName().contains("-")) + { + return Math.max(FUZZY.fuzzyScore(item.getName().replace('-', ' '), input), score); + } + return score; + }; + + List ids = IntStream.range(0, client.getItemCount()) + .mapToObj(itemManager::getItemComposition) + .filter(item -> item.isTradeable() && item.getNote() == -1) + .filter(item -> getScore.applyAsInt(item) > 0) + .sorted(Comparator.comparingInt(getScore).reversed() + .thenComparing(ItemComposition::getName)) + .limit(MAX_RESULT_COUNT) + .map(ItemComposition::getId) + .collect(Collectors.toList()); + + client.setGeSearchResultCount(ids.size()); + client.setGeSearchResultIds(Shorts.toArray(ids)); + + wasFuzzySearch = true; + } } @Subscribe diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangeSearchMode.java b/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangeSearchMode.java new file mode 100644 index 0000000000..6c09e4ddcf --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/grandexchange/GrandExchangeSearchMode.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2020, Dennis + * 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.grandexchange; + +public enum GrandExchangeSearchMode +{ + DEFAULT, + + FUZZY_FALLBACK, + + FUZZY_ONLY +} \ No newline at end of file