From 481388c5c2a997ef74f16ac55b972ff615224862 Mon Sep 17 00:00:00 2001 From: foxnne Date: Thu, 4 Jun 2026 08:52:44 -0500 Subject: [PATCH] Better touch controls, stability and sprite pane scrolling Add momentum to touch/drag on sprite area Allow panning via click/touch + drag on empty area of workspace Better touch controls on workspace, hold to open radial menu, single touch panning on empty space fix drop shadow and resize button on pinch to zoom scrolling behavior in the sprite pane refined Fix error for singleton app on unix hold to open radial menu stays open scrolling when cards flown out snaps positions --- libs/dvui-singleton-app/src/unix_impl.zig | 63 +++- src/editor/Editor.zig | 77 ++++- src/editor/Fling.zig | 90 ++++++ src/editor/Keybinds.zig | 18 +- src/editor/Settings.zig | 6 +- src/editor/Tools.zig | 26 ++ src/editor/explorer/settings.zig | 2 +- src/editor/panel/sprites.zig | 362 ++++++++++++++++++---- src/editor/widgets/CanvasWidget.zig | 210 +++++++++++-- src/editor/widgets/FileWidget.zig | 29 +- src/fizzy.zig | 1 + src/internal/File.zig | 50 +++ 12 files changed, 816 insertions(+), 118 deletions(-) create mode 100644 src/editor/Fling.zig diff --git a/libs/dvui-singleton-app/src/unix_impl.zig b/libs/dvui-singleton-app/src/unix_impl.zig index 2068a55c..78f877e3 100644 --- a/libs/dvui-singleton-app/src/unix_impl.zig +++ b/libs/dvui-singleton-app/src/unix_impl.zig @@ -110,22 +110,69 @@ pub const Primary = struct { } }; -fn trySendArgv(io: std.Io, addr: std.Io.net.UnixAddress, argv: []const []const u8) !bool { +fn trySendArgv(_: std.Io, addr: std.Io.net.UnixAddress, argv: []const []const u8) !bool { // Either the primary accepts us, or the socket file is stale. We can't // distinguish between "no listener" and other transient connect errors // without enumerating the impl's error set, so treat any failure here // as "no live primary" — the caller will fall through to remove the // stale socket and retry binding. - const stream = addr.connect(io) catch return false; - defer stream.close(io); - - var wbuf: [4096]u8 = undefined; - var sw = stream.writer(io, &wbuf); - try root.writeArgvIo(&sw.interface, argv); - try sw.interface.flush(); + // + // Use libc connect directly: std.Io's posixConnectUnix does not map + // ECONNREFUSED, so a stale socket triggers unexpectedErrno + stack trace + // even though the caller catches the error. + const fd = connectUnixClient(addr.path) orelse return false; + defer _ = std.c.close(fd); + + writeArgvFd(fd, argv) catch return false; return true; } +fn connectUnixClient(path: []const u8) ?std.c.fd_t { + const sock = std.c.socket(std.c.AF.UNIX, std.c.SOCK.STREAM, 0); + if (sock < 0) return null; + errdefer _ = std.c.close(@intCast(sock)); + + var storage: std.c.sockaddr.un = .{ + .family = std.c.AF.UNIX, + .path = undefined, + }; + const addr_len: std.c.socklen_t = @intCast(@offsetOf(std.c.sockaddr.un, "path") + path.len + 1); + if (path.len >= storage.path.len) return null; + @memcpy(storage.path[0..path.len], path); + storage.path[path.len] = 0; + const rc = std.c.connect(@intCast(sock), @ptrCast(&storage), addr_len); + if (rc == 0) return @intCast(sock); + _ = std.c.close(@intCast(sock)); + switch (std.posix.errno(rc)) { + .CONNREFUSED => return null, + else => return null, + } +} + +fn writeArgvFd(fd: std.c.fd_t, argv: []const []const u8) !void { + var hdr: [4]u8 = undefined; + std.mem.writeInt(u32, &hdr, @intCast(argv.len), .little); + try writeAllFd(fd, &hdr); + var total: u64 = 4; + for (argv) |arg| { + if (arg.len > root.max_arg_bytes) return root.Error.ArgTooLong; + total += 4 + @as(u64, arg.len); + if (total > root.max_total_bytes) return root.Error.PayloadTooLarge; + std.mem.writeInt(u32, &hdr, @intCast(arg.len), .little); + try writeAllFd(fd, &hdr); + if (arg.len > 0) try writeAllFd(fd, arg); + } +} + +fn writeAllFd(fd: std.c.fd_t, bytes: []const u8) !void { + var index: usize = 0; + while (index < bytes.len) { + const n = std.c.write(fd, bytes[index..].ptr, bytes.len - index); + if (n < 0) return error.WriteFailed; + index += @intCast(n); + } +} + fn buildSocketPath(allocator: std.mem.Allocator, dir: []const u8, app_id: []const u8) ![]u8 { const trimmed = trimTrailingSlash(dir); const uid: u32 = @intCast(std.c.getuid()); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index cb193da9..d2a63ed8 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -1126,14 +1126,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { dvui.log.err("Failed to tick hotkeys", .{}); }; - for (dvui.events()) |*e| { - switch (e.evt) { - .mouse => |me| { - editor.tools.radial_menu.mouse_position = me.p; - }, - else => {}, - } - } + processHoldOpenRadialMenu(editor); if (editor.tools.radial_menu.visible) { editor.drawRadialMenu() catch { @@ -1333,6 +1326,59 @@ pub fn setWindowStyle(_: *Editor) void { fizzy.backend.setWindowStyle(dvui.currentWindow()); } +/// Dismiss rules for the hold-opened radial menu (empty workspace area): stay open after +/// the opening finger lifts; close on tool button click or a non-drag click outside. +fn processHoldOpenRadialMenu(editor: *Editor) void { + const rm = &editor.tools.radial_menu; + if (!rm.visible or !rm.opened_by_press) { + rm.outside_click_press_p = null; + return; + } + + const dismiss_move_threshold: f32 = dvui.Dragging.threshold; + + for (dvui.events()) |*e| { + if (e.evt != .mouse) continue; + const me = e.evt.mouse; + rm.mouse_position = me.p; + + const primary = me.button.pointer() or me.button.touch(); + if (!primary) continue; + + switch (me.action) { + .press => { + if (!rm.containsPhysical(me.p)) { + rm.outside_click_press_p = me.p; + } else { + rm.outside_click_press_p = null; + } + }, + .motion => { + if (rm.outside_click_press_p) |press_p| { + if (me.p.diff(press_p).length() > dismiss_move_threshold) { + rm.outside_click_press_p = null; + } + } + }, + .release => { + if (rm.suppress_next_pointer_release) { + rm.suppress_next_pointer_release = false; + rm.outside_click_press_p = null; + continue; + } + if (rm.outside_click_press_p) |press_p| { + const moved = me.p.diff(press_p).length() > dismiss_move_threshold; + if (!moved and !rm.containsPhysical(me.p) and !rm.containsPhysical(press_p)) { + rm.close(); + } + rm.outside_click_press_p = null; + } + }, + else => {}, + } + } +} + pub fn drawRadialMenu(editor: *Editor) !void { var fw: dvui.FloatingWidget = undefined; fw.init(@src(), .{}, .{ @@ -1342,10 +1388,8 @@ pub fn drawRadialMenu(editor: *Editor) !void { const menu_color = dvui.themeGet().color(.content, .fill).lighten(4.0); - if (dvui.firstFrame(fw.data().id)) { - editor.tools.radial_menu.center = editor.tools.radial_menu.mouse_position; - } - + // `center` is set when the menu opens (Space down or hold on empty workspace) and stays + // fixed until close so tool buttons remain hoverable/clickable. const center = fw.data().rectScale().pointFromPhysical(editor.tools.radial_menu.center); const tool_count: usize = std.meta.fields(Editor.Tools.Tool).len; @@ -1493,8 +1537,12 @@ pub fn drawRadialMenu(editor: *Editor) !void { }; angle += step; - if (button.clicked() or button.hovered()) { + if (button.hovered()) { + editor.tools.set(tool); + } + if (button.clicked()) { editor.tools.set(tool); + editor.tools.radial_menu.close(); } button.deinit(); @@ -1530,6 +1578,9 @@ pub fn drawRadialMenu(editor: *Editor) !void { .rect = rect, })) { file.editor.playing = !file.editor.playing; + if (editor.tools.radial_menu.opened_by_press) { + editor.tools.radial_menu.close(); + } } } } diff --git a/src/editor/Fling.zig b/src/editor/Fling.zig new file mode 100644 index 00000000..3123c482 --- /dev/null +++ b/src/editor/Fling.zig @@ -0,0 +1,90 @@ +//! Reusable flick / momentum helper. +//! +//! Tracks a smoothed drag velocity (sampled once per frame during a drag), then +//! coasts with exponential decay after release. It's one-dimensional: use a single +//! instance for a 1-D scrub (the sprite cover flow) or one per axis for a 2-D pan +//! (the canvas). All values are in the caller's own units — index units, viewport +//! pixels, whatever — so only the `Tuning` thresholds need to match those units. +//! +//! Typical wiring per frame: +//! press: fling.begin() +//! drag: accumulate this frame's movement, then fling.sample(delta) once +//! release: if (!fling.release(tuning)) settleImmediately() +//! draw: while (fling.coasting) pos += fling.step(tuning).? // then check coasting + +const std = @import("std"); +const dvui = @import("dvui"); + +const Fling = @This(); + +/// Per-call-site feel tuning. Units match whatever the caller flings. +pub const Tuning = struct { + /// Velocity decay rate (1/s); higher stops the coast sooner. + decay: f32 = 4.0, + /// Minimum release speed needed to start coasting (units/s). + min_start: f32 = 1.2, + /// Speed at which the coast ends (units/s). + stop: f32 = 0.6, + /// Clamp on coast speed so a hard flick can't run away (units/s). + max: f32 = 50.0, + /// If the pointer was still longer than this before release (s), don't coast. + idle_s: f32 = 0.08, +}; + +/// True while a release coast is still running. +coasting: bool = false, +/// Current coast velocity (units/s). +vel: f32 = 0.0, +/// Smoothed velocity built up during the drag, sampled on release (units/s). +drag_vel: f32 = 0.0, +/// Frame timestamp (ns) of the last frame that had drag motion — used to detect a +/// pause before release, which should cancel the coast. +last_drag_ns: i128 = 0, + +/// Begin a fresh drag: cancel any coast and clear the velocity estimate. +pub fn begin(self: *Fling) void { + self.coasting = false; + self.vel = 0.0; + self.drag_vel = 0.0; + self.last_drag_ns = dvui.frameTimeNS(); +} + +/// Feed the total drag movement for this frame. Call once per frame that had drag +/// motion — `frameTimeNS` is constant within a frame, so per-event sampling is moot. +pub fn sample(self: *Fling, frame_delta: f32) void { + const dt = dvui.secondsSinceLastFrame(); + if (dt > 0.0 and dt < 0.1) { + const inst = frame_delta / dt; + self.drag_vel = self.drag_vel * 0.6 + inst * 0.4; + } + self.last_drag_ns = dvui.frameTimeNS(); +} + +/// Decide what happens on release. Starts a coast (returns true) when the pointer +/// was still moving fast enough and wasn't paused; otherwise returns false so the +/// caller can settle immediately. Always clears the drag velocity estimate. +pub fn release(self: *Fling, t: Tuning) bool { + const idle_s: f32 = @floatCast(@as(f64, @floatFromInt(dvui.frameTimeNS() - self.last_drag_ns)) / 1_000_000_000.0); + self.coasting = idle_s <= t.idle_s and @abs(self.drag_vel) > t.min_start; + if (self.coasting) self.vel = std.math.clamp(self.drag_vel, -t.max, t.max); + self.drag_vel = 0.0; + return self.coasting; +} + +/// Advance the coast one frame and return the position delta to apply, or null when +/// not coasting. On the frame the coast finishes it still returns the final delta +/// but leaves `coasting` false, so check `coasting` afterward to detect the stop. +pub fn step(self: *Fling, t: Tuning) ?f32 { + if (!self.coasting) return null; + const dt = dvui.secondsSinceLastFrame(); + const delta = self.vel * dt; + self.vel *= @exp(-t.decay * dt); + if (@abs(self.vel) < t.stop) self.coasting = false; + return delta; +} + +/// Immediately stop any coast (e.g. input got suppressed). +pub fn cancel(self: *Fling) void { + self.coasting = false; + self.vel = 0.0; +} diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 8cb30b93..cc66b279 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -103,10 +103,20 @@ pub fn tick() !void { } if (ke.matchBind("quick_tools")) { - fizzy.editor.tools.radial_menu.visible = switch (ke.action) { - .down, .repeat => true, - .up => false, - }; + const rm = &fizzy.editor.tools.radial_menu; + switch (ke.action) { + .down => { + const mp = dvui.currentWindow().mouse_pt; + rm.mouse_position = mp; + rm.center = mp; + rm.opened_by_press = false; + rm.suppress_next_pointer_release = false; + rm.outside_click_press_p = null; + rm.visible = true; + }, + .repeat => rm.visible = true, + .up => rm.close(), + } // If we include a refresh here, the underlying gui has a chance to reset the cursor dvui.refresh(null, @src(), dvui.currentWindow().data().id); } diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig index 8a2c8951..83a012df 100644 --- a/src/editor/Settings.zig +++ b/src/editor/Settings.zig @@ -51,9 +51,9 @@ 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, +/// Sprites panel: when true, show side cards in the cover-flow strip; when false, +/// fly them away for single-card focus (snap scroll) +scrolling_cards: bool = true, /// 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/Tools.zig b/src/editor/Tools.zig index e47c2196..68555989 100644 --- a/src/editor/Tools.zig +++ b/src/editor/Tools.zig @@ -33,6 +33,32 @@ pub const RadialMenu = struct { mouse_position: dvui.Point.Physical = .{ .x = 0.0, .y = 0.0 }, center: dvui.Point.Physical = .{ .x = 0.0, .y = 0.0 }, visible: bool = false, + /// Opened by press-and-hold on empty workspace (not Space / quick-tools). Both paths pin + /// `center` at open; this flag only selects hold-specific dismiss behavior. + opened_by_press: bool = false, + /// Ignore the first pointer release after a hold-open (lifting the opening finger). + suppress_next_pointer_release: bool = false, + /// Press began outside the menu while it is hold-open; used for click-outside dismiss. + outside_click_press_p: ?dvui.Point.Physical = null, + + pub fn close(self: *RadialMenu) void { + self.visible = false; + self.opened_by_press = false; + self.suppress_next_pointer_release = false; + self.outside_click_press_p = null; + } + + /// Physical hit radius for the radial tool ring (matches `drawRadialMenu` outer disc). + pub fn hitRadiusPhysical() f32 { + return 165.0; + } + + pub fn containsPhysical(self: RadialMenu, p: dvui.Point.Physical) bool { + const r = hitRadiusPhysical(); + const dx = p.x - self.center.x; + const dy = p.y - self.center.y; + return dx * dx + dy * dy <= r * r; + } }; pub const default_pencil_stroke_size: u8 = 1; diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 63f3d694..8b7aba09 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -205,7 +205,7 @@ pub fn draw() !void { fizzy.editor.markSettingsDirty(); } - if (dvui.checkbox(@src(), &fizzy.editor.settings.scrolling_cards, "Scrolling sprite cards", .{ + if (dvui.checkbox(@src(), &fizzy.editor.settings.scrolling_cards, "Show sprite cover-flow cards", .{ .expand = .none, })) { fizzy.editor.markSettingsDirty(); diff --git a/src/editor/panel/sprites.zig b/src/editor/panel/sprites.zig index afee739e..4ff97bfe 100644 --- a/src/editor/panel/sprites.zig +++ b/src/editor/panel/sprites.zig @@ -9,6 +9,15 @@ const Sprites = @This(); /// Side-card fly-out / fly-in master timeline (microseconds, linear 0↔1). const fly_anim_duration_us: i64 = 750_000; +/// Cover-flow scrub momentum tuning (sprite-index units). See `fizzy.Fling`. +const sprite_fling: fizzy.Fling.Tuning = .{ + .decay = 4.0, + .min_start = 1.2, + .stop = 0.6, + .max = 50.0, + .idle_s = 0.08, +}; + // Animated fit-scale state (shared, like a singleton preview). var prev_scale: f32 = 1.0; var current_scale: f32 = 1.0; @@ -21,15 +30,19 @@ 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 +/// Last virtual center index we observed 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), +last_sel_virtual: usize = std.math.maxInt(usize), +/// Last virtual index we pushed into editor state from the cover flow. +last_committed_virtual: 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, +/// Release momentum for the scrub: coasts the flow after a flick, then snaps. +fling: fizzy.Fling = .{}, /// 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 / @@ -62,7 +75,8 @@ pub fn draw(self: *Sprites) !void { const parent = dvui.parentGet().data().rect; const parent_height = parent.h; - const count = file.spriteCount(); + const mode = scrollMode(file); + const count = scrollCount(file, mode); if (count == 0) { return; } @@ -162,24 +176,26 @@ pub fn draw(self: *Sprites) !void { const gap_ramp: f32 = 1.0; // ---- Seed the flow position from the current selection on first frame. ---- - const sel_index = currentTargetIndex(file, count); + const sel_virtual = currentVirtualTarget(file, mode, count); if (!self.initialized) { - self.scroll_pos = @floatFromInt(sel_index); + self.scroll_pos = @floatFromInt(sel_virtual); self.goal = self.scroll_pos; - self.last_sel_index = sel_index; + self.last_sel_virtual = sel_virtual; + self.last_committed_virtual = sel_virtual; self.initialized = true; } // ---- User input (wheel / drag) may override the flow and the selection. ---- - self.handleInput(file, count, front_gap); + self.handleInput(file, mode, count, front_gap, flown); // 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; + if (!self.drag_active and sel_virtual != self.last_sel_virtual) { + self.goal = nearestWrapped(self.scroll_pos, sel_virtual, count); + self.last_sel_virtual = sel_virtual; + self.last_committed_virtual = sel_virtual; } // ---- Move toward the goal. While cards are flown (playback, drawing @@ -188,7 +204,25 @@ pub fn draw(self: *Sprites) !void { // Otherwise ease (frame-rate independent). ---- if (flown or dvui.reduce_motion) { self.scroll_pos = self.goal; - } else if (!self.drag_active) { + self.fling.cancel(); + self.commitCenteredIfNeeded(file, mode, count); + } else if (self.drag_active) { + // Position is driven directly by the drag in handleInput. + self.fling.cancel(); + } else if (self.fling.coasting) { + // Coast with decaying momentum from the release, then snap to (and + // select) the nearest sprite once the coast slows to a stop. + if (self.fling.step(sprite_fling)) |d| { + self.scroll_pos += d; + self.goal = self.scroll_pos; + } + if (!self.fling.coasting) { + const snapped: i64 = @intFromFloat(@round(self.scroll_pos)); + self.goal = @floatFromInt(snapped); + self.commitVirtualCenter(file, mode, wrapIndex(snapped, count)); + } + dvui.refresh(null, @src(), dvui.parentGet().data().id); + } else { const diff = self.goal - self.scroll_pos; if (@abs(diff) > 0.001) { const dt = dvui.secondsSinceLastFrame(); @@ -197,6 +231,8 @@ pub fn draw(self: *Sprites) !void { dvui.refresh(null, @src(), dvui.parentGet().data().id); } else { self.scroll_pos = self.goal; + // Passive ease finished — sync editor state once at the destination. + self.commitCenteredIfNeeded(file, mode, count); } } // Infinite wrap: keep scroll_pos (and the goal it chases) within one loop @@ -211,6 +247,13 @@ pub fn draw(self: *Sprites) !void { } } + // Only push selection / frame changes while the user is actively scrubbing. + // During passive ease toward a goal, scroll_pos lags behind — per-frame + // commits would fight wheel/drag commits and retrigger canvas bubble animations. + if (self.drag_active or self.fling.coasting) { + self.commitCenteredIfNeeded(file, mode, count); + } + if (parent.h < 32.0) { return; } @@ -235,7 +278,6 @@ pub fn draw(self: *Sprites) !void { 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 @@ -246,8 +288,9 @@ pub fn draw(self: *Sprites) !void { var d: i64 = -window; while (d <= window) : (d += 1) { const slot = center_i + d; + const virtual = wrapIndex(slot, count); items[n] = .{ - .idx = @intCast(@mod(slot, count_i)), + .idx = virtualToSpriteIndex(file, mode, virtual), .off = @as(f32, @floatFromInt(slot)) - self.scroll_pos, .id = @intCast(d + window), .center = d == 0, @@ -368,10 +411,9 @@ pub fn draw(self: *Sprites) !void { } /// 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). +/// `settings.scrolling_cards` is off (focus mode; toggled in settings or the sprites pane). fn sideCardsFlown(playing: bool) bool { - return playing or fizzy.editor.settings.scrolling_cards or drawingToolActive(); + return playing or drawingToolActive() or !fizzy.editor.settings.scrolling_cards; } /// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee). @@ -382,43 +424,129 @@ fn drawingToolActive() bool { }; } -/// 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; +/// How the cover-flow loop and scroll-to-editor sync behave. +const ScrollMode = enum { + /// All sprites; scrolling does not change selection or animation frame. + all_passive, + /// All sprites; the centered sprite becomes the sole selection. + all_follow_selection, + /// Animation frames only; the active frame follows the center; no sprite selection. + animation_passive, + /// Animation frames; active frame and a single in-animation sprite follow the center. + animation_follow_selection, + /// Multi-sprite selection only; primary tile follows the centered sprite. + selection_only, +}; + +fn scrollMode(file: anytype) ScrollMode { + const sel_count = file.editor.selected_sprites.count(); + if (sel_count > 1) return .selection_only; + + if (file.selected_animation_index) |ai| { + const frames = file.animations.get(ai).frames; + if (frames.len == 0) return .all_passive; + if (sel_count == 1) { + const si = file.editor.selected_sprites.findFirstSet() orelse return .all_passive; + for (frames) |f| { + if (f.sprite_index == si) return .animation_follow_selection; + } + return .all_follow_selection; + } + return .animation_passive; + } + + if (sel_count == 1) return .all_follow_selection; + return .all_passive; +} + +fn scrollCount(file: anytype, mode: ScrollMode) usize { + return switch (mode) { + .all_passive, .all_follow_selection => file.spriteCount(), + .animation_passive, .animation_follow_selection => blk: { + const ai = file.selected_animation_index orelse return file.spriteCount(); + break :blk file.animations.get(ai).frames.len; + }, + .selection_only => file.editor.selected_sprites.count(), + }; +} + +fn nthSelectedSprite(file: anytype, n: usize) usize { + var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); + var i: usize = 0; + while (iter.next()) |si| { + if (i == n) return si; + i += 1; + } + return 0; +} + +fn selectedSpriteVirtual(file: anytype, sprite_index: usize) ?usize { + var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); + var i: usize = 0; + while (iter.next()) |si| { + if (si == sprite_index) return i; + i += 1; + } + return null; +} + +fn virtualToSpriteIndex(file: anytype, mode: ScrollMode, virtual: usize) usize { + return switch (mode) { + .all_passive, .all_follow_selection => virtual, + .animation_passive, .animation_follow_selection => { + const ai = file.selected_animation_index orelse return virtual; + const frames = file.animations.get(ai).frames; + if (frames.len == 0) return virtual; + return frames[@min(virtual, frames.len - 1)].sprite_index; + }, + .selection_only => nthSelectedSprite(file, virtual), + }; +} + +fn virtualFromSprite(file: anytype, mode: ScrollMode, sprite_index: usize) ?usize { + return switch (mode) { + .all_passive, .all_follow_selection => sprite_index, + .animation_passive, .animation_follow_selection => { + const ai = file.selected_animation_index orelse return sprite_index; + const frames = file.animations.get(ai).frames; + for (frames, 0..) |f, i| { + if (f.sprite_index == sprite_index) return i; + } + return null; + }, + .selection_only => selectedSpriteVirtual(file, 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 { +/// Virtual center index the cover flow eases toward when the user isn't driving it. +fn currentVirtualTarget(file: anytype, mode: ScrollMode, count: usize) usize { if (count == 0) return 0; - if (file.editor.playing) { - if (animationFrameSpriteIndex(file)) |idx| return @min(idx, count - 1); + if (file.editor.playing and (mode == .animation_passive or mode == .animation_follow_selection)) { + return @min(file.selected_animation_frame_index, 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 (virtualFromSprite(file, mode, sprite_index)) |v| return @min(v, 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; + return switch (mode) { + .all_passive, .all_follow_selection => blk: { + if (file.editor.selected_sprites.count() > 0) { + if (file.editor.selected_sprites.findLastSet()) |last| break :blk @min(last, count - 1); + } + break :blk 0; + }, + .animation_passive, .animation_follow_selection => @min(file.selected_animation_frame_index, count - 1), + .selection_only => blk: { + if (file.primarySpriteIndex()) |primary| { + if (selectedSpriteVirtual(file, primary)) |v| break :blk @min(v, count - 1); + } + break :blk 0; + }, + }; } /// Wrap an unbounded slot index into a real sprite index in [0, count). @@ -426,6 +554,18 @@ fn wrapIndex(slot: i64, count: usize) usize { return @intCast(@mod(slot, @as(i64, @intCast(count)))); } +/// Advance the cover flow by one whole item and snap `scroll_pos` to match (flown-out mode). +fn stepScrollGoal(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, step: f32) void { + const next_slot: i64 = @as(i64, @intFromFloat(@round(self.goal))) + @as(i64, @intFromFloat(step)); + const v = wrapIndex(next_slot, count); + self.goal = @floatFromInt(v); + self.scroll_pos = self.goal; + self.fling.cancel(); + if (mode != .all_passive) { + self.commitVirtualCenter(file, mode, v); + } +} + /// 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 { @@ -434,15 +574,59 @@ fn nearestWrapped(from: f32, target: usize, count: usize) f32 { 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); +/// Sync editor state to the sprite/frame under the cover-flow center, if it changed. +fn commitCenteredIfNeeded(self: *Sprites, file: anytype, mode: ScrollMode, count: usize) void { + if (mode == .all_passive or count == 0) return; + const centered = wrapIndex(@intFromFloat(@round(self.scroll_pos)), count); + if (centered == self.last_committed_virtual) return; + self.commitVirtualCenter(file, mode, centered); +} + +/// Apply the centered virtual index to editor state. Records the virtual index so +/// external-selection sync doesn't treat our own change as a new target to chase. +fn commitVirtualCenter(self: *Sprites, file: anytype, mode: ScrollMode, virtual: usize) void { + switch (mode) { + .all_passive => return, + .all_follow_selection => { + const si = virtualToSpriteIndex(file, mode, virtual); + if (file.editor.selected_sprites.count() != 1 or + si >= file.editor.selected_sprites.capacity() or + !file.editor.selected_sprites.isSet(si)) + { + file.clearSelectedSprites(); + if (si < file.editor.selected_sprites.capacity()) { + file.editor.selected_sprites.set(si); + } + } + file.editor.primary_sprite_index = si; + }, + .selection_only => { + const si = virtualToSpriteIndex(file, mode, virtual); + file.promotePrimarySprite(si); + }, + .animation_passive => { + if (file.selected_animation_frame_index != virtual) { + file.selected_animation_frame_index = virtual; + } + }, + .animation_follow_selection => { + const si = virtualToSpriteIndex(file, mode, virtual); + if (file.selected_animation_frame_index != virtual or + file.editor.selected_sprites.count() != 1 or + si >= file.editor.selected_sprites.capacity() or + !file.editor.selected_sprites.isSet(si)) + { + file.selected_animation_frame_index = virtual; + file.clearSelectedSprites(); + if (si < file.editor.selected_sprites.capacity()) { + file.editor.selected_sprites.set(si); + } + } + file.promotePrimarySprite(si); + }, } - self.last_sel_index = index; - dvui.refresh(null, @src(), dvui.parentGet().data().id); + self.last_committed_virtual = virtual; + self.last_sel_virtual = virtual; } /// True when pointer events at `p` belong to the main workspace, not a floating @@ -458,15 +642,22 @@ fn pointerTargetsMainPane(p: dvui.Point.Physical) bool { 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 { +/// Wheel scrolls one step at a time; horizontal drag scrubs the flow freely and +/// snaps to the nearest item on release. When `snap_scroll` (cards flown out), +/// every step jumps straight to the next centered sprite with no in-between pan. +fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px_per_index: f32, snap_scroll: bool) void { const pane = dvui.parentGet().data(); const rs = pane.rectScale(); const id = pane.id; self.drag_active = false; + // Total drag distance (index units) accumulated across this frame's motion + // events, plus whether a drag was released this frame — both finalized after + // the loop so velocity is computed once per frame (frameTimeNS is per-frame). + var frame_dx: f32 = 0.0; + var released_moved = 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()) { @@ -494,8 +685,11 @@ fn handleInput(self: *Sprites, file: anytype, count: usize, px_per_index: f32) v if (me.button.pointer()) { e.handle(@src(), pane); dvui.captureMouse(pane, e.num); - dvui.dragPreStart(me.p, .{ .name = "coverflow_drag" }); + dvui.dragPreStart(me.p, .{ .name = "coverflow_drag", .cursor = .hand }); self.moved_since_press = false; + self.wheel_accum = 0.0; + // Grabbing again cancels any in-flight coast and its velocity. + self.fling.begin(); } }, .release => { @@ -503,11 +697,7 @@ fn handleInput(self: *Sprites, file: anytype, count: usize, px_per_index: f32) v 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)); - } + if (self.moved_since_press) released_moved = true; self.moved_since_press = false; } }, @@ -517,8 +707,19 @@ fn handleInput(self: *Sprites, file: anytype, count: usize, px_per_index: f32) v 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; + const di = -dps.x / rs.s / px_per_index; + if (snap_scroll) { + self.wheel_accum += di; + 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; + stepScrollGoal(self, file, mode, count, step); + } + } else { + self.scroll_pos += di; + self.goal = self.scroll_pos; + frame_dx += di; + } } dvui.refresh(null, @src(), id); } @@ -532,9 +733,19 @@ fn handleInput(self: *Sprites, file: anytype, count: usize, px_per_index: f32) v 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)); + if (snap_scroll) { + stepScrollGoal(self, file, mode, count, step); + } else { + const ng = @round(self.goal) + step; + self.goal = ng; + if (mode != .all_passive) { + const v = wrapIndex(@intFromFloat(ng), count); + self.commitVirtualCenter(file, mode, v); + // scroll_pos may still be easing toward ng; don't let a + // passive-ease commit revert this until we arrive. + self.last_committed_virtual = v; + } + } } dvui.refresh(null, @src(), id); } @@ -542,6 +753,29 @@ fn handleInput(self: *Sprites, file: anytype, count: usize, px_per_index: f32) v else => {}, } } + + // Sample the flick velocity once per frame the drag moved. + if (self.drag_active and !snap_scroll) self.fling.sample(frame_dx); + + // On release, coast with the built-up velocity — unless the pointer had paused + // or barely moved, in which case snap straight to the nearest sprite. + if (released_moved) { + if (snap_scroll) { + const v = wrapIndex(@intFromFloat(@round(self.goal)), count); + self.goal = @floatFromInt(v); + self.scroll_pos = self.goal; + self.fling.cancel(); + if (mode != .all_passive) { + self.commitVirtualCenter(file, mode, v); + } + } else if (!self.fling.release(sprite_fling)) { + const snapped: i64 = @intFromFloat(@round(self.scroll_pos)); + self.goal = @floatFromInt(snapped); + if (mode != .all_passive) { + self.commitVirtualCenter(file, mode, wrapIndex(snapped, count)); + } + } + } } pub fn drawAnimationControlsDialog(_: *Sprites) void { diff --git a/src/editor/widgets/CanvasWidget.zig b/src/editor/widgets/CanvasWidget.zig index 530a20a6..54ec0044 100644 --- a/src/editor/widgets/CanvasWidget.zig +++ b/src/editor/widgets/CanvasWidget.zig @@ -128,6 +128,35 @@ touch_eval_press_p: dvui.Point.Physical = .{}, touch_eval_released: bool = false, touch_eval_release_p: dvui.Point.Physical = .{}, +// Momentum for the drag-pan (middle button, or a left/touch drag starting off the +// artboard). One coast per axis so a flick keeps gliding after release; see Fling. +pan_fling_x: fizzy.Fling = .{}, +pan_fling_y: fizzy.Fling = .{}, + +// Pinch / two-finger pan input accumulated during this frame's `updateTouchGesture`. +// Mutating `scale` / `scroll_info.viewport` mid-frame jitters the canvas because the +// scaler's own `data().rectScale()` is locked in at scaler creation (before the pinch +// runs) — scaler-child widgets would render at post-pinch scale relative to pre-pinch +// origin. Instead we collect the deltas here and apply them at end-of-frame from +// `processEvents`, so the next frame's install caches everything consistently (this +// is the same pattern wheel zoom uses, which is why it stays smooth). +pending_pinch_zoom: f32 = 1.0, +pending_pinch_zoom_p: dvui.Point.Physical = .{}, +pending_touch_pan: dvui.Point.Physical = .{}, +pending_trackpad_ratio: f32 = 1.0, +pending_trackpad_cursor: dvui.Point.Physical = .{}, +pending_trackpad: bool = false, + +// An off-artboard left/touch press that hasn't resolved yet. It becomes a pan once +// it moves, a tap (clear selection) on a quick release, or — if held still past the +// context-menu hold duration — opens the radial tool menu. Middle-button pans never +// arm this, so they stay pan-only. +tap_gesture: bool = false, +tap_press_p: dvui.Point.Physical = .{}, +tap_press_ns: i128 = 0, +tap_moved: bool = false, +tap_radial: bool = false, + const TouchSlot = struct { active: bool = false, p: dvui.Point.Physical = .{}, @@ -135,6 +164,16 @@ const TouchSlot = struct { const touch_eval_duration_ns: i128 = 80 * std.time.ns_per_ms; +/// Drag-pan momentum tuning. Units are viewport (data) pixels per second — the same +/// units `scroll_info.viewport.x/y` move in — so the feel scales naturally with zoom. +const pan_fling: fizzy.Fling.Tuning = .{ + .decay = 4.0, + .min_start = 50.0, + .stop = 10.0, + .max = 8000.0, + .idle_s = 0.08, +}; + /// True while a 2-finger pan/pinch is in progress, or while we're still deciding whether /// a single touch will become one. Tools should skip their input processing whenever this /// returns true so previews / strokes / fills aren't triggered by the touch that's about @@ -516,8 +555,10 @@ pub fn updateTouchGesture(self: *CanvasWidget) void { const dy_centroid = new_c.y - self.last_centroid.y; const rs = self.scroll_rect_scale; if (rs.s > 0) { - self.scroll_info.viewport.x -= dx_centroid / rs.s; - self.scroll_info.viewport.y -= dy_centroid / rs.s; + // Defer the pan to end-of-frame so the canvas widget tree this + // frame stays internally consistent (see field doc). + self.pending_touch_pan.x -= dx_centroid / rs.s; + self.pending_touch_pan.y -= dy_centroid / rs.s; } const new_d = self.touchPinchDistance(); @@ -572,15 +613,10 @@ pub fn updateTouchGesture(self: *CanvasWidget) void { } if (zoom != 1.0) { - // Same scale-around-point math as the wheel-zoom path in processEvents. - const prevP = self.dataFromScreenPoint(zoomP); - var pp = prevP.scale(1 / self.scale, dvui.Point); - self.scale *= zoom; - pp = pp.scale(self.scale, dvui.Point); - const newP = self.screenFromDataPoint(pp); - const diff = self.viewportFromScreenPoint(newP).diff(self.viewportFromScreenPoint(zoomP)); - self.scroll_info.viewport.x += diff.x; - self.scroll_info.viewport.y += diff.y; + // Defer the 2-finger pinch zoom to `processEvents` (end-of-frame) — see the + // pending field docs for the jitter reason. + self.pending_pinch_zoom *= zoom; + self.pending_pinch_zoom_p = zoomP; dvui.refresh(null, @src(), self.scroll_container.data().id); } @@ -596,14 +632,10 @@ pub fn updateTouchGesture(self: *CanvasWidget) void { // user pinching while their pointer sits on a side panel / toolbar would unexpectedly // zoom the canvas. if (self.scroll_container.data().contentRectScale().r.contains(cursor_phys)) { - const prevP = self.dataFromScreenPoint(cursor_phys); - var pp = prevP.scale(1 / self.scale, dvui.Point); - self.scale *= trackpad_ratio; - pp = pp.scale(self.scale, dvui.Point); - const newP = self.screenFromDataPoint(pp); - const diff = self.viewportFromScreenPoint(newP).diff(self.viewportFromScreenPoint(cursor_phys)); - self.scroll_info.viewport.x += diff.x; - self.scroll_info.viewport.y += diff.y; + // Defer the trackpad pinch zoom to `processEvents` for the same reason. + self.pending_trackpad_ratio *= trackpad_ratio; + self.pending_trackpad_cursor = cursor_phys; + self.pending_trackpad = true; self.trackpad_pinch_last_ns = dvui.currentWindow().frame_time_ns; dvui.refresh(null, @src(), self.scroll_container.data().id); } @@ -775,8 +807,54 @@ fn pointerInputSuppressed(self: *const CanvasWidget) bool { } pub fn processEvents(self: *CanvasWidget) void { + // Apply pinch / two-finger pan deferred from this frame's `updateTouchGesture`. + // We do it at end-of-frame so the body above rendered with stable widget state + // (matching wheel zoom). The mutations land on `scale` / `scroll_info.viewport`, + // and next frame's install picks them up consistently across the image, shadow, + // and resize handle. The scale-around-point math here is identical to the wheel + // and old inline pinch paths — only the timing changed. + if (self.pending_pinch_zoom != 1.0) { + const zoom = self.pending_pinch_zoom; + const zoomP = self.pending_pinch_zoom_p; + const prevP = self.dataFromScreenPoint(zoomP); + var pp = prevP.scale(1 / self.scale, dvui.Point); + self.scale *= zoom; + pp = pp.scale(self.scale, dvui.Point); + const newP = self.screenFromDataPoint(pp); + const diff = self.viewportFromScreenPoint(newP).diff(self.viewportFromScreenPoint(zoomP)); + self.scroll_info.viewport.x += diff.x; + self.scroll_info.viewport.y += diff.y; + self.pending_pinch_zoom = 1.0; + } + if (self.pending_trackpad) { + const ratio = self.pending_trackpad_ratio; + const cursor_phys = self.pending_trackpad_cursor; + const prevP = self.dataFromScreenPoint(cursor_phys); + var pp = prevP.scale(1 / self.scale, dvui.Point); + self.scale *= ratio; + pp = pp.scale(self.scale, dvui.Point); + const newP = self.screenFromDataPoint(pp); + const diff = self.viewportFromScreenPoint(newP).diff(self.viewportFromScreenPoint(cursor_phys)); + self.scroll_info.viewport.x += diff.x; + self.scroll_info.viewport.y += diff.y; + self.pending_trackpad_ratio = 1.0; + self.pending_trackpad = false; + } + if (self.pending_touch_pan.x != 0 or self.pending_touch_pan.y != 0) { + self.scroll_info.viewport.x += self.pending_touch_pan.x; + self.scroll_info.viewport.y += self.pending_touch_pan.y; + self.pending_touch_pan = .{}; + } + if (self.pointerInputSuppressed()) { self.hovered = false; + self.pan_fling_x.cancel(); + self.pan_fling_y.cancel(); + // The radial menu (opened on hold below) suppresses canvas input while it's + // up; its release/close is handled in Editor.drawRadialMenu, so just drop our + // pending gesture state here. + self.tap_gesture = false; + self.tap_radial = false; return; } @@ -785,6 +863,13 @@ pub fn processEvents(self: *CanvasWidget) void { var zoom: f32 = 1; var zoomP: dvui.Point.Physical = .{}; + // Drag-pan movement accumulated across this frame's motion events, finalized + // after the loop so the fling velocity is sampled once per frame. + var pan_dx: f32 = 0; + var pan_dy: f32 = 0; + var pan_motion = false; + var pan_released = false; + // Suppress DVUI's built-in single-touch auto-pan inside the canvas. By this point in the // frame the drawing tools have already consumed any single-finger touches, and the scroll // container's processEvents runs at scroll.deinit (which comes after this) — so claiming @@ -795,6 +880,13 @@ pub fn processEvents(self: *CanvasWidget) void { if (e.evt != .mouse) continue; const me = e.evt.mouse; if (!me.button.touch()) continue; + // Let single-finger touches that belong to an empty-area canvas pan fall + // through to the pan handler below instead of being swallowed here: a press + // starting off the artboard, or any touch while such a pan is captured. + // The pan handler claims those itself, so the built-in scroll pan still + // stays suppressed for touches over the drawable (where we want to draw). + if (dvui.captured(self.scroll_container.data().id)) continue; + if (me.action == .press and !self.pointerOverDrawable(me.p)) continue; if (self.scroll_container.matchEvent(e)) { e.handle(@src(), self.scroll_container.data()); } @@ -814,22 +906,52 @@ pub fn processEvents(self: *CanvasWidget) void { self.hovered = self.pointerOverDrawable(me.p); } - if (me.action == .press and me.button == .middle) { + // Pan the canvas on a middle-button drag, or on a left/touch drag + // that starts in the empty scroll area (not over the artboard) — + // same scrub-the-viewport feel as the middle-button pan. + if (me.action == .press and (me.button == .middle or (me.button.pointer() and !self.pointerOverDrawable(me.p)))) { e.handle(@src(), self.scroll_container.data()); dvui.captureMouse(self.scroll_container.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "scroll_drag" }); - } else if (me.action == .release and me.button == .middle) { + dvui.dragPreStart(me.p, .{ .name = "scroll_drag", .cursor = .hand }); + self.pan_fling_x.begin(); + self.pan_fling_y.begin(); + // A non-middle (left/touch) off-artboard press may still become a tap + // or a hold — arm the gesture so the release/hold logic can resolve it. + self.tap_gesture = me.button != .middle; + self.tap_press_p = me.p; + self.tap_press_ns = dvui.frameTimeNS(); + self.tap_moved = false; + self.tap_radial = false; + } else if (me.action == .release and (me.button == .middle or me.button.pointer())) { if (dvui.captured(self.scroll_container.data().id)) { e.handle(@src(), self.scroll_container.data()); dvui.captureMouse(null, e.num); dvui.dragEnd(); + pan_released = true; + // A press that never moved and never opened the radial menu is a + // plain click on empty space — clear the selection and animation. + if (self.tap_gesture and !self.tap_moved and !self.tap_radial) { + fizzy.editor.cancel() catch {}; + } + self.tap_gesture = false; } } else if (me.action == .motion) { if (dvui.captured(self.scroll_container.data().id)) { + // Claim the event so the scroll container's built-in + // touch-to-scroll doesn't also pan from the same finger. + e.handle(@src(), self.scroll_container.data()); if (dvui.dragging(me.p, "scroll_drag")) |dps| { const rs = self.scroll_rect_scale; - self.scroll_info.viewport.x -= dps.x / rs.s; - self.scroll_info.viewport.y -= dps.y / rs.s; + const ddx = -dps.x / rs.s; + const ddy = -dps.y / rs.s; + self.scroll_info.viewport.x += ddx; + self.scroll_info.viewport.y += ddy; + pan_dx += ddx; + pan_dy += ddy; + pan_motion = true; + // Movement past the drag threshold means this is a pan, not a + // tap or a hold. + self.tap_moved = true; dvui.refresh(null, @src(), self.scroll_container.data().id); } } @@ -868,6 +990,46 @@ pub fn processEvents(self: *CanvasWidget) void { } } + // ---- Drag-pan momentum. Sample the flick velocity once per frame, decide on + // release whether to coast, and advance an in-flight coast — each axis is + // independent so a mostly-horizontal flick doesn't drift vertically. ---- + if (pan_motion) { + self.pan_fling_x.sample(pan_dx); + self.pan_fling_y.sample(pan_dy); + } + if (pan_released) { + _ = self.pan_fling_x.release(pan_fling); + _ = self.pan_fling_y.release(pan_fling); + } + if (self.pan_fling_x.coasting or self.pan_fling_y.coasting) { + if (self.pan_fling_x.step(pan_fling)) |dx| self.scroll_info.viewport.x += dx; + if (self.pan_fling_y.step(pan_fling)) |dy| self.scroll_info.viewport.y += dy; + dvui.refresh(null, @src(), self.scroll_container.data().id); + } + + // ---- Press-and-hold over empty space opens the radial tool menu (same gesture + // as the tools-menu color button). Hand the press over to the menu by releasing + // our capture so its buttons can be hovered; Editor keeps it open until a tool + // is chosen or the user clicks outside the menu. ---- + if (self.tap_gesture and !self.tap_moved and !self.tap_radial) { + if (dvui.frameTimeNS() - self.tap_press_ns >= dvui.currentWindow().hold_menu_duration_ns) { + fizzy.editor.tools.radial_menu.mouse_position = self.tap_press_p; + fizzy.editor.tools.radial_menu.center = self.tap_press_p; + fizzy.editor.tools.radial_menu.visible = true; + fizzy.editor.tools.radial_menu.opened_by_press = true; + fizzy.editor.tools.radial_menu.suppress_next_pointer_release = true; + fizzy.editor.tools.radial_menu.outside_click_press_p = null; + self.tap_radial = true; + if (dvui.captured(self.scroll_container.data().id)) { + dvui.captureMouse(null, 0); + dvui.dragEnd(); + } + self.pan_fling_x.cancel(); + self.pan_fling_y.cancel(); + } + dvui.refresh(null, @src(), self.scroll_container.data().id); + } + // scale around mouse point // first get data point of mouse // data from screen diff --git a/src/editor/widgets/FileWidget.zig b/src/editor/widgets/FileWidget.zig index 1d7358d6..e46e72d9 100644 --- a/src/editor/widgets/FileWidget.zig +++ b/src/editor/widgets/FileWidget.zig @@ -265,7 +265,16 @@ pub fn sampleColorAtPoint( } if (change_tool) { - if (color[3] == 0) { + const off_canvas = point.x < 0 or point.y < 0 or + point.x >= @as(f32, @floatFromInt(file.width())) or + point.y >= @as(f32, @floatFromInt(file.height())); + if (off_canvas) { + // Sampling the empty margin outside the artboard isn't an erase — drop back + // to the pointer tool so the click reads as "leave drawing mode". + if (fizzy.editor.tools.current != .pointer) { + fizzy.editor.tools.set(.pointer); + } + } else if (color[3] == 0) { if (fizzy.editor.tools.current != .eraser) { fizzy.editor.tools.set(.eraser); } @@ -529,6 +538,16 @@ pub fn processSpriteSelection(self: *FileWidget) void { const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p); if (me.action == .press and me.button.pointer()) { + // A press off the artboard with no selection modifier belongs to + // the canvas pan (handled later in canvas.processEvents) — yield it + // so dragging empty space pans instead of starting a marquee. Holding + // ctrl/cmd (add) or shift (subtract) keeps the selection meaning even + // out in the margins, so those still fall through to the logic below. + const sel_mod = me.mod.matchBind("ctrl/cmd") or me.mod.matchBind("shift"); + if (!sel_mod and !file.editor.canvas.pointerOverDrawable(me.p)) { + continue; + } + if (me.mod.matchBind("shift")) { self.shift_key_down = true; if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| { @@ -537,6 +556,7 @@ pub fn processSpriteSelection(self: *FileWidget) void { } else if (me.mod.matchBind("ctrl/cmd")) { if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| { file.editor.selected_sprites.set(sprite_index); + file.editor.primary_sprite_index = sprite_index; } } else { if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| { @@ -545,6 +565,7 @@ pub fn processSpriteSelection(self: *FileWidget) void { if (!selected) { file.editor.selected_sprites.set(sprite_index); + file.editor.primary_sprite_index = sprite_index; } } else if (!file.editor.canvas.hovered) { fizzy.editor.cancel() catch { @@ -1249,6 +1270,12 @@ pub fn drawSpriteBubble( } } } + } else if (self.init_options.file.editor.selected_sprites.count() > 1) { + if (self.init_options.file.primarySpriteIndex()) |primary| { + if (sprite_index != primary) { + bubble_max_height = @min(sprite_rect.h, sprite_rect.w) * 0.3333; + } + } } const bubble_height = std.math.clamp((bubble_max_height * t / self.init_options.file.editor.canvas.scale) * baseline_scale, 0.0, bubble_max_height * t); diff --git a/src/fizzy.zig b/src/fizzy.zig index 97c77b74..6b5bee81 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -25,6 +25,7 @@ pub const App = @import("App.zig"); pub const Assets = @import("Assets.zig"); pub const Editor = @import("editor/Editor.zig"); pub const Explorer = @import("editor/explorer/Explorer.zig"); +pub const Fling = @import("editor/Fling.zig"); pub const Packer = @import("tools/Packer.zig"); //pub const Popups = @import("editor/popups/Popups.zig"); pub const Sidebar = @import("editor/Sidebar.zig"); diff --git a/src/internal/File.zig b/src/internal/File.zig index 044fa893..f4d8ed70 100644 --- a/src/internal/File.zig +++ b/src/internal/File.zig @@ -71,6 +71,9 @@ pub const EditorData = struct { selection_layer: Layer = undefined, transform_layer: Layer = undefined, selected_sprites: std.DynamicBitSet = undefined, + /// Primary tile among a multi-sprite selection (cover-flow center / tallest bubble). + /// When an animation is selected, `selected_animation_frame_index` is kept in sync. + primary_sprite_index: ?usize = null, checkerboard: std.DynamicBitSet = undefined, checkerboard_tile: ?dvui.Texture = null, @@ -1527,6 +1530,53 @@ pub fn rowRect(file: *File, row_index: usize) dvui.Rect { } pub fn clearSelectedSprites(file: *File) void { file.editor.selected_sprites.setRangeValue(.{ .start = 0, .end = file.spriteCount() }, false); + file.editor.primary_sprite_index = null; +} + +/// Sprite that should read as primary (tallest frame bubble, cover-flow focus). +pub fn primarySpriteIndex(file: *const File) ?usize { + if (file.editor.primary_sprite_index) |p| { + if (p < file.editor.selected_sprites.capacity() and file.editor.selected_sprites.isSet(p)) { + return p; + } + } + if (file.selected_animation_index) |ai| { + const frames = file.animations.get(ai).frames; + if (frames.len > 0 and file.selected_animation_frame_index < frames.len) { + return frames[file.selected_animation_frame_index].sprite_index; + } + } + return file.editor.selected_sprites.findLastSet(); +} + +/// Move the primary sprite/frame to `sprite_index` without changing the selection set. +pub fn promotePrimarySprite(file: *File, sprite_index: usize) void { + if (sprite_index >= file.editor.selected_sprites.capacity() or + !file.editor.selected_sprites.isSet(sprite_index)) + { + return; + } + file.editor.primary_sprite_index = sprite_index; + + const animation_index = file.selected_animation_index orelse return; + const frames = file.animations.get(animation_index).frames; + if (frames.len == 0) return; + + if (file.editor.selected_frame_indices.items.len > 0) { + for (file.editor.selected_frame_indices.items) |fi| { + if (fi < frames.len and frames[fi].sprite_index == sprite_index) { + file.selected_animation_frame_index = fi; + return; + } + } + } + + for (frames, 0..) |f, fi| { + if (f.sprite_index == sprite_index) { + file.selected_animation_frame_index = fi; + return; + } + } } /// Collapse animation list multi-selection to the current primary only. Used when the primary is