summaryrefslogtreecommitdiff
path: root/src/main/java/cuchaz/enigma/utils/search
diff options
context:
space:
mode:
authorGravatar 2xsaiko2020-04-29 18:24:29 +0200
committerGravatar GitHub2020-04-29 12:24:29 -0400
commit0eff06096bc4852f2580f20a0c5bf970ecf66987 (patch)
treefc6c996c66abde850aa8598a24da134c37d1d531 /src/main/java/cuchaz/enigma/utils/search
parentThis doesn't need to be scaled, potentially fixes circular class loading cras... (diff)
downloadenigma-fork-0eff06096bc4852f2580f20a0c5bf970ecf66987.tar.gz
enigma-fork-0eff06096bc4852f2580f20a0c5bf970ecf66987.tar.xz
enigma-fork-0eff06096bc4852f2580f20a0c5bf970ecf66987.zip
Rewrite search dialog (#233)
* Fix searching * Make buttons use localization * Fix rename field opening when pressing shift+space * Tweak search algorithm * Add a bit of documentation * Remove duplicate example line * Use max() when building the inner map instead of overwriting the old value * Keep search dialog state * Formatting * Fix cursor key selection not scrolling to selected item * Don't set font size * Rename close0 to exit * Fix wrong scrolling when selecting search dialog entry
Diffstat (limited to 'src/main/java/cuchaz/enigma/utils/search')
-rw-r--r--src/main/java/cuchaz/enigma/utils/search/SearchEntry.java17
-rw-r--r--src/main/java/cuchaz/enigma/utils/search/SearchUtil.java195
2 files changed, 212 insertions, 0 deletions
diff --git a/src/main/java/cuchaz/enigma/utils/search/SearchEntry.java b/src/main/java/cuchaz/enigma/utils/search/SearchEntry.java
new file mode 100644
index 0000000..48b255f
--- /dev/null
+++ b/src/main/java/cuchaz/enigma/utils/search/SearchEntry.java
@@ -0,0 +1,17 @@
1package cuchaz.enigma.utils.search;
2
3import java.util.List;
4
5public interface SearchEntry {
6
7 List<String> getSearchableNames();
8
9 /**
10 * Returns a type that uniquely identifies this search entry across possible changes.
11 * This is used for tracking the amount of times this entry has been selected.
12 *
13 * @return a unique identifier for this search entry
14 */
15 String getIdentifier();
16
17}
diff --git a/src/main/java/cuchaz/enigma/utils/search/SearchUtil.java b/src/main/java/cuchaz/enigma/utils/search/SearchUtil.java
new file mode 100644
index 0000000..e5ed35f
--- /dev/null
+++ b/src/main/java/cuchaz/enigma/utils/search/SearchUtil.java
@@ -0,0 +1,195 @@
1package cuchaz.enigma.utils.search;
2
3import java.util.*;
4import java.util.function.BiFunction;
5import java.util.stream.Collectors;
6import java.util.stream.Stream;
7
8import cuchaz.enigma.utils.Pair;
9
10public class SearchUtil<T extends SearchEntry> {
11
12 private final Map<T, Entry<T>> entries = new HashMap<>();
13 private final Map<String, Integer> hitCount = new HashMap<>();
14
15 public void add(T entry) {
16 Entry<T> e = Entry.from(entry);
17 entries.put(entry, e);
18 }
19
20 public void add(Entry<T> entry) {
21 entries.put(entry.searchEntry, entry);
22 }
23
24 public void addAll(Collection<T> entries) {
25 this.entries.putAll(entries.parallelStream().collect(Collectors.toMap(e -> e, Entry::from)));
26 }
27
28 public void remove(T entry) {
29 entries.remove(entry);
30 }
31
32 public void clear() {
33 entries.clear();
34 }
35
36 public void clearHits() {
37 hitCount.clear();
38 }
39
40 public Stream<T> search(String term) {
41 return entries.values().parallelStream()
42 .map(e -> new Pair<>(e, e.getScore(term, hitCount.getOrDefault(e.searchEntry.getIdentifier(), 0))))
43 .filter(e -> e.b > 0)
44 .sorted(Comparator.comparingDouble(o -> -o.b))
45 .map(e -> e.a.searchEntry)
46 .sequential();
47 }
48
49 public void hit(T entry) {
50 if (entries.containsKey(entry)) {
51 hitCount.compute(entry.getIdentifier(), (_id, i) -> i == null ? 1 : i + 1);
52 }
53 }
54
55 public static final class Entry<T extends SearchEntry> {
56
57 public final T searchEntry;
58 private final String[][] components;
59
60 private Entry(T searchEntry, String[][] components) {
61 this.searchEntry = searchEntry;
62 this.components = components;
63 }
64
65 public float getScore(String term, int hits) {
66 String ucTerm = term.toUpperCase(Locale.ROOT);
67 float maxScore = (float) Arrays.stream(components)
68 .mapToDouble(name -> getScoreFor(ucTerm, name))
69 .max().orElse(0.0);
70 return maxScore * (hits + 1);
71 }
72
73 /**
74 * Computes the score for the given <code>name</code> against the given search term.
75 *
76 * @param term the search term (expected to be upper-case)
77 * @param name the entry name, split at word boundaries (see {@link Entry#wordwiseSplit(String)})
78 * @return the computed score for the entry
79 */
80 private static float getScoreFor(String term, String[] name) {
81 int totalLength = Arrays.stream(name).mapToInt(String::length).sum();
82 float scorePerChar = 1f / totalLength;
83
84 // This map contains a snapshot of all the states the search has
85 // been in. The keys are the remaining characters of the search
86 // term, the values are the maximum scores for that remaining
87 // search term part.
88 Map<String, Float> snapshots = new HashMap<>();
89 snapshots.put(term, 0f);
90
91 // For each component, start at each existing snapshot, searching
92 // for the next longest match, and calculate the new score for each
93 // match length until the maximum. Then the new scores are put back
94 // into the snapshot map.
95 for (int componentIndex = 0; componentIndex < name.length; componentIndex++) {
96 String component = name[componentIndex];
97 float posMultiplier = (name.length - componentIndex) * 0.3f;
98 Map<String, Float> newSnapshots = new HashMap<>();
99 for (Map.Entry<String, Float> snapshot : snapshots.entrySet()) {
100 String remaining = snapshot.getKey();
101 float score = snapshot.getValue();
102 component = component.toUpperCase(Locale.ROOT);
103 int l = compareEqualLength(remaining, component);
104 for (int i = 1; i <= l; i++) {
105 float baseScore = scorePerChar * i;
106 float chainBonus = (i - 1) * 0.5f;
107 merge(newSnapshots, Collections.singletonMap(remaining.substring(i), score + baseScore * posMultiplier + chainBonus), Math::max);
108 }
109 }
110 merge(snapshots, newSnapshots, Math::max);
111 }
112
113 // Only return the score for when the search term was completely
114 // consumed.
115 return snapshots.getOrDefault("", 0f);
116 }
117
118 private static <K, V> void merge(Map<K, V> self, Map<K, V> source, BiFunction<V, V, V> combiner) {
119 source.forEach((k, v) -> self.compute(k, (_k, v1) -> v1 == null ? v : v == null ? v1 : combiner.apply(v, v1)));
120 }
121
122 public static <T extends SearchEntry> Entry<T> from(T e) {
123 String[][] components = e.getSearchableNames().parallelStream()
124 .map(Entry::wordwiseSplit)
125 .toArray(String[][]::new);
126 return new Entry<>(e, components);
127 }
128
129 private static int compareEqualLength(String s1, String s2) {
130 int len = 0;
131 while (len < s1.length() && len < s2.length() && s1.charAt(len) == s2.charAt(len)) {
132 len += 1;
133 }
134 return len;
135 }
136
137 /**
138 * Splits the given input into components, trying to detect word parts.
139 * <p>
140 * Example of how words get split (using <code>|</code> as seperator):
141 * <p><code>MinecraftClientGame -> Minecraft|Client|Game</code></p>
142 * <p><code>HTTPInputStream -> HTTP|Input|Stream</code></p>
143 * <p><code>class_932 -> class|_|932</code></p>
144 * <p><code>X11FontManager -> X|11|Font|Manager</code></p>
145 * <p><code>openHTTPConnection -> open|HTTP|Connection</code></p>
146 * <p><code>open_http_connection -> open|_|http|_|connection</code></p>
147 *
148 * @param input the input to split
149 * @return the resulting components
150 */
151 private static String[] wordwiseSplit(String input) {
152 List<String> list = new ArrayList<>();
153 while (!input.isEmpty()) {
154 int take;
155 if (Character.isLetter(input.charAt(0))) {
156 if (input.length() == 1) {
157 take = 1;
158 } else {
159 boolean nextSegmentIsUppercase = Character.isUpperCase(input.charAt(0)) && Character.isUpperCase(input.charAt(1));
160 if (nextSegmentIsUppercase) {
161 int nextLowercase = 1;
162 while (Character.isUpperCase(input.charAt(nextLowercase))) {
163 nextLowercase += 1;
164 if (nextLowercase == input.length()) {
165 nextLowercase += 1;
166 break;
167 }
168 }
169 take = nextLowercase - 1;
170 } else {
171 int nextUppercase = 1;
172 while (nextUppercase < input.length() && Character.isLowerCase(input.charAt(nextUppercase))) {
173 nextUppercase += 1;
174 }
175 take = nextUppercase;
176 }
177 }
178 } else if (Character.isDigit(input.charAt(0))) {
179 int nextNonNum = 1;
180 while (nextNonNum < input.length() && Character.isLetter(input.charAt(nextNonNum)) && !Character.isLowerCase(input.charAt(nextNonNum))) {
181 nextNonNum += 1;
182 }
183 take = nextNonNum;
184 } else {
185 take = 1;
186 }
187 list.add(input.substring(0, take));
188 input = input.substring(take);
189 }
190 return list.toArray(new String[0]);
191 }
192
193 }
194
195}