feat(console+core): tui, core

This commit is contained in:
2025-04-24 14:49:18 +02:00
parent 8a39c36aa8
commit 693167bd1d
9 changed files with 305 additions and 188 deletions

View File

@@ -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);
}

View File

@@ -36,45 +36,11 @@
// Once all dependencies are fetched, `zig build` no longer requires
// internet connectivity.
.dependencies = .{
// See `zig fetch --save <url>` 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",

209
src/console/main.zig Normal file
View File

@@ -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);
}

5
src/console_main.zig Normal file
View File

@@ -0,0 +1,5 @@
const run = @import("console/main.zig").main;
pub fn main() !void {
try run();
}

View File

@@ -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);
}
}
};

View File

@@ -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);
}

View File

@@ -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 },

View File

@@ -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;

View File

@@ -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);
}
};