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