Skip to content

Commit 629c2d1

Browse files
Manuals: Add fuzzy searching and tags for search view (#698)
1 parent a4e40f7 commit 629c2d1

File tree

4 files changed

+283
-39
lines changed

4 files changed

+283
-39
lines changed

po/POTFILES

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,5 @@ src/Manuals/Shortcuts.js
8080
src/Manuals/DocumentationViewer.js
8181
src/Library/EntryRow.blp
8282
src/Library/EntryRow.js
83+
84+
src/Manuals/fzy.js

src/Manuals/DocumentationViewer.blp

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ Adw.Window documentation_viewer {
5353
}
5454

5555
content: Stack stack {
56-
transition-type: crossfade;
56+
transition-type: none;
5757

5858
ScrolledWindow browse_page {
5959
vexpand: true;
@@ -84,31 +84,34 @@ Adw.Window documentation_viewer {
8484

8585
ListView search_list_view {
8686
enable-rubberband: false;
87-
model: SingleSelection search_model {
88-
autoselect: false;
89-
model: SortListModel {
90-
sorter: StringSorter search_sorter {};
91-
model: FilterListModel filter_model {
92-
filter: StringFilter filter {
93-
match-mode: substring;
94-
};
95-
};
96-
};
97-
};
98-
9987
factory: BuilderListItemFactory {
10088
template ListItem {
101-
child: Inscription {
102-
hexpand: true;
103-
nat-chars: 10;
104-
text-overflow: ellipsize_end;
105-
text: bind template.item as <$DocumentationPage>.search_name;
89+
child: Box {
90+
Inscription {
91+
valign: center;
92+
hexpand: true;
93+
nat-chars: 25;
94+
text-overflow: ellipsize_end;
95+
text: bind template.item as <$DocumentationPage>.search_name;
96+
}
97+
Button {
98+
valign: center;
99+
label: bind template.item as <$DocumentationPage>.tag;
100+
styles ["pill", "small", "doc-tag"]
101+
}
106102
};
107103
}
108104
};
109105
styles ["navigation-sidebar"]
110106
}
111107
}
108+
109+
Adw.StatusPage status_page {
110+
title: _("No Results Found");
111+
description: _("Try a different search term");
112+
icon-name: "loupe-symbolic";
113+
styles ["compact"]
114+
}
112115
};
113116
};
114117
};

src/Manuals/DocumentationViewer.js

Lines changed: 125 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { decode } from "../util.js";
77
import resource from "./DocumentationViewer.blp";
88

99
import Shortcuts from "./Shortcuts.js";
10+
import { hasMatch, score } from "./fzy.js";
1011

1112
import {
1213
action_extensions,
@@ -24,13 +25,29 @@ const DocumentationPage = GObject.registerClass(
2425
GObject.ParamFlags.READWRITE,
2526
"",
2627
),
28+
tag: GObject.ParamSpec.string(
29+
"tag",
30+
"tag",
31+
"Tag of symbol",
32+
GObject.ParamFlags.READWRITE,
33+
"",
34+
),
2735
search_name: GObject.ParamSpec.string(
2836
"search_name",
2937
"search_name",
3038
"Name used to search the item in sidebar",
3139
GObject.ParamFlags.READWRITE,
3240
"",
3341
),
42+
score: GObject.ParamSpec.double(
43+
"score",
44+
"score",
45+
"Score assigned when searching an item in the sidebar",
46+
GObject.ParamFlags.READWRITE,
47+
Number.MIN_SAFE_INTEGER,
48+
Number.MAX_SAFE_INTEGER,
49+
Number.MIN_SAFE_INTEGER,
50+
),
3451
uri: GObject.ParamSpec.string(
3552
"uri",
3653
"uri",
@@ -62,9 +79,11 @@ export default function DocumentationViewer({ application }) {
6279
const button_back = builder.get_object("button_back");
6380
const button_forward = builder.get_object("button_forward");
6481
const stack = builder.get_object("stack");
65-
const browse_list_view = builder.get_object("browse_list_view");
82+
const status_page = builder.get_object("status_page");
6683
const browse_page = builder.get_object("browse_page");
84+
const browse_list_view = builder.get_object("browse_list_view");
6785
const search_page = builder.get_object("search_page");
86+
const search_list_view = builder.get_object("search_list_view");
6887
const search_entry = builder.get_object("search_entry");
6988
const button_shortcuts = builder.get_object("button_shortcuts");
7089

@@ -170,35 +189,21 @@ export default function DocumentationViewer({ application }) {
170189
}
171190
});
172191

173-
const expr = new Gtk.ClosureExpression(
174-
GObject.TYPE_STRING,
175-
(item) => item.search_name,
176-
null,
177-
);
178-
const filter_model = builder.get_object("filter_model");
179-
const filter = filter_model.filter;
180-
filter.expression = expr;
181-
182192
function onSearchChanged() {
183193
if (search_entry.text) {
184194
stack.visible_child = search_page;
185-
filter.search = search_entry.text;
195+
const selection_model = search_list_view.model;
196+
selection_model.unselect_item(selection_model.selected);
197+
selection_model.model.model.filter = createFilter(search_entry.text);
198+
if (!selection_model.n_items) stack.visible_child = status_page;
199+
search_list_view.scroll_to(0, Gtk.ListScrollFlags.NONE, null);
186200
} else {
187201
stack.visible_child = browse_page;
188202
}
189203
}
190204

191205
search_entry.connect("search-changed", onSearchChanged);
192206

193-
const search_model = builder.get_object("search_model");
194-
const sorter = builder.get_object("search_sorter");
195-
sorter.expression = expr;
196-
search_model.connect("selection-changed", () => {
197-
const uri = search_model.selected_item.uri;
198-
const sidebar_path = URI_TO_SIDEBAR_PATH[uri];
199-
selectSidebarItem(browse_list_view, sidebar_path);
200-
});
201-
202207
let promise_load;
203208
async function load() {
204209
if (!promise_load) {
@@ -211,7 +216,10 @@ export default function DocumentationViewer({ application }) {
211216
scanLibraries(root_model, Gio.File.new_for_path("/app/share/doc")),
212217
]).then(() => {
213218
const search_model = flattenModel(root_model);
214-
filter_model.model = search_model;
219+
search_list_view.model = createSearchSelectionModel(
220+
search_model,
221+
browse_list_view,
222+
);
215223
});
216224
}
217225
return promise_load;
@@ -305,6 +313,7 @@ async function loadLibrary(model, directory) {
305313
const namespace = `${index.meta.ns}-${index.meta.version}`;
306314
const page = new DocumentationPage({
307315
name: namespace,
316+
tag: "namespace",
308317
search_name: namespace,
309318
uri: html_file.get_uri(),
310319
children: getChildren(index, directory),
@@ -388,6 +397,78 @@ function createBrowseSelectionModel(root_model, webview) {
388397
return selection_model;
389398
}
390399

400+
function createSearchSelectionModel(model, browse_list_view) {
401+
const filter_model = Gtk.FilterListModel.new(model, null);
402+
const sorter = Gtk.CustomSorter.new((item1, item2) => {
403+
return Math.sign(item2.score - item1.score);
404+
});
405+
const sort_model = new Gtk.SortListModel({
406+
model: filter_model,
407+
sorter: sorter,
408+
});
409+
const selection_model = Gtk.SingleSelection.new(sort_model);
410+
selection_model.autoselect = false;
411+
selection_model.can_unselect = true;
412+
selection_model.connect("selection-changed", () => {
413+
if (!selection_model.selected_item) return;
414+
const uri = selection_model.selected_item.uri;
415+
const sidebar_path = URI_TO_SIDEBAR_PATH[uri];
416+
selectSidebarItem(browse_list_view, sidebar_path);
417+
});
418+
return selection_model;
419+
}
420+
421+
const QUERY_TYPES = [
422+
"additional",
423+
"alias",
424+
"bitfield",
425+
"callback",
426+
"class",
427+
"constant",
428+
"constructor",
429+
"enum",
430+
"error",
431+
"function",
432+
"interface",
433+
"namespace",
434+
"macro",
435+
"method",
436+
"property",
437+
"signal",
438+
"struct",
439+
"union",
440+
"vfunc",
441+
];
442+
443+
const QUERY_PATTERN = new RegExp(
444+
"^(" + QUERY_TYPES.join("|") + ")\\s*:\\s*",
445+
"i",
446+
);
447+
448+
function createFilter(search_term) {
449+
const matches = search_term.match(QUERY_PATTERN);
450+
let tag = null;
451+
452+
if (matches) {
453+
tag = matches[1].toLowerCase();
454+
search_term = search_term.substring(matches[0].length);
455+
}
456+
457+
const needle = search_term.replace(/\s+/g, "");
458+
const isCaseSensitive = needle.toLowerCase() !== needle;
459+
const actualNeedle = isCaseSensitive ? needle : needle.toLowerCase();
460+
461+
return Gtk.CustomFilter.new((item) => {
462+
const haystack = isCaseSensitive
463+
? item.search_name
464+
: item.search_name.toLowerCase();
465+
const match = hasMatch(actualNeedle, haystack);
466+
const shouldKeep = match && (!tag || item.tag === tag);
467+
if (shouldKeep) item.score = score(actualNeedle, haystack);
468+
return shouldKeep;
469+
});
470+
}
471+
391472
const SECTION_TYPES = {
392473
class: ["Classes", "#classes"],
393474
content: ["Addition Documentation", "#extra"],
@@ -438,6 +519,7 @@ function getChildren(index, dir) {
438519
location.insert_sorted(
439520
new DocumentationPage({
440521
name: symbol.name,
522+
tag: getTagForDocument(symbol),
441523
search_name: getSearchNameForDocument(symbol, index.meta),
442524
uri: `${dir.get_uri()}/${getLinkForDocument(symbol)}`,
443525
}),
@@ -566,3 +648,25 @@ function getLinkForDocument(doc) {
566648
return `vfunc.${doc.type_name}.${doc.name}.html`;
567649
}
568650
}
651+
652+
function getTagForDocument(doc) {
653+
switch (doc.type) {
654+
case "method":
655+
case "class_method":
656+
return "method";
657+
case "content":
658+
return "additional";
659+
case "ctor":
660+
return "constructor";
661+
case "domain":
662+
return "error";
663+
case "function_macro":
664+
return "macro";
665+
case "record":
666+
return "struct";
667+
case "type_func":
668+
return "function";
669+
default:
670+
return doc.type;
671+
}
672+
}

0 commit comments

Comments
 (0)