From 693167bd1db9910d9d16bac3061b3f3a9c40278c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81d=C3=A1m=20Kov=C3=A1cs?= Date: Thu, 24 Apr 2025 14:49:18 +0200 Subject: [PATCH] feat(console+core): tui, core --- build.zig | 78 ++++-------- build.zig.zon | 42 +------ src/console/main.zig | 209 +++++++++++++++++++++++++++++++++ src/console_main.zig | 5 + src/core/deinit.zig | 42 ------- src/core/models.zig | 4 - src/core/provider/local.zig | 60 ++++------ src/core/provider/provider.zig | 14 ++- src/core/tab/tab.zig | 39 +++++- 9 files changed, 305 insertions(+), 188 deletions(-) create mode 100644 src/console/main.zig create mode 100644 src/console_main.zig delete mode 100644 src/core/deinit.zig diff --git a/build.zig b/build.zig index c7336a4..9ffb10d 100644 --- a/build.zig +++ b/build.zig @@ -1,75 +1,43 @@ const std = @import("std"); -// Although this function looks imperative, note that its job is to -// declaratively construct a build graph that will be executed by an external -// runner. pub fn build(b: *std.Build) void { - // Standard target options allows the person running `zig build` to choose - // what target to build for. Here we do not override the defaults, which - // means any target is allowed, and the default is native. Other options - // for restricting supported target set are available. const target = b.standardTargetOptions(.{}); - - // Standard optimization options allow the person running `zig build` to select - // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not - // set a preferred release mode, allowing the user to decide how to optimize. const optimize = b.standardOptimizeOption(.{}); - // We will also create a module for our other entry point, 'main.zig'. - const exe_mod = b.createModule(.{ - // `root_source_file` is the Zig "entry point" of the module. If a module - // only contains e.g. external object files, you can make this `null`. - // In this case the main source file is merely a path, however, in more - // complicated build scripts, this could be a generated file. - .root_source_file = b.path("src/main.zig"), + const console_exe_mod = b.createModule(.{ + .root_source_file = b.path("src/console_main.zig"), + .target = target, + .optimize = optimize, + }); + const console_exe = b.addExecutable(.{ + .name = "FileTime3", + .root_module = console_exe_mod, + }); + + const vaxis = b.dependency("vaxis", .{ .target = target, .optimize = optimize, }); - // This creates another `std.Build.Step.Compile`, but this one builds an executable - // rather than a static library. - const exe = b.addExecutable(.{ - .name = "FileTime3", - .root_module = exe_mod, - }); + console_exe.root_module.addImport("vaxis", vaxis.module("vaxis")); + b.installArtifact(console_exe); - // This declares intent for the executable to be installed into the - // standard location when the user invokes the "install" step (the default - // step when running `zig build`). - b.installArtifact(exe); + const run_console_cmd = b.addRunArtifact(console_exe); + run_console_cmd.step.dependOn(b.getInstallStep()); - // This *creates* a Run step in the build graph, to be executed when another - // step is evaluated that depends on it. The next line below will establish - // such a dependency. - const run_cmd = b.addRunArtifact(exe); - - // By making the run step depend on the install step, it will be run from the - // installation directory rather than directly from within the cache directory. - // This is not necessary, however, if the application depends on other installed - // files, this ensures they will be present and in the expected location. - run_cmd.step.dependOn(b.getInstallStep()); - - // This allows the user to pass arguments to the application in the build - // command itself, like this: `zig build run -- arg1 arg2 etc` if (b.args) |args| { - run_cmd.addArgs(args); + run_console_cmd.addArgs(args); } - // This creates a build step. It will be visible in the `zig build --help` menu, - // and can be selected like this: `zig build run` - // This will evaluate the `run` step rather than the default, which is "install". - const run_step = b.step("run", "Run the app"); - run_step.dependOn(&run_cmd.step); + const run_console_step = b.step("run", "Run the console app"); + run_console_step.dependOn(&run_console_cmd.step); - const exe_unit_tests = b.addTest(.{ - .root_module = exe_mod, + const console_unit_tests = b.addTest(.{ + .root_module = console_exe_mod, }); - const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + const run_console_unit_tests = b.addRunArtifact(console_unit_tests); - // Similar to creating the run step earlier, this exposes a `test` step to - // the `zig build --help` menu, providing a way for the user to request - // running the unit tests. - const test_step = b.step("test", "Run unit tests"); - test_step.dependOn(&run_exe_unit_tests.step); + const test_step = b.step("test", "Run unit console tests"); + test_step.dependOn(&run_console_unit_tests.step); } diff --git a/build.zig.zon b/build.zig.zon index ef91a00..6854420 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,45 +36,11 @@ // Once all dependencies are fetched, `zig build` no longer requires // internet connectivity. .dependencies = .{ - // See `zig fetch --save ` for a command-line interface for adding dependencies. - //.example = .{ - // // When updating this field to a new URL, be sure to delete the corresponding - // // `hash`, otherwise you are communicating that you expect to find the old hash at - // // the new URL. If the contents of a URL change this will result in a hash mismatch - // // which will prevent zig from using it. - // .url = "https://example.com/foo.tar.gz", - // - // // This is computed from the file contents of the directory of files that is - // // obtained after fetching `url` and applying the inclusion rules given by - // // `paths`. - // // - // // This field is the source of truth; packages do not come from a `url`; they - // // come from a `hash`. `url` is just one of many possible mirrors for how to - // // obtain a package matching this `hash`. - // // - // // Uses the [multihash](https://multiformats.io/multihash/) format. - // .hash = "...", - // - // // When this is provided, the package is found in a directory relative to the - // // build root. In this case the package's hash is irrelevant and therefore not - // // computed. This field and `url` are mutually exclusive. - // .path = "foo", - // - // // When this is set to `true`, a package is declared to be lazily - // // fetched. This makes the dependency only get fetched if it is - // // actually used. - // .lazy = false, - //}, + .vaxis = .{ + .url = "git+https://github.com/rockorager/libvaxis.git#ae71b6545c099e73d45df7e5dd7c7a3081839468", + .hash = "vaxis-0.1.0-BWNV_JEMCQBskZQsnlzh6GoyHSDgOi41bCoZIB2pW-E7", + }, }, - - // Specifies the set of files and directories that are included in this package. - // Only files and directories listed here are included in the `hash` that - // is computed for this package. Only files listed here will remain on disk - // when using the zig package manager. As a rule of thumb, one should list - // files required for compilation plus any license(s). - // Paths are relative to the build root. Use the empty string (`""`) to refer to - // the build root itself. - // A directory listed here means that all files within, recursively, are included. .paths = .{ "build.zig", "build.zig.zon", diff --git a/src/console/main.zig b/src/console/main.zig new file mode 100644 index 0000000..3639bbe --- /dev/null +++ b/src/console/main.zig @@ -0,0 +1,209 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const vxfw = vaxis.vxfw; + +const models = @import("../core/models.zig"); +const provider = @import("../core/provider/provider.zig"); +const local_provider = @import("../core/provider/local.zig"); +const tab = @import("../core/tab/tab.zig"); +const Tab = tab.Tab; + +/// Our main application state +const Model = struct { + current_items: vxfw.ListView, + tab: *Tab, + /// State of the counter + count: u32 = 0, + /// The button. This widget is stateful and must live between frames + button: vxfw.Button, + + /// Helper function to return a vxfw.Widget struct + pub fn widget(self: *Model) vxfw.Widget { + return .{ + .userdata = self, + .eventHandler = Model.typeErasedEventHandler, + .drawFn = Model.typeErasedDrawFn, + }; + } + + /// This function will be called from the vxfw runtime. + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void { + const self: *Model = @ptrCast(@alignCast(ptr)); + switch (event) { + // The root widget is always sent an init event as the first event. Users of the + // library can also send this event to other widgets they create if they need to do + // some initialization. + .init => return ctx.requestFocus(self.current_items.widget()), + .key_press => |key| { + if (key.matches('c', .{ .ctrl = true })) { + ctx.quit = true; + return; + } + }, + // We can request a specific widget gets focus. In this case, we always want to focus + // our button. Having focus means that key events will be sent up the widget tree to + // the focused widget, and then bubble back down the tree to the root. Users can tell + // the runtime the event was handled and the capture or bubble phase will stop + .focus_in => return ctx.requestFocus(self.current_items.widget()), + else => {}, + } + } + + /// This function is called from the vxfw runtime. It will be called on a regular interval, and + /// only when any event handler has marked the redraw flag in EventContext as true. By + /// explicitly requiring setting the redraw flag, vxfw can prevent excessive redraws for events + /// which don't change state (ie mouse motion, unhandled key events, etc) + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vxfw.DrawContext) std.mem.Allocator.Error!vxfw.Surface { + const vm: *Model = @ptrCast(@alignCast(ptr)); + // The DrawContext is inspired from Flutter. Each widget will receive a minimum and maximum + // constraint. The minimum constraint will always be set, even if it is set to 0x0. The + // maximum constraint can have null width and/or height - meaning there is no constraint in + // that direction and the widget should take up as much space as it needs. By calling size() + // on the max, we assert that it has some constrained size. This is *always* the case for + // the root widget - the maximum size will always be the size of the terminal screen. + const max_size = ctx.max.size(); + + // The DrawContext also contains an arena allocator that can be used for each frame. The + // lifetime of this allocation is until the next time we draw a frame. This is useful for + // temporary allocations such as the one below: we have an integer we want to print as text. + // We can safely allocate this with the ctx arena since we only need it for this frame. + const count_text = try std.fmt.allocPrint(ctx.arena, "{d}", .{vm.count}); + const text: vxfw.Text = .{ .text = count_text }; + + // Each widget returns a Surface from it's draw function. A Surface contains the rectangular + // area of the widget, as well as some information about the surface or widget: can we focus + // it? does it handle the mouse? + // + // It DOES NOT contain the location it should be within it's parent. Only the parent can set + // this via a SubSurface. Here, we will return a Surface for the root widget (Model), which + // has two SubSurfaces: one for the text and one for the button. A SubSurface is a Surface + // with an offset and a z-index - the offset can be negative. This lets a parent draw a + // child and place it within itself + const text_child: vxfw.SubSurface = .{ + .origin = .{ .row = 0, .col = 0 }, + .surface = try text.draw(ctx), + }; + + const button_child: vxfw.SubSurface = .{ + .origin = .{ .row = 2, .col = 0 }, + .surface = try vm.button.draw(ctx.withConstraints( + ctx.min, + // Here we explicitly set a new maximum size constraint for the Button. A Button will + // expand to fill it's area and must have some hard limit in the maximum constraint + .{ .width = 16, .height = 3 }, + )), + }; + + const list_surface: vxfw.SubSurface = .{ + .origin = .{ .row = 12, .col = 30 }, + .surface = try vm.current_items.widget().draw(ctx.withConstraints(ctx.min, .{ .width = 30, .height = 10 })), + }; + + // We also can use our arena to allocate the slice for our SubSurfaces. This slice only + // needs to live until the next frame, making this safe. + const children = try ctx.arena.alloc(vxfw.SubSurface, 3); + children[0] = text_child; + children[1] = button_child; + // children[2] = splitView_surface; + children[2] = list_surface; + + return .{ + // A Surface must have a size. Our root widget is the size of the screen + .size = max_size, + .widget = vm.widget(), + // We didn't actually need to draw anything for the root. In this case, we can set + // buffer to a zero length slice. If this slice is *not zero length*, the runtime will + // assert that it's length is equal to the size.width * size.height. + .buffer = &.{}, + .children = children, + }; + } + + /// The onClick callback for our button. This is also called if we press enter while the button + /// has focus + fn onClick(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext) anyerror!void { + const ptr = maybe_ptr orelse return; + const self: *Model = @ptrCast(@alignCast(ptr)); + self.count +|= 1; + return ctx.consumeAndRedraw(); + } +}; + +pub fn main() !void { + var gpa = std.heap.DebugAllocator(.{}){}; + const gp_allocator = gpa.allocator(); + defer { + _ = gpa.detectLeaks(); + _ = gpa.deinit(); + } + + var tsa = std.heap.ThreadSafeAllocator{ .child_allocator = gp_allocator }; + const allocator = tsa.allocator(); + + var pool: std.Thread.Pool = undefined; + try pool.init(.{ + .allocator = allocator, + }); + defer pool.deinit(); + + var localContentProvider = local_provider.LocalContentProvider{ .threadPool = &pool }; + + var app = try vxfw.App.init(allocator); + defer app.deinit(); + + const homeFullName: models.FullName = .{ .path = "/home/adam" }; + const homeItem = try localContentProvider.getItemByFullName(homeFullName, &.{}, allocator); + // defer homeItem.deinit(); + const c = switch (homeItem.item) { + .container => |c| c, + .element => unreachable, + }; + + var tab1 = try allocator.create(Tab); + defer allocator.destroy(tab1); + + tab1.init(&pool, allocator); + defer tab1.deinit(); + + tab1.setCurrentLocation(c); + + // We heap allocate our model because we will require a stable pointer to it in our Button + // widget + const model = try allocator.create(Model); + defer allocator.destroy(model); + + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const arena_allocator = arena.allocator(); + + std.Thread.sleep(1 * std.time.ns_per_s); + const children1 = if (tab1.currentItems) |items| blk: { + const children = try arena_allocator.alloc(vxfw.Widget, items.items.len); + for (0.., items.items[0..children.len]) |i, child| { + const text1 = try arena_allocator.dupe(u8, child.fullName.path); + + const text_c = try arena_allocator.create(vxfw.Text); + text_c.* = vxfw.Text{ .text = text1, .overflow = .clip, .softwrap = false, .style = .{ .bold = true } }; + children[i] = text_c.widget(); + } + break :blk children; + } else &.{}; + + const list: vxfw.ListView = .{ + .children = .{ .slice = children1 }, + }; + // Set the initial state of our button + model.* = .{ + .current_items = list, + .tab = tab1, + .count = 0, + .button = .{ + .label = "Click me!", + .onClick = Model.onClick, + .userdata = model, + }, + }; + + try app.run(model.widget(), .{}); + std.Thread.sleep(1 * std.time.ns_per_s); +} diff --git a/src/console_main.zig b/src/console_main.zig new file mode 100644 index 0000000..96f9ef6 --- /dev/null +++ b/src/console_main.zig @@ -0,0 +1,5 @@ +const run = @import("console/main.zig").main; + +pub fn main() !void { + try run(); +} diff --git a/src/core/deinit.zig b/src/core/deinit.zig deleted file mode 100644 index aa4b86c..0000000 --- a/src/core/deinit.zig +++ /dev/null @@ -1,42 +0,0 @@ -const std = @import("std"); -const Mutex = std.Thread.Mutex; - -const Error = error{ - Deinitialized, -}; - -pub const DeinitTracker = struct { - allocator: std.mem.Allocator, - deinited: bool, - usageCount: u16, - mutex: Mutex, - - pub fn init(self: *DeinitTracker, allocator: std.mem.Allocator) void { - self.* = .{ - .allocator = allocator, - .deinited = false, - .usageCount = 1, - .mutex = Mutex{}, - }; - } - - pub fn use(self: *DeinitTracker) Error!void { - self.mutex.lock(); - defer self.mutex.unlock(); - - if (self.deinited) return Error.Deinitialized; - - self.usageCount += 1; - } - - pub fn unuse(self: *DeinitTracker) void { - self.mutex.lock(); - defer self.mutex.unlock(); - - self.usageCount -= 1; - - if (self.usageCount == 0) { - self.allocator.destroy(self); - } - } -}; diff --git a/src/core/models.zig b/src/core/models.zig index 439e48a..d76c7c5 100644 --- a/src/core/models.zig +++ b/src/core/models.zig @@ -1,12 +1,10 @@ const std = @import("std"); const Provider = @import("provider/provider.zig").Provider; -const DeinitTracker = @import("deinit.zig").DeinitTracker; const consts = @import("consts.zig"); pub const Item = struct { allocator: std.mem.Allocator, provider: Provider, - deinitTracker: *DeinitTracker, name: []const u8, displayName: []const u8, fullName: FullName, @@ -20,8 +18,6 @@ pub const Item = struct { self.allocator.free(self.displayName); self.allocator.free(self.fullName.path); self.allocator.free(self.nativePath.path); - self.deinitTracker.deinited = true; - self.deinitTracker.unuse(); for (self.errors.items) |e| { self.allocator.free(e.content); } diff --git a/src/core/provider/local.zig b/src/core/provider/local.zig index 3e4706a..50f9923 100644 --- a/src/core/provider/local.zig +++ b/src/core/provider/local.zig @@ -3,7 +3,7 @@ const models = @import("../models.zig"); const Provider = @import("provider.zig").Provider; const ProviderVTable = @import("provider.zig").VTable; const GetItemsError = @import("provider.zig").GetItemsError; -const DeinitTracker = @import("../deinit.zig").DeinitTracker; +const InitContext = @import("provider.zig").InitContext; const FullName = models.FullName; const Item = models.Item; @@ -11,21 +11,14 @@ const ItemEnum = models.ItemEnum; const Element = models.Element; const Container = models.Container; -fn loadChildren(container: *Container, deinitTracker: *DeinitTracker) void { +// TODO: the container might be freed while this runs +// Tab should hold something at pass it here +fn loadChildren(container: *Container) void { defer { - if (!deinitTracker.deinited) - container.childrenLoading = false; + container.childrenLoading = false; } - deinitTracker.use() catch { - std.debug.print("already deinitialized", .{}); - return; - }; - defer deinitTracker.unuse(); - var dir = std.fs.cwd().openDir(container.item.nativePath.path, .{ .iterate = true }) catch { - if (deinitTracker.deinited) return; - const errorContent = std.fmt.allocPrint(container.item.allocator, "Could not open directory '{s}'.", .{container.item.nativePath.path}) catch return; container.item.errors.append(.{ .content = errorContent }) catch return; return; @@ -36,7 +29,6 @@ fn loadChildren(container: *Container, deinitTracker: *DeinitTracker) void { while (it.next() catch return) |entry| { const child = container.item.fullName.getChild(entry.name, container.item.allocator) catch return; - if (deinitTracker.deinited) return; container.children.append(child) catch return; } } @@ -48,11 +40,11 @@ const VTable: ProviderVTable = .{ pub fn getItemByFullNameGeneric( context: *anyopaque, fullName: FullName, + initContext: *const InitContext, allocator: std.mem.Allocator, - globalAllocator: std.mem.Allocator, -) GetItemsError!Item { +) GetItemsError!*Item { const self: *LocalContentProvider = @ptrCast(@alignCast(context)); - return self.getItemByFullName(fullName, allocator, globalAllocator); + return self.getItemByFullName(fullName, initContext, allocator); } pub const LocalContentProvider = struct { @@ -61,9 +53,9 @@ pub const LocalContentProvider = struct { pub fn getItemByFullName( self: *LocalContentProvider, fullName: FullName, + initContext: *const InitContext, allocator: std.mem.Allocator, - globalAllocator: std.mem.Allocator, - ) GetItemsError!Item { + ) GetItemsError!*Item { const stat = std.fs.cwd().statFile(fullName.path) catch return GetItemsError.NotExists; return switch (stat.kind) { @@ -84,12 +76,15 @@ pub const LocalContentProvider = struct { val, fullName, allocator, - globalAllocator, ); - try self.threadPool.spawn(loadChildren, .{ container, container.item.deinitTracker }); + if (!initContext.skipChildInit) { + try self.threadPool.spawn(loadChildren, .{container}); + } else { + container.childrenLoading = false; + } - break :blk container.item; + break :blk &container.item; }, .file => blk: { const element = try allocator.create(Element); @@ -107,10 +102,9 @@ pub const LocalContentProvider = struct { val, fullName, allocator, - globalAllocator, ); - break :blk element.item; + break :blk &element.item; }, else => @panic( "Unsupported file type\n", @@ -124,29 +118,17 @@ pub const LocalContentProvider = struct { innerItem: ItemEnum, fullName: FullName, allocator: std.mem.Allocator, - globalAllocator: std.mem.Allocator, ) !void { - var deinitTracker = try globalAllocator.create(DeinitTracker); - deinitTracker.init(globalAllocator); - const basename = std.fs.path.basename(fullName.path); - const name = try allocator.alloc(u8, basename.len); - @memcpy(name, basename); - - const displayName = try allocator.alloc(u8, basename.len); - @memcpy(displayName, basename); - - const fullName2 = try allocator.alloc(u8, fullName.path.len); - @memcpy(fullName2, fullName.path); - - const nativePath = try allocator.alloc(u8, fullName.path.len); - @memcpy(nativePath, fullName.path); + const name = try allocator.dupe(u8, basename); + const displayName = try allocator.dupe(u8, basename); + const fullName2 = try allocator.dupe(u8, fullName.path); + const nativePath = try allocator.dupe(u8, fullName.path); item.* = Item{ .allocator = allocator, .provider = contentProvider.provider(), - .deinitTracker = deinitTracker, .name = name, .displayName = displayName, .fullName = .{ .path = fullName2 }, diff --git a/src/core/provider/provider.zig b/src/core/provider/provider.zig index 6785aa4..c7d222a 100644 --- a/src/core/provider/provider.zig +++ b/src/core/provider/provider.zig @@ -2,9 +2,9 @@ pub const VTable = struct { getItemByFullName: *const fn ( self: *anyopaque, fullName: FullName, + initContext: *const InitContext, allocator: std.mem.Allocator, - globalAllocator: std.mem.Allocator, - ) GetItemsError!Item, + ) GetItemsError!*Item, }; pub const GetItemsError = error{ @@ -19,13 +19,17 @@ pub const Provider = struct { pub inline fn getItemByFullName( self: *const Provider, fullName: FullName, + initContext: *const InitContext, allocator: std.mem.Allocator, - globalAllocator: std.mem.Allocator, - ) GetItemsError!Item { - return &self.vtable.getItemByFullName(self.object, fullName, allocator, globalAllocator); + ) GetItemsError!*Item { + return self.vtable.getItemByFullName(self.object, fullName, initContext, allocator); } }; +pub const InitContext = struct { + skipChildInit: bool = false, +}; + const std = @import("std"); const models = @import("../models.zig"); const Item = models.Item; diff --git a/src/core/tab/tab.zig b/src/core/tab/tab.zig index 97d3617..8b09ec2 100644 --- a/src/core/tab/tab.zig +++ b/src/core/tab/tab.zig @@ -9,7 +9,8 @@ const Item = models.Item; pub const Tab = struct { allocator: std.mem.Allocator, currentLocation: ?*Container, - currentItems: ?std.ArrayList(Item), + currentItems: ?std.ArrayList(*Item), + currentItemsChanged: bool = false, threadPool: *std.Thread.Pool, _private: Private, @@ -41,17 +42,46 @@ pub const Tab = struct { self.currentLocation = newLocation; //TODO: Proper error handling - std.Thread.Pool.spawn(self.threadPool, loadItems, .{ self, newLocation }) catch unreachable; + std.Thread.Pool.spawn(self.threadPool, loadItemsWrapper, .{ self, newLocation }) catch unreachable; } - fn loadItems(self: *Tab, location: *Container) void { + fn loadItemsWrapper(self: *Tab, location: *Container) void { + loadItems(self, location) catch return; + } + fn loadItems(self: *Tab, location: *Container) !void { if (self._private.currentItemsAllocator) |arena| { arena.deinit(); } + if (self.currentItems) |items| { + items.deinit(); + } self._private.currentItemsAllocator = std.heap.ArenaAllocator.init(self.allocator); + const arenaAllocator = &self._private.currentItemsAllocator.?; + const arena = arenaAllocator.allocator(); + + var threadSafeAllocator = std.heap.ThreadSafeAllocator{.child_allocator = arena}; + const allocator = threadSafeAllocator.allocator(); + + errdefer { + arenaAllocator.deinit(); + self._private.currentItemsAllocator = null; + } + + self.currentItems = std.ArrayList(*Item).init(allocator); + errdefer { + self.currentItems.?.deinit(); + self.currentItems = null; + } + + while (location.childrenLoading) { + std.Thread.sleep(1 * std.time.ns_per_ms); + } + for (location.children.items) |item| { - _ = item; + const resolvedItem = try location.item.provider.getItemByFullName(item, &.{ .skipChildInit = false }, allocator); + try self.currentItems.?.append(resolvedItem); + self.currentItemsChanged = true; } } @@ -62,6 +92,5 @@ pub const Tab = struct { if (self._private.currentItemsAllocator) |arena| { arena.deinit(); } - self.allocator.destroy(self); } };