diff --git a/src/WindowContext.zig b/src/WindowContext.zig index 58695a4..b096d47 100644 --- a/src/WindowContext.zig +++ b/src/WindowContext.zig @@ -27,6 +27,17 @@ pub const OpenDocument = struct { self.selected_object_id = null; } + pub fn initWithDocument(allocator: std.mem.Allocator, self: *OpenDocument, doc: Document) void { + self.document = doc; + self.cpu_render = CpuRenderEngine.init(allocator, .Squares); + self.canvas = Canvas.init( + allocator, + &self.document, + (&self.cpu_render).renderEngine(), + ); + self.selected_object_id = null; + } + pub fn deinit(self: *OpenDocument) void { self.document.deinit(); self.canvas.deinit(); @@ -73,6 +84,16 @@ pub fn addNewDocument(self: *WindowContext) !void { self.active_document_index = self.documents.items.len - 1; } +pub fn addDocument(self: *WindowContext, doc: Document) !void { + const ptr = try self.allocator.create(OpenDocument); + errdefer self.allocator.destroy(ptr); + var doc_mut = doc; + errdefer doc_mut.deinit(); + OpenDocument.initWithDocument(self.allocator, ptr, doc_mut); + try self.documents.append(self.allocator, ptr); + self.active_document_index = self.documents.items.len - 1; +} + pub fn setActiveDocument(self: *WindowContext, index: usize) void { if (index < self.documents.items.len) { self.active_document_index = index; diff --git a/src/persistence/document_json.zig b/src/persistence/document_json.zig new file mode 100644 index 0000000..e837517 --- /dev/null +++ b/src/persistence/document_json.zig @@ -0,0 +1,365 @@ +const std = @import("std"); +const Document = @import("../models/Document.zig"); +const Object = Document.Object; +const Property = @import("../models/Property.zig").Property; +const PropertyData = @import("../models/Property.zig").Data; +const basic_models = @import("../models/basic_models.zig"); +const Point2_f = basic_models.Point2_f; +const Size_f = basic_models.Size_f; +const Scale2_f = basic_models.Scale2_f; +const Radii_f = basic_models.Radii_f; + +pub const json_version: u32 = 1; +const JsonError = std.json.Stringify.Error; + +pub fn saveToFile(doc: *const Document, path: []const u8) !void { + var file = try std.fs.cwd().createFile(path, .{ .truncate = true }); + defer file.close(); + + var buffer: [4096]u8 = undefined; + var writer = file.writer(&buffer); + var jw: std.json.Stringify = .{ + .writer = &writer.interface, + .options = .{ .whitespace = .indent_2 }, + }; + + try writeDocument(&jw, doc); + try writer.interface.flush(); +} + +pub fn loadFromFile(allocator: std.mem.Allocator, path: []const u8) !Document { + var file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + + const data = try file.readToEndAlloc(allocator, std.math.maxInt(usize)); + defer allocator.free(data); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, data, .{ .parse_numbers = true }); + defer parsed.deinit(); + + return try parseDocumentValue(allocator, parsed.value); +} + +fn writeDocument(jw: *std.json.Stringify, doc: *const Document) JsonError!void { + try jw.beginObject(); + try jw.objectField("version"); + try jw.write(json_version); + try jw.objectField("size"); + try writeSize(jw, doc.size); + try jw.objectField("next_object_id"); + try jw.write(doc.next_object_id); + try jw.objectField("objects"); + try writeObjectArray(jw, doc.objects.items); + try jw.endObject(); +} + +fn writeSize(jw: *std.json.Stringify, size: Size_f) JsonError!void { + try jw.beginObject(); + try jw.objectField("w"); + try jw.write(size.w); + try jw.objectField("h"); + try jw.write(size.h); + try jw.endObject(); +} + +fn writePoint(jw: *std.json.Stringify, pt: Point2_f) JsonError!void { + try jw.beginObject(); + try jw.objectField("x"); + try jw.write(pt.x); + try jw.objectField("y"); + try jw.write(pt.y); + try jw.endObject(); +} + +fn writeScale(jw: *std.json.Stringify, scale: Scale2_f) JsonError!void { + try jw.beginObject(); + try jw.objectField("scale_x"); + try jw.write(scale.scale_x); + try jw.objectField("scale_y"); + try jw.write(scale.scale_y); + try jw.endObject(); +} + +fn writeRadii(jw: *std.json.Stringify, radii: Radii_f) JsonError!void { + try jw.beginObject(); + try jw.objectField("x"); + try jw.write(radii.x); + try jw.objectField("y"); + try jw.write(radii.y); + try jw.endObject(); +} + +fn writeObjectArray(jw: *std.json.Stringify, objects: []const Object) JsonError!void { + try jw.beginArray(); + for (objects) |obj| { + try writeObject(jw, obj); + } + try jw.endArray(); +} + +fn writeObject(jw: *std.json.Stringify, obj: Object) JsonError!void { + try jw.beginObject(); + try jw.objectField("id"); + try jw.write(obj.id); + try jw.objectField("shape"); + try jw.write(@tagName(obj.shape)); + try jw.objectField("properties"); + try writeProperties(jw, obj.properties.items); + try jw.objectField("children"); + try writeObjectArray(jw, obj.children.items); + try jw.endObject(); +} + +fn writeProperties(jw: *std.json.Stringify, props: []const Property) JsonError!void { + try jw.beginArray(); + for (props) |prop| { + try writeProperty(jw, prop); + } + try jw.endArray(); +} + +fn writeProperty(jw: *std.json.Stringify, prop: Property) JsonError!void { + try jw.beginObject(); + try jw.objectField("tag"); + try jw.write(@tagName(prop.data)); + try jw.objectField("value"); + switch (prop.data) { + .position => |p| try writePoint(jw, p), + .angle => |v| try jw.write(v), + .scale => |s| try writeScale(jw, s), + .visible => |v| try jw.write(v), + .opacity => |v| try jw.write(v), + .locked => |v| try jw.write(v), + .size => |s| try writeSize(jw, s), + .radii => |r| try writeRadii(jw, r), + .end_point => |p| try writePoint(jw, p), + .points => |list| { + try jw.beginArray(); + for (list.items) |pt| { + try writePoint(jw, pt); + } + try jw.endArray(); + }, + .fill_rgba => |v| try jw.write(v), + .stroke_rgba => |v| try jw.write(v), + .thickness => |v| try jw.write(v), + } + try jw.endObject(); +} + +fn parseDocumentValue(allocator: std.mem.Allocator, value: std.json.Value) !Document { + const obj = try expectObject(value); + const version_val = try getField(obj, "version"); + const version = try valueToU32(version_val); + if (version != json_version) return error.UnsupportedVersion; + + const size_val = try getField(obj, "size"); + const size = try parseSize(size_val); + + var document = Document{ + .size = size, + .allocator = allocator, + .objects = std.ArrayList(Object).empty, + .next_object_id = 1, + }; + errdefer document.deinit(); + + const objects_val = try getField(obj, "objects"); + const objects_arr = try expectArray(objects_val); + for (objects_arr.items) |item| { + const obj_item = try parseObject(allocator, item); + try document.objects.append(allocator, obj_item); + } + + if (obj.get("next_object_id")) |next_val| { + const next_id = try valueToU64(next_val); + document.next_object_id = next_id; + } else { + document.next_object_id = computeNextObjectId(document.objects.items); + } + + return document; +} + +fn parseObject(allocator: std.mem.Allocator, value: std.json.Value) !Object { + const obj = try expectObject(value); + const id = try valueToU64(try getField(obj, "id")); + const shape_str = try valueToString(try getField(obj, "shape")); + const shape = std.meta.stringToEnum(Object.ShapeKind, shape_str) orelse return error.InvalidJson; + + var properties = std.ArrayList(Property).empty; + errdefer { + for (properties.items) |*p| p.deinit(allocator); + properties.deinit(allocator); + } + var children = std.ArrayList(Object).empty; + errdefer { + for (children.items) |*c| c.deinit(allocator); + children.deinit(allocator); + } + + const props_val = try getField(obj, "properties"); + const props_arr = try expectArray(props_val); + for (props_arr.items) |item| { + try properties.append(allocator, try parseProperty(allocator, item)); + } + + const children_val = try getField(obj, "children"); + const children_arr = try expectArray(children_val); + for (children_arr.items) |item| { + try children.append(allocator, try parseObject(allocator, item)); + } + + return .{ + .id = id, + .shape = shape, + .properties = properties, + .children = children, + }; +} + +fn parseProperty(allocator: std.mem.Allocator, value: std.json.Value) !Property { + const obj = try expectObject(value); + const tag = try valueToString(try getField(obj, "tag")); + const val = try getField(obj, "value"); + + const data: PropertyData = if (std.mem.eql(u8, tag, "position")) blk: { + break :blk .{ .position = try parsePoint(val) }; + } else if (std.mem.eql(u8, tag, "angle")) blk: { + break :blk .{ .angle = try valueToF32(val) }; + } else if (std.mem.eql(u8, tag, "scale")) blk: { + break :blk .{ .scale = try parseScale(val) }; + } else if (std.mem.eql(u8, tag, "visible")) blk: { + break :blk .{ .visible = try valueToBool(val) }; + } else if (std.mem.eql(u8, tag, "opacity")) blk: { + break :blk .{ .opacity = try valueToF32(val) }; + } else if (std.mem.eql(u8, tag, "locked")) blk: { + break :blk .{ .locked = try valueToBool(val) }; + } else if (std.mem.eql(u8, tag, "size")) blk: { + break :blk .{ .size = try parseSize(val) }; + } else if (std.mem.eql(u8, tag, "radii")) blk: { + break :blk .{ .radii = try parseRadii(val) }; + } else if (std.mem.eql(u8, tag, "end_point")) blk: { + break :blk .{ .end_point = try parsePoint(val) }; + } else if (std.mem.eql(u8, tag, "points")) blk: { + const arr = try expectArray(val); + var list = std.ArrayList(Point2_f).empty; + errdefer list.deinit(allocator); + for (arr.items) |item| { + try list.append(allocator, try parsePoint(item)); + } + break :blk .{ .points = list }; + } else if (std.mem.eql(u8, tag, "fill_rgba")) blk: { + break :blk .{ .fill_rgba = try valueToU32(val) }; + } else if (std.mem.eql(u8, tag, "stroke_rgba")) blk: { + break :blk .{ .stroke_rgba = try valueToU32(val) }; + } else if (std.mem.eql(u8, tag, "thickness")) blk: { + break :blk .{ .thickness = try valueToF32(val) }; + } else { + return error.InvalidJson; + }; + + return .{ .data = data }; +} + +fn parsePoint(value: std.json.Value) !Point2_f { + const obj = try expectObject(value); + const x = try valueToF32(try getField(obj, "x")); + const y = try valueToF32(try getField(obj, "y")); + return .{ .x = x, .y = y }; +} + +fn parseSize(value: std.json.Value) !Size_f { + const obj = try expectObject(value); + const w = try valueToF32(try getField(obj, "w")); + const h = try valueToF32(try getField(obj, "h")); + return .{ .w = w, .h = h }; +} + +fn parseScale(value: std.json.Value) !Scale2_f { + const obj = try expectObject(value); + const sx = try valueToF32(try getField(obj, "scale_x")); + const sy = try valueToF32(try getField(obj, "scale_y")); + return .{ .scale_x = sx, .scale_y = sy }; +} + +fn parseRadii(value: std.json.Value) !Radii_f { + const obj = try expectObject(value); + const x = try valueToF32(try getField(obj, "x")); + const y = try valueToF32(try getField(obj, "y")); + return .{ .x = x, .y = y }; +} + +fn expectObject(value: std.json.Value) !std.json.ObjectMap { + return switch (value) { + .object => |o| o, + else => error.InvalidJson, + }; +} + +fn expectArray(value: std.json.Value) !std.json.Array { + return switch (value) { + .array => |a| a, + else => error.InvalidJson, + }; +} + +fn getField(obj: std.json.ObjectMap, key: []const u8) !std.json.Value { + return obj.get(key) orelse error.InvalidJson; +} + +fn valueToString(value: std.json.Value) ![]const u8 { + return switch (value) { + .string => |s| s, + else => error.InvalidJson, + }; +} + +fn valueToBool(value: std.json.Value) !bool { + return switch (value) { + .bool => |b| b, + else => error.InvalidJson, + }; +} + +fn valueToF32(value: std.json.Value) !f32 { + return switch (value) { + .float => |f| @floatCast(f), + .integer => |i| @floatFromInt(i), + else => error.InvalidJson, + }; +} + +fn valueToU32(value: std.json.Value) !u32 { + const v = switch (value) { + .integer => |i| i, + else => return error.InvalidJson, + }; + if (v < 0 or v > std.math.maxInt(u32)) return error.InvalidJson; + return @intCast(v); +} + +fn valueToU64(value: std.json.Value) !u64 { + const v = switch (value) { + .integer => |i| i, + else => return error.InvalidJson, + }; + if (v < 0) return error.InvalidJson; + return @intCast(v); +} + +fn computeNextObjectId(objects: []const Object) u64 { + var max_id: u64 = 0; + for (objects) |obj| { + max_id = @max(max_id, maxIdInObject(obj)); + } + return max_id + 1; +} + +fn maxIdInObject(obj: Object) u64 { + var max_id = obj.id; + for (obj.children.items) |child| { + max_id = @max(max_id, maxIdInObject(child)); + } + return max_id; +} diff --git a/src/ui/frame.zig b/src/ui/frame.zig index b571dd3..009c559 100644 --- a/src/ui/frame.zig +++ b/src/ui/frame.zig @@ -1,5 +1,6 @@ const dvui = @import("dvui"); const WindowContext = @import("../WindowContext.zig"); +const menu_bar = @import("menu_bar.zig"); const tab_bar = @import("tab_bar.zig"); const left_panel = @import("left_panel.zig"); const right_panel = @import("right_panel.zig"); @@ -16,6 +17,7 @@ pub fn guiFrame(ctx: *WindowContext) bool { .{ .expand = .both, .background = true, .style = .window }, ); { + menu_bar.menuBar(ctx); tab_bar.tabBar(ctx); var content_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); diff --git a/src/ui/menu_bar.zig b/src/ui/menu_bar.zig new file mode 100644 index 0000000..c59764e --- /dev/null +++ b/src/ui/menu_bar.zig @@ -0,0 +1,89 @@ +const std = @import("std"); +const dvui = @import("dvui"); +const WindowContext = @import("../WindowContext.zig"); +const document_json = @import("../persistence/document_json.zig"); + +pub fn menuBar(ctx: *WindowContext) void { + var m = dvui.menu(@src(), .horizontal, .{ .background = true, .expand = .horizontal }); + defer m.deinit(); + + if (dvui.menuItemLabel(@src(), "File", .{ .submenu = true }, .{ .expand = .none })) |r| { + var fm = dvui.floatingMenu(@src(), .{ .from = r }, .{}); + defer fm.deinit(); + + if (dvui.menuItemLabel(@src(), "Open", .{}, .{ .expand = .horizontal }) != null) { + m.close(); + openDocumentDialog(ctx); + } + + if (dvui.menuItemLabel(@src(), "Save As", .{}, .{ .expand = .horizontal }) != null) { + m.close(); + saveAsDialog(ctx); + } + } +} + +fn openDocumentDialog(ctx: *WindowContext) void { + const path_z = dvui.dialogNativeFileOpen(ctx.allocator, .{ + .title = "Open", + .filters = &.{ "*.json" }, + .filter_description = "JSON files", + }) catch |err| { + std.debug.print("Open dialog error: {}\n", .{err}); + return; + } orelse return; + defer ctx.allocator.free(path_z); + + const path = path_z[0..path_z.len]; + const doc = document_json.loadFromFile(ctx.allocator, path) catch |err| { + std.debug.print("Open file error: {}\n", .{err}); + return; + }; + ctx.addDocument(doc) catch |err| { + std.debug.print("Add document error: {}\n", .{err}); + return; + }; +} + +fn saveAsDialog(ctx: *WindowContext) void { + const open_doc = ctx.activeDocument() orelse return; + const path_z = dvui.dialogNativeFileSave(ctx.allocator, .{ + .title = "Save As", + .filters = &.{ "*.json" }, + .filter_description = "JSON files", + }) catch |err| { + std.debug.print("Save dialog error: {}\n", .{err}); + return; + } orelse return; + defer ctx.allocator.free(path_z); + + const path_raw = path_z[0..path_z.len]; + const resolved = ensureJsonPath(ctx.allocator, path_raw) catch |err| { + std.debug.print("Save path error: {}\n", .{err}); + return; + }; + defer if (resolved.owned) ctx.allocator.free(resolved.path); + + document_json.saveToFile(&open_doc.document, resolved.path) catch |err| { + std.debug.print("Save file error: {}\n", .{err}); + return; + }; +} + +const ResolvedPath = struct { + path: []const u8, + owned: bool, +}; + +fn ensureJsonPath(allocator: std.mem.Allocator, path: []const u8) !ResolvedPath { + if (endsWithIgnoreCase(path, ".json")) { + return .{ .path = path, .owned = false }; + } + const joined = try std.mem.concat(allocator, u8, &.{ path, ".json" }); + return .{ .path = joined, .owned = true }; +} + +fn endsWithIgnoreCase(haystack: []const u8, needle: []const u8) bool { + if (haystack.len < needle.len) return false; + return std.ascii.eqlIgnoreCase(haystack[haystack.len - needle.len ..], needle); +}