From 2616127b86a63d65ecf3a3cbe0aee452ec62faaa Mon Sep 17 00:00:00 2001 From: foxnne Date: Tue, 2 Jun 2026 10:57:13 -0500 Subject: [PATCH] cover flow style animation for sprites pane batch together sprites in panel add flyaway animation for playing make reflection fly away downwards make cards able to be toggled fly-away cards button better easing Fix overlapping dialogs with sprites pane Fix dialog interaction with resizing --- src/dvui.zig | 77 ++- src/editor/Settings.zig | 4 + src/editor/dialogs/GridLayout.zig | 53 +- src/editor/explorer/settings.zig | 6 + src/editor/panel/sprites.zig | 706 ++++++++++++++++---- src/editor/widgets/CanvasWidget.zig | 13 +- src/editor/widgets/FloatingWindowWidget.zig | 8 +- src/gfx/render.zig | 45 +- tests/integration.zig | 5 +- 9 files changed, 735 insertions(+), 182 deletions(-) diff --git a/src/dvui.zig b/src/dvui.zig index 465012e7..9704a17e 100644 --- a/src/dvui.zig +++ b/src/dvui.zig @@ -126,19 +126,27 @@ pub fn defaultDialogCallAfter(id: dvui.Id, response: dvui.enums.DialogResponse) } } -/// True when the canvas should not hide the OS cursor or draw tool cursors, and should not -/// treat the pointer as hovering the artboard. -/// - Modal dialogs: block the entire main pane. +/// True when the main workspace canvas should not hide the OS cursor, draw tool cursors, or +/// consume pointer events. +/// - Modal dialogs: always block the editor canvas (not in-dialog previews). /// - Non-modal floating windows (e.g. Export): block only while the cursor is over that window. pub fn canvasPointerInputSuppressed() bool { const cw = dvui.currentWindow(); const main_id = cw.data().id; - var i = cw.subwindows.stack.items.len; - while (i > 1) : (i -= 1) { - if (cw.subwindows.stack.items[i - 1].modal) return true; + for (cw.subwindows.stack.items[1..]) |sub| { + if (sub.modal) return true; } const target = cw.subwindows.windowFor(cw.mouse_pt); - return target != main_id and target != .zero; + return target != .zero and target != main_id; +} + +/// In-dialog preview canvases (Grid Layout): allow pan/zoom while the pointer is over the +/// dialog subwindow that owns the preview. +pub fn dialogCanvasPointerInputSuppressed() bool { + const cw = dvui.currentWindow(); + const sub = cw.subwindows.current() orelse return true; + const target = cw.subwindows.windowFor(cw.mouse_pt); + return target != sub.id; } /// Creates a new file dialog with necessary data set and returns the id mutex. @@ -950,6 +958,13 @@ pub const SpriteInitOptions = struct { depth: f32 = 0.0, // -1.0 is front, 1.0 is back reflection: bool = false, overlap: f32 = 0.0, + /// Overall opacity in [0, 1]; 1.0 is fully opaque. Used to fade cards out + /// toward the background the further they sit from the focus. + opacity: f32 = 1.0, + /// Vertical shift (logical px, positive = down) applied to the reflection + /// only. Lets the reflection slide away from the card — e.g. as a card flies + /// up out of view, its reflection sinks down, like peeling off a waterline. + reflection_offset: f32 = 0.0, }; pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opts: dvui.Options) dvui.WidgetData { @@ -1037,14 +1052,27 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt path.addPoint(bottom_right); path.addPoint(bottom_left); + // Distance fade toward transparent: `fade_white` tints textured draws by the + // card opacity, and `op` scales the alpha of solid fills. No-ops at op == 1. + const op = std.math.clamp(init_opts.opacity, 0.0, 1.0); + const fade_white = dvui.Color.white.opacity(op); + if (init_opts.reflection) { var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena()); defer path2.deinit(); - path2.addPoint(bottom_left.plus(.{ .x = -(top_right.x - top_left.x) * 0.5, .y = (bottom_left.y - top_left.y) * 0.75 })); - path2.addPoint(bottom_right.plus(.{ .x = (bottom_right.x - bottom_left.x) * 0.5, .y = (bottom_left.y - top_left.y) * 0.75 })); - path2.addPoint(bottom_right); - path2.addPoint(bottom_left); + // Direct vertical mirror: reflect each (already skewed) top corner straight + // down through its bottom corner, so the reflection is a true flip of the + // card — same width and skew at every height, sharing the bottom edge — + // rather than a trapezoid that flares outward. pathToSubdividedQuad reads + // these as (tl, tr, br, bl); the far edge (tl, tr) samples the sprite top + // and the near edge (br, bl) the sprite bottom, giving the mirrored uv. + // `refl_off` slides the whole reflection down independently of the card. + const refl_off = dvui.Point.Physical{ .x = 0.0, .y = init_opts.reflection_offset * wd.contentRectScale().s }; + path2.addPoint(bottom_left.plus(bottom_left.diff(top_left)).plus(refl_off)); + path2.addPoint(bottom_right.plus(bottom_right.diff(top_right)).plus(refl_off)); + path2.addPoint(bottom_right.plus(refl_off)); + path2.addPoint(bottom_left.plus(refl_off)); const preview_extent = @min(wd.contentRectScale().r.w, wd.contentRectScale().r.h); const subdivisions_f = std.math.clamp(preview_extent / 96.0, 2.0, 6.0); @@ -1052,9 +1080,10 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt if (init_opts.alpha_source) |alpha_source| { const reflection_path = path2.build(); + var reflection_triangles_bg = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ .subdivisions = subdivisions, - .color_mod = dvui.themeGet().color(.content, .fill).lighten(4.0), + .color_mod = dvui.themeGet().color(.content, .fill).lighten(4.0).opacity(op), .vertical_fade = true, }) catch unreachable; defer reflection_triangles_bg.deinit(dvui.currentWindow().arena()); @@ -1063,7 +1092,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt .subdivisions = subdivisions, .uv = uv, .vertical_fade = true, - .color_mod = dvui.Color.white, + .color_mod = fade_white, }) catch unreachable; defer reflection_triangles_layers.deinit(dvui.currentWindow().arena()); @@ -1117,11 +1146,24 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt } if (init_opts.alpha_source) |alpha_source| { - wd.contentRectScale().r.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 1.5 }); + if (init_opts.depth != 0.0) { + // Skew the opaque base along with the art so no axis-aligned sliver + // of fill colour pokes out past the receding edge. + var base_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ + .subdivisions = 8, + .color_mod = dvui.themeGet().color(.content, .fill).opacity(op), + }) catch unreachable; + defer base_triangles.deinit(dvui.currentWindow().arena()); + dvui.renderTriangles(base_triangles, null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } else { + wd.contentRectScale().r.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill).opacity(op), .fade = 1.5 }); + } const alpha_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ .subdivisions = 8, - .color_mod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5), + .color_mod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(op), }) catch unreachable; dvui.renderTriangles(alpha_triangles, alpha_source.getTexture() catch null) catch { dvui.log.err("Failed to render triangles", .{}); @@ -1137,6 +1179,10 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt }, .uv = uv, .corner_radius = .all(0), + .color_mod = fade_white, + // When skewed, render the layer stack into the same quad as the + // background so the art tilts like a record on a shelf. + .quad = if (init_opts.depth != 0.0) .{ top_left, top_right, bottom_right, bottom_left } else null, }) catch { dvui.log.err("Failed to render layers", .{}); }; @@ -1144,6 +1190,7 @@ pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opt const triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ .subdivisions = 8, .uv = uv, + .color_mod = fade_white, }) catch unreachable; dvui.renderTriangles(triangles, init_opts.source.getTexture() catch null) catch { diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig index 7d3d0586..8a2c8951 100644 --- a/src/editor/Settings.zig +++ b/src/editor/Settings.zig @@ -51,6 +51,10 @@ hold_menu_duration_ms: u32 = 500, /// Whether or not to show rulers on each canvas. show_rulers: bool = true, +/// Sprites panel: fly side cards away for a single-card focus view and snap +/// scroll when the focus sprite changes (also toggled from the sprites pane). +scrolling_cards: bool = false, + /// When true, print frame/draw perf stats to the console (Debug / ReleaseSafe only for tick stats). perf_logging: bool = false, diff --git a/src/editor/dialogs/GridLayout.zig b/src/editor/dialogs/GridLayout.zig index 25c88371..bb55014e 100644 --- a/src/editor/dialogs/GridLayout.zig +++ b/src/editor/dialogs/GridLayout.zig @@ -12,6 +12,7 @@ const std = @import("std"); const NewFile = @import("NewFile.zig"); const CanvasWidget = @import("../widgets/CanvasWidget.zig"); +const FloatingWindowWidget = @import("../widgets/FloatingWindowWidget.zig"); const builtin = @import("builtin"); /// Editable grid fields for one mode (Slice vs Resize each keep their own backing). @@ -107,7 +108,7 @@ pub fn presetFromFile(file: *fizzy.Internal.File) void { // `prev_size` matches `data_size` and `second_center` is false, so `install` skips the // rescale/recenter pass and the preview ends up offscreen / at a stale zoom. Resetting to // a fresh widget forces a fit-to-pane on the next frame. - preview_canvas = .{}; + preview_canvas = .{ .pointer_scope = .dialog }; left_scroll = .{ .horizontal = .auto }; dialog_middle_scroll = .{ .horizontal = .auto, .vertical = .auto }; preview_pane_fit_w = 0; @@ -551,32 +552,30 @@ fn renderPreview( const dims_changed = nw != preview_last_nw or nh != preview_last_nh; - const shell_drag_or_resize = blk: { + const shell_resize_drag = blk: { const wid = dvui.dataGet(null, mutex_id, "_grid_layout_float_wd_id", dvui.Id) orelse break :blk false; - break :blk dvui.captured(wid); + break :blk FloatingWindowWidget.DragPart.isResizeDrag(wid); }; - const host_vp_versus_stored = host_vp_ok and (preview_viewport_fit_w < 4 or preview_viewport_fit_h < 4 or - @abs(vp_host_w - preview_viewport_fit_w) >= preview_layout_min_delta or - @abs(vp_host_h - preview_viewport_fit_h) >= preview_layout_min_delta); - const needs_preinstall_refit = host_vp_ok and (fit_key_changed or dims_changed or host_vp_versus_stored or shell_drag_or_resize); + const host_changed = host_vp_ok and (preview_pane_fit_w < 4 or preview_pane_fit_h < 4 or + @abs(vp_host_w - preview_pane_fit_w) >= preview_layout_min_delta or + @abs(vp_host_h - preview_pane_fit_h) >= preview_layout_min_delta); + const needs_preinstall_refit = host_vp_ok and (fit_key_changed or dims_changed or host_changed or shell_resize_drag); const preview_data: dvui.Size = .{ .w = @floatFromInt(nw), .h = @floatFromInt(nh) }; + const reset_viewport_after_fit = fit_key_changed or dims_changed or host_changed or shell_resize_drag; + if (needs_preinstall_refit) { preview_canvas.fitContentContainInHost( preview_data, dvui.Rect{ .x = 0, .y = 0, .w = vp_host_w, .h = vp_host_h }, 1.2, ); - preview_canvas.scroll_info.viewport.x = 0; - preview_canvas.scroll_info.viewport.y = 0; - // `CanvasWidget.install` restores origin/viewport from this snapshot whenever it sees the - // parent rect moving (e.g. during window resize) — without syncing it here, the scaler is - // placed at the pre-resize origin and the image looks "stuck" until the resize ends. - preview_canvas.stable_origin = preview_canvas.origin; - preview_canvas.stable_viewport = preview_canvas.scroll_info.viewport; - preview_canvas.stable_virtual_size = preview_canvas.scroll_info.virtual_size; + if (reset_viewport_after_fit) { + preview_canvas.scroll_info.viewport.x = 0; + preview_canvas.scroll_info.viewport.y = 0; + } if (fit_key_changed) { preview_fit_key_cache = fit_key; } @@ -609,14 +608,17 @@ fn renderPreview( var did_post_install_refit = false; const needs_bootstrap_refit = !host_vp_ok and vp_ok and (fit_key_changed or dims_changed); - if (needs_bootstrap_refit) { + const needs_post_install_host_refit = vp_ok and (host_changed or shell_resize_drag); + if (needs_bootstrap_refit or needs_post_install_host_refit) { preview_canvas.fitContentContainInHost( preview_data, dvui.Rect{ .x = 0, .y = 0, .w = vpw, .h = vph }, 1.2, ); - preview_canvas.scroll_info.viewport.x = 0; - preview_canvas.scroll_info.viewport.y = 0; + if (reset_viewport_after_fit or needs_post_install_host_refit) { + preview_canvas.scroll_info.viewport.x = 0; + preview_canvas.scroll_info.viewport.y = 0; + } if (fit_key_changed) { preview_fit_key_cache = fit_key; } @@ -629,10 +631,21 @@ fn renderPreview( dvui.refresh(null, @src(), preview_canvas.id); } + // `CanvasWidget.install` restores this snapshot while the parent rect is mid-resize; keep + // origin and viewport in sync so the preview stays centered, not pinned to the upper-left. + if (needs_preinstall_refit or did_post_install_refit) { + preview_canvas.stable_origin = preview_canvas.origin; + preview_canvas.stable_viewport = preview_canvas.scroll_info.viewport; + preview_canvas.stable_virtual_size = preview_canvas.scroll_info.virtual_size; + preview_canvas.has_stable_snapshot = true; + } + preview_viewport_fit_w = vpw; preview_viewport_fit_h = vph; - preview_pane_fit_w = vpw; - preview_pane_fit_h = vph; + if (host_vp_ok) { + preview_pane_fit_w = vp_host_w; + preview_pane_fit_h = vp_host_h; + } const any_refit = needs_preinstall_refit or did_post_install_refit; diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index f0eed976..63f3d694 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -204,6 +204,12 @@ pub fn draw() !void { })) { fizzy.editor.markSettingsDirty(); } + + if (dvui.checkbox(@src(), &fizzy.editor.settings.scrolling_cards, "Scrolling sprite cards", .{ + .expand = .none, + })) { + fizzy.editor.markSettingsDirty(); + } } { diff --git a/src/editor/panel/sprites.zig b/src/editor/panel/sprites.zig index 30850c4d..afee739e 100644 --- a/src/editor/panel/sprites.zig +++ b/src/editor/panel/sprites.zig @@ -6,9 +6,39 @@ const Editor = fizzy.Editor; const Sprites = @This(); +/// Side-card fly-out / fly-in master timeline (microseconds, linear 0↔1). +const fly_anim_duration_us: i64 = 750_000; + +// Animated fit-scale state (shared, like a singleton preview). var prev_scale: f32 = 1.0; var current_scale: f32 = 1.0; +// ---- Cover-flow state (persisted on the Panel's Sprites instance) ---- +/// Current fractional center index that the flow is rendered around. The sprite +/// nearest this value is drawn flat and on top; neighbours rotate away like +/// records on a shelf. +scroll_pos: f32 = 0.0, +/// Index the flow is easing toward. Driven either by the editor selection or by +/// the user scrolling/dragging the flow itself. +goal: f32 = 0.0, +/// Last selection index we observed coming from the rest of the editor, so we +/// can tell an external selection change apart from one we caused ourselves. +last_sel_index: usize = std.math.maxInt(usize), +/// Accumulates fractional wheel deltas until they cross a whole step. +wheel_accum: f32 = 0.0, +/// True only on frames where the user is actively dragging the flow. +drag_active: bool = false, +/// Whether the pointer moved between press and release (drag vs. click). +moved_since_press: bool = false, +/// Set once we've seeded `scroll_pos` from the initial selection. +initialized: bool = false, +/// Previous "flown" state (see `sideCardsFlown`), so we can fire the fly-out / +/// fly-in transition the frame it flips. While flown, the side cards lift up +/// out of view so only the focused card shows (less distracting). +was_flown: bool = false, +/// Direction of the in-flight `play_fly` animation (outBack vs inBack). +fly_anim_out: bool = false, + pub fn draw(self: *Sprites) !void { if (fizzy.editor.activeFile()) |file| { const prev_clip = dvui.clip(dvui.parentGet().data().rectScale().r); @@ -32,46 +62,45 @@ pub fn draw(self: *Sprites) !void { const parent = dvui.parentGet().data().rect; const parent_height = parent.h; - var index: usize = 0; - var src_rect = file.spriteRect(index); // Default to the first sprite - - if (file.editor.playing) { - if (file.selected_animation_index) |i| { - index = i; - const animation = file.animations.get(index); - const frame_index = file.selected_animation_frame_index; + const count = file.spriteCount(); + if (count == 0) { + return; + } - if (frame_index < animation.frames.len) { - src_rect = file.spriteRect(animation.frames[frame_index].sprite_index); - } - } - } else { - if (file.editor.canvas.hovered) { - if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt_prev))) |sprite_index| { - src_rect = file.spriteRect(sprite_index); - index = sprite_index; - } - } else if (file.editor.selected_sprites.count() > 0) { - if (file.editor.selected_sprites.findLastSet()) |last| { - src_rect = file.spriteRect(last); - index = last; - } - } else { - if (file.selected_animation_index) |animation_index| { - const animation = file.animations.get(animation_index); - if (animation.frames.len > 0 and file.selected_animation_frame_index < animation.frames.len) { - src_rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); - } - } - } + // ---- Fly-out / fly-in master timeline. `fly_t` runs 0 (all cards at + // rest) → 1 (side cards lifted out of view) as a linear master clock; each + // card derives a staggered, eased offset from it below. We flip the target + // the frame playback starts/stops. ---- + const playing = file.editor.playing; + const flown = sideCardsFlown(playing); + const panel_id = dvui.parentGet().data().id; + if (flown != self.was_flown) { + const cur: f32 = if (dvui.animationGet(panel_id, "play_fly")) |a| a.value() else (if (self.was_flown) 1.0 else 0.0); + self.fly_anim_out = flown; + dvui.animation(panel_id, "play_fly", .{ + .end_time = fly_anim_duration_us, + .easing = dvui.easing.linear, + .start_val = cur, + .end_val = if (flown) 1.0 else 0.0, + }); + self.was_flown = flown; } + const fly_t: f32 = if (dvui.animationGet(panel_id, "play_fly")) |a| + std.math.clamp(a.value(), 0.0, 1.0) + else if (flown) 1.0 else 0.0; + // Every sprite in a file shares the same cell size, so any sprite rect + // works for sizing the flow. + const src_rect = file.spriteRect(0); + + // ---- Animated fit-scale: aim the front sprite at a fraction of the + // pane so several neighbours are visible at once. ---- const scale = blk: { const steps = fizzy.editor.settings.zoom_steps; - const sprite_width = src_rect.w * 1.2; - const sprite_height = src_rect.h * 1.2; - const target_width = if (sprite_width < parent.w) parent.w else sprite_width; - const target_height = if (sprite_height < parent.h) parent.h else sprite_height; + const sprite_width = src_rect.w; + const sprite_height = src_rect.h; + const target_width = parent.w * 0.34; + const target_height = parent.h * 0.62; var target_scale: f32 = 1.0; for (steps, 0..) |zoom, i| { @@ -117,31 +146,70 @@ pub fn draw(self: *Sprites) !void { break :blk current_scale; }; - var rect = dvui.Rect{ - .x = parent.center().x, - .y = parent.center().y, - .w = @as(f32, @floatFromInt(file.column_width)) * scale, - .h = @as(f32, @floatFromInt(file.row_height)) * scale, - }; + const item_w = @as(f32, @floatFromInt(file.column_width)) * scale; + const item_h = @as(f32, @floatFromInt(file.row_height)) * scale; - rect.x -= rect.w / 2.0; - rect.y -= rect.h / 2.0; - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = rect, - .border = .all(0), - .color_border = dvui.themeGet().color(.control, .text), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = 0.0, .y = 8.0 }, - .fade = 12.0, - .alpha = 0.25, - .corner_radius = dvui.Rect.all(parent_height / 32.0), - }, - .min_size_content = .{ .w = 32.0, .h = 32.0 }, - }); - defer hbox.deinit(); + // Front group: the focus card plus `flat_zone` neighbours each side sit + // flat, spaced `front_gap` apart. Past the group a `shelf_gap` opens up + // (eased in, not a hard step) and the rest tile `far_spread` apart while + // rotating onto the shelf over `tilt_ramp` index units. + const front_gap = item_w * 1.2; + const shelf_gap = item_w * 0.5; + const far_spread = item_w * 0.62; + const max_depth: f32 = 0.55; + const flat_zone: f32 = 1.0; + const tilt_ramp: f32 = 1.5; + const gap_ramp: f32 = 1.0; + + // ---- Seed the flow position from the current selection on first frame. ---- + const sel_index = currentTargetIndex(file, count); + if (!self.initialized) { + self.scroll_pos = @floatFromInt(sel_index); + self.goal = self.scroll_pos; + self.last_sel_index = sel_index; + self.initialized = true; + } + + // ---- User input (wheel / drag) may override the flow and the selection. ---- + self.handleInput(file, count, front_gap); + + // An external selection change (clicking a sprite, picking an animation, + // playback advancing a frame) retargets the flow. Pick the wrapped + // representative nearest the current position so we ease the short way + // around the loop (e.g. from the first sprite leftwards to the last). + if (!self.drag_active and sel_index != self.last_sel_index) { + self.goal = nearestWrapped(self.scroll_pos, sel_index, count); + self.last_sel_index = sel_index; + } + + // ---- Move toward the goal. While cards are flown (playback, drawing + // tools, or the preview toggle) we snap so the focus card swaps instantly + // instead of sliding through neighbours; reduce_motion snaps always. + // Otherwise ease (frame-rate independent). ---- + if (flown or dvui.reduce_motion) { + self.scroll_pos = self.goal; + } else if (!self.drag_active) { + const diff = self.goal - self.scroll_pos; + if (@abs(diff) > 0.001) { + const dt = dvui.secondsSinceLastFrame(); + const t = 1.0 - @exp(-12.0 * dt); + self.scroll_pos += diff * t; + dvui.refresh(null, @src(), dvui.parentGet().data().id); + } else { + self.scroll_pos = self.goal; + } + } + // Infinite wrap: keep scroll_pos (and the goal it chases) within one loop + // by shifting both by whole turns. The wrapped rendering below is identical + // regardless of which turn we're on, so this is seamless even mid-ease. + { + const c: f32 = @floatFromInt(count); + const k = @floor(self.scroll_pos / c); + if (k != 0.0) { + self.scroll_pos -= k * c; + self.goal -= k * c; + } + } if (parent.h < 32.0) { return; @@ -150,93 +218,467 @@ pub fn draw(self: *Sprites) !void { const perf_sp = fizzy.perf.spritePreviewBegin(); defer fizzy.perf.spritePreviewEnd(perf_sp); - _ = fizzy.dvui.sprite(@src(), .{ - .source = file.layers.items(.source)[file.selected_layer_index], - .file = file, - .alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null, - .sprite = .{ - .source = .{ - @intFromFloat(src_rect.x), - @intFromFloat(src_rect.y), - @intFromFloat(src_rect.w), - @intFromFloat(src_rect.h), - }, - .origin = .{ - 0, - 0, - }, - }, - .scale = scale, - // Compute a normalized depth in [-1.0, 1.0] where 0.0 is the center of the viewport - // .depth = blk: { - // const viewport = fizzy.editor.panel.scroll_info.viewport; - // const cx = viewport.x + viewport.w / 2.0; - // const px = hbox.data().rectScale().r.center().x; - // break :blk (px - cx) / (viewport.w / 2.0); - // }, - //.overlap = 0.8, - .reflection = true, - }, .{ - .id_extra = index, - .margin = .all(0), - .padding = .all(0), - //.border = .all(1), - //.color_border = dvui.themeGet().color(.control, .text), - }); - } -} + const center_x = parent.center().x; + // Lift the row a little so the reflection has room below it. + const center_y = parent.center().y - item_h * 0.10; -pub fn drawAnimationControlsDialog(_: *Sprites) void { - if (fizzy.editor.activeFile()) |file| { - if (file.selected_animation_index) |_| { - var rect = dvui.parentGet().data().rectScale().r; + // ---- Collect a window of sprites around the centre and draw them back + // to front so the focused sprite lands on top. The window grows with the + // pane so we show as many cards as actually fit, up to a sane cap. ---- + const max_window: i64 = 12; + const window: i64 = blk: { + const half_visible = parent.w / 2.0 + item_w; + const front_extent = flat_zone * front_gap + shelf_gap; + if (far_spread <= 0.0 or half_visible <= front_extent) break :blk @max(1, @as(i64, @intFromFloat(flat_zone))); + const extra = @floor((half_visible - front_extent) / far_spread); + const fit = @as(i64, @intFromFloat(flat_zone)) + 1 + @as(i64, @intFromFloat(extra)); + break :blk std.math.clamp(fit, 1, max_window); + }; + const center_i: i64 = @intFromFloat(@round(self.scroll_pos)); + const count_i: i64 = @intCast(count); + + // `slot` is the unwrapped position (so `off` and the skew stay continuous); + // `idx` is the wrapped sprite it shows; `id` is a per-slot widget id so + // duplicate sprites (loop shorter than the window) don't collide. + const Item = struct { idx: usize, off: f32, id: usize, center: bool }; + var items: [2 * 12 + 1]Item = undefined; + var n: usize = 0; + var d: i64 = -window; + while (d <= window) : (d += 1) { + const slot = center_i + d; + items[n] = .{ + .idx = @intCast(@mod(slot, count_i)), + .off = @as(f32, @floatFromInt(slot)) - self.scroll_pos, + .id = @intCast(d + window), + .center = d == 0, + }; + n += 1; + } - if (dvui.parentGet().data().rect.h < 48.0) { - return; + const SortCtx = struct { + fn lessThan(_: void, a: Item, b: Item) bool { + return @abs(a.off) > @abs(b.off); } + }; + std.sort.pdq(Item, items[0..n], {}, SortCtx.lessThan); + + // Cull side cards only once the fly-out has finished — not when outBack + // crosses 1 mid-animation (that overshoot is the visible fling). + const fly_cull_side_cards = blk: { + if (dvui.animationGet(panel_id, "play_fly")) |a| break :blk a.done() and flown; + break :blk flown; + }; + + for (items[0..n]) |it| { + const off = it.off; + const a = std.math.clamp(off, -flat_zone, flat_zone); + const beyond = off - a; + + // Tilt eases in over `tilt_ramp` cards (so the flat/skewed boundary is + // soft); the separation gap eases in faster, over `gap_ramp`. Both + // ramps start at 0 at the edge of the flat group, keeping x continuous + // so cards never pop as you scroll. + const tilt = std.math.clamp((@abs(off) - flat_zone) / tilt_ramp, 0.0, 1.0); + const gap_t = std.math.clamp((@abs(off) - flat_zone) / gap_ramp, 0.0, 1.0); + const x_off = a * front_gap + beyond * far_spread + std.math.sign(off) * gap_t * shelf_gap; + + // Left side recedes on its left edge, right side on its right edge. + const depth = -std.math.sign(off) * tilt * max_depth; + + // Subtle shrink with distance to reinforce depth. + const dist = @min(@abs(off), 4.0); + const item_scale = 1.0 - 0.05 * dist; + const w = item_w * item_scale; + const h = item_h * item_scale; + + // Fade cards out toward the background the further they sit from the + // focus; the front card and its immediate neighbours stay opaque. + const opacity = std.math.clamp(1.0 - 0.28 * (@abs(off) - 1.0), 0.0, 1.0); + + const is_focus = it.center; - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{}, .{ - .rect = .{ .x = rect.toNatural().x + 10, .y = rect.toNatural().y + 10, .w = 0, .h = 0 }, + // Side cards lift up and out of view (staggered by distance from the + // focus) and drop back on fly-in. The focus card never moves. `local` + // is this card's slice of the master `fly_t` clock; outBack flings out, + // inBack settles back with a matching overshoot. + var fly_offset: f32 = 0.0; + if (!is_focus and fly_t > 0.0) { + const s = std.math.clamp((@abs(off) - 1.0) / @as(f32, @floatFromInt(window)), 0.0, 1.0); + const stagger_span: f32 = 0.5; + const local = std.math.clamp((fly_t - s * stagger_span) / (1.0 - stagger_span), 0.0, 1.0); + const f = if (self.fly_anim_out) dvui.easing.outBack(local) else dvui.easing.inBack(local); + if (fly_cull_side_cards and f >= 1.0) continue; + fly_offset = f * (parent.h + item_h); + } + + const rect = dvui.Rect{ + .x = center_x + x_off - w / 2.0, + .y = center_y - h / 2.0 - fly_offset, + .w = w, + .h = h, + }; + + // Every card casts a shadow so the stack reads with depth; the shadow + // softens and fades as cards recede, the focus card keeps the deepest. + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .id_extra = it.id, .expand = .none, - .background = true, - .color_fill = dvui.themeGet().color(.control, .fill), - .corner_radius = dvui.Rect.all(8), + .rect = rect, .box_shadow = .{ .color = .black, - .alpha = 0.2, - .fade = 8, - .corner_radius = dvui.Rect.all(8), + .offset = .{ .x = 0.0, .y = if (is_focus) 8.0 else 5.0 }, + .fade = if (is_focus) 12.0 else 8.0, + .alpha = (if (is_focus) @as(f32, 0.25) else @as(f32, 0.2)) * opacity, + .corner_radius = dvui.Rect.all(parent_height / 32.0), }, }); - defer fw.deinit(); + defer hbox.deinit(); - var anim = dvui.animate(@src(), .{ .kind = .alpha, .duration = 450_000, .easing = dvui.easing.outBack }, .{}); - defer anim.deinit(); + const item_src = file.spriteRect(it.idx); - var anim_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = false, - }); - defer anim_box.deinit(); - - { - if (dvui.buttonIcon(@src(), "Play", if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, .{}, .{}, .{ - .expand = .ratio, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.0, .y = 2.0 }, - .fade = 6.0, - .alpha = 0.15, - .corner_radius = dvui.Rect.all(1000), + _ = fizzy.dvui.sprite(@src(), .{ + .source = file.layers.items(.source)[file.selected_layer_index], + .file = file, + .alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null, + .sprite = .{ + .source = .{ + @intFromFloat(item_src.x), + @intFromFloat(item_src.y), + @intFromFloat(item_src.w), + @intFromFloat(item_src.h), }, - .color_fill = dvui.themeGet().color(.control, .fill), - .min_size_content = .{ .w = 1.0, .h = 12.0 }, - })) { - file.editor.playing = !file.editor.playing; + .origin = .{ 0, 0 }, + }, + .scale = scale * item_scale, + .depth = depth, + .opacity = opacity, + .reflection = true, + // The card lifts up by `fly_offset`; sink the reflection by twice + // that so it mirrors across the resting waterline — the card peels + // up and out the top while its reflection sinks down and out the + // bottom. + .reflection_offset = 2.0 * fly_offset, + }, .{ + .id_extra = it.id, + .margin = .all(0), + .padding = .all(0), + }); + } + } +} + +/// Side cards lift away during playback, while a drawing tool is active, or when +/// `settings.scrolling_cards` is enabled (app-wide, toggled in settings or the +/// sprites pane). +fn sideCardsFlown(playing: bool) bool { + return playing or fizzy.editor.settings.scrolling_cards or drawingToolActive(); +} + +/// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee). +fn drawingToolActive() bool { + return switch (fizzy.editor.tools.current) { + .pointer, .selection => false, + .pencil, .eraser, .bucket => true, + }; +} + +/// Sprite index for the active animation's current frame, if any. +fn animationFrameSpriteIndex(file: anytype) ?usize { + const animation_index = file.selected_animation_index orelse return null; + const animation = file.animations.get(animation_index); + if (animation.frames.len == 0) return null; + const frame_index = file.selected_animation_frame_index; + if (frame_index >= animation.frames.len) return null; + return animation.frames[frame_index].sprite_index; +} + +/// The sprite index the cover flow scrolls toward when the user isn't driving +/// it directly. Matches the old single-sprite preview priority: +/// 1. Playback → current animation frame +/// 2. Drawing (pencil / eraser / bucket) + canvas hover → hovered cell +/// 3. Canvas / grid selection (e.g. last painted cell after Escape) → last selected +/// 4. Animation selected, nothing in the selection set → that frame's sprite +/// 5. Otherwise → sprite 0 +fn currentTargetIndex(file: anytype, count: usize) usize { + if (count == 0) return 0; + + if (file.editor.playing) { + if (animationFrameSpriteIndex(file)) |idx| return @min(idx, count - 1); + } + + if (file.editor.canvas.hovered and drawingToolActive()) { + if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt_prev))) |sprite_index| { + return @min(sprite_index, count - 1); + } + } + + if (file.editor.selected_sprites.count() > 0) { + if (file.editor.selected_sprites.findLastSet()) |last| return @min(last, count - 1); + } + + if (animationFrameSpriteIndex(file)) |idx| return @min(idx, count - 1); + + return 0; +} + +/// Wrap an unbounded slot index into a real sprite index in [0, count). +fn wrapIndex(slot: i64, count: usize) usize { + return @intCast(@mod(slot, @as(i64, @intCast(count)))); +} + +/// The representative of sprite `target` nearest to `from` in the infinite wrapped +/// index space, so easing crosses the seam the short way round. +fn nearestWrapped(from: f32, target: usize, count: usize) f32 { + const c: f32 = @floatFromInt(count); + const base: f32 = @floatFromInt(target); + return base + @round((from - base) / c) * c; +} + +/// Make `index` the sole selected sprite, and record it so the external-selection +/// sync doesn't treat our own change as a new target to chase. +fn commitSelection(self: *Sprites, file: anytype, index: usize) void { + file.clearSelectedSprites(); + if (index < file.editor.selected_sprites.capacity()) { + file.editor.selected_sprites.set(index); + } + self.last_sel_index = index; + dvui.refresh(null, @src(), dvui.parentGet().data().id); +} + +/// True when pointer events at `p` belong to the main workspace, not a floating +/// dialog/tooltip drawn above it (e.g. Grid Layout over this pane). +fn pointerTargetsMainPane(p: dvui.Point.Physical) bool { + const cw = dvui.currentWindow(); + const main_id = cw.data().id; + const target = cw.subwindows.windowFor(p); + if (target != .zero and target != main_id) return false; + for (cw.subwindows.stack.items[1..]) |sub| { + if (sub.modal) return false; + } + return true; +} + +/// Wheel scrolls one sprite at a time; horizontal drag scrubs the flow freely and +/// snaps to (and selects) the nearest sprite on release. +fn handleInput(self: *Sprites, file: anytype, count: usize, px_per_index: f32) void { + const pane = dvui.parentGet().data(); + const rs = pane.rectScale(); + const id = pane.id; + + self.drag_active = false; + + // Dialogs/subwindows stack above the sprites pane in z-order but share the same + // screen rect — don't capture clicks meant for their footer or chrome. + if (fizzy.dvui.canvasPointerInputSuppressed()) { + if (dvui.captured(id)) { + for (dvui.events()) |*e| { + if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + dvui.captureMouse(null, e.num); + dvui.dragEnd(); } } } + return; } + + for (dvui.events()) |*e| { + if (e.handled) continue; + if (e.evt != .mouse) continue; + const me = e.evt.mouse; + if (!pointerTargetsMainPane(me.p)) continue; + const inside = rs.r.contains(me.p); + if (!inside and !dvui.captured(id)) continue; + + switch (me.action) { + .press => { + if (me.button.pointer()) { + e.handle(@src(), pane); + dvui.captureMouse(pane, e.num); + dvui.dragPreStart(me.p, .{ .name = "coverflow_drag" }); + self.moved_since_press = false; + } + }, + .release => { + if (me.button.pointer() and dvui.captured(id)) { + e.handle(@src(), pane); + dvui.captureMouse(null, e.num); + dvui.dragEnd(); + if (self.moved_since_press) { + const snapped: i64 = @intFromFloat(@round(self.scroll_pos)); + self.goal = @floatFromInt(snapped); + self.commitSelection(file, wrapIndex(snapped, count)); + } + self.moved_since_press = false; + } + }, + .motion => { + if (dvui.captured(id)) { + if (dvui.dragging(me.p, "coverflow_drag")) |dps| { + self.drag_active = true; + self.moved_since_press = true; + if (px_per_index > 0.0) { + self.scroll_pos -= dps.x / rs.s / px_per_index; + self.goal = self.scroll_pos; + } + dvui.refresh(null, @src(), id); + } + } + }, + .wheel_x, .wheel_y => { + if (inside) { + e.handle(@src(), pane); + const amt = if (me.action == .wheel_x) me.action.wheel_x else me.action.wheel_y; + self.wheel_accum += amt * 0.01; + while (@abs(self.wheel_accum) >= 1.0) { + const step: f32 = if (self.wheel_accum > 0.0) 1.0 else -1.0; + self.wheel_accum -= step; + const ng = @round(self.goal) + step; + self.goal = ng; + self.commitSelection(file, wrapIndex(@intFromFloat(ng), count)); + } + dvui.refresh(null, @src(), id); + } + }, + else => {}, + } + } +} + +pub fn drawAnimationControlsDialog(_: *Sprites) void { + if (fizzy.editor.activeFile()) |file| { + const rect = dvui.parentGet().data().rectScale().r; + + if (dvui.parentGet().data().rect.h < 48.0) { + return; + } + + // Round controls floating in the top-left corner. Mirrors the workspace + // hamburger / sample buttons: content-fill circles with a soft drop + // shadow and a centered icon. + const button_size: f32 = 32; + const gap: f32 = 6; + const base_x = rect.toNatural().x + 10; + const base_y = rect.toNatural().y + 10; + + // Play / pause. Always present; "disabled" (muted, no action) when no + // animation is selected. + const play_enabled = file.selected_animation_index != null; + if (drawRoundButton( + @src(), + base_x, + base_y, + button_size, + "Play", + if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, + play_enabled, + file.editor.playing, + ) and play_enabled) { + file.editor.playing = !file.editor.playing; + } + + // Fly-out preview. Toggles the side cards out / in without advancing + // playback — a static look at the focused-card layout. Highlighted while + // active; inert while playback or drawing tools already flew them. + const playing = file.editor.playing; + const flown = sideCardsFlown(playing); + const fly_forced = playing or drawingToolActive(); + if (drawRoundButton( + @src(), + base_x + button_size + gap, + base_y, + button_size, + "Toggle card focus", + if (flown) icons.tvg.entypo.doc else icons.tvg.entypo.docs, + !fly_forced, + flown, + ) and !fly_forced) { + fizzy.editor.settings.scrolling_cards = !fizzy.editor.settings.scrolling_cards; + fizzy.editor.markSettingsDirty(); + dvui.refresh(null, @src(), dvui.parentGet().data().id); + } + } +} + +/// One round, floating action button matching the workspace hamburger / sample +/// buttons. Returns true on click. `enabled` mutes the icon (the caller also +/// gates the action on it); `active` tints the fill to show a toggled-on state. +/// Each call site supplies its own `@src()` for a stable, distinct id. +fn drawRoundButton( + src: std.builtin.SourceLocation, + x: f32, + y: f32, + size: f32, + name: []const u8, + icon_tvg: []const u8, + enabled: bool, + active: bool, +) bool { + const btn_radius: f32 = size / 2; + const icon_padding: f32 = size * 0.33; + + var fw: dvui.FloatingWidget = undefined; + fw.init(src, .{}, .{ + .rect = .{ .x = x, .y = y, .w = size, .h = size }, + .expand = .none, + .background = false, + }); + defer fw.deinit(); + + const fill = if (active) + dvui.themeGet().color(.highlight, .fill) + else + dvui.themeGet().color(.content, .fill); + + var btn: dvui.ButtonWidget = undefined; + btn.init(src, .{}, .{ + .expand = .both, + .min_size_content = .{ .w = size, .h = size }, + .background = true, + .corner_radius = dvui.Rect.all(btn_radius), + .color_fill = fill, + .color_fill_hover = fill.lighten(if (dvui.themeGet().dark) 10.0 else -10.0), + .color_border = .transparent, + // Inset lives on the button (not the icon): a uniform pad on the icon + // would force its content rect square and skew non-square glyphs like + // the entypo play/pause. Padding here keeps the icon's own rect free to + // take the glyph's native aspect under `expand = .ratio`. + .padding = dvui.Rect.all(icon_padding), + .margin = .{}, + .box_shadow = .{ + .color = .black, + .alpha = 0.2, + .fade = 4, + .offset = .{ .x = 0, .y = 2 }, + .corner_radius = dvui.Rect.all(btn_radius), + }, + }); + defer btn.deinit(); + btn.processEvents(); + btn.drawBackground(); + + const text_color = if (active) + dvui.themeGet().color(.highlight, .text) + else + dvui.themeGet().color(.content, .text); + const icon_color = if (enabled) text_color else text_color.opacity(0.35); + + // `min_size_content.h` must be a real height: IconWidget derives width as + // `iconWidth(h)` but clamps it up to at least `min_size_content.w`. With a + // height of 1 a glyph taller than wide derives width < 1, gets clamped to a + // square min size, and `expand = .ratio` then stretches it. A full-size + // height keeps the derived width true to the glyph's aspect. + dvui.icon( + src, + name, + icon_tvg, + .{ .stroke_color = icon_color, .fill_color = icon_color }, + .{ + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1.0, .h = size }, + }, + ); + + return btn.clicked(); } diff --git a/src/editor/widgets/CanvasWidget.zig b/src/editor/widgets/CanvasWidget.zig index 13949aaa..530a20a6 100644 --- a/src/editor/widgets/CanvasWidget.zig +++ b/src/editor/widgets/CanvasWidget.zig @@ -74,6 +74,8 @@ fade_pending: bool = false, // Saved between `install` and `deinit` so the parent alpha is restored exactly. prev_alpha: f32 = 1.0, hovered: bool = false, +/// `.dialog` for embedded previews (Grid Layout); uses `dialogCanvasPointerInputSuppressed`. +pointer_scope: enum { main, dialog } = .main, // Last frame's scroll viewport in physical pixels (latched in `deinit`). Used when the // scroll container is not installed yet this frame (e.g. UI chrome before `FileWidget`). sample_viewport_physical: ?dvui.Rect.Physical = null, @@ -344,7 +346,7 @@ pub fn install(self: *CanvasWidget, src: std.builtin.SourceLocation, init_opts: // read it during the same frame) don't see stale state on the first touch frame. The // tail-end `processEvents()` pass also updates it, but by then the brush has already // skipped the press because `hovered` was still false from the previous frame. - self.hovered = !fizzy.dvui.canvasPointerInputSuppressed() and + self.hovered = !self.pointerInputSuppressed() and self.pointerOverDrawable(dvui.currentWindow().mouse_pt); // Process two-finger gesture BEFORE any drawing tool event loop so we can capture the @@ -765,8 +767,15 @@ pub fn mouse(self: *CanvasWidget) ?dvui.Event.Mouse { return null; } +fn pointerInputSuppressed(self: *const CanvasWidget) bool { + return switch (self.pointer_scope) { + .main => fizzy.dvui.canvasPointerInputSuppressed(), + .dialog => fizzy.dvui.dialogCanvasPointerInputSuppressed(), + }; +} + pub fn processEvents(self: *CanvasWidget) void { - if (fizzy.dvui.canvasPointerInputSuppressed()) { + if (self.pointerInputSuppressed()) { self.hovered = false; return; } diff --git a/src/editor/widgets/FloatingWindowWidget.zig b/src/editor/widgets/FloatingWindowWidget.zig index a2ac2a72..8e6747ab 100644 --- a/src/editor/widgets/FloatingWindowWidget.zig +++ b/src/editor/widgets/FloatingWindowWidget.zig @@ -50,7 +50,7 @@ pub const InitOptions = struct { } = .nudge_once, }; -const DragPart = enum { +pub const DragPart = enum { middle, top, bottom, @@ -61,6 +61,12 @@ const DragPart = enum { top_right, bottom_left, + pub fn isResizeDrag(widget_id: dvui.Id) bool { + if (!dvui.captured(widget_id)) return false; + const dp = dvui.dataGet(null, widget_id, "_drag_part", DragPart) orelse return false; + return dp != .middle; + } + pub fn cursor(self: DragPart) dvui.enums.Cursor { return switch (self) { .middle => .arrow_all, diff --git a/src/gfx/render.zig b/src/gfx/render.zig index 26350fda..07598195 100644 --- a/src/gfx/render.zig +++ b/src/gfx/render.zig @@ -15,6 +15,12 @@ pub const RenderFileOptions = struct { uv: dvui.Rect = .{ .w = 1.0, .h = 1.0 }, corner_radius: dvui.Rect = .all(0), allow_peek: bool = true, + /// Optional skewed quad in physical corner order (tl, tr, br, bl). When set, + /// the layer stack renders into this quad instead of the axis-aligned `rs.r`, + /// so perspective/depth skew applies to the art itself — not just the + /// background. Leave null for normal (canvas) rendering. + quad: ?[4]dvui.Point.Physical = null, + quad_subdivisions: usize = 8, }; /// Web backends without `textureUpdateSubRect` recreate the GPU texture on upload; sync the cache @@ -335,7 +341,9 @@ fn fullCompositeEligible( if (needs_dimmed) return false; if (min_layer_index != 0) return false; if (init_opts.fade != 0) return false; - if (!std.meta.eql(init_opts.color_mod, dvui.Color.white)) return false; + // A uniform color_mod (e.g. the cover-flow opacity fade) is correct to apply + // once to the flattened composite — and avoids the per-layer translucency + // artifacts you'd get fading each layer separately. if (init_opts.file.editor.transform != null) return false; if (init_opts.file.editor.active_drawing) return false; const ce = layerCompositeExtent(init_opts.file); @@ -352,7 +360,8 @@ fn splitCompositeEligible( if (needs_dimmed) return false; if (min_layer_index != 0) return false; if (init_opts.fade != 0) return false; - if (!std.meta.eql(init_opts.color_mod, dvui.Color.white)) return false; + // See fullCompositeEligible: a uniform color_mod applies cleanly to the + // split composites too, so it no longer forces the per-layer path. const ce = layerCompositeExtent(init_opts.file); if (ce.w == 0 or ce.h == 0) return false; return true; @@ -626,16 +635,32 @@ pub fn renderLayers(init_opts: RenderFileOptions) !void { const min_layer_index = vs.min_layer_index; const needs_dimmed = vs.needs_dimmed; - var path: dvui.Path.Builder = .init(fizzy.app.allocator); - defer path.deinit(); - - path.addRect(content_rs.r, init_opts.corner_radius.scale(content_rs.s, dvui.Rect.Physical)); - - var triangles = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = init_opts.color_mod, .fade = init_opts.fade }); + var triangles = if (init_opts.quad) |q| blk: { + // Skewed quad: build a subdivided mesh so the texture follows the + // perspective instead of being mapped onto an axis-aligned rect. + var qpath: dvui.Path.Builder = .init(fizzy.app.allocator); + defer qpath.deinit(); + qpath.addPoint(q[0]); + qpath.addPoint(q[1]); + qpath.addPoint(q[2]); + qpath.addPoint(q[3]); + break :blk try fizzy.dvui.pathToSubdividedQuad(qpath.build(), fizzy.app.allocator, .{ + .subdivisions = init_opts.quad_subdivisions, + .uv = init_opts.uv, + .color_mod = init_opts.color_mod, + }); + } else blk: { + var path: dvui.Path.Builder = .init(fizzy.app.allocator); + defer path.deinit(); + + path.addRect(content_rs.r, init_opts.corner_radius.scale(content_rs.s, dvui.Rect.Physical)); + + var t = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = init_opts.color_mod, .fade = init_opts.fade }); + t.uvFromRectuv(content_rs.r, init_opts.uv); + break :blk t; + }; defer triangles.deinit(fizzy.app.allocator); - triangles.uvFromRectuv(content_rs.r, init_opts.uv); - var dimmed_triangles: ?dvui.Triangles = null; defer { if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator); diff --git a/tests/integration.zig b/tests/integration.zig index 636c997b..fcbc2361 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -33,7 +33,7 @@ fn makeBlankFile(width_: u32, height_: u32) !Internal.File { /// paths free the rest via routes we don't take here): specifically /// it does NOT release per-layer `mask` bit-sets and pixel buffers /// for entries in `file.layers`, nor `editor.selected_sprites`, -/// `editor.checkerboard`, `editor.checkerboard_tile`. Note also that +/// `editor.checkerboard`. Note also that /// `Internal.File.deinit` already frees each layer's `name`, so /// calling `layer.deinit()` here would double-free it. We free the /// leaked-in-tests pieces by hand. @@ -51,7 +51,8 @@ fn deinitFile(file: *Internal.File) void { } file.editor.selected_sprites.deinit(); file.editor.checkerboard.deinit(); - fizzy.app.allocator.free(file.editor.checkerboard_tile.pixelsPMA.rgba); + // `file.editor.checkerboard_tile` is an optional GPU `dvui.Texture` + // now; `file.deinit()` destroys it (and nulls it) on its own. file.deinit(); }