Compare commits

...

5 Commits

16 changed files with 438 additions and 244 deletions

View File

@@ -4,6 +4,7 @@ const dvui = @import("dvui");
const Document = @import("models/Document.zig"); const Document = @import("models/Document.zig");
const RenderEngine = @import("render/RenderEngine.zig").RenderEngine; const RenderEngine = @import("render/RenderEngine.zig").RenderEngine;
const ImageRect = @import("models/basic_models.zig").ImageRect; const ImageRect = @import("models/basic_models.zig").ImageRect;
const Point2 = @import("models/basic_models.zig").Point2;
const Color = dvui.Color; const Color = dvui.Color;
const Canvas = @This(); const Canvas = @This();
@@ -18,12 +19,12 @@ scroll: dvui.ScrollInfo = .{
.horizontal = .auto, .horizontal = .auto,
}, },
native_scaling: bool = true, native_scaling: bool = true,
/// Максимальная частота перерисовки при зуме. 0 = без ограничения.
redraw_throttle_ms: u32 = 50, redraw_throttle_ms: u32 = 50,
_visible_rect: ?ImageRect = null, _visible_rect: ?ImageRect = null,
_zoom: f32 = 1, _zoom: f32 = 1,
_redraw_pending: bool = false, _redraw_pending: bool = false,
_last_redraw_time_ms: i64 = 0, _last_redraw_time_ms: i64 = 0,
cursor_document_point: ?Point2 = null,
pub fn init(allocator: std.mem.Allocator, document: *Document, engine: RenderEngine) Canvas { pub fn init(allocator: std.mem.Allocator, document: *Document, engine: RenderEngine) Canvas {
return .{ return .{
@@ -40,9 +41,8 @@ pub fn deinit(self: *Canvas) void {
} }
} }
/// Заполнить canvas градиентом
pub fn redrawExample(self: *Canvas) !void { pub fn redrawExample(self: *Canvas) !void {
const full = self.getScaledImageSize(); const full = self.getZoomedImageSize();
const vis: ImageRect = self._visible_rect orelse ImageRect{ .x = 0, .y = 0, .w = 0, .h = 0 }; const vis: ImageRect = self._visible_rect orelse ImageRect{ .x = 0, .y = 0, .w = 0, .h = 0 };
@@ -57,7 +57,6 @@ pub fn redrawExample(self: *Canvas) !void {
const new_texture = self.render_engine.example(.{ .w = full.w, .h = full.h }, vis) catch null; const new_texture = self.render_engine.example(.{ .w = full.w, .h = full.h }, vis) catch null;
if (new_texture) |tex| { if (new_texture) |tex| {
// Удалить старую текстуру
if (self.texture) |old_tex| { if (self.texture) |old_tex| {
dvui.Texture.destroyLater(old_tex); dvui.Texture.destroyLater(old_tex);
} }
@@ -67,7 +66,6 @@ pub fn redrawExample(self: *Canvas) !void {
self._last_redraw_time_ms = std.time.milliTimestamp(); self._last_redraw_time_ms = std.time.milliTimestamp();
} }
// Ресетнуть example изображение в renderEngine
pub fn exampleReset(self: *Canvas) !void { pub fn exampleReset(self: *Canvas) !void {
self.render_engine.exampleReset(); self.render_engine.exampleReset();
try self.redrawExample(); try self.redrawExample();
@@ -82,12 +80,10 @@ pub fn addZoom(self: *Canvas, value: f32) void {
self._zoom = @max(self._zoom, 0.01); self._zoom = @max(self._zoom, 0.01);
} }
/// Запросить перерисовку (выполнится с учётом redraw_throttle_ms при вызове processPendingRedraw).
pub fn requestRedraw(self: *Canvas) void { pub fn requestRedraw(self: *Canvas) void {
self._redraw_pending = true; self._redraw_pending = true;
} }
/// Выполнить отложенную перерисовку, если прошло не менее redraw_throttle_ms с прошлой отрисовки.
pub fn processPendingRedraw(self: *Canvas) !void { pub fn processPendingRedraw(self: *Canvas) !void {
if (!self._redraw_pending) return; if (!self._redraw_pending) return;
if (self.redraw_throttle_ms == 0) { if (self.redraw_throttle_ms == 0) {
@@ -102,7 +98,7 @@ pub fn processPendingRedraw(self: *Canvas) !void {
try self.redrawExample(); try self.redrawExample();
} }
pub fn getScaledImageSize(self: Canvas) ImageRect { pub fn getZoomedImageSize(self: Canvas) ImageRect {
const doc = self.document; const doc = self.document;
return .{ return .{
.x = @intFromFloat(self.pos.x), .x = @intFromFloat(self.pos.x),
@@ -112,11 +108,25 @@ pub fn getScaledImageSize(self: Canvas) ImageRect {
}; };
} }
/// Обновить видимую часть изображения (в пикселях холста) и сохранить в `visible_rect`. pub fn contentPointToDocument(self: Canvas, content_point: dvui.Point, natural_scale: f32) ?Point2 {
/// const img = self.getZoomedImageSize();
/// `viewport` и `scroll_offset` ожидаются в *physical* пикселях (т.е. уже умноженные на windowNaturalScale). const left_n = @as(f32, @floatFromInt(img.x)) / natural_scale;
/// const top_n = @as(f32, @floatFromInt(img.y)) / natural_scale;
/// После обновления (или если текстуры ещё нет) перерисовывает текстуру, чтобы она содержала только видимую часть. const right_n = @as(f32, @floatFromInt(img.x + img.w)) / natural_scale;
const bottom_n = @as(f32, @floatFromInt(img.y + img.h)) / natural_scale;
if (content_point.x < left_n or content_point.x >= right_n or
content_point.y < top_n or content_point.y >= bottom_n)
return null;
const px_x = content_point.x * natural_scale - @as(f32, @floatFromInt(img.x));
const px_y = content_point.y * natural_scale - @as(f32, @floatFromInt(img.y));
return Point2{
.x = px_x / self._zoom,
.y = px_y / self._zoom,
};
}
pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) bool { pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) bool {
const next = computeVisibleImageRect(self.*, viewport, scroll_offset); const next = computeVisibleImageRect(self.*, viewport, scroll_offset);
var changed = false; var changed = false;
@@ -134,16 +144,14 @@ pub fn updateVisibleImageRect(self: *Canvas, viewport: dvui.Rect, scroll_offset:
} }
fn computeVisibleImageRect(self: Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) ImageRect { fn computeVisibleImageRect(self: Canvas, viewport: dvui.Rect, scroll_offset: dvui.Point) ImageRect {
const image_rect = self.getScaledImageSize(); const image_rect = self.getZoomedImageSize();
const img_w: u32 = image_rect.w; const img_w: u32 = image_rect.w;
const img_h: u32 = image_rect.h; const img_h: u32 = image_rect.h;
// Видимый размер всегда равен размеру viewport, но не больше холста
const vis_w: u32 = @min(@as(u32, @intFromFloat(viewport.w)), img_w); const vis_w: u32 = @min(@as(u32, @intFromFloat(viewport.w)), img_w);
const vis_h: u32 = @min(@as(u32, @intFromFloat(viewport.h)), img_h); const vis_h: u32 = @min(@as(u32, @intFromFloat(viewport.h)), img_h);
// Вычисляем x и y на основе scroll_offset, clamped чтобы не выходить за границы
const raw_x: i64 = @intFromFloat(scroll_offset.x - @as(f32, @floatFromInt(image_rect.x))); const raw_x: i64 = @intFromFloat(scroll_offset.x - @as(f32, @floatFromInt(image_rect.x)));
const raw_y: i64 = @intFromFloat(scroll_offset.y - @as(f32, @floatFromInt(image_rect.y))); const raw_y: i64 = @intFromFloat(scroll_offset.y - @as(f32, @floatFromInt(image_rect.y)));

View File

@@ -7,13 +7,11 @@ const basic_models = @import("models/basic_models.zig");
const WindowContext = @This(); const WindowContext = @This();
/// Один открытый документ: документ + свой холст и движок рендера
pub const OpenDocument = struct { pub const OpenDocument = struct {
document: Document, document: Document,
cpu_render: CpuRenderEngine, cpu_render: CpuRenderEngine,
canvas: Canvas, canvas: Canvas,
/// Инициализировать по месту (canvas хранит указатель на cpu_render этого же экземпляра).
pub fn init(allocator: std.mem.Allocator, self: *OpenDocument) void { pub fn init(allocator: std.mem.Allocator, self: *OpenDocument) void {
const default_size = basic_models.Size{ .width = 800, .height = 600 }; const default_size = basic_models.Size{ .width = 800, .height = 600 };
self.document = Document.init(allocator, default_size); self.document = Document.init(allocator, default_size);
@@ -29,9 +27,7 @@ pub const OpenDocument = struct {
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
frame_index: u64, frame_index: u64,
/// Список открытых документов (вкладок): указатели на документ+холст
documents: std.ArrayList(*OpenDocument), documents: std.ArrayList(*OpenDocument),
/// Индекс активной вкладки; null — ни один документ не выбран
active_document_index: ?usize, active_document_index: ?usize,
pub fn init(allocator: std.mem.Allocator) !WindowContext { pub fn init(allocator: std.mem.Allocator) !WindowContext {
@@ -54,30 +50,27 @@ pub fn deinit(self: *WindowContext) void {
self.documents.deinit(self.allocator); self.documents.deinit(self.allocator);
} }
/// Вернуть указатель на активный открытый документ (null если нет выбранной вкладки).
pub fn activeDocument(self: *WindowContext) ?*OpenDocument { pub fn activeDocument(self: *WindowContext) ?*OpenDocument {
const i = self.active_document_index orelse return null; const i = self.active_document_index orelse return null;
if (i >= self.documents.items.len) return null; if (i >= self.documents.items.len) return null;
return self.documents.items[i]; return self.documents.items[i];
} }
/// Добавить новый документ и сделать его активным.
pub fn addNewDocument(self: *WindowContext) !void { pub fn addNewDocument(self: *WindowContext) !void {
const ptr = try self.allocator.create(OpenDocument); const ptr = try self.allocator.create(OpenDocument);
errdefer self.allocator.destroy(ptr); errdefer self.allocator.destroy(ptr);
OpenDocument.init(self.allocator, ptr); OpenDocument.init(self.allocator, ptr);
try ptr.document.addRandomShapes(std.crypto.random);
try self.documents.append(self.allocator, ptr); try self.documents.append(self.allocator, ptr);
self.active_document_index = self.documents.items.len - 1; self.active_document_index = self.documents.items.len - 1;
} }
/// Выбрать вкладку по индексу.
pub fn setActiveDocument(self: *WindowContext, index: usize) void { pub fn setActiveDocument(self: *WindowContext, index: usize) void {
if (index < self.documents.items.len) { if (index < self.documents.items.len) {
self.active_document_index = index; self.active_document_index = index;
} }
} }
/// Закрыть вкладку по индексу; активная вкладка сдвигается при необходимости.
pub fn closeDocument(self: *WindowContext, index: usize) void { pub fn closeDocument(self: *WindowContext, index: usize) void {
if (index >= self.documents.items.len) return; if (index >= self.documents.items.len) return;
const open_doc = self.documents.items[index]; const open_doc = self.documents.items[index];

View File

@@ -1,13 +1,12 @@
const std = @import("std"); const std = @import("std");
const basic_models = @import("basic_models.zig"); const basic_models = @import("basic_models.zig");
const properties = @import("Property.zig");
const Property = properties.Property;
const Size = basic_models.Size; const Size = basic_models.Size;
const Document = @This(); const Document = @This();
pub const Object = @import("Object.zig");
size: Size, size: Size,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Корневые объекты документа (вложенность через Object.children).
objects: std.ArrayList(Object), objects: std.ArrayList(Object),
pub fn init(allocator: std.mem.Allocator, size: Size) Document { pub fn init(allocator: std.mem.Allocator, size: Size) Document {
@@ -23,112 +22,61 @@ pub fn deinit(self: *Document) void {
self.objects.deinit(self.allocator); self.objects.deinit(self.allocator);
} }
/// Добавить корневой объект в документ (клонирует в allocator документа).
pub fn addObject(self: *Document, template: Object) !void { pub fn addObject(self: *Document, template: Object) !void {
const obj = try template.clone(self.allocator); const obj = try template.clone(self.allocator);
try self.objects.append(self.allocator, obj); try self.objects.append(self.allocator, obj);
} }
/// Тип фигуры: определяет, как RenderEngine интерпретирует свойства и рисует объект. pub fn addShape(self: *Document, parent: ?*Object, shape: Object.ShapeKind) !void {
pub const ShapeKind = enum { const obj = switch (shape) {
rect, .line => try Object.createLine(self.allocator),
ellipse, .ellipse => try Object.createEllipse(self.allocator),
line, .broken => try Object.createBrokenLine(self.allocator),
path, .arc => return error.ArcNotImplemented,
}; };
if (parent) |p| {
/// Объект документа: тип фигуры, свойства и вложенные дочерние объекты. try p.addChild(self.allocator, obj);
/// Типы объектов задаются конструкторами; UI и RenderEngine работают с union Property. } else {
pub const Object = struct { try self.addObject(obj);
shape: ShapeKind,
properties: std.ArrayList(Property),
/// Вложенные объекты (дерево).
children: std.ArrayList(Object),
/// Найти свойство по тегу и вернуть вариант union (caller делает switch).
pub fn getProperty(self: Object, tag: std.meta.Tag(Property)) ?Property {
for (self.properties.items) |prop| {
if (std.meta.activeTag(prop) == tag) return prop;
}
return null;
}
/// Установить свойство: если уже есть с таким тегом — заменить, иначе добавить.
pub fn setProperty(self: *Object, allocator: std.mem.Allocator, prop: Property) !void {
for (self.properties.items, 0..) |*p, i| {
if (std.meta.activeTag(p.*) == std.meta.activeTag(prop)) {
self.properties.items[i] = prop;
return;
} }
} }
try self.properties.append(allocator, prop);
fn randomShapeKind(rng: std.Random) Object.ShapeKind {
const shapes_implemented = [_]Object.ShapeKind{ .line, .ellipse, .broken };
return shapes_implemented[rng.intRangeLessThan(usize, 0, shapes_implemented.len)];
} }
/// Добавить дочерний объект (клонирует в переданный allocator). /// Создаёт случайное количество фигур в документе (в т.ч. вложенных).
pub fn addChild(self: *Object, allocator: std.mem.Allocator, template: Object) !void { /// Используются только реализованные типы: line, ellipse, broken.
const obj = try template.clone(allocator); /// Ограничение max_total предотвращает экспоненциальный рост и переполнение.
try self.children.append(allocator, obj); pub fn addRandomShapes(self: *Document, rng: std.Random) !void {
const max_total: usize = 80;
var total_count: usize = 0;
const n_root = rng.intRangeLessThan(usize, 1, 5);
for (0..n_root) |_| {
if (total_count >= max_total) break;
try self.addShape(null, randomShapeKind(rng));
total_count += 1;
} }
/// Клонировать объект рекурсивно (свойства и дети). var stack = std.ArrayList(*Object).empty;
pub fn clone(self: Object, allocator: std.mem.Allocator) !Object { defer stack.deinit(self.allocator);
var properties_list = std.ArrayList(Property).empty; for (self.objects.items) |*obj| {
errdefer properties_list.deinit(allocator); try stack.append(self.allocator, obj);
try properties_list.appendSlice(allocator, self.properties.items);
var children_list = std.ArrayList(Object).empty;
errdefer children_list.deinit(allocator);
for (self.children.items) |child| {
try children_list.append(allocator, try child.clone(allocator));
} }
while (stack.pop()) |obj| {
return .{ if (total_count >= max_total) continue;
.shape = self.shape, const n_children = rng.intRangeLessThan(usize, 0, 2);
.properties = properties_list, const base_len = obj.children.items.len;
.children = children_list, for (0..n_children) |_| {
}; if (total_count >= max_total) break;
try self.addShape(obj, randomShapeKind(rng));
total_count += 1;
} }
// Пушим в стек только после всех append, чтобы не держать указатели при реаллокации obj.children
pub fn deinit(self: *Object, allocator: std.mem.Allocator) void { for (obj.children.items[base_len..]) |*child| {
for (self.children.items) |*child| child.deinit(allocator); try stack.append(self.allocator, child);
self.children.deinit(allocator);
self.properties.deinit(allocator);
self.* = undefined;
} }
/// Базовый объект с общим набором свойств (для внутреннего использования конструкторами).
fn createWithCommon(allocator: std.mem.Allocator, shape: ShapeKind) !Object {
const common = properties.defaultCommonProperties();
var properties_list = std.ArrayList(Property).empty;
errdefer properties_list.deinit(allocator);
try properties_list.appendSlice(allocator, &common);
return .{
.shape = shape,
.properties = properties_list,
.children = std.ArrayList(Object).empty,
};
} }
// --- Публичные конструкторы: базовый объект + одно свойство фигуры ---
pub fn createRect(allocator: std.mem.Allocator) !Object {
var obj = try createWithCommon(allocator, .rect);
errdefer obj.deinit(allocator);
try obj.properties.append(allocator, .{ .size = .{ .width = 100, .height = 100 } });
return obj;
} }
pub fn createEllipse(allocator: std.mem.Allocator) !Object {
var obj = try createWithCommon(allocator, .ellipse);
errdefer obj.deinit(allocator);
try obj.properties.append(allocator, .{ .radii = .{ .x = 50, .y = 50 } });
return obj;
}
pub fn createLine(allocator: std.mem.Allocator) !Object {
var obj = try createWithCommon(allocator, .line);
errdefer obj.deinit(allocator);
try obj.properties.append(allocator, .{ .end_point = .{ .x = 100, .y = 0 } });
return obj;
}
};

108
src/models/Object.zig Normal file
View File

@@ -0,0 +1,108 @@
const std = @import("std");
const basic_models = @import("basic_models.zig");
const Size = basic_models.Size;
const Point2 = basic_models.Point2;
const Property = @import("Property.zig").Property;
const PropertyData = @import("Property.zig").Data;
const defaultCommonProperties = @import("Property.zig").defaultCommonProperties;
const Object = @This();
pub const ShapeKind = enum {
line,
ellipse,
arc,
broken,
};
shape: ShapeKind,
properties: std.ArrayList(Property),
children: std.ArrayList(Object),
pub fn getProperty(self: Object, tag: std.meta.Tag(PropertyData)) ?*const PropertyData {
for (self.properties.items) |*prop| {
if (std.meta.activeTag(prop.data) == tag) return &prop.data;
}
return null;
}
pub fn setProperty(self: *Object, allocator: std.mem.Allocator, prop: Property) !void {
for (self.properties.items, 0..) |*p, i| {
if (std.meta.activeTag(p.data) == std.meta.activeTag(prop.data)) {
if (p.data == .points) p.data.points.deinit(allocator);
self.properties.items[i] = prop;
return;
}
}
try self.properties.append(allocator, prop);
}
pub fn addChild(self: *Object, allocator: std.mem.Allocator, template: Object) !void {
const obj = try template.clone(allocator);
try self.children.append(allocator, obj);
}
pub fn clone(self: Object, allocator: std.mem.Allocator) !Object {
var properties_list = std.ArrayList(Property).empty;
errdefer properties_list.deinit(allocator);
for (self.properties.items) |prop| {
try properties_list.append(allocator, try prop.clone(allocator));
}
var children_list = std.ArrayList(Object).empty;
errdefer children_list.deinit(allocator);
for (self.children.items) |child| {
try children_list.append(allocator, try child.clone(allocator));
}
return .{
.shape = self.shape,
.properties = properties_list,
.children = children_list,
};
}
pub fn deinit(self: *Object, allocator: std.mem.Allocator) void {
for (self.children.items) |*child| child.deinit(allocator);
self.children.deinit(allocator);
for (self.properties.items) |*prop| prop.deinit(allocator);
self.properties.deinit(allocator);
self.* = undefined;
}
fn createWithCommonProperties(allocator: std.mem.Allocator, shape: ShapeKind) !Object {
var properties_list = std.ArrayList(Property).empty;
errdefer properties_list.deinit(allocator);
for (defaultCommonProperties) |prop| try properties_list.append(allocator, prop);
return .{
.shape = shape,
.properties = properties_list,
.children = std.ArrayList(Object).empty,
};
}
pub fn createEllipse(allocator: std.mem.Allocator) !Object {
var obj = try createWithCommonProperties(allocator, .ellipse);
errdefer obj.deinit(allocator);
try obj.properties.append(allocator, .{ .data = .{ .radii = .{ .x = 50, .y = 50 } } });
return obj;
}
pub fn createLine(allocator: std.mem.Allocator) !Object {
var obj = try createWithCommonProperties(allocator, .line);
errdefer obj.deinit(allocator);
try obj.properties.append(allocator, .{ .data = .{ .end_point = .{ .x = 100, .y = 0 } } });
return obj;
}
pub fn createBrokenLine(allocator: std.mem.Allocator) !Object {
var obj = try createWithCommonProperties(allocator, .broken);
errdefer obj.deinit(allocator);
var points = std.ArrayList(Point2).empty;
try points.appendSlice(allocator, &.{
.{ .x = 0, .y = 0 },
.{ .x = 80, .y = 0 },
.{ .x = 80, .y = 60 },
});
try obj.properties.append(allocator, .{ .data = .{ .points = points } });
return obj;
}

View File

@@ -1,62 +1,77 @@
// Модель свойств объекта документа. const std = @import("std");
// Каждое свойство — отдельный тип в union; UI и RenderEngine работают с полиморфным Property.
// Комплексные значения (размер, радиусы) — один вариант свойства, а не несколько полей.
const basic_models = @import("basic_models.zig"); const basic_models = @import("basic_models.zig");
const Point2 = basic_models.Point2; const Point2 = basic_models.Point2;
const Scale2 = basic_models.Scale2; const Scale2 = basic_models.Scale2;
const Size = basic_models.Size; const Size = basic_models.Size;
const Radii = basic_models.Radii; const Radii = basic_models.Radii;
/// Одно свойство объекта: полиморфный union. pub const Data = union(enum) {
/// Варианты — целостные значения (size, radii), не разбитые на отдельные поля.
/// UI перебирает свойства и по тегу показывает нужный редактор;
/// RenderEngine по тегу фигуры читает нужные свойства для отрисовки.
pub const Property = union(enum) {
// --- Общие для всех фигур (см. defaultCommonProperties) ---
/// Левый верхний угол bbox для rect/path; центр для ellipse; начало для line.
position: Point2, position: Point2,
angle: f32,
scale: Scale2, scale: Scale2,
visible: bool, visible: bool,
opacity: f32, opacity: f32,
locked: bool, locked: bool,
// --- Прямоугольник: один вариант ---
size: Size, size: Size,
// --- Эллипс: один вариант ---
radii: Radii, radii: Radii,
// --- Линия: конечная точка (относительно position) ---
end_point: Point2, end_point: Point2,
// --- Визуал (опционально для будущего) --- points: std.ArrayList(Point2),
fill_rgba: u32, fill_rgba: u32,
stroke_rgba: u32, stroke_rgba: u32,
}; };
const std = @import("std"); pub const Property = struct {
data: Data,
/// Общий набор свойств по умолчанию для любого объекта (одно место определения). pub fn deinit(self: *Property, allocator: std.mem.Allocator) void {
/// Конструкторы фигур добавляют сначала его, затем специфичные свойства. switch (self.data) {
pub fn defaultCommonProperties() []Property { .points => |*list| list.deinit(allocator),
return .{ else => {},
}
self.* = undefined;
}
pub fn clone(self: Property, allocator: std.mem.Allocator) !Property {
return switch (self.data) {
.points => |list| .{
.data = .{
.points = try list.clone(allocator),
},
},
else => .{ .data = self.data },
};
}
};
const default_common_data = [_]Data{
.{ .position = .{ .x = 0, .y = 0 } }, .{ .position = .{ .x = 0, .y = 0 } },
.{ .angle = 0 },
.{ .scale = .{ .scale_x = 1, .scale_y = 1 } }, .{ .scale = .{ .scale_x = 1, .scale_y = 1 } },
.{ .visible = true }, .{ .visible = true },
.{ .opacity = 1.0 }, .{ .opacity = 1.0 },
.{ .locked = false }, .{ .locked = false },
}; };
}
test "Property is union" { pub const defaultCommonProperties: [default_common_data.len]Property = blk: {
const p: Property = .{ .opacity = 0.5 }; var result: [default_common_data.len]Property = undefined;
try std.testing.expect(p == .opacity); for (default_common_data, &result) |d, *p| {
try std.testing.expect(p.opacity == 0.5); p.* = .{ .data = d };
}
break :blk result;
};
test "Property wrapper and Data" {
const p = Property{ .data = .{ .opacity = 0.5 } };
try std.testing.expect(p.data == .opacity);
try std.testing.expect(p.data.opacity == 0.5);
} }
test "common properties" { test "common properties" {
const common = defaultCommonProperties(); try std.testing.expect(defaultCommonProperties[0].data == .position);
try std.testing.expect(common[0].position.x == 0); try std.testing.expect(defaultCommonProperties[0].data.position.x == 0);
try std.testing.expect(common[2].visible == true); try std.testing.expect(defaultCommonProperties[3].data == .visible);
try std.testing.expect(defaultCommonProperties[3].data.visible == true);
} }

View File

@@ -15,19 +15,16 @@ pub const Size = struct {
height: f32, height: f32,
}; };
/// Точка в 2D (документные единицы)
pub const Point2 = struct { pub const Point2 = struct {
x: f32 = 0, x: f32 = 0,
y: f32 = 0, y: f32 = 0,
}; };
/// Радиусы эллипса по осям (одно свойство).
pub const Radii = struct { pub const Radii = struct {
x: f32, x: f32,
y: f32, y: f32,
}; };
/// Масштаб объекта
pub const Scale2 = struct { pub const Scale2 = struct {
scale_x: f32 = 1, scale_x: f32 = 1,
scale_y: f32 = 1, scale_y: f32 = 1,

View File

@@ -27,7 +27,6 @@ pub fn init(allocator: Allocator, render_type: Type) CpuRenderEngine {
} }
pub fn exampleReset(self: *CpuRenderEngine) void { pub fn exampleReset(self: *CpuRenderEngine) void {
// Сгенерировать случайные цвета градиента
var prng = std.Random.DefaultPrng.init(@intCast(std.time.microTimestamp())); var prng = std.Random.DefaultPrng.init(@intCast(std.time.microTimestamp()));
const random = prng.random(); const random = prng.random();
self.gradient_start = Color.PMA{ .r = random.int(u8), .g = random.int(u8), .b = random.int(u8), .a = 255 }; self.gradient_start = Color.PMA{ .r = random.int(u8), .g = random.int(u8), .b = random.int(u8), .a = 255 };
@@ -64,12 +63,12 @@ fn renderSquares(self: CpuRenderEngine, pixels: []Color.PMA, canvas_size: ImageS
_ = self; _ = self;
const colors = [_]Color.PMA{ const colors = [_]Color.PMA{
.{ .r = 255, .g = 0, .b = 0, .a = 255 }, // red .{ .r = 255, .g = 0, .b = 0, .a = 255 },
.{ .r = 255, .g = 165, .b = 0, .a = 255 }, // orange .{ .r = 255, .g = 165, .b = 0, .a = 255 },
.{ .r = 255, .g = 255, .b = 0, .a = 255 }, // yellow .{ .r = 255, .g = 255, .b = 0, .a = 255 },
.{ .r = 0, .g = 255, .b = 0, .a = 255 }, // green .{ .r = 0, .g = 255, .b = 0, .a = 255 },
.{ .r = 0, .g = 255, .b = 255, .a = 255 }, // cyan .{ .r = 0, .g = 255, .b = 255, .a = 255 },
.{ .r = 0, .g = 0, .b = 255, .a = 255 }, // blue .{ .r = 0, .g = 0, .b = 255, .a = 255 },
}; };
const squares_num = 5; const squares_num = 5;
@@ -106,7 +105,6 @@ fn renderSquares(self: CpuRenderEngine, pixels: []Color.PMA, canvas_size: ImageS
const canvas_x = x + visible_rect.x; const canvas_x = x + visible_rect.x;
if (canvas_x >= canvas_size.w) continue; if (canvas_x >= canvas_size.w) continue;
// Check vertical line index
var vertical_index: ?u32 = null; var vertical_index: ?u32 = null;
for (0..x_pos.len) |i| { for (0..x_pos.len) |i| {
if (canvas_x >= x_pos[i] and canvas_x < x_pos[i] + thikness) { if (canvas_x >= x_pos[i] and canvas_x < x_pos[i] + thikness) {
@@ -115,7 +113,6 @@ fn renderSquares(self: CpuRenderEngine, pixels: []Color.PMA, canvas_size: ImageS
} }
} }
// Check horizontal line index
var horizontal_index: ?u32 = null; var horizontal_index: ?u32 = null;
for (0..y_pos.len) |i| { for (0..y_pos.len) |i| {
if (canvas_y >= y_pos[i] and canvas_y < y_pos[i] + thikness) { if (canvas_y >= y_pos[i] and canvas_y < y_pos[i] + thikness) {
@@ -129,7 +126,6 @@ fn renderSquares(self: CpuRenderEngine, pixels: []Color.PMA, canvas_size: ImageS
} else if (horizontal_index) |idx| { } else if (horizontal_index) |idx| {
pixels[y * visible_rect.w + x] = colors[idx]; pixels[y * visible_rect.w + x] = colors[idx];
} else { } else {
// Find square
var square_x: u32 = 0; var square_x: u32 = 0;
for (0..squares_num) |i| { for (0..squares_num) |i| {
if (canvas_x >= x_pos[i] + thikness and canvas_x < x_pos[i + 1]) { if (canvas_x >= x_pos[i] + thikness and canvas_x < x_pos[i + 1]) {
@@ -161,12 +157,9 @@ pub fn example(self: CpuRenderEngine, canvas_size: ImageSize, visible_rect: Imag
const width = visible_rect.w; const width = visible_rect.w;
const height = visible_rect.h; const height = visible_rect.h;
// Выделить буфер пиксельных данных
const pixels = try self._allocator.alloc(Color.PMA, @as(usize, width) * height); const pixels = try self._allocator.alloc(Color.PMA, @as(usize, width) * height);
defer self._allocator.free(pixels); defer self._allocator.free(pixels);
// std.debug.print("w={any}, fw={any};\th={any}, fh={any}\n", .{ width, full_w, height, full_h });
switch (self.type) { switch (self.type) {
.Gradient => self.renderGradient(pixels, width, height, full_w, full_h, visible_rect), .Gradient => self.renderGradient(pixels, width, height, full_w, full_h, visible_rect),
.Squares => self.renderSquares(pixels, canvas_size, visible_rect), .Squares => self.renderSquares(pixels, canvas_size, visible_rect),

View File

@@ -1,4 +1,3 @@
// Интерфейс для рендеринга документа
const dvui = @import("dvui"); const dvui = @import("dvui");
const CpuRenderEngine = @import("CpuRenderEngine.zig"); const CpuRenderEngine = @import("CpuRenderEngine.zig");
const basic_models = @import("../models/basic_models.zig"); const basic_models = @import("../models/basic_models.zig");

View File

@@ -1,6 +1,13 @@
// Test root for `zig build test`. // Корень для `zig build test`. Тесты из импортированных здесь модулей выполняются (в Zig не подтягиваются из транзитивных импортов).
// Import modules here to ensure their `test` blocks are discovered. // Добавляй сюда _ = @import("path/to/module.zig"); для каждого модуля с test-блоками.
// Чтобы увидеть список всех тестов: после `zig build test` выполни `./zig-out/bin/test`.
test "module test discovery" { test "discover tests" {
_ = @import("render/CpuRenderEngine.zig"); _ = @import("main.zig");
_ = @import("models/Property.zig");
}
// Убедиться, что выполнились все ожидаемые тесты: этот тест пройдёт только если до него дошли (т.е. все предыдущие прошли).
test "all module tests completed" {
const std = @import("std");
std.debug.print("\n (все тесты модулей выполнены)\n", .{});
} }

View File

@@ -1,4 +1,3 @@
// Виджет холста: скролл, текстура, зум по Ctrl+колёсико.
const std = @import("std"); const std = @import("std");
const dvui = @import("dvui"); const dvui = @import("dvui");
const dvui_ext = @import("dvui_ext.zig"); const dvui_ext = @import("dvui_ext.zig");
@@ -22,6 +21,7 @@ pub fn canvasView(canvas: *Canvas, content_rect_scale: dvui.RectScale) void {
{ {
drawCanvasContent(canvas, scroll); drawCanvasContent(canvas, scroll);
handleCanvasZoom(canvas, scroll); handleCanvasZoom(canvas, scroll);
handleCanvasMouse(canvas, scroll);
} }
scroll.deinit(); scroll.deinit();
@@ -34,7 +34,7 @@ pub fn canvasView(canvas: *Canvas, content_rect_scale: dvui.RectScale) void {
fn drawCanvasContent(canvas: *Canvas, scroll: anytype) void { fn drawCanvasContent(canvas: *Canvas, scroll: anytype) void {
const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale(); const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale();
const img_size = canvas.getScaledImageSize(); const img_size = canvas.getZoomedImageSize();
const viewport_rect = scroll.data().contentRect(); const viewport_rect = scroll.data().contentRect();
const scroll_current = dvui.Point{ .x = canvas.scroll.viewport.x, .y = canvas.scroll.viewport.y }; const scroll_current = dvui.Point{ .x = canvas.scroll.viewport.x, .y = canvas.scroll.viewport.y };
@@ -118,3 +118,26 @@ fn handleCanvasZoom(canvas: *Canvas, scroll: anytype) void {
} }
} }
} }
fn handleCanvasMouse(canvas: *Canvas, scroll: anytype) void {
const natural_scale = if (canvas.native_scaling) 1 else dvui.windowNaturalScale();
for (dvui.events()) |*e| {
switch (e.evt) {
.mouse => |*mouse| {
if (mouse.action != .press or mouse.button != .left) continue;
if (!dvui.eventMatchSimple(e, scroll.data())) continue;
const viewport_pt = scroll.data().contentRectScale().pointFromPhysical(mouse.p);
const content_pt = dvui.Point{
.x = viewport_pt.x + canvas.scroll.viewport.x,
.y = viewport_pt.y + canvas.scroll.viewport.y,
};
canvas.cursor_document_point = canvas.contentPointToDocument(content_pt, natural_scale);
if (canvas.cursor_document_point) |point|
std.debug.print("cursor_document_point: {}\n", .{point});
},
else => {},
}
}
}

View File

@@ -1,4 +1,3 @@
// Расширения для dvui
const std = @import("std"); const std = @import("std");
const dvui = @import("dvui"); const dvui = @import("dvui");
const TexturedBox = @import("./types/TexturedBox.zig"); const TexturedBox = @import("./types/TexturedBox.zig");

View File

@@ -1,11 +1,9 @@
// Корневой кадр UI: разметка и сборка панелей.
const dvui = @import("dvui"); const dvui = @import("dvui");
const WindowContext = @import("../WindowContext.zig"); const WindowContext = @import("../WindowContext.zig");
const tab_bar = @import("tab_bar.zig"); const tab_bar = @import("tab_bar.zig");
const left_panel = @import("left_panel.zig"); const left_panel = @import("left_panel.zig");
const right_panel = @import("right_panel.zig"); const right_panel = @import("right_panel.zig");
/// Отрисовать один кадр GUI. Возвращает false при закрытии окна/выходе.
pub fn guiFrame(ctx: *WindowContext) bool { pub fn guiFrame(ctx: *WindowContext) bool {
for (dvui.events()) |*e| { for (dvui.events()) |*e| {
if (e.evt == .window and e.evt.window.action == .close) return false; if (e.evt == .window and e.evt.window.action == .close) return false;
@@ -24,16 +22,8 @@ pub fn guiFrame(ctx: *WindowContext) bool {
{ {
left_panel.leftPanel(ctx); left_panel.leftPanel(ctx);
var back = dvui.box(
@src(),
.{ .dir = .horizontal },
.{ .expand = .both, .padding = dvui.Rect.all(12), .background = true },
);
{
right_panel.rightPanel(ctx); right_panel.rightPanel(ctx);
} }
back.deinit();
}
content_row.deinit(); content_row.deinit();
} }
root.deinit(); root.deinit();

View File

@@ -1,15 +1,112 @@
// Левая панель: инструменты для активного документа (scaling, тип рендера).
const dvui = @import("dvui"); const dvui = @import("dvui");
const WindowContext = @import("../WindowContext.zig"); const WindowContext = @import("../WindowContext.zig");
const Document = @import("../models/Document.zig");
const Object = Document.Object;
const panel_gap: f32 = 12;
const panel_padding: f32 = 5;
const panel_radius: f32 = 24;
const fill_color = dvui.Color.black.opacity(0.2);
fn shapeLabel(shape: Object.ShapeKind) []const u8 {
return switch (shape) {
.line => "Line",
.ellipse => "Ellipse",
.arc => "Arc",
.broken => "Broken line",
};
}
fn objectTreeRow(obj: *const Object, depth: u32, row_id: *usize) void {
const id = row_id.*;
row_id.* += 1;
const indent_px = depth * 18;
var row = dvui.box(
@src(),
.{ .dir = .horizontal },
.{ .padding = dvui.Rect{ .x = @floatFromInt(indent_px) }, .id_extra = id },
);
{
dvui.labelNoFmt(@src(), shapeLabel(obj.shape), .{}, .{ .id_extra = id });
}
row.deinit();
for (obj.children.items) |*child| {
objectTreeRow(child, depth + 1, row_id);
}
}
fn objectTree(ctx: *WindowContext) void {
const active_doc = ctx.activeDocument();
if (active_doc) |open_doc| {
const doc = &open_doc.document;
if (doc.objects.items.len == 0) {
dvui.label(@src(), "No objects", .{}, .{});
} else {
var row_id: usize = 0;
for (doc.objects.items) |*obj| {
objectTreeRow(obj, 0, &row_id);
}
}
} else {
dvui.label(@src(), "No document", .{}, .{});
}
}
pub fn leftPanel(ctx: *WindowContext) void { pub fn leftPanel(ctx: *WindowContext) void {
var padding = dvui.Rect.all(panel_gap);
padding.w = 0;
var panel = dvui.box( var panel = dvui.box(
@src(), @src(),
.{ .dir = .vertical }, .{ .dir = .vertical },
.{ .expand = .vertical, .min_size_content = .{ .w = 200 }, .background = true }, .{
.expand = .vertical,
.min_size_content = .{ .w = 220 },
.background = true,
.padding = padding,
},
); );
{ {
dvui.label(@src(), "Tools", .{}, .{}); // Верхняя часть: дерево объектов
var tree_section = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.expand = .both,
.padding = dvui.Rect.all(panel_padding),
.corner_radius = dvui.Rect.all(panel_radius),
.color_fill = fill_color,
.background = true,
},
);
{
dvui.label(@src(), "Objects", .{}, .{});
var scroll = dvui.scrollArea(
@src(),
.{ .vertical = .auto },
.{ .expand = .vertical, .background = false },
);
{
objectTree(ctx);
}
scroll.deinit();
}
tree_section.deinit();
// Нижняя часть: настройки
var settings_section = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.expand = .horizontal,
.margin = .{ .y = 5 },
.padding = dvui.Rect.all(panel_padding),
.corner_radius = dvui.Rect.all(panel_radius),
.color_fill = fill_color,
.background = true,
},
);
{
dvui.label(@src(), "Settings", .{}, .{});
const active_doc = ctx.activeDocument(); const active_doc = ctx.activeDocument();
if (active_doc) |doc| { if (active_doc) |doc| {
@@ -27,5 +124,7 @@ pub fn leftPanel(ctx: *WindowContext) void {
dvui.label(@src(), "No document", .{}, .{}); dvui.label(@src(), "No document", .{}, .{});
} }
} }
settings_section.deinit();
}
panel.deinit(); panel.deinit();
} }

View File

@@ -1,4 +1,3 @@
// Правая панель: контент документа (холст) или заглушка «Нет документа».
const std = @import("std"); const std = @import("std");
const dvui = @import("dvui"); const dvui = @import("dvui");
const WindowContext = @import("../WindowContext.zig"); const WindowContext = @import("../WindowContext.zig");
@@ -6,6 +5,12 @@ const canvas_view = @import("canvas_view.zig");
pub fn rightPanel(ctx: *WindowContext) void { pub fn rightPanel(ctx: *WindowContext) void {
const fill_color = dvui.Color.black.opacity(0.25); const fill_color = dvui.Color.black.opacity(0.25);
var back = dvui.box(
@src(),
.{ .dir = .horizontal },
.{ .expand = .both, .padding = dvui.Rect.all(12), .background = true },
);
{
var panel = dvui.box( var panel = dvui.box(
@src(), @src(),
.{ .dir = .vertical }, .{ .dir = .vertical },
@@ -28,12 +33,26 @@ pub fn rightPanel(ctx: *WindowContext) void {
} }
panel.deinit(); panel.deinit();
} }
back.deinit();
}
fn noDocView(ctx: *WindowContext) void { fn noDocView(ctx: *WindowContext) void {
var center = dvui.box( var center = dvui.box(
@src(), @src(),
.{ .dir = .vertical }, .{ .dir = .vertical },
.{ .expand = .both, .padding = dvui.Rect.all(20) }, .{
.expand = .both,
.padding = dvui.Rect.all(20),
},
);
{
var box = dvui.box(
@src(),
.{ .dir = .vertical },
.{
.gravity_x = 0.5,
.gravity_y = 0.5,
},
); );
{ {
dvui.label(@src(), "No document open", .{}, .{}); dvui.label(@src(), "No document open", .{}, .{});
@@ -43,5 +62,7 @@ fn noDocView(ctx: *WindowContext) void {
}; };
} }
} }
box.deinit();
}
center.deinit(); center.deinit();
} }

View File

@@ -1,4 +1,3 @@
// Верхняя строка: вкладки документов + кнопка «Новый».
const std = @import("std"); const std = @import("std");
const dvui = @import("dvui"); const dvui = @import("dvui");
const WindowContext = @import("../WindowContext.zig"); const WindowContext = @import("../WindowContext.zig");

View File

@@ -1,4 +1,3 @@
// Отрисовка дочернего контента как текстуры с параметрами скругления
const std = @import("std"); const std = @import("std");
const dvui = @import("dvui"); const dvui = @import("dvui");
const TexturedBox = @This(); const TexturedBox = @This();
@@ -26,10 +25,6 @@ pub fn deinit(self: *TexturedBox) void {
const tex = dvui.textureFromTarget(picture.texture) catch null; const tex = dvui.textureFromTarget(picture.texture) catch null;
if (tex) |t| { if (tex) |t| {
dvui.Texture.destroyLater(t); dvui.Texture.destroyLater(t);
// self.rs.r.y -= 2;
// self.rs.r.x -= 2;
// self.rs.r.h += 2;
// self.rs.r.w += 2;
dvui.renderTexture(t, self.rs, .{ dvui.renderTexture(t, self.rs, .{
.corner_radius = self.corner_radius, .corner_radius = self.corner_radius,
}) catch {}; }) catch {};