Quick-jump tool made in Zig
at main 455 lines 15 kB view raw
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}