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

feat(images): cache images to avoid unecessary re-processing

+105 -33
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## v1.2.0 (2025-05-26) 4 + - feat(images): Cache images to avoid unecessary re-processing 5 + 3 6 ## v1.1.0 (2025-05-21) 4 7 - fix(images): Improve performance by only locking critical parts of image loading 5 8 - fix(images): Thread the image loading process as not to block user input
+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 = 1, .patch = 0 }; 5 + const version = std.SemanticVersion{ .major = 1, .minor = 2, .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.1.0", 4 + .version = "1.2.0", 5 5 .minimum_zig_version = "0.14.0", 6 6 7 7 .dependencies = .{
+32 -8
src/app.zig
··· 78 78 winsize: vaxis.Winsize, 79 79 }; 80 80 81 + pub const Image = struct { 82 + const Status = enum { 83 + ready, 84 + processing, 85 + }; 86 + 87 + ///Only use on first transmission. Subsequent draws should use 88 + ///`Image.image`. 89 + data: ?vaxis.zigimg.Image = null, 90 + image: ?vaxis.Image = null, 91 + path: ?[]const u8 = null, 92 + status: Status = .processing, 93 + 94 + pub fn deinit(self: @This(), alloc: std.mem.Allocator) void { 95 + if (self.data) |data| { 96 + var d = data; 97 + d.deinit(); 98 + } 99 + if (self.path) |path| alloc.free(path); 100 + } 101 + }; 102 + 81 103 const actions_len = 100; 104 + const image_cache_cap = 100; 82 105 83 106 const App = @This(); 84 107 ··· 103 126 yanked: ?struct { dir: []const u8, entry: std.fs.Dir.Entry } = null, 104 127 last_known_height: usize, 105 128 106 - image: struct { 129 + images: struct { 107 130 mutex: std.Thread.Mutex = .{}, 108 - data: ?vaxis.zigimg.Image = null, 109 - path: ?[]const u8 = null, 110 - } = .{}, 131 + cache: std.StringHashMap(Image), 132 + }, 111 133 112 134 pub fn init(alloc: std.mem.Allocator) !App { 113 135 var vx = try vaxis.init(alloc, .{ ··· 133 155 .text_input = vaxis.widgets.TextInput.init(alloc, &vx.unicode), 134 156 .actions = CircStack(Action, actions_len).init(), 135 157 .last_known_height = vx.window().height, 158 + .images = .{ .cache = .init(alloc) }, 136 159 }; 137 160 138 161 app.loop = vaxis.Loop(Event){ ··· 171 194 self.vx.deinit(self.alloc, self.tty.anyWriter()); 172 195 self.tty.deinit(); 173 196 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(); 197 + 198 + var image_iter = self.images.cache.iterator(); 199 + while (image_iter.next()) |img| { 200 + img.value_ptr.deinit(self.alloc); 178 201 } 202 + self.images.cache.deinit(); 179 203 } 180 204 181 205 pub fn inputToSlice(self: *App) []const u8 {
+68 -23
src/drawer.zig
··· 197 197 } 198 198 if (!match) break :unsupported; 199 199 200 - { 201 - app.image.mutex.lock(); 202 - defer app.image.mutex.unlock(); 200 + app.images.mutex.lock(); 201 + defer app.images.mutex.unlock(); 202 + 203 + if (app.images.cache.getPtr(self.current_item_path)) |cache_entry| { 204 + if (cache_entry.status == .processing) { 205 + _ = preview_win.print(&.{ 206 + .{ .text = "Image still processing." }, 207 + }, .{}); 208 + break :file; 209 + } 210 + 211 + if (cache_entry.image) |img| { 212 + img.draw(preview_win, .{ .scale = .contain }) catch |err| { 213 + const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); 214 + defer app.alloc.free(message); 215 + app.notification.write(message, .err) catch {}; 216 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 203 217 204 - if (std.mem.eql(u8, self.current_item_path, app.image.path orelse "")) { 205 - if (app.image.data == null) break :unsupported; 218 + _ = preview_win.print(&.{ 219 + .{ .text = "Failed to draw image to screen. No preview available." }, 220 + }, .{}); 221 + cache_entry.image = null; 222 + break :file; 223 + }; 224 + } else { 225 + if (cache_entry.data == null) { 226 + const path = try app.alloc.dupe(u8, self.current_item_path); 227 + processImage(app, path) catch break :unsupported; 228 + } 206 229 207 - if (app.vx.transmitImage(app.alloc, app.tty.anyWriter(), &app.image.data.?, .rgba)) |img| { 230 + if (app.vx.transmitImage(app.alloc, app.tty.anyWriter(), &cache_entry.data.?, .rgba)) |img| { 208 231 img.draw(preview_win, .{ .scale = .contain }) catch |err| { 209 232 const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); 210 233 defer app.alloc.free(message); ··· 214 237 _ = preview_win.print(&.{ 215 238 .{ .text = "Failed to draw image to screen. No preview available." }, 216 239 }, .{}); 240 + break :file; 217 241 }; 242 + cache_entry.image = img; 243 + cache_entry.data.?.deinit(); 244 + cache_entry.data = null; 218 245 } else |_| { 219 246 break :unsupported; 220 247 } 221 - 222 - break :file; 223 248 } 224 249 250 + break :file; 251 + } else { 225 252 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(); 253 + processImage(app, path) catch break :unsupported; 231 254 } 232 255 233 256 break :file; ··· 605 628 }, .{ .wrap = .word }); 606 629 } 607 630 631 + fn processImage(app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 632 + app.images.cache.put(path, .{ .path = path, .status = .processing }) catch { 633 + const message = try std.fmt.allocPrint(app.alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 634 + defer app.alloc.free(message); 635 + app.notification.write(message, .err) catch {}; 636 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 637 + return error.Unsupported; 638 + }; 639 + 640 + const load_img_thread = std.Thread.spawn(.{}, loadImage, .{ 641 + app, 642 + path, 643 + }) catch return error.Unsupported; 644 + load_img_thread.detach(); 645 + } 646 + 608 647 fn loadImage(app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 609 - const image = vaxis.zigimg.Image.fromFilePath(app.alloc, path) catch { 648 + const data = vaxis.zigimg.Image.fromFilePath(app.alloc, path) catch { 649 + const message = try std.fmt.allocPrint(app.alloc, "Failed to load image '{s}' - error occurred while attempting to read image from path.", .{path}); 650 + defer app.alloc.free(message); 651 + app.notification.write(message, .err) catch {}; 652 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 610 653 return error.Unsupported; 611 654 }; 612 655 613 - app.image.mutex.lock(); 614 - if (app.image.data) |data| { 615 - var img_data = data; 616 - img_data.deinit(); 656 + app.images.mutex.lock(); 657 + if (app.images.cache.getPtr(path)) |entry| { 658 + entry.status = .ready; 659 + entry.data = data; 660 + } else { 661 + const message = try std.fmt.allocPrint(app.alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 662 + defer app.alloc.free(message); 663 + app.notification.write(message, .err) catch {}; 664 + if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 665 + return error.Unsupported; 617 666 } 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(); 667 + app.images.mutex.unlock(); 623 668 624 669 app.loop.postEvent(.image_ready); 625 670 }