地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.

feat: thread image processing (#19)

This PR threads the image processing code in an attempt to reduce the terminal freezing when scrolling past or loading large images

closes to #4

authored by brookjeynes.dev and committed by GitHub a355aed1 c474c033

+4
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## v1.1.0 (2025-05-21) 4 + - fix(images): Improve performance by only locking critical parts of image loading 5 + - fix(images): Thread the image loading process as not to block user input 6 + 3 7 ## v1.0.1 (2025-04-14) 4 8 - fix(errors): Ensure logged enums are wrapped in `@tagName()` for readability. 5 9
-3
PROJECT_BOARD.md
··· 6 6 - `[x]` Done 7 7 8 8 ## Backlog 9 - - [ ] Improve image reading. 10 - Current reading can be slow which pauses users movement if they are simply 11 - scrolling past. 12 9 - [ ] Keybind to unzip archives.
+1 -1
build.zig
··· 2 2 const builtin = @import("builtin"); 3 3 4 4 ///Must match the `version` in `build.zig.zon`. 5 - const version = std.SemanticVersion{ .major = 1, .minor = 0, .patch = 1 }; 5 + const version = std.SemanticVersion{ .major = 1, .minor = 1, .patch = 0 }; 6 6 7 7 const targets: []const std.Target.Query = &.{ 8 8 .{ .cpu_arch = .aarch64, .os_tag = .macos },
+1 -1
build.zig.zon
··· 1 1 .{ 2 2 .name = .jido, 3 3 .fingerprint = 0xee45eabe36cafb57, 4 - .version = "1.0.1", 4 + .version = "1.1.0", 5 5 .minimum_zig_version = "0.14.0", 6 6 7 7 .dependencies = .{
+27 -12
src/app.zig
··· 73 73 }; 74 74 75 75 pub const Event = union(enum) { 76 + image_ready, 76 77 key_press: Key, 77 78 winsize: vaxis.Winsize, 78 79 }; ··· 85 86 should_quit: bool, 86 87 vx: vaxis.Vaxis = undefined, 87 88 tty: vaxis.Tty = undefined, 89 + loop: vaxis.Loop(Event) = undefined, 88 90 state: State = .normal, 89 91 actions: CircStack(Action, actions_len), 90 92 command_history: CommandHistory = CommandHistory{}, ··· 99 101 text_input_buf: [std.fs.max_path_bytes]u8 = undefined, 100 102 101 103 yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null, 102 - image: ?vaxis.Image = null, 103 104 last_known_height: usize, 104 105 106 + image: struct { 107 + mutex: std.Thread.Mutex = .{}, 108 + data: ?vaxis.zigimg.Image = null, 109 + path: ?[]const u8 = null, 110 + } = .{}, 111 + 105 112 pub fn init(alloc: std.mem.Allocator) !App { 106 113 var vx = try vaxis.init(alloc, .{ 107 114 .kitty_keyboard_flags = .{ ··· 116 123 var help_menu = List([]const u8).init(alloc); 117 124 try help_menu.fromArray(&help_menu_items); 118 125 119 - return App{ 126 + var app: App = .{ 120 127 .alloc = alloc, 121 128 .should_quit = false, 122 129 .vx = vx, ··· 127 134 .actions = CircStack(Action, actions_len).init(), 128 135 .last_known_height = vx.window().height, 129 136 }; 137 + 138 + app.loop = vaxis.Loop(Event){ 139 + .vaxis = &app.vx, 140 + .tty = &app.tty, 141 + }; 142 + 143 + return app; 130 144 } 131 145 132 146 pub fn deinit(self: *App) void { ··· 157 171 self.vx.deinit(self.alloc, self.tty.anyWriter()); 158 172 self.tty.deinit(); 159 173 if (self.file_logger) |file_logger| file_logger.deinit(); 174 + if (self.image.path) |path| self.alloc.free(path); 175 + if (self.image.data) |data| { 176 + var img_data = data; 177 + img_data.deinit(); 178 + } 160 179 } 161 180 162 181 pub fn inputToSlice(self: *App) []const u8 { ··· 176 195 177 196 pub fn run(self: *App) !void { 178 197 try self.repopulateDirectory(""); 179 - 180 - var loop: vaxis.Loop(Event) = .{ 181 - .vaxis = &self.vx, 182 - .tty = &self.tty, 183 - }; 184 - try loop.start(); 185 - defer loop.stop(); 198 + try self.loop.start(); 199 + defer self.loop.stop(); 186 200 187 201 try self.vx.enterAltScreen(self.tty.anyWriter()); 188 202 try self.vx.queryTerminal(self.tty.anyWriter(), 1 * std.time.ns_per_s); 203 + self.vx.caps.kitty_graphics = true; 189 204 190 205 while (!self.should_quit) { 191 - loop.pollEvent(); 192 - while (loop.tryEvent()) |event| { 206 + self.loop.pollEvent(); 207 + while (self.loop.tryEvent()) |event| { 193 208 // Global keybinds. 194 209 switch (event) { 195 210 .key_press => |key| { ··· 232 247 // State specific keybinds. 233 248 switch (self.state) { 234 249 .normal => { 235 - try EventHandlers.handleNormalEvent(self, event, &loop); 250 + try EventHandlers.handleNormalEvent(self, event); 236 251 }, 237 252 .help_menu => { 238 253 try EventHandlers.handleHelpMenuEvent(self, event);
+45 -28
src/drawer.zig
··· 197 197 } 198 198 if (!match) break :unsupported; 199 199 200 - if (std.mem.eql(u8, self.last_item_path, self.current_item_path)) break :unsupported; 200 + { 201 + app.image.mutex.lock(); 202 + defer app.image.mutex.unlock(); 201 203 202 - var image = vaxis.zigimg.Image.fromFilePath( 203 - app.alloc, 204 - self.current_item_path, 205 - ) catch { 206 - break :unsupported; 207 - }; 208 - defer image.deinit(); 204 + if (std.mem.eql(u8, self.current_item_path, app.image.path orelse "")) { 205 + if (app.image.data == null) break :unsupported; 209 206 210 - if (app.vx.transmitImage(app.alloc, app.tty.anyWriter(), &image, .rgba)) |img| { 211 - app.image = img; 212 - } else |_| { 213 - if (app.image) |img| { 214 - app.vx.freeImage(app.tty.anyWriter(), img.id); 215 - } 216 - app.image = null; 217 - break :unsupported; 218 - } 219 - 220 - if (app.image) |img| { 221 - img.draw(preview_win, .{ .scale = .contain }) catch |err| { 222 - const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); 223 - defer app.alloc.free(message); 224 - app.notification.write(message, .err) catch {}; 225 - if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 207 + if (app.vx.transmitImage(app.alloc, app.tty.anyWriter(), &app.image.data.?, .rgba)) |img| { 208 + img.draw(preview_win, .{ .scale = .contain }) catch |err| { 209 + const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); 210 + defer app.alloc.free(message); 211 + app.notification.write(message, .err) catch {}; 212 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 226 213 227 - _ = preview_win.print(&.{ 228 - .{ .text = "Failed to draw image to screen. No preview available." }, 229 - }, .{}); 214 + _ = preview_win.print(&.{ 215 + .{ .text = "Failed to draw image to screen. No preview available." }, 216 + }, .{}); 217 + }; 218 + } else |_| { 219 + break :unsupported; 220 + } 230 221 231 222 break :file; 232 - }; 223 + } 224 + 225 + const path = try app.alloc.dupe(u8, self.current_item_path); 226 + const load_img_thread = std.Thread.spawn(.{}, loadImage, .{ 227 + app, 228 + path, 229 + }) catch break :unsupported; 230 + load_img_thread.detach(); 233 231 } 234 232 235 233 break :file; ··· 606 604 .style = config.styles.notification.box, 607 605 }, .{ .wrap = .word }); 608 606 } 607 + 608 + fn loadImage(app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 609 + const image = vaxis.zigimg.Image.fromFilePath(app.alloc, path) catch { 610 + return error.Unsupported; 611 + }; 612 + 613 + app.image.mutex.lock(); 614 + if (app.image.data) |data| { 615 + var img_data = data; 616 + img_data.deinit(); 617 + } 618 + app.image.data = image; 619 + 620 + if (app.image.path) |p| app.alloc.free(p); 621 + app.image.path = path; 622 + app.image.mutex.unlock(); 623 + 624 + app.loop.postEvent(.image_ready); 625 + }
+4 -2
src/event_handlers.zig
··· 12 12 pub fn handleNormalEvent( 13 13 app: *App, 14 14 event: App.Event, 15 - loop: *vaxis.Loop(App.Event), 16 15 ) !void { 17 16 switch (event) { 18 17 .key_press => |key| { ··· 82 81 } else { 83 82 switch (key.codepoint) { 84 83 '-', 'h', Key.left => try events.traverseLeft(app), 85 - Key.enter, 'l', Key.right => try events.traverseRight(app, loop), 84 + Key.enter, 'l', Key.right => try events.traverseRight(app), 86 85 'j', Key.down => app.directories.entries.next(), 87 86 'k', Key.up => app.directories.entries.previous(), 88 87 'u' => try events.undo(app), ··· 90 89 } 91 90 } 92 91 }, 92 + .image_ready => {}, 93 93 .winsize => |ws| try app.vx.resize(app.alloc, app.tty.anyWriter(), ws), 94 94 } 95 95 } ··· 239 239 }, 240 240 } 241 241 }, 242 + .image_ready => {}, 242 243 .winsize => |ws| try app.vx.resize(app.alloc, app.tty.anyWriter(), ws), 243 244 } 244 245 } ··· 253 254 else => {}, 254 255 } 255 256 }, 257 + .image_ready => {}, 256 258 .winsize => |ws| try app.vx.resize(app.alloc, app.tty.anyWriter(), ws), 257 259 } 258 260 }
+3 -3
src/events.zig
··· 391 391 } 392 392 } 393 393 394 - pub fn traverseRight(app: *App, loop: *vaxis.Loop(App.Event)) !void { 394 + pub fn traverseRight(app: *App) !void { 395 395 var message: ?[]const u8 = null; 396 396 defer if (message) |msg| app.alloc.free(msg); 397 397 ··· 421 421 if (environment.getEditor()) |editor| { 422 422 try app.vx.exitAltScreen(app.tty.anyWriter()); 423 423 try app.vx.resetState(app.tty.anyWriter()); 424 - loop.stop(); 424 + app.loop.stop(); 425 425 426 426 environment.openFile(app.alloc, app.directories.dir, entry.name, editor) catch |err| { 427 427 message = try std.fmt.allocPrint(app.alloc, "Failed to open file '{s}' - {}.", .{ entry.name, err }); ··· 429 429 if (app.file_logger) |file_logger| file_logger.write(message.?, .err) catch {}; 430 430 }; 431 431 432 - try loop.start(); 432 + try app.loop.start(); 433 433 try app.vx.enterAltScreen(app.tty.anyWriter()); 434 434 try app.vx.enableDetectedFeatures(app.tty.anyWriter()); 435 435 app.vx.queueRefresh();