diff --git a/src/heavens-net/compo/search.zig b/src/heavens-net/compo/search.zig index 5ba1204..a11a87c 100644 --- a/src/heavens-net/compo/search.zig +++ b/src/heavens-net/compo/search.zig @@ -69,6 +69,11 @@ fn TaskCardCtx(T: type) type { ctx : T, task: Task, + pub fn open(self: *Self) void { + _ = self; + std.debug.print("OPEN\n", .{}); + } + pub fn id(self: *const Self) usize { return self.task.id; } diff --git a/src/heavens-net/compo/stage.zig b/src/heavens-net/compo/stage.zig index 5579911..dd670f2 100644 --- a/src/heavens-net/compo/stage.zig +++ b/src/heavens-net/compo/stage.zig @@ -1,10 +1,12 @@ const std = @import("std"); const dvui = @import("dvui"); +const compo = @import("./root.zig"); + pub const Task = struct { id: usize, - name: [:0]const u8, - mark: bool, + summary: [:0]const u8, + done: bool, }; pub fn gui(ctx: anytype) !void { @@ -24,39 +26,70 @@ pub fn gui(ctx: anytype) !void { }); defer reorder.deinit(); - var list = try dvui.box(@src(), .vertical, .{ .expand = .both, }); + var list = try dvui.box(@src(), .vertical, .{ + .expand = .both, + .padding = dvui.Rect.all(4), + }); defer list.deinit(); const tasks = ctx.get_tasks(); for (0.., tasks) |idx, task| { - var reorderable = try reorder.reorderable(@src(), .{}, .{ + if (idx > 0) { + _ = try dvui.spacer(@src(), .{ .w = 0, .h = 4}, .{ + .id_extra = idx, + .expand = .horizontal, + }); + } + + var reorderable = try reorder.reorderable(@src(), .{ + .draw_target = false, + }, .{ .id_extra = idx, .expand = .horizontal, }); defer reorderable.deinit(); - var hbox = try dvui.box(@src(), .horizontal, .{ - .expand = .both, - .background = true, - .border = dvui.Rect.all(1), - }); - defer hbox.deinit(); + var subctx = TaskCardCtx(@TypeOf(ctx)) { + .ctx = ctx, + .task = task, + .reorderable = reorderable, + }; + try compo.taskcard.gui(@src(), &subctx); + } +} - var mark: bool = task.mark; - if (try dvui.checkbox(@src(), &mark, null, .{})) { - ctx.mark(idx, mark); +fn TaskCardCtx(T: type) type { + return struct { + const Self = @This(); + + ctx : T, + task: Task, + reorderable: *dvui.Reorderable, + + pub fn open(self: *Self) void { + _ = self; + std.debug.print("OPEN\n", .{}); + } + pub fn startDragging(self: *Self, p: dvui.Point) void { + self.reorderable.reorder.dragStart(self.reorderable.data().id, p); } - try dvui.label(@src(), "#{d} {s}", .{ task.id, task.name, }, .{}); - - _ = try dvui.ReorderWidget.draggable(@src(), .{ .reorderable = reorderable, }, .{ - .expand = .vertical, - .gravity_x = 1.0, - .gravity_y = 0.5, - }); - } - - try dvui.labelNoFmt(@src(), "no tasks anymore :)", .{ .gravity_x = 0.5 }); + pub fn id(self: *const Self) usize { + return self.task.id; + } + pub fn summary(self: *const Self) []const u8 { + return self.task.summary; + } + pub fn done(self: *const Self) bool { + return self.task.done; + } + pub fn staged(_: *const Self) bool { + return true; + } + pub fn archived(_: *const Self) bool { + return false; + } + }; } pub const Mock = struct { @@ -69,8 +102,10 @@ pub const Mock = struct { var tasks = TaskList.init(alloc); errdefer tasks.deinit(); - try tasks.append(Task { .id = 0, .name = "helloworld", .mark = true, }); - try tasks.append(Task { .id = 1, .name = "goodbye", .mark = false, }); + for (0..100) |id| { + try tasks.append(Task { .id = id*2, .summary = "helloworld", .done = true, }); + try tasks.append(Task { .id = id*2+1, .summary = "goodbye", .done = false, }); + } return Mock { .tasks = tasks, @@ -81,7 +116,7 @@ pub const Mock = struct { } pub fn mark(self: *Self, idx: usize, check: bool) void { - self.tasks.items[idx].mark = check; + self.tasks.items[idx].done = check; } pub fn get_tasks(self: *const Self) []const Task { return self.tasks.items; diff --git a/src/heavens-net/compo/taskcard.zig b/src/heavens-net/compo/taskcard.zig index eb08a2a..ae8f177 100644 --- a/src/heavens-net/compo/taskcard.zig +++ b/src/heavens-net/compo/taskcard.zig @@ -4,14 +4,8 @@ const dvui = @import("dvui"); const ui = @import("../ui/root.zig"); pub fn gui(src: std.builtin.SourceLocation, ctx: anytype) !void { - const id = ctx.id(); - const summary = ctx.summary(); - const done = ctx.done(); - const archived = ctx.archived(); - const staged = ctx.staged(); - var card = try dvui.box(src, .horizontal, .{ - .id_extra = id, + .id_extra = ctx.id(), .expand = .horizontal, .background = true, .border = dvui.Rect.all(1), @@ -19,7 +13,7 @@ pub fn gui(src: std.builtin.SourceLocation, ctx: anytype) !void { defer card.deinit(); { - var check = done; + var check = ctx.done(); const changed = try dvui.checkbox(@src(), &check, null, .{ .gravity_y = 0.5, .gravity_x = 0, @@ -37,7 +31,7 @@ pub fn gui(src: std.builtin.SourceLocation, ctx: anytype) !void { }); defer icons.deinit(); - if (staged) { + if (ctx.staged()) { if (try buttonIcon(@src(), "unstage", dvui.entypo.light_down)) { std.debug.print("UNSTAGE\n", .{}); } @@ -46,7 +40,7 @@ pub fn gui(src: std.builtin.SourceLocation, ctx: anytype) !void { std.debug.print("STAGE\n", .{}); } } - if (archived) { + if (ctx.archived()) { if (try buttonIcon(@src(), "unarchive", dvui.entypo.back_in_time)) { std.debug.print("UNARCHIVE\n", .{}); } @@ -57,12 +51,12 @@ pub fn gui(src: std.builtin.SourceLocation, ctx: anytype) !void { } } - const open = try dvui.labelClick(@src(), "#{d} {s}", .{ ctx.id(), summary, }, .{ - .expand = .horizontal, - .gravity_x = 0, - }); - if (open) { - std.debug.print("OPEN\n", .{}); + const rc = try title(ctx, card.data().rectScale().r.topLeft()); + + // dragging link + if (!@hasDecl(@TypeOf(ctx.*), "startDragging")) { + // WIP + // dvui.pathStroke([_]dvui.Point{rc.topLeft(), cw.}); } } @@ -73,3 +67,119 @@ fn buttonIcon(src: std.builtin.SourceLocation, name: []const u8, icon: []const u .gravity_y = 0.5, }); } + +fn title(ctx: anytype, topLeft: dvui.Point) !dvui.Rect { + // this codes are based on dvui.labelClick + + var lw = dvui.LabelWidget.init(@src(), "#{d} {s}", .{ctx.id(), ctx.summary()}, .{ + .expand = .horizontal, + .gravity_x = 0, + .name = "LabelClick" + }); + // now lw has a Rect from its parent but hasn't processed events or drawn + + const lwid = lw.data().id; + + // if lw is visible, we want to be able to keyboard navigate to it + if (lw.data().visible()) { + try dvui.tabIndexSet(lwid, lw.data().options.tab_index); + } + + // draw border and background + try lw.install(); + + // loop over all events this frame in order of arrival + for (dvui.events()) |*e| { + + // skip if lw would not normally process this event + if (!lw.matchEvent(e)) + continue; + + switch (e.evt) { + .mouse => |me| { + if (me.action == .focus) { + e.handled = true; + + // focus this widget for events after this one (starting with e.num) + dvui.focusWidget(lwid, null, e.num); + } else if (me.action == .press and me.button.pointer()) { + e.handled = true; + dvui.captureMouse(lwid); + + // for touch events, we want to cancel our click if a drag is started + dvui.dragPreStart(me.p, .{ .name = "TASK", .offset = topLeft.diff(me.p), }); + } else if (me.action == .release and me.button.pointer()) { + // mouse button was released, do we still have mouse capture? + if (dvui.captured(lwid)) { + e.handled = true; + + // cancel our capture + dvui.captureMouse(null); + dvui.dragEnd(); + + // if the release was within our border, the click is successful + if (lw.data().borderRectScale().r.contains(me.p)) { + ctx.open(); + + // if the user interacts successfully with a + // widget, it usually means part of the GUI is + // changing, so the convention is to call refresh + // so the user doesn't have to remember + dvui.refresh(null, @src(), lwid); + } + } + } else if (me.action == .motion) { + if (me.button.touch()) { + if (dvui.captured(lwid)) { + if (dvui.dragging(me.p)) |_| { + // touch: if we overcame the drag threshold, then + // that means the person probably didn't want to + // touch this button, they were trying to scroll + dvui.captureMouse(null); + dvui.dragEnd(); + } + } + } else if (@hasDecl(@TypeOf(ctx.*), "startDragging")) { + // call ctx.startDragging if the method exists + if (dvui.captured(lwid)) { + e.handled = true; + if (dvui.dragging(me.p)) |_| { + ctx.startDragging(me.p); + } + } + } + } else if (me.action == .position) { + // a single .position mouse event is at the end of each + // frame, so this means the mouse ended above us + dvui.cursorSet(.hand); + } + }, + .key => |ke| { + if (ke.action == .down and ke.matchBind("activate")) { + e.handled = true; + ctx.open(); + dvui.refresh(null, @src(), lwid); + } + }, + else => {}, + } + + // if we didn't handle this event, send it to lw - this means we don't + // need to call lw.processEvents() + if (!e.handled) { + lw.processEvent(e, false); + } + } + + // draw text + try lw.draw(); + + // draw an accent border if we are focused + if (lwid == dvui.focusedWidgetId()) { + try lw.data().focusBorder(); + } + + // done with lw, have it report min size to parent + defer lw.deinit(); + return lw.data().scaleRect().r; +} diff --git a/src/hncore/Command.zig b/src/hncore/Command.zig new file mode 100644 index 0000000..746479e --- /dev/null +++ b/src/hncore/Command.zig @@ -0,0 +1,51 @@ +//! + +const std = @import("std"); + +/// +pub const Self = @This(); + +/// +pub const Error = error { + /// + AllocationFailure, + + /// + InvalidState, + + /// + Unknown, +}; + +/// +pub const VTable = struct { + /// + deinit: *const fn(ctx: *anyopaque) void, + + /// + apply: *const fn(ctx: *anyopaque) Error!void, + + /// + revert: *const fn(ctx: *anyopaque) Error!void, +}; + +/// +ctx: *anyopaque, + +/// +vtable: *const VTable, + +/// +pub fn deinit(self: Self) void { + self.vtable.deinit(self.ctx); +} + +/// +pub fn apply(self: Self) !void { + return self.vtable.apply(self.ctx); +} + +/// +pub fn revert(self: Self) !void { + return self.vtable.revert(self.ctx); +} diff --git a/src/hncore/CommandHistory.zig b/src/hncore/CommandHistory.zig new file mode 100644 index 0000000..eef780e --- /dev/null +++ b/src/hncore/CommandHistory.zig @@ -0,0 +1,149 @@ +const std = @import("std"); + +const Command = @import("./Command.zig"); + +/// +const Self = @This(); + +/// +const CommandList = std.ArrayList(Command); + +/// +commands: CommandList, + +/// next command to be applied by redoing +head: usize, + +/// +pub fn init(alloc: std.mem.Allocator) Self { + return Self { + .commands = CommandList.init(alloc), + .head = 0, + }; +} + +/// +pub fn deinit(self: *Self) void { + for (self.commands.items) |*command| { + command.deinit(); + } + self.commands.deinit(); +} + +/// +pub fn exec(self: *Self, command: Command) !void { + if (self.head >= self.commands.items.len) { + try self.commands.append(command); + } else { + try self.commands.resize(self.head + 1); + self.commands.items[self.head] = command; + } + errdefer _ = self.commands.pop(); + + self.head = self.commands.items.len; + try command.apply(); +} + +/// +pub fn undo(self: *Self) !void { + if (self.head == 0) { + return error.NoCommand; + } + self.head -= 1; + errdefer self.head += 1; + + try self.commands.items[self.head].revert(); +} + +/// +pub fn redo(self: *Self) !void { + if (self.head >= self.commands.items.len) { + return error.NoCommand; + } + try self.commands.items[self.head].apply(); + self.head += 1; +} + +/// +pub fn isUndoAvailable(self: *const Self) bool { + return self.head > 0; +} +/// +pub fn isRedoAvailable(self: *const Self) bool { + return self.head < self.commands.items.len; +} + +test { + const Mock = struct { + const vt = Command.VTable { + .deinit = deinit_, + .apply = apply_, + .revert = revert_, + }; + count: i32 = 0, + fn deinit_(_: *anyopaque) void { } + fn apply_(ctx: *anyopaque) Command.Error!void { + var self: *@This() = @ptrCast(@alignCast(ctx)); + self.count += 1; + } + fn revert_(ctx: *anyopaque) Command.Error!void { + var self: *@This() = @ptrCast(@alignCast(ctx)); + self.count -= 1; + } + }; + var mock1 = Mock {}; + var mock2 = Mock {}; + + var history = init(std.testing.allocator); + defer history.deinit(); + + try std.testing.expectEqual(false, history.isUndoAvailable()); + try std.testing.expectEqual(false, history.isRedoAvailable()); + try std.testing.expectError(error.NoCommand, history.undo()); + try std.testing.expectError(error.NoCommand, history.redo()); + + try history.exec(Command { .ctx = &mock1, .vtable = &Mock.vt, }); + try std.testing.expectEqual(history.isUndoAvailable(), true); + try std.testing.expectEqual(history.isRedoAvailable(), false); + try std.testing.expectEqual(mock1.count, 1); + + try history.undo(); + try std.testing.expectEqual(history.isUndoAvailable(), false); + try std.testing.expectEqual(history.isRedoAvailable(), true); + try std.testing.expectEqual(mock1.count, 0); + + try history.redo(); + try std.testing.expectEqual(history.isUndoAvailable(), true); + try std.testing.expectEqual(history.isRedoAvailable(), false); + try std.testing.expectEqual(mock1.count, 1); + + try history.exec(Command { .ctx = &mock2, .vtable = &Mock.vt, }); + try std.testing.expectEqual(history.isUndoAvailable(), true); + try std.testing.expectEqual(history.isRedoAvailable(), false); + try std.testing.expectEqual(mock1.count, 1); + try std.testing.expectEqual(mock2.count, 1); + + try history.undo(); + try std.testing.expectEqual(history.isUndoAvailable(), true); + try std.testing.expectEqual(history.isRedoAvailable(), true); + try std.testing.expectEqual(mock1.count, 1); + try std.testing.expectEqual(mock2.count, 0); + + try history.redo(); + try std.testing.expectEqual(history.isUndoAvailable(), true); + try std.testing.expectEqual(history.isRedoAvailable(), false); + try std.testing.expectEqual(mock1.count, 1); + try std.testing.expectEqual(mock2.count, 1); + + try history.undo(); + try std.testing.expectEqual(history.isUndoAvailable(), true); + try std.testing.expectEqual(history.isRedoAvailable(), true); + try std.testing.expectEqual(mock1.count, 1); + try std.testing.expectEqual(mock2.count, 0); + + try history.undo(); + try std.testing.expectEqual(history.isUndoAvailable(), false); + try std.testing.expectEqual(history.isRedoAvailable(), true); + try std.testing.expectEqual(mock1.count, 0); + try std.testing.expectEqual(mock2.count, 0); +} diff --git a/src/hncore/Task.zig b/src/hncore/Task.zig new file mode 100644 index 0000000..25787c2 --- /dev/null +++ b/src/hncore/Task.zig @@ -0,0 +1,71 @@ +const std = @import("std"); + +const Self = @This(); + +/// +alloc: std.mem.Allocator, + +/// +id: usize, + +/// +summary: [:0]const u8, + +/// +details: [:0]const u8, + +/// +done: bool, + +/// +archived: bool, + +/// +pub fn init(alloc: std.mem.Allocator, id: usize) !Self { + return Self { + .alloc = alloc, + .id = id, + .summary = try alloc.dupeZ(u8, ""), + .details = try alloc.dupeZ(u8, ""), + .done = true, + .archived = true, + }; +} +/// +pub fn deinit(self: *Self) void { + self.alloc.free(self.details); + self.alloc.free(self.summary); +} + +/// +pub fn setSummary(self: *Self, v: []const u8) ![:0]const u8 { + self.alloc.free(self.summary); + self.summary = try self.alloc.dupeZ(u8, v); + return self.summary; +} +/// +pub fn setDetails(self: *Self, v: []const u8) ![:0]const u8 { + self.alloc.free(self.details); + self.details = try self.alloc.dupeZ(u8, v); + return self.details; +} + +test { + var task = try init(std.testing.allocator, 0); + defer task.deinit(); + try std.testing.expectEqual(task.id, 0); + try std.testing.expectEqualStrings("", task.summary); + try std.testing.expectEqualStrings("", task.details); + + _ = try task.setSummary("helloworld"); + try std.testing.expectEqualStrings("helloworld", task.summary); + try std.testing.expectEqualStrings("", task.details); + + _ = try task.setDetails("good afternoon"); + try std.testing.expectEqualStrings("helloworld", task.summary); + try std.testing.expectEqualStrings("good afternoon", task.details); + + _ = try task.setSummary("goodbye"); + try std.testing.expectEqualStrings("goodbye", task.summary); + try std.testing.expectEqualStrings("good afternoon", task.details); +} diff --git a/src/hncore/TaskStore.zig b/src/hncore/TaskStore.zig new file mode 100644 index 0000000..bd4ab55 --- /dev/null +++ b/src/hncore/TaskStore.zig @@ -0,0 +1,59 @@ +const std = @import("std"); + +const Task = @import("./Task.zig"); + +const Self = @This(); +const Map = std.AutoHashMap(usize, *Task); + +alloc: std.mem.Allocator, +map: Map, +nextId: usize, + +/// +pub fn init(alloc: std.mem.Allocator) Self { + return Self { + .alloc = alloc, + .map = Map.init(alloc), + .nextId = 0, + }; +} +/// +pub fn deinit(self: *Self) void { + var itr = self.map.valueIterator(); + while (itr.next()) |task| { + task.*.deinit(); + self.alloc.destroy(task.*); + } + self.map.deinit(); +} +/// +pub fn add(self: *Self) !*Task { + var task = try self.alloc.create(Task); + errdefer self.alloc.destroy(task); + + task.* = try Task.init(self.alloc, self.nextId); + errdefer task.deinit(); + + try self.map.putNoClobber(task.id, task); + + self.nextId += 1; + return task; +} +/// +pub fn query(self: *const Self, id: usize) !*Task { + return self.map.get(id) orelse error.NotFound; +} + +test { + var store = init(std.testing.allocator); + defer store.deinit(); + + const task1 = try store.add(); + const task2 = try store.add(); + + try std.testing.expect(task1.id != task2.id); + + try std.testing.expectEqual(task1, store.query(task1.id)); + try std.testing.expectEqual(task2, store.query(task2.id)); + try std.testing.expectError(error.NotFound, store.query(1234)); +} diff --git a/src/hncore/Workspace.zig b/src/hncore/Workspace.zig new file mode 100644 index 0000000..25da730 --- /dev/null +++ b/src/hncore/Workspace.zig @@ -0,0 +1,23 @@ +const std = @import("std"); + +const CommandHistory = @import("./CommandHistory.zig"); +const TaskStore = @import("./TaskStore.zig"); + +const Self = @This(); + +tasks: TaskStore, +commands: CommandHistory, + +pub fn init(allocator: std.mem.Allocator) Self { + return Self { + .commands = CommandHistory.init(allocator), + .tasks = TaskStore.init(allocator), + }; +} +pub fn deinit(self: *Self) void { + self.tasks.deinit(); +} +test { + var ws = init(std.testing.allocator); + defer ws.deinit(); +} diff --git a/src/hncore/root.zig b/src/hncore/root.zig index 8eab4c3..1b62c22 100644 --- a/src/hncore/root.zig +++ b/src/hncore/root.zig @@ -1,3 +1,9 @@ +pub const Command = @import("./Command.zig"); +pub const CommandHistory = @import("./CommandHistory.zig"); +pub const Task = @import("./Task.zig"); +pub const TaskStore = @import("./TaskStore.zig"); +pub const Workspace = @import("./Workspace.zig"); + test { @import("std").testing.refAllDecls(@This()); }