diff --git a/src/Canvas.zig b/src/Canvas.zig index 62498a3..f070528 100644 --- a/src/Canvas.zig +++ b/src/Canvas.zig @@ -117,7 +117,7 @@ pub fn getScaledImageSize(self: Canvas) ImageRect { /// `viewport` и `scroll_offset` ожидаются в *physical* пикселях (т.е. уже умноженные на windowNaturalScale). /// /// После обновления (или если текстуры ещё нет) перерисовывает текстуру, чтобы она содержала только видимую часть. -pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) !void { +pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) !bool { const next = computeVisibleImageRect(self.*, viewport, scroll_offset); var changed = false; if (self._visible_rect) |vis| { @@ -128,8 +128,9 @@ pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset: std.debug.print("Visible Image Rect: {{ x: {}, y: {}, w: {}, h: {} }}\n", .{ next.x, next.y, next.w, next.h }); } if (changed or self.texture == null) { - requestRedraw(self); + return true; } + return false; } fn computeVisibleImageRect(self: Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) ImageRect { diff --git a/src/main.zig b/src/main.zig index eb264a0..0a46dd0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,13 +1,8 @@ const std = @import("std"); -const builtin = @import("builtin"); const dvui = @import("dvui"); -const dvui_ext = @import("ui/dvui_ext.zig"); const SDLBackend = @import("sdl-backend"); -const ImageRect = @import("models/basic_models.zig").ImageRect; const WindowContext = @import("WindowContext.zig"); -const sdl_c = SDLBackend.c; -const Allocator = std.mem.Allocator; -const Color = dvui.Color; +const ui = @import("ui/frame.zig"); pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; @@ -34,255 +29,21 @@ pub fn main() !void { var interrupted = false; main_loop: while (true) { - // beginWait coordinates with waitTime below to run frames only when needed const nstime = win.beginWait(interrupted); - - // marks the beginning of a frame for dvui, can call dvui functions after this try win.begin(nstime); - - // send all SDL events to dvui for processing try backend.addAllEvents(&win); - // if dvui widgets might not cover the whole window, then need to clear - // the previous frame's render _ = SDLBackend.c.SDL_SetRenderDrawColor(backend.renderer, 0, 0, 0, 255); _ = SDLBackend.c.SDL_RenderClear(backend.renderer); - const keep_running = gui_frame(&ctx); - if (!keep_running) break :main_loop; + if (!ui.guiFrame(&ctx)) break :main_loop; - // marks end of dvui frame, don't call dvui functions after this - // - sends all dvui stuff to backend for rendering, must be called before renderPresent() const end_micros = try win.end(.{}); - - // cursor management try backend.setCursor(win.cursorRequested()); try backend.textInputRect(win.textInputRequested()); - - // render frame to OS try backend.renderPresent(); - // waitTime and beginWait combine to achieve variable framerates const wait_event_micros = win.waitTime(end_micros); interrupted = try backend.waitEventTimeout(wait_event_micros); } } - -fn gui_frame(ctx: *WindowContext) bool { - 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; - if (e.evt == .app and e.evt.app.action == .quit) return false; - } - - const root = dvui.box( - @src(), - .{ .dir = .vertical }, - .{ .expand = .both, .background = true, .style = .window }, - ); - defer root.deinit(); - - // Верхняя строка: таббар (вкладки документов + кнопка "Новый") - var tab_bar = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .min_size_content = .{ .h = 32 }, .background = true, .padding = dvui.Rect.all(4) }); - { - 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); - } - } - if (dvui.button(@src(), "+", .{}, .{})) { - ctx.addNewDocument() catch |err| { - std.debug.print("addNewDocument error: {}\n", .{err}); - }; - } - } - tab_bar.deinit(); - - // Нижняя строка: левая панель + контент - var content_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); // no background to not override - { - // Левая панель с фиксированной шириной (инструменты для активного документа) - 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.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 = .horizontal }, - .{ .expand = .both, .padding = dvui.Rect.all(12), .background = true }, - ); - { - 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, - }, - ); - { - if (active_doc) |doc| { - const canvas = &doc.canvas; - var textured = dvui_ext.texturedBox(right_panel.data().contentRectScale(), dvui.Rect.all(20)); - { - 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, - }, - ); - { - const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); - const img_size = canvas.getScaledImageSize(); - - 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}); - }; - canvas.processPendingRedraw() catch |err| { - std.debug.print("processPendingRedraw 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(), - .{ .min_size_content = .{ .w = content_w, .h = content_h }, .background = false }, - ); - { - 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; - - _ = 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, - }, - .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.requestRedraw(); - }, - else => {}, - } - e.handled = true; - } - }, - 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}); - }; - } - } - no_doc_center.deinit(); - } - } - right_panel.deinit(); - } - back.deinit(); - } - content_row.deinit(); - - ctx.frame_index += 1; - - return true; -} diff --git a/src/ui/canvas_view.zig b/src/ui/canvas_view.zig new file mode 100644 index 0000000..6b5d983 --- /dev/null +++ b/src/ui/canvas_view.zig @@ -0,0 +1,122 @@ +// Виджет холста: скролл, текстура, зум по Ctrl+колёсико. +const std = @import("std"); +const dvui = @import("dvui"); +const dvui_ext = @import("dvui_ext.zig"); +const Canvas = @import("../Canvas.zig"); +const ImageRect = @import("../models/basic_models.zig").ImageRect; + +pub fn canvasView(canvas: *Canvas, content_rect_scale: dvui.RectScale) void { + var textured = dvui_ext.texturedBox(content_rect_scale, dvui.Rect.all(20)); + { + 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 }, + ); + { + drawCanvasContent(canvas, scroll); + handleCanvasZoom(canvas, scroll); + } + scroll.deinit(); + + dvui.label(@src(), "Canvas", .{}, .{ .gravity_x = 0.5, .gravity_y = 0.0 }); + } + overlay.deinit(); + } + textured.deinit(); +} + +fn drawCanvasContent(canvas: *Canvas, scroll: anytype) void { + const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); + const img_size = canvas.getScaledImageSize(); + 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, + }; + + const changed = canvas.updateVisibleImageRect(viewport_px, scroll_px) catch |err| { + std.debug.print("updateVisibleImageRect error: {}\n", .{err}); + return false; + }; + if (changed) canvas.requestRedraw(); + canvas.processPendingRedraw() catch |err| { + std.debug.print("processPendingRedraw 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(), + .{ .min_size_content = .{ .w = content_w, .h = content_h }, .background = false }, + ); + { + 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; + + _ = 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, + }, + .max_size_content = .{ + .w = @as(f32, @floatFromInt(vis.w)) / natural_scale, + .h = @as(f32, @floatFromInt(vis.h)) / natural_scale, + }, + }, + ); + } + } + canvas_layer.deinit(); +} + +fn handleCanvasZoom(canvas: *Canvas, scroll: anytype) void { + const ctrl = dvui.currentWindow().modifiers.control(); + if (!ctrl) return; + + 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.requestRedraw(); + }, + else => {}, + } + e.handled = true; + } + }, + else => {}, + } + } +} diff --git a/src/ui/frame.zig b/src/ui/frame.zig new file mode 100644 index 0000000..b19fab3 --- /dev/null +++ b/src/ui/frame.zig @@ -0,0 +1,43 @@ +// Корневой кадр UI: разметка и сборка панелей. +const dvui = @import("dvui"); +const WindowContext = @import("../WindowContext.zig"); +const tab_bar = @import("tab_bar.zig"); +const left_panel = @import("left_panel.zig"); +const right_panel = @import("right_panel.zig"); + +/// Отрисовать один кадр GUI. Возвращает false при закрытии окна/выходе. +pub fn guiFrame(ctx: *WindowContext) bool { + for (dvui.events()) |*e| { + if (e.evt == .window and e.evt.window.action == .close) return false; + if (e.evt == .app and e.evt.app.action == .quit) return false; + } + + var root = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ .expand = .both, .background = true, .style = .window }, + ); + { + tab_bar.tabBar(ctx); + + var content_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); + { + left_panel.leftPanel(ctx); + + var back = dvui.box( + @src(), + .{ .dir = .horizontal }, + .{ .expand = .both, .padding = dvui.Rect.all(12), .background = true }, + ); + { + right_panel.rightPanel(ctx); + } + back.deinit(); + } + content_row.deinit(); + } + root.deinit(); + + ctx.frame_index += 1; + return true; +} diff --git a/src/ui/left_panel.zig b/src/ui/left_panel.zig new file mode 100644 index 0000000..6fd8a7d --- /dev/null +++ b/src/ui/left_panel.zig @@ -0,0 +1,31 @@ +// Левая панель: инструменты для активного документа (scaling, тип рендера). +const dvui = @import("dvui"); +const WindowContext = @import("../WindowContext.zig"); + +pub fn leftPanel(ctx: *WindowContext) void { + var panel = dvui.box( + @src(), + .{ .dir = .vertical }, + .{ .expand = .vertical, .min_size_content = .{ .w = 200 }, .background = true }, + ); + { + dvui.label(@src(), "Tools", .{}, .{}); + + const active_doc = ctx.activeDocument(); + if (active_doc) |doc| { + const canvas = &doc.canvas; + 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", .{}, .{}); + } + } + panel.deinit(); +} diff --git a/src/ui/right_panel.zig b/src/ui/right_panel.zig new file mode 100644 index 0000000..1619e3d --- /dev/null +++ b/src/ui/right_panel.zig @@ -0,0 +1,47 @@ +// Правая панель: контент документа (холст) или заглушка «Нет документа». +const std = @import("std"); +const dvui = @import("dvui"); +const WindowContext = @import("../WindowContext.zig"); +const canvas_view = @import("canvas_view.zig"); + +pub fn rightPanel(ctx: *WindowContext) void { + const fill_color = dvui.Color.black.opacity(0.25); + var 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, + }, + ); + { + const active_doc = ctx.activeDocument(); + if (active_doc) |doc| { + const content_rect_scale = panel.data().contentRectScale(); + canvas_view.canvasView(&doc.canvas, content_rect_scale); + } else { + noDocView(ctx); + } + } + panel.deinit(); +} + +fn noDocView(ctx: *WindowContext) void { + var 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}); + }; + } + } + center.deinit(); +} diff --git a/src/ui/tab_bar.zig b/src/ui/tab_bar.zig new file mode 100644 index 0000000..c5521d2 --- /dev/null +++ b/src/ui/tab_bar.zig @@ -0,0 +1,27 @@ +// Верхняя строка: вкладки документов + кнопка «Новый». +const std = @import("std"); +const dvui = @import("dvui"); +const WindowContext = @import("../WindowContext.zig"); + +pub fn tabBar(ctx: *WindowContext) void { + var bar = dvui.box( + @src(), + .{ .dir = .horizontal }, + .{ .expand = .horizontal, .min_size_content = .{ .h = 32 }, .background = true, .padding = dvui.Rect.all(4) }, + ); + { + for (ctx.documents.items, 0..) |_, i| { + 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); + } + } + if (dvui.button(@src(), "+", .{}, .{})) { + ctx.addNewDocument() catch |err| { + std.debug.print("addNewDocument error: {}\n", .{err}); + }; + } + } + bar.deinit(); +}