From bee9513ba0343eb112a83cc312098f55c925df56 Mon Sep 17 00:00:00 2001 From: Roman Pytkov Date: Sun, 22 Feb 2026 22:01:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/WindowContext.zig | 103 +++++++++++--- src/main.zig | 310 ++++++++++++++++++++++++------------------ 2 files changed, 257 insertions(+), 156 deletions(-) diff --git a/src/WindowContext.zig b/src/WindowContext.zig index 52ac7dd..205286d 100644 --- a/src/WindowContext.zig +++ b/src/WindowContext.zig @@ -1,36 +1,99 @@ const std = @import("std"); const Canvas = @import("Canvas.zig"); const CpuRenderEngine = @import("render/CpuRenderEngine.zig"); +const RenderEngine = @import("render/RenderEngine.zig").RenderEngine; const Document = @import("models/Document.zig"); +const basic_models = @import("models/basic_models.zig"); const WindowContext = @This(); +/// Один открытый документ: документ + свой холст и движок рендера +pub const OpenDocument = struct { + document: Document, + cpu_render: CpuRenderEngine, + canvas: Canvas, + + /// Инициализировать по месту (canvas хранит указатель на cpu_render этого же экземпляра). + pub fn init(allocator: std.mem.Allocator, self: *OpenDocument) void { + const default_size = basic_models.Size{ .width = 800, .height = 600 }; + self.document = Document.init(allocator, default_size); + self.cpu_render = CpuRenderEngine.init(allocator, .Squares); + self.canvas = Canvas.init(allocator, (&self.cpu_render).renderEngine()); + } + + pub fn deinit(self: *OpenDocument) void { + self.document.deinit(); + self.canvas.deinit(); + } +}; + allocator: std.mem.Allocator, -canvas: Canvas, -cpu_render: *CpuRenderEngine, frame_index: u64, -/// Открытые документы в текущем окне -documents: std.ArrayList(Document), +/// Список открытых документов (вкладок): указатели на документ+холст +documents: std.ArrayList(*OpenDocument), +/// Индекс активной вкладки; null — ни один документ не выбран +active_document_index: ?usize, pub fn init(allocator: std.mem.Allocator) !WindowContext { - var self: WindowContext = undefined; - self.allocator = allocator; - - self.cpu_render = try allocator.create(CpuRenderEngine); - errdefer allocator.destroy(self.cpu_render); - self.cpu_render.* = CpuRenderEngine.init(allocator, .Squares); - - self.canvas = Canvas.init(allocator, self.cpu_render.renderEngine()); - - self.frame_index = 0; - self.documents = std.ArrayList(Document).empty; - - return self; + const frame_index: u64 = 0; + const documents = std.ArrayList(*OpenDocument).empty; + const active_document_index: ?usize = null; + return .{ + .allocator = allocator, + .frame_index = frame_index, + .documents = documents, + .active_document_index = active_document_index, + }; } pub fn deinit(self: *WindowContext) void { - for (self.documents.items) |*doc| doc.deinit(); + for (self.documents.items) |ptr| { + ptr.deinit(); + self.allocator.destroy(ptr); + } self.documents.deinit(self.allocator); - self.canvas.deinit(); - self.allocator.destroy(self.cpu_render); +} + +/// Вернуть указатель на активный открытый документ (null если нет выбранной вкладки). +pub fn activeDocument(self: *WindowContext) ?*OpenDocument { + const i = self.active_document_index orelse return null; + if (i >= self.documents.items.len) return null; + return self.documents.items[i]; +} + +/// Добавить новый документ и сделать его активным. +pub fn addNewDocument(self: *WindowContext) !void { + const ptr = try self.allocator.create(OpenDocument); + errdefer self.allocator.destroy(ptr); + OpenDocument.init(self.allocator, ptr); + 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; + } +} + +/// Закрыть вкладку по индексу; активная вкладка сдвигается при необходимости. +pub fn closeDocument(self: *WindowContext, index: usize) void { + if (index >= self.documents.items.len) return; + const open_doc = self.documents.items[index]; + open_doc.deinit(); + self.allocator.destroy(open_doc); + _ = self.documents.orderedRemove(index); + + if (self.active_document_index) |*active| { + if (index < active.*) { + active.* -= 1; + } else if (index == active.*) { + if (self.documents.items.len > 0) { + active.* = @min(index, self.documents.items.len - 1); + } else { + self.active_document_index = null; + } + } + } } diff --git a/src/main.zig b/src/main.zig index 1826d17..3689d64 100644 --- a/src/main.zig +++ b/src/main.zig @@ -3,7 +3,6 @@ const builtin = @import("builtin"); const dvui = @import("dvui"); const dvui_ext = @import("ui/dvui_ext.zig"); const SDLBackend = @import("sdl-backend"); -const Document = @import("models/Document.zig"); const ImageRect = @import("models/basic_models.zig").ImageRect; const WindowContext = @import("WindowContext.zig"); const sdl_c = SDLBackend.c; @@ -70,8 +69,8 @@ pub fn main() !void { } fn gui_frame(ctx: *WindowContext) bool { - const canvas = &ctx.canvas; const ctrl: bool = dvui.currentWindow().modifiers.control(); + const active_doc = ctx.activeDocument(); for (dvui.events()) |*e| { if (e.evt == .window and e.evt.window.action == .close) return false; @@ -80,172 +79,211 @@ fn gui_frame(ctx: *WindowContext) bool { const root = dvui.box( @src(), - .{ .dir = .horizontal }, + .{ .dir = .vertical }, .{ .expand = .both, .background = true, .style = .window }, ); defer root.deinit(); - // Левая панель с фиксированной шириной - var left_panel = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .vertical, .min_size_content = .{ .w = 200 }, .background = true }); + // Верхняя строка: таббар (вкладки документов + кнопка "Новый") + var tab_bar = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .min_size_content = .{ .h = 32 }, .background = true, .padding = dvui.Rect.all(4) }); { - dvui.label(@src(), "Tools", .{}, .{}); - if (dvui.button(@src(), "Fill Random Color", .{}, .{}) or ctx.frame_index == 0) { - canvas.exampleReset() catch |err| { - std.debug.print("Error reset example: {}\n", .{err}); - }; - canvas.pos = .{ .x = 400, .y = 400 }; - } - if (dvui.checkbox(@src(), &canvas.native_scaling, "Scaling", .{})) {} - if (dvui.button(@src(), if (ctx.cpu_render.type == .Gradient) "Gradient" else "Squares", .{}, .{})) { - if (ctx.cpu_render.type == .Gradient) { - ctx.cpu_render.type = .Squares; - } else { - ctx.cpu_render.type = .Gradient; + for (ctx.documents.items, 0..) |open_doc, i| { + _ = open_doc; + var buf: [32]u8 = undefined; + const label = std.fmt.bufPrint(&buf, "Doc {d}", .{i + 1}) catch "Doc"; + if (dvui.button(@src(), label, .{}, .{ .id_extra = i })) { + ctx.setActiveDocument(i); } - canvas.redrawExample() catch {}; + } + if (dvui.button(@src(), "+", .{}, .{})) { + ctx.addNewDocument() catch |err| { + std.debug.print("addNewDocument error: {}\n", .{err}); + }; } } - left_panel.deinit(); + tab_bar.deinit(); - // Правая панель - занимает оставшееся пространство - const back = dvui.box( - @src(), - .{ .dir = .horizontal }, - .{ .expand = .both, .padding = dvui.Rect.all(12), .background = true }, - ); + // Нижняя строка: левая панель + контент + var content_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); // no background to not override { - const fill_color = Color.black.opacity(0.25); - var right_panel = dvui.box( + // Левая панель с фиксированной шириной (инструменты для активного документа) + var left_panel = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .vertical, .min_size_content = .{ .w = 200 }, .background = true }); + { + dvui.label(@src(), "Tools", .{}, .{}); + if (active_doc) |doc| { + const canvas = &doc.canvas; + if (dvui.button(@src(), "Fill Random Color", .{}, .{}) or ctx.frame_index == 0) { + canvas.exampleReset() catch |err| { + std.debug.print("Error reset example: {}\n", .{err}); + }; + canvas.pos = .{ .x = 400, .y = 400 }; + } + if (dvui.checkbox(@src(), &canvas.native_scaling, "Scaling", .{})) {} + if (dvui.button(@src(), if (doc.cpu_render.type == .Gradient) "Gradient" else "Squares", .{}, .{})) { + if (doc.cpu_render.type == .Gradient) { + doc.cpu_render.type = .Squares; + } else { + doc.cpu_render.type = .Gradient; + } + canvas.redrawExample() catch {}; + } + } else { + dvui.label(@src(), "No document", .{}, .{}); + } + } + left_panel.deinit(); + + // Правая панель — контент выбранного документа или заглушка + const back = dvui.box( @src(), - .{ .dir = .vertical }, - .{ - .expand = .both, - .background = true, - .padding = dvui.Rect.all(5), - .corner_radius = dvui.Rect.all(24), - .color_fill = fill_color, - }, + .{ .dir = .horizontal }, + .{ .expand = .both, .padding = dvui.Rect.all(12), .background = true }, ); { - var textured = dvui_ext.texturedBox(right_panel.data().contentRectScale(), dvui.Rect.all(20)); + const fill_color = Color.black.opacity(0.25); + var right_panel = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ + .expand = .both, + .background = true, + .padding = dvui.Rect.all(5), + .corner_radius = dvui.Rect.all(24), + .color_fill = fill_color, + }, + ); { - var overlay = dvui.overlay( - @src(), - .{ .expand = .both }, - ); - { - var scroll = dvui.scrollArea( - @src(), - .{ - .scroll_info = &canvas.scroll, - .vertical_bar = .auto, - .horizontal_bar = .auto, - }, - .{ - .expand = .both, - .background = false, - }, - ); + if (active_doc) |doc| { + const canvas = &doc.canvas; + var textured = dvui_ext.texturedBox(right_panel.data().contentRectScale(), dvui.Rect.all(20)); { - const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); - const img_size = canvas.getScaledImageSize(); - - // Получить viewport и scroll offset - const viewport_rect = scroll.data().contentRect(); - const scroll_current = dvui.Point{ .x = canvas.scroll.viewport.x, .y = canvas.scroll.viewport.y }; - - // viewport_rect/scroll_current — в natural единицах. - // Для расчёта видимой области в пикселях изображения переводим в physical. - const viewport_px = dvui.Rect{ - .x = viewport_rect.x * natural_scale, - .y = viewport_rect.y * natural_scale, - .w = viewport_rect.w * natural_scale, - .h = viewport_rect.h * natural_scale, - }; - const scroll_px = dvui.Point{ - .x = scroll_current.x * natural_scale, - .y = scroll_current.y * natural_scale, - }; - - canvas.updateVisibleImageRect(viewport_px, scroll_px) catch |err| { - std.debug.print("updateVisibleImageRect error: {}\n", .{err}); - }; - - // `canvas.texture` contains ONLY the visible part. - // If we render it inside a widget sized as the full image, dvui will stretch it. - // Instead: create a scroll content surface sized like the full image, then place - // the visible texture at the correct offset at 1:1. - const content_w_px: u32 = img_size.x + img_size.w; - const content_h_px: u32 = img_size.y + img_size.h; - const content_w = @as(f32, @floatFromInt(content_w_px)) / natural_scale; - const content_h = @as(f32, @floatFromInt(content_h_px)) / natural_scale; - - var canvas_layer = dvui.overlay( + var overlay = dvui.overlay( @src(), - .{ .min_size_content = .{ .w = content_w, .h = content_h }, .background = false }, + .{ .expand = .both }, ); { - if (canvas.texture) |tex| { - const vis = canvas._visible_rect orelse ImageRect{ .x = 0, .y = 0, .w = 0, .h = 0 }; - const left = @as(f32, @floatFromInt(img_size.x + vis.x)) / natural_scale; - const top = @as(f32, @floatFromInt(img_size.y + vis.y)) / natural_scale; + var scroll = dvui.scrollArea( + @src(), + .{ + .scroll_info = &canvas.scroll, + .vertical_bar = .auto, + .horizontal_bar = .auto, + }, + .{ + .expand = .both, + .background = false, + }, + ); + { + const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); + const img_size = canvas.getScaledImageSize(); - _ = dvui.image( + const viewport_rect = scroll.data().contentRect(); + const scroll_current = dvui.Point{ .x = canvas.scroll.viewport.x, .y = canvas.scroll.viewport.y }; + + const viewport_px = dvui.Rect{ + .x = viewport_rect.x * natural_scale, + .y = viewport_rect.y * natural_scale, + .w = viewport_rect.w * natural_scale, + .h = viewport_rect.h * natural_scale, + }; + const scroll_px = dvui.Point{ + .x = scroll_current.x * natural_scale, + .y = scroll_current.y * natural_scale, + }; + + canvas.updateVisibleImageRect(viewport_px, scroll_px) catch |err| { + std.debug.print("updateVisibleImageRect error: {}\n", .{err}); + }; + + const content_w_px: u32 = img_size.x + img_size.w; + const content_h_px: u32 = img_size.y + img_size.h; + const content_w = @as(f32, @floatFromInt(content_w_px)) / natural_scale; + const content_h = @as(f32, @floatFromInt(content_h_px)) / natural_scale; + + var canvas_layer = dvui.overlay( @src(), - .{ .source = .{ .texture = tex } }, - .{ - .background = false, - .expand = .none, - .gravity_x = 0.0, - .gravity_y = 0.0, - .margin = .{ .x = left, .y = top, .w = canvas.pos.x, .h = canvas.pos.y }, - .min_size_content = .{ - .w = @as(f32, @floatFromInt(vis.w)) / natural_scale, - .h = @as(f32, @floatFromInt(vis.h)) / natural_scale, - }, - .max_size_content = .{ - .w = @as(f32, @floatFromInt(vis.w)) / natural_scale, - .h = @as(f32, @floatFromInt(vis.h)) / natural_scale, - }, - }, + .{ .min_size_content = .{ .w = content_w, .h = content_h }, .background = false }, ); - } - } - canvas_layer.deinit(); + { + if (canvas.texture) |tex| { + const vis = canvas._visible_rect orelse ImageRect{ .x = 0, .y = 0, .w = 0, .h = 0 }; + const left = @as(f32, @floatFromInt(img_size.x + vis.x)) / natural_scale; + const top = @as(f32, @floatFromInt(img_size.y + vis.y)) / natural_scale; - // Заблокировать события скролла, если нажат ctrl - if (ctrl) { - for (dvui.events()) |*e| { - switch (e.evt) { - .mouse => |mouse| { - const action = mouse.action; - if (dvui.eventMatchSimple(e, scroll.data()) and (action == .wheel_x or action == .wheel_y)) { - switch (action) { - .wheel_y => |y| { - canvas.addZoom(y / 1000); - canvas.redrawExample() catch {}; + _ = dvui.image( + @src(), + .{ .source = .{ .texture = tex } }, + .{ + .background = false, + .expand = .none, + .gravity_x = 0.0, + .gravity_y = 0.0, + .margin = .{ .x = left, .y = top, .w = canvas.pos.x, .h = canvas.pos.y }, + .min_size_content = .{ + .w = @as(f32, @floatFromInt(vis.w)) / natural_scale, + .h = @as(f32, @floatFromInt(vis.h)) / natural_scale, }, - else => {}, - } - e.handled = true; + .max_size_content = .{ + .w = @as(f32, @floatFromInt(vis.w)) / natural_scale, + .h = @as(f32, @floatFromInt(vis.h)) / natural_scale, + }, + }, + ); + } + } + canvas_layer.deinit(); + + if (ctrl) { + for (dvui.events()) |*e| { + switch (e.evt) { + .mouse => |mouse| { + const action = mouse.action; + if (dvui.eventMatchSimple(e, scroll.data()) and (action == .wheel_x or action == .wheel_y)) { + switch (action) { + .wheel_y => |y| { + canvas.addZoom(y / 1000); + canvas.redrawExample() catch {}; + }, + else => {}, + } + e.handled = true; + } + }, + else => {}, } - }, - else => {}, + } } } + scroll.deinit(); + + dvui.label(@src(), "Canvas", .{}, .{ .gravity_x = 0.5, .gravity_y = 0.0 }); + } + overlay.deinit(); + } + textured.deinit(); + } else { + var no_doc_center = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ .expand = .both, .padding = dvui.Rect.all(20) }, + ); + { + dvui.label(@src(), "No document open", .{}, .{}); + if (dvui.button(@src(), "New document", .{}, .{})) { + ctx.addNewDocument() catch |err| { + std.debug.print("addNewDocument error: {}\n", .{err}); + }; } } - scroll.deinit(); - - dvui.label(@src(), "Canvas", .{}, .{ .gravity_x = 0.5, .gravity_y = 0.0 }); + no_doc_center.deinit(); } - overlay.deinit(); } - textured.deinit(); + right_panel.deinit(); } - right_panel.deinit(); + back.deinit(); } - back.deinit(); + content_row.deinit(); ctx.frame_index += 1;