Quick-jump tool made in Zig
1// Heavily based on the fuzzy example from libvaxis:
2// https://github.com/rockorager/libvaxis/blob/5a8112b78be7f8c52d7404a28d997f0638d1c665/examples/fuzzy.zig
3
4const std = @import("std");
5const kf = @import("known-folders");
6const vaxis = @import("vaxis");
7const vxfw = vaxis.vxfw;
8const zf = @import("zf");
9
10pub const known_folders_config: kf.KnownFolderConfig = .{
11 .xdg_on_mac = true,
12};
13
14const Candidate = struct {
15 str: []const u8,
16 rank: f64,
17};
18
19const HighlightSlicer = struct {
20 matches: []const usize,
21 highlight: bool,
22 str: []const u8,
23 index: usize = 0,
24
25 const Slice = struct {
26 str: []const u8,
27 highlight: bool,
28 };
29
30 pub fn init(str: []const u8, matches: []const usize) HighlightSlicer {
31 const highlight = std.mem.indexOfScalar(usize, matches, 0) != null;
32 return .{ .str = str, .matches = matches, .highlight = highlight };
33 }
34
35 pub fn next(slicer: *HighlightSlicer) ?Slice {
36 if (slicer.index >= slicer.str.len) return null;
37
38 const start_state = slicer.highlight;
39 var index: usize = slicer.index;
40 while (index < slicer.str.len) : (index += 1) {
41 const highlight = std.mem.indexOfScalar(usize, slicer.matches, index) != null;
42 if (start_state != highlight) break;
43 }
44
45 const slice = Slice{ .str = slicer.str[slicer.index..index], .highlight = slicer.highlight };
46 slicer.highlight = !slicer.highlight;
47 slicer.index = index;
48 return slice;
49 }
50};
51
52const ProjectPicker = struct {
53 /// The full list of available items.
54 list: std.ArrayList(vxfw.Text),
55 /// The filtered list of available items.
56 filtered: std.ArrayList(vxfw.RichText),
57 /// The ListView used to render the filtered list of items.
58 list_view: vxfw.ListView,
59 /// The input box to type in a search pattern.
60 text_field: vxfw.TextField,
61
62 /// Used to allocate RichText widgets in the ListView.
63 arena: std.heap.ArenaAllocator,
64
65 /// Stores the selected path.
66 result: std.ArrayList(u8),
67
68 pub fn widget(self: *ProjectPicker) vxfw.Widget {
69 return .{
70 .userdata = self,
71 .eventHandler = ProjectPicker.typeErasedEventHandler,
72 .drawFn = ProjectPicker.typeErasedDrawFn,
73 };
74 }
75
76 pub fn eventHandler(
77 self: *ProjectPicker,
78 ctx: *vxfw.EventContext,
79 event: vxfw.Event,
80 ) anyerror!void {
81 switch (event) {
82 .init => {
83 // Initialize the filtered list
84 const allocator = self.arena.allocator();
85 for (self.list.items) |line| {
86 var spans = std.ArrayList(vxfw.RichText.TextSpan).init(allocator);
87 const span: vxfw.RichText.TextSpan = .{ .text = line.text };
88 try spans.append(span);
89 try self.filtered.append(.{ .text = spans.items });
90 }
91
92 return ctx.requestFocus(self.text_field.widget());
93 },
94 .key_press => |key| {
95 if (key.matches('c', .{ .ctrl = true })) {
96 ctx.quit = true;
97 return;
98 }
99
100 try self.list_view.handleEvent(ctx, event);
101 },
102 .focus_in => {
103 return ctx.requestFocus(self.text_field.widget());
104 },
105 else => {},
106 }
107 }
108
109 pub fn draw(
110 self: *ProjectPicker,
111 ctx: vxfw.DrawContext,
112 ) std.mem.Allocator.Error!vxfw.Surface {
113 const max = ctx.max.size();
114
115 const list_view: vxfw.SubSurface = .{
116 .origin = .{ .row = 2, .col = 0 },
117 .surface = try self.list_view.draw(ctx.withConstraints(
118 ctx.min,
119 .{ .width = max.width, .height = max.height - 3 },
120 )),
121 };
122
123 const text_field: vxfw.SubSurface = .{
124 .origin = .{ .row = 0, .col = 2 },
125 .surface = try self.text_field.draw(ctx.withConstraints(
126 ctx.min,
127 .{ .width = max.width, .height = 1 },
128 )),
129 };
130
131 const prompt: vxfw.Text = .{ .text = "", .style = .{ .fg = .{ .index = 4 } } };
132
133 const prompt_surface: vxfw.SubSurface = .{
134 .origin = .{ .row = 0, .col = 0 },
135 .surface = try prompt.draw(ctx.withConstraints(ctx.min, .{ .width = 2, .height = 1 })),
136 };
137
138 const children = try ctx.arena.alloc(vxfw.SubSurface, 3);
139 children[0] = list_view;
140 children[1] = text_field;
141 children[2] = prompt_surface;
142
143 return .{
144 .size = max,
145 .widget = self.widget(),
146 .buffer = &.{},
147 .children = children,
148 };
149 }
150
151 fn typeErasedEventHandler(
152 ptr: *anyopaque,
153 ctx: *vxfw.EventContext,
154 event: vxfw.Event,
155 ) anyerror!void {
156 const self: *ProjectPicker = @ptrCast(@alignCast(ptr));
157 try self.eventHandler(ctx, event);
158 }
159
160 fn typeErasedDrawFn(
161 ptr: *anyopaque,
162 ctx: vxfw.DrawContext,
163 ) std.mem.Allocator.Error!vxfw.Surface {
164 const self: *ProjectPicker = @ptrCast(@alignCast(ptr));
165 return try self.draw(ctx);
166 }
167
168 pub fn widget_builder(ptr: *const anyopaque, idx: usize, _: usize) ?vxfw.Widget {
169 const self: *const ProjectPicker = @ptrCast(@alignCast(ptr));
170 if (idx >= self.filtered.items.len) return null;
171
172 return self.filtered.items[idx].widget();
173 }
174
175 fn sort(_: void, a: Candidate, b: Candidate) bool {
176 // first by rank
177 if (a.rank < b.rank) return true;
178 if (a.rank > b.rank) return false;
179
180 // then by length
181 if (a.str.len < b.str.len) return true;
182 if (a.str.len > b.str.len) return false;
183
184 // then alphabetically
185 for (a.str, 0..) |c, i| {
186 if (c < b.str[i]) return true;
187 if (c > b.str[i]) return false;
188 }
189 return false;
190 }
191
192 pub fn on_change(maybe_ptr: ?*anyopaque, _: *vxfw.EventContext, str: []const u8) anyerror!void {
193 const ptr = maybe_ptr orelse return;
194 const self: *ProjectPicker = @ptrCast(@alignCast(ptr));
195
196 // Clear the filtered list and the arena.
197 self.filtered.clearAndFree();
198 _ = self.arena.reset(.free_all);
199
200 const arena = self.arena.allocator();
201
202 // If there is text in the search box we only render items that contain the search string.
203 // Otherwise we render all the items.
204 if (str.len > 0) {
205 var case_sensitive = false;
206 for (str) |c| {
207 if (std.ascii.isUpper(c)) {
208 case_sensitive = true;
209 break;
210 }
211 }
212
213 var tokens: std.ArrayListUnmanaged([]const u8) = .empty;
214 var it = std.mem.tokenizeScalar(u8, str, ' ');
215 while (it.next()) |token| {
216 try tokens.append(arena, token);
217 }
218
219 var fuzzy_ranked: std.ArrayListUnmanaged(Candidate) = .empty;
220
221 for (self.list.items) |item| {
222 if (zf.rank(item.text, tokens.items, .{ .to_lower = !case_sensitive })) |r| {
223 try fuzzy_ranked.append(arena, .{ .str = item.text, .rank = r });
224 }
225 }
226
227 std.sort.block(Candidate, fuzzy_ranked.items, {}, sort);
228
229 for (fuzzy_ranked.items) |item| {
230 var matches_buf: [2048]usize = undefined;
231 const matches = zf.highlight(
232 item.str,
233 tokens.items,
234 &matches_buf,
235 .{ .to_lower = !case_sensitive },
236 );
237
238 var spans = std.ArrayList(vxfw.RichText.TextSpan).init(arena);
239
240 if (matches.len == 0) {
241 const span: vxfw.RichText.TextSpan = .{ .text = item.str };
242 try spans.append(span);
243 try self.filtered.append(.{ .text = spans.items });
244 continue;
245 }
246
247 var slicer: HighlightSlicer = .init(item.str, matches);
248
249 while (slicer.next()) |slice| {
250 const span: vxfw.RichText.TextSpan = .{
251 .text = slice.str,
252 .style = .{ .reverse = slice.highlight },
253 };
254 try spans.append(span);
255 }
256
257 try self.filtered.append(.{ .text = spans.items });
258 }
259 } else {
260 for (self.list.items) |line| {
261 var spans = std.ArrayList(vxfw.RichText.TextSpan).init(arena);
262 const span: vxfw.RichText.TextSpan = .{ .text = line.text };
263 try spans.append(span);
264 try self.filtered.append(.{ .text = spans.items });
265 }
266 }
267
268 self.list_view.scroll.top = 0;
269 self.list_view.scroll.offset = 0;
270 self.list_view.cursor = 0;
271 }
272
273 pub fn on_submit(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext, _: []const u8) anyerror!void {
274 const ptr = maybe_ptr orelse return;
275 const self: *ProjectPicker = @ptrCast(@alignCast(ptr));
276
277 const arena = self.arena.allocator();
278 self.result.clearAndFree();
279
280 // 1. We want to quit on every submit, even ones that fail.
281
282 ctx.quit = true;
283
284 // 2. Get the selected item.
285
286 if (self.list_view.cursor >= self.filtered.items.len) return;
287
288 var selected_item = std.ArrayList(u8).init(arena);
289 defer selected_item.deinit();
290
291 for (self.filtered.items[self.list_view.cursor].text) |span| {
292 try selected_item.appendSlice(span.text);
293 }
294
295 // 3. If we can find a home directory replace any `~` in the chosen path with the path to
296 // the home directory.
297
298 const home_path = kf.getPath(arena, .home) catch null;
299 if (home_path) |home| {
300 const replace_len = std.mem.replacementSize(u8, selected_item.items, "~", home);
301 const result = try arena.alloc(u8, replace_len);
302
303 _ = std.mem.replace(
304 u8,
305 selected_item.items,
306 "~",
307 home,
308 result,
309 );
310
311 try self.result.appendSlice(result);
312 return;
313 }
314
315 // 4. Otherwise just return the chosen item unmodified.
316
317 try self.result.appendSlice(selected_item.items);
318 }
319};
320
321pub fn main() !void {
322 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
323 defer _ = gpa.deinit();
324
325 const allocator = gpa.allocator();
326
327 var app = try vxfw.App.init(allocator);
328 errdefer app.deinit();
329
330 const picker = try allocator.create(ProjectPicker);
331 defer allocator.destroy(picker);
332
333 picker.* = .{
334 .list = std.ArrayList(vxfw.Text).init(allocator),
335 .filtered = std.ArrayList(vxfw.RichText).init(allocator),
336 .list_view = .{
337 .children = .{
338 .builder = .{
339 .userdata = picker,
340 .buildFn = ProjectPicker.widget_builder,
341 },
342 },
343 },
344 .text_field = .{
345 .buf = vxfw.TextField.Buffer.init(allocator),
346 .unicode = &app.vx.unicode,
347 .userdata = picker,
348 .onChange = ProjectPicker.on_change,
349 .onSubmit = ProjectPicker.on_submit,
350 },
351 .arena = std.heap.ArenaAllocator.init(allocator),
352 .result = std.ArrayList(u8).init(allocator),
353 };
354 defer picker.text_field.deinit();
355 defer picker.list.deinit();
356 defer picker.filtered.deinit();
357 defer picker.arena.deinit();
358 defer picker.result.deinit();
359
360 // 1. Open the ~/.config directory, or equivalent on Windows.
361
362 const config_dir = kf.open(
363 allocator,
364 .local_configuration,
365 .{ .access_sub_paths = true },
366 ) catch |err| {
367 std.log.err("failed to open config directory: {}", .{err});
368 std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file.
369 };
370
371 if (config_dir) |config| {
372 // 2. Read the contents of the ~/.config/project-picker/projects file.
373
374 const pp_dir = config.makeOpenPath("project-picker", .{}) catch |err| {
375 std.log.err("failed to open project-picker config directory: {}", .{err});
376 std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file.
377 };
378
379 const projects_file = pp_dir.createFile(
380 "projects",
381 .{ .truncate = false, .read = true },
382 ) catch |err| {
383 std.log.err("failed to load project-picker project file: {}", .{err});
384 std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file.
385 };
386
387 const projects = projects_file.reader().readAllAlloc(
388 allocator,
389 std.math.maxInt(usize),
390 ) catch |err| {
391 std.log.err("failed to read project-picker project file: {}", .{err});
392 std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file.
393 };
394 defer allocator.free(projects);
395
396 // 3. Parse the lines of the file and add them to the available items in the project picker.
397
398 var arena_state: std.heap.ArenaAllocator = .init(allocator);
399 defer arena_state.deinit();
400 const arena = arena_state.allocator();
401
402 var it = std.mem.tokenizeScalar(u8, projects, '\n');
403 while (it.next()) |token| {
404 if (std.mem.endsWith(u8, token, "/*")) {
405 const dir_path = dir_path: {
406 const dir_path = token[0 .. token.len - 2];
407
408 if (!std.mem.startsWith(u8, dir_path, "~/")) {
409 break :dir_path dir_path;
410 }
411
412 var env = try std.process.getEnvMap(arena);
413 defer env.deinit();
414 const home = env.get("HOME") orelse return error.NoHomeDirectoryFound;
415 break :dir_path try std.fs.path.join(arena, &.{ home, dir_path[1..] });
416 };
417
418 const dir = try std.fs.cwd().openDir(dir_path, .{});
419 var dir_it = dir.iterate();
420
421 while (try dir_it.next()) |f| {
422 if (f.kind == .directory) {
423 try picker.list.append(
424 .{ .text = try std.fs.path.join(arena, &.{ dir_path, f.name }) },
425 );
426 }
427 }
428 } else {
429 try picker.list.append(.{ .text = token });
430 }
431 }
432
433 // 4. Run the picker.
434
435 try app.run(picker.widget(), .{});
436 app.deinit();
437
438 // 5. If no selection was made exit with $status == 1.
439
440 if (picker.result.items.len == 0) {
441 std.process.exit(1);
442 }
443
444 // 6. Print the chosen path to STDOUT.
445
446 const stdout = std.io.getStdOut().writer();
447 nosuspend stdout.print("{s}", .{picker.result.items}) catch |err| {
448 std.log.err("{s}", .{@errorName(err)});
449 std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file.
450 };
451 } else {
452 std.log.err("failed to open config directory", .{});
453 std.process.exit(74); // EX_IOERR from sysexits.h - I/O error on file.
454 }
455}