OLD | NEW |
---|---|
(Empty) | |
1 // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file | |
2 // for details. All rights reserved. Use of this source code is governed by a | |
3 // BSD-style license that can be found in the LICENSE file. | |
4 | |
5 /** | |
6 * [SearchText] represent the search field text. The text is viewed in three | |
7 * ways: [text] holds the original search text, used for performing | |
8 * case-sensitive matches, [lowerCase] holds the lower-case search text, used | |
9 * for performing case-insenstive matches, [camelCase] holds a camel-case | |
10 * interpretation of the search text, used to order matches in camel-case. | |
11 */ | |
12 class SearchText { | |
13 final String text; | |
14 final String lowerCase; | |
15 final String camelCase; | |
16 | |
17 SearchText(String searchText) | |
18 : text = searchText, | |
19 lowerCase = searchText.toLowerCase(), | |
20 camelCase = searchText.isEmpty() ? '' | |
21 : '${searchText.substring(0, 1).toUpperCase()}' | |
22 '${searchText.substring(1)}'; | |
23 | |
24 int get length() => text.length; | |
25 | |
26 bool isEmpty() => length == 0; | |
27 } | |
28 | |
29 /** | |
30 * [StringMatch] represents the case-insensitive matching of [searchText] as a | |
31 * substring within a [text]. The matched [text] is split into the [prefixText], | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
What variable does [text] refer to?
Johnni Winther
2012/08/23 10:19:50
To the property, that is the getter 'text'.
| |
32 * [infixText], and [suffixText], where [infixText] is the part matching the | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
Why not just call it "matchText"? And perhaps "pre
Johnni Winther
2012/08/23 10:19:50
They are used extensively so I prefer creating the
| |
33 * [searchText]. | |
34 */ | |
35 class StringMatch { | |
36 final SearchText searchText; | |
37 final String prefixText; | |
38 final String infixText; | |
39 final String suffixText; | |
40 | |
41 StringMatch(this.searchText, | |
42 this.prefixText, this.infixText, this.suffixText); | |
43 | |
44 /** | |
45 * Returns the HTML representation of the match. | |
46 */ | |
47 String toHtml() { | |
48 return '$prefixText' | |
49 '<span class="drop-down-link-highlight">$infixText</span>' | |
50 '$suffixText'; | |
51 } | |
52 | |
53 /** | |
54 * The text in which a substring matches [searchText]. | |
55 */ | |
56 String get text() => | |
57 '$prefixText$infixText$suffixText'; | |
58 | |
59 /** | |
60 * Is `true` iff [searchText] matches the full [text] case-sensitively. | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
Drop the quoting around 'true'. If you want someth
Johnni Winther
2012/08/23 10:19:50
Done.
| |
61 */ | |
62 bool get isFullMatch() => text == searchText.text; | |
63 | |
64 /** | |
65 * Is `true` iff [searchText] matches a substring of [text] case-sensitively. | |
66 */ | |
67 bool get isExactMatch() => infixText == searchText.text; | |
68 | |
69 /** | |
70 * Is `true` iff [searchText] matches a substring of [text] when [searchText] | |
71 * is interpreted as camel case. | |
72 */ | |
73 bool get isCamelCaseMatch() => infixText == searchText.camelCase; | |
74 } | |
75 | |
76 /** | |
77 * [Result] represents a match of the search text on a library, type or member. | |
78 */ | |
79 class Result { | |
80 final StringMatch prefix; | |
81 final StringMatch match; | |
82 | |
83 final String library; | |
84 final String type; | |
85 final String args; | |
86 final String kind; | |
87 final String url; | |
88 | |
89 TableRowElement row; | |
90 | |
91 Result(this.match, this.kind, this.url, | |
92 [this.library, this.type, String args, this.prefix]) | |
93 : this.args = args != null ? '<$args>' : ''; | |
94 | |
95 bool get isTopLevel() => prefix == null && type == null; | |
96 | |
97 void addRow(TableElement table) { | |
98 if (row != null) return; | |
99 | |
100 clickHandler(Event event) { | |
101 window.location.href = url; | |
102 hideDropDown(); | |
103 } | |
104 | |
105 row = table.insertRow(table.rows.length); | |
106 row.classes.add('drop-down-link-tr'); | |
107 row.on.mouseDown.add((event) => hideDropDownSuspend = true); | |
108 row.on.click.add(clickHandler); | |
109 row.on.mouseUp.add((event) => hideDropDownSuspend = false); | |
110 var sb = new StringBuffer(); | |
111 sb.add('<td class="drop-down-link-td">'); | |
112 sb.add('<table class="drop-down-table"><tr><td colspan="2">'); | |
113 if (kind == GETTER) { | |
114 sb.add('get '); | |
115 } else if (kind == SETTER) { | |
116 sb.add('set '); | |
117 } | |
118 sb.add(match.toHtml()); | |
119 if (kind == CLASS || kind == INTERFACE || kind == TYPEDEF) { | |
120 sb.add(args); | |
121 } else if (kind == CONSTRUCTOR || kind == METHOD) { | |
122 sb.add('(...)'); | |
123 } | |
124 sb.add('</td></tr><tr><td class="drop-down-link-kind">'); | |
125 sb.add(kindToString(kind)); | |
126 if (prefix != null) { | |
127 sb.add(' in '); | |
128 sb.add(prefix.toHtml()); | |
129 sb.add(args); | |
130 } else if (type != null) { | |
131 sb.add(' in '); | |
132 sb.add(type); | |
133 sb.add(args); | |
134 } | |
135 | |
136 sb.add('</td><td class="drop-down-link-library">'); | |
137 if (library != null) { | |
138 sb.add('library $library'); | |
139 } | |
140 sb.add('</td></tr></table></td>'); | |
141 row.innerHTML = sb.toString(); | |
142 } | |
143 } | |
144 | |
145 /** | |
146 * Creates a [StringMatch] object for [text] if a substring matches | |
147 * [searchText], or returns [: null :] if no match is found. | |
148 */ | |
149 StringMatch obtainMatch(SearchText searchText, String text) { | |
150 if (searchText.isEmpty()) { | |
151 return new StringMatch(searchText, '', '', text); | |
152 } | |
153 int offset = text.toLowerCase().indexOf(searchText.lowerCase); | |
154 if (offset != -1) { | |
155 String prefixText = text.substring(0, offset); | |
156 String infixText = text.substring(offset, offset+searchText.length); | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
Spaces around '+'. Ditto next line.
Johnni Winther
2012/08/23 10:19:50
Done.
| |
157 String suffixText = text.substring(offset+searchText.length); | |
158 return new StringMatch(searchText, prefixText, infixText, suffixText); | |
159 } | |
160 return null; | |
161 } | |
162 | |
163 int compareBools(bool a, bool b) { | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
Document that it considers true bigger than false.
Johnni Winther
2012/08/23 10:19:50
Done.
| |
164 if (a && !b) return -1; | |
165 else if (!a && b) return 1; | |
166 else return 0; | |
167 } | |
168 | |
169 int compareInts(int a, int b) { | |
170 if (a < b) return -1; | |
171 else if (a > b) return 1; | |
172 else return 0; | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
Aren't numbers comparable? I.e., a.compareTo(b)?
Johnni Winther
2012/08/23 10:19:50
Done.
| |
173 } | |
174 | |
175 int resultComparator(Result a, Result b) { | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
That's a lot of work to do in a sort comparison fu
Johnni Winther
2012/08/23 10:19:50
Yes, but we have a lot of situations to handle!
| |
176 // Favor top level entities. | |
177 int result = compareBools(a.isTopLevel, b.isTopLevel); | |
178 if (result != 0) return result; | |
179 | |
180 if (a.prefix != null && b.prefix != null) { | |
181 // Favor full prefix matches. | |
182 result = compareBools(a.prefix.isFullMatch, b.prefix.isFullMatch); | |
183 if (result != 0) return result; | |
184 } | |
185 | |
186 // Favor matches in the start. | |
187 result = compareBools(a.match.prefixText.isEmpty(), | |
188 b.match.prefixText.isEmpty()); | |
189 if (result != 0) return result; | |
190 | |
191 // Favor exact case-sensitive matches. | |
192 result = compareBools(a.match.isExactMatch, b.match.isExactMatch); | |
193 if (result != 0) return result; | |
194 | |
195 // Favor matches that do not break camel-case. | |
196 result = compareBools(a.match.isCamelCaseMatch, b.match.isCamelCaseMatch); | |
197 if (result != 0) return result; | |
198 | |
199 // Favor matches close to the begining. | |
200 result = compareInts(a.match.prefixText.length, | |
201 b.match.prefixText.length); | |
202 if (result != 0) return result; | |
203 | |
204 if (a.type != null && b.type != null) { | |
205 // Favor short type names over long. | |
206 result = compareInts(a.type.length, b.type.length); | |
207 if (result != 0) return result; | |
208 | |
209 // Sort type alphabetically. | |
210 result = a.type.toLowerCase().compareTo(b.type.toLowerCase()); | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
This is particularly worrisome. You can have a qua
Johnni Winther
2012/08/23 10:19:50
Yes, I looked for that!
| |
211 if (result != 0) return result; | |
212 } | |
213 | |
214 // Sort match alphabetically. | |
215 return a.match.text.toLowerCase().compareTo(b.match.text.toLowerCase()); | |
216 } | |
217 | |
218 List libraryList; | |
219 InputElement searchInput; | |
220 DivElement dropdown; | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
Is this a global variable? That isn't even private
Johnni Winther
2012/08/23 10:19:50
This is a web page and the DOM is global!
| |
221 | |
222 /** | |
223 * Update the search drop down based on the current search text. | |
224 */ | |
225 updateDropDown(Event event) { | |
226 if (libraryList == null) return; | |
227 if (searchInput == null) return; | |
228 if (dropdown == null) return; | |
229 | |
230 var results = <Result>[]; | |
231 String text = searchInput.value; | |
232 if (text == currentSearchText) { | |
233 return; | |
234 } | |
235 if (text.isEmpty()) { | |
236 updateResults(text, results); | |
237 hideDropDown(); | |
238 return; | |
239 } | |
240 if (text.contains('.')) { | |
241 // Search type members. | |
242 String typeText = text.substring(0, text.indexOf('.')); | |
243 String memberText = text.substring(text.indexOf('.') + 1); | |
244 | |
245 if (typeText.isEmpty() && memberText.isEmpty()) { | |
246 // Don't search on '.'. | |
247 } else if (typeText.isEmpty()) { | |
248 // Search text is of the form '.id' => Look up members. | |
249 var searchText = new SearchText(memberText); | |
250 for (Map library in libraryList) { | |
251 String libraryName = library[NAME]; | |
252 if (library.containsKey(TYPES)) { | |
253 for (Map type in library[TYPES]) { | |
254 String typeName = type[NAME]; | |
255 if (type.containsKey(MEMBERS)) { | |
256 for (Map member in type[MEMBERS]) { | |
257 StringMatch memberMatch = obtainMatch(searchText, member[NAME]); | |
258 if (memberMatch != null) { | |
259 results.add(new Result(memberMatch, member[KIND], | |
260 getTypeMemberUrl(libraryName, typeName, member), | |
261 library: libraryName, type: typeName, args: type[ARGS])); | |
262 } | |
263 } | |
264 } | |
265 } | |
266 } | |
267 } | |
268 } else if (memberText.isEmpty()) { | |
269 // Search text is of the form 'Type.' => Look up members in 'Type'. | |
270 var searchText = new SearchText(typeText); | |
271 var emptyText = new SearchText(memberText); | |
272 for (Map library in libraryList) { | |
273 String libraryName = library[NAME]; | |
274 if (library.containsKey(TYPES)) { | |
275 for (Map type in library[TYPES]) { | |
276 String typeName = type[NAME]; | |
277 StringMatch typeMatch = obtainMatch(searchText, typeName); | |
278 if (typeMatch != null) { | |
279 if (type.containsKey(MEMBERS)) { | |
280 for (Map member in type[MEMBERS]) { | |
281 StringMatch memberMatch = obtainMatch(emptyText, member[NAME]) ; | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
Line too long.
Johnni Winther
2012/08/23 10:19:50
Done.
| |
282 results.add(new Result(memberMatch, member[KIND], | |
283 getTypeMemberUrl(libraryName, typeName, member), | |
284 library: libraryName, prefix: typeMatch)); | |
285 } | |
286 } | |
287 } | |
288 } | |
289 } | |
290 } | |
291 } else { | |
292 // Search text is of the form 'Type.id' => Look up member 'id' in 'Type'. | |
293 var searchText = new SearchText(text); | |
294 var typeSearchText = new SearchText(typeText); | |
295 var memberSearchText = new SearchText(memberText); | |
296 for (Map library in libraryList) { | |
297 String libraryName = library[NAME]; | |
298 if (library.containsKey(TYPES)) { | |
299 for (Map type in library[TYPES]) { | |
300 String typeName = type[NAME]; | |
301 StringMatch typeMatch = obtainMatch(typeSearchText, typeName); | |
302 if (typeMatch != null) { | |
303 if (type.containsKey(MEMBERS)) { | |
304 for (Map member in type[MEMBERS]) { | |
305 StringMatch constructorMatch = obtainMatch(searchText, | |
306 member[NAME]); | |
307 if (constructorMatch != null) { | |
308 results.add(new Result(constructorMatch, member[KIND], | |
309 getTypeMemberUrl(libraryName, typeName, member), | |
310 library: libraryName)); | |
311 } else { | |
312 StringMatch memberMatch = obtainMatch(memberSearchText, | |
313 member[NAME]); | |
314 if (memberMatch != null) { | |
315 results.add(new Result(memberMatch, member[KIND], | |
316 getTypeMemberUrl(libraryName, typeName, member), | |
317 library: libraryName, prefix: typeMatch, | |
318 args: type[ARGS])); | |
319 } | |
320 } | |
321 } | |
322 } | |
323 } | |
324 } | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
These huge blobs of complex code would be great as
Johnni Winther
2012/08/23 10:19:50
Refactored into several methods.
| |
325 } | |
326 } | |
327 } | |
328 } else { | |
329 // Search all entities. | |
330 var searchText = new SearchText(text); | |
331 for (Map library in libraryList) { | |
332 String libraryName = library[NAME]; | |
333 StringMatch libraryMatch = obtainMatch(searchText, libraryName); | |
334 if (libraryMatch != null) { | |
335 results.add(new Result(libraryMatch, LIBRARY, | |
336 getLibraryUrl(libraryName))); | |
337 } | |
338 if (library.containsKey(MEMBERS)) { | |
339 for (Map member in library[MEMBERS]) { | |
340 StringMatch memberMatch = obtainMatch(searchText, member[NAME]); | |
341 if (memberMatch != null) { | |
342 results.add(new Result(memberMatch, member[KIND], | |
343 getLibraryMemberUrl(libraryName, member), | |
344 library: libraryName)); | |
345 } | |
346 } | |
347 } | |
348 if (library.containsKey(TYPES)) { | |
349 for (Map type in library[TYPES]) { | |
350 String typeName = type[NAME]; | |
351 StringMatch typeMatch = obtainMatch(searchText, typeName); | |
352 if (typeMatch != null) { | |
353 results.add(new Result(typeMatch, type[KIND], | |
354 getTypeUrl(libraryName, type), | |
355 library: libraryName, args: type[ARGS])); | |
356 } | |
357 if (type.containsKey(MEMBERS)) { | |
358 for (Map member in type[MEMBERS]) { | |
359 StringMatch memberMatch = obtainMatch(searchText, member[NAME]); | |
360 if (memberMatch != null) { | |
361 results.add(new Result(memberMatch, member[KIND], | |
362 getTypeMemberUrl(libraryName, typeName, member), | |
363 library: libraryName, type: typeName, args: type[ARGS])); | |
364 } | |
365 } | |
366 } | |
367 } | |
368 } | |
369 } | |
370 } | |
371 var elements = <Element>[]; | |
372 var table = new TableElement(); | |
373 table.classes.add('drop-down-table'); | |
374 elements.add(table); | |
375 | |
376 if (results.isEmpty()) { | |
377 var row = table.insertRow(0); | |
378 row.innerHTML = "<tr><td>No matches found for '$text'.</td></tr>"; | |
379 } else { | |
380 results.sort(resultComparator); | |
381 | |
382 var count = 0; | |
383 for (Result result in results) { | |
384 result.addRow(table); | |
385 if (++count >= 10) { | |
386 break; | |
387 } | |
388 } | |
389 if (results.length >= 10) { | |
390 var row = table.insertRow(table.rows.length); | |
391 row.innerHTML = '<tr><td>+ ${results.length-10} more.</td></tr>'; | |
392 results = results.getRange(0, 10); | |
393 } | |
394 } | |
395 dropdown.elements = elements; | |
396 updateResults(text, results); | |
397 showDropDown(); | |
398 } | |
399 | |
400 String currentSearchText; | |
401 Result _currentResult; | |
402 List<Result> currentResults = const <Result>[]; | |
403 | |
404 void updateResults(String searchText, List<Result> results) { | |
405 currentSearchText = searchText; | |
406 currentResults = results; | |
407 if (currentResults.isEmpty()) { | |
408 _currentResultIndex = -1; | |
409 currentResult = null; | |
410 } else { | |
411 _currentResultIndex = 0; | |
412 currentResult = currentResults[0]; | |
413 } | |
414 } | |
415 | |
416 int _currentResultIndex; | |
417 | |
418 void set currentResultIndex(int index) { | |
419 if (index < -1) { | |
420 return; | |
421 } | |
422 if (index >= currentResults.length) { | |
423 return; | |
424 } | |
425 if (index != _currentResultIndex) { | |
426 _currentResultIndex = index; | |
427 if (index >= 0) { | |
428 currentResult = currentResults[_currentResultIndex]; | |
429 } else { | |
430 currentResult = null; | |
431 } | |
432 } | |
433 } | |
434 | |
435 int get currentResultIndex() => _currentResultIndex; | |
436 | |
437 void set currentResult(Result result) { | |
438 if (_currentResult != result) { | |
439 if (_currentResult != null) { | |
440 _currentResult.row.classes.remove('drop-down-link-select'); | |
441 } | |
442 _currentResult = result; | |
443 if (_currentResult != null) { | |
444 _currentResult.row.classes.add('drop-down-link-select'); | |
445 } | |
446 } | |
447 } | |
448 | |
449 Result get currentResult() => _currentResult; | |
450 | |
451 /** | |
452 * Navigate the search drop down using up/down inside the search field. Follow | |
453 * the result link on enter. | |
454 */ | |
455 void handleUpDown(KeyboardEvent event) { | |
456 if (event.keyIdentifier == KeyName.UP) { | |
457 currentResultIndex--; | |
458 event.preventDefault(); | |
459 } else if (event.keyIdentifier == KeyName.DOWN) { | |
460 currentResultIndex++; | |
461 event.preventDefault(); | |
462 } else if (event.keyIdentifier == KeyName.ENTER) { | |
463 if (currentResult != null) { | |
464 window.location.href = currentResult.url; | |
465 event.preventDefault(); | |
466 hideDropDown(); | |
467 } | |
468 } | |
469 } | |
470 | |
471 /** Show the search drop down unless there are no current results. */ | |
472 void showDropDown() { | |
473 if (currentResults.isEmpty()) { | |
474 hideDropDown(); | |
475 } else { | |
476 dropdown.style.visibility = 'visible'; | |
477 } | |
478 } | |
479 | |
480 /** Used to prevent hiding the drop down when it is clicked. */ | |
481 bool hideDropDownSuspend = false; | |
482 | |
483 /** Hide the search drop down unless suspended. */ | |
484 void hideDropDown() { | |
485 if (hideDropDownSuspend) return; | |
486 | |
487 dropdown.style.visibility = 'hidden'; | |
488 } | |
489 | |
490 /** Activate search on Ctrl+F and F3. */ | |
491 void shortcutHandler(KeyboardEvent event) { | |
492 if (event.keyCode == 70/*F*/ && event.ctrlKey || | |
Lasse Reichstein Nielsen
2012/08/20 13:07:58
I prefer hex for keycodes. I.e., 0x46.
Johnni Winther
2012/08/23 10:19:50
Done.
| |
493 event.keyIdentifier == KeyName.F3) { | |
494 searchInput.focus(); | |
495 event.preventDefault(); | |
496 } | |
497 } | |
498 | |
499 /** Setup search hooks. */ | |
500 void setupSearch(var libraries) { | |
501 libraryList = libraries; | |
502 searchInput = query('#q'); | |
503 dropdown = query('#drop-down'); | |
504 | |
505 searchInput.on.keyDown.add(handleUpDown); | |
506 searchInput.on.keyUp.add(updateDropDown); | |
507 searchInput.on.change.add(updateDropDown); | |
508 searchInput.on.reset.add(updateDropDown); | |
509 searchInput.on.focus.add((event) => showDropDown()); | |
510 searchInput.on.blur.add((event) => hideDropDown()); | |
511 window.on.keyDown.add(shortcutHandler); | |
512 } | |
OLD | NEW |